From 84e769e454f5202416dd2c2cbcf44216fbb2aeff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 18:23:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20=E2=80=94=20=D0=B6?= =?UTF-8?q?=D0=B8=D0=B2=D0=BE=D0=B9=20findCompetitors=20(=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=20=D0=90:=20=D0=BD=D0=B8=D1=88=D0=B0=20?= =?UTF-8?q?=E2=86=92=202=D0=93=D0=98=D0=A1+=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=20=E2=86=92=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=20?= =?UTF-8?q?=E2=86=92=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveFindCompetitors: ниша из формы → поиск 2ГИС (по слагу города) + Яндекс (по «ниша город») → парсер выдачи → резолв каждой карточки напрямую → FindCompetitorsAssembler (фильтр/слияние/ похожесть → DTO §7.2). Добыча за PageFetcher — обход тестируется офлайн на фикстурах. RegionCity — слаг/имя города центра субъекта (как RegionAreaCode, только уверенные). Тесты: live 3/3 (склейка 2ГИС+Яндекс одной фирмы, вычет своего сайта, пустая выдача); модуль Автоподбора unit 99/99; Pint чисто. Провайдер ещё не флипнут — следующий шаг. Co-Authored-By: Claude Opus 4.8 --- .../Autopodbor/Agent/LiveFindCompetitors.php | 119 ++++++++++++++++++ app/app/Support/RegionCity.php | 62 +++++++++ .../Autopodbor/LiveFindCompetitorsTest.php | 116 +++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php create mode 100644 app/app/Support/RegionCity.php create mode 100644 app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php new file mode 100644 index 00000000..a3770b71 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -0,0 +1,119 @@ +aboutSelf[0] ?? '')); + $city = RegionCity::name($r->regionCode) ?? ''; + + $candidates = []; + if ($niche !== '') { + $candidates = array_merge( + $this->fromTwoGis($niche, $r->regionCode), + $this->fromYandex($niche, $city), + ); + } + + // Свои сайт/имя (about_self) — для вычета себя из конкурентов. + $clientKeys = array_values(array_filter(array_map( + static fn ($v): string => (string) $v, + $r->aboutSelf, + ), static fn (string $v): bool => $v !== '')); + + // examples — для похожести (если подключён живой эмбеддер); без него ранжирование = 0. + $examples = array_values(array_filter(array_map( + static fn ($v): string => (string) $v, + $r->examples, + ), static fn (string $v): bool => $v !== '')); + + return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors); + } + + /** 2ГИС: /<город>/search/<ниша> → карточки firm (только если знаем слаг города). */ + private function fromTwoGis(string $niche, int $regionCode): array + { + $slug = RegionCity::slug($regionCode); + if ($slug === null) { + return []; // нет уверенного слага — пропускаем 2ГИС, остаётся Яндекс + } + $searchUrl = "https://2gis.ru/{$slug}/search/".rawurlencode($niche); + $items = array_slice($this->parser->twoGis($this->pages->html($searchUrl)), 0, self::PER_SOURCE_CAP); + + $out = []; + foreach ($items as $it) { + $url = 'https://2gis.ru'.$it['path']; + $card = $this->twoGis->parse($this->pages->html($url), $url); + if ($card !== null) { + $out[] = $this->toArray($card); + } + } + + return $out; + } + + /** Яндекс: поиск «ниша город» → карточки org (имя/город проверяет резолвер). */ + private function fromYandex(string $niche, string $city): array + { + $searchUrl = 'https://yandex.ru/maps/?text='.rawurlencode(trim($niche.' '.$city)); + $items = array_slice($this->parser->yandex($this->pages->html($searchUrl)), 0, self::PER_SOURCE_CAP); + + $out = []; + foreach ($items as $it) { + $url = 'https://yandex.ru'.$it['url']; + $card = $this->yandex->parse($this->pages->html($url), $url, $it['name'], $city); + if ($card !== null) { + $out[] = $this->toArray($card); + } + } + + return $out; + } + + /** Карточка резолвера → строка-кандидат для сборщика. */ + private function toArray(ResolvedCompetitor $c): array + { + return [ + 'name' => $c->name, + 'site_url' => $c->siteUrl, + 'description' => $c->description, + 'is_federal' => $c->isFederal, + 'directory_urls' => $c->directoryUrl !== null ? [$c->directoryUrl] : [], + 'phones' => $c->phones, + 'provenance' => ['via' => 'engine', 'source' => $c->source], + ]; + } +} diff --git a/app/app/Support/RegionCity.php b/app/app/Support/RegionCity.php new file mode 100644 index 00000000..a4ba8310 --- /dev/null +++ b/app/app/Support/RegionCity.php @@ -0,0 +1,62 @@ +/search/<запрос>`, Яндекс — по тексту «<ниша> <город>». + * + * Как и {@see RegionAreaCode}: внесены ТОЛЬКО уверенные центры (ключ — каноничное имя + * субъекта из RussianRegions::CODE_TO_NAME); неизвестный субъект → null, и движок не + * выдумывает город (для 2ГИС просто пропустит этот источник, останется Яндекс по имени субъекта). + * Карта расширяема. + */ +final class RegionCity +{ + /** @var array имя субъекта => {слаг 2ГИС, имя города} */ + private const BY_NAME = [ + 'Москва' => ['slug' => 'moscow', 'name' => 'Москва'], + 'Санкт-Петербург' => ['slug' => 'spb', 'name' => 'Санкт-Петербург'], + 'Красноярский край' => ['slug' => 'krasnoyarsk', 'name' => 'Красноярск'], + 'Новосибирская область' => ['slug' => 'novosibirsk', 'name' => 'Новосибирск'], + 'Свердловская область' => ['slug' => 'ekaterinburg', 'name' => 'Екатеринбург'], + 'Республика Татарстан' => ['slug' => 'kazan', 'name' => 'Казань'], + 'Нижегородская область' => ['slug' => 'n_novgorod', 'name' => 'Нижний Новгород'], + 'Самарская область' => ['slug' => 'samara', 'name' => 'Самара'], + 'Ростовская область' => ['slug' => 'rostov', 'name' => 'Ростов-на-Дону'], + 'Воронежская область' => ['slug' => 'voronezh', 'name' => 'Воронеж'], + 'Пермский край' => ['slug' => 'perm', 'name' => 'Пермь'], + 'Приморский край' => ['slug' => 'vladivostok', 'name' => 'Владивосток'], + 'Республика Башкортостан' => ['slug' => 'ufa', 'name' => 'Уфа'], + 'Челябинская область' => ['slug' => 'chelyabinsk', 'name' => 'Челябинск'], + 'Краснодарский край' => ['slug' => 'krasnodar', 'name' => 'Краснодар'], + ]; + + /** Слаг города для URL 2ГИС (`krasnoyarsk`) или null, если уверенного нет. */ + public static function slug(int $subjectCode): ?string + { + return self::entry($subjectCode)['slug'] ?? null; + } + + /** Имя города («Красноярск») или имя субъекта как запасной вариант (для запроса Яндекса). */ + public static function name(int $subjectCode): ?string + { + $name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null; + + return self::entry($subjectCode)['name'] ?? $name; + } + + /** @return array{slug:string, name:string}|array{} */ + private static function entry(int $subjectCode): array + { + $name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null; + if ($name === null) { + return []; + } + + return self::BY_NAME[$name] ?? []; + } +} diff --git a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php new file mode 100644 index 00000000..41fa1161 --- /dev/null +++ b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php @@ -0,0 +1,116 @@ + [0.0], $texts); + } + }; + $assembler = new FindCompetitorsAssembler( + new AggregatorFilter($nullClassifier), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance($zeroEmbedder), + ); + + return new LiveFindCompetitors( + stubPages([ + // выдача + '2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'), + 'maps/?text=' => autopodborFixture('yandex-search-kraslombard.html'), + // карточки + '/firm/' => autopodborFixture('2gis-firm-kraslombard.html'), + '/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'), + ]), + new SearchResultsParser, + new TwoGisResolver, + new YandexResolver, + $assembler, + ); +} + +it('живой поиск по нише → настоящий конкурент, склеенный из 2ГИС и Яндекса', function () { + $res = liveEngine()->find(new FindCompetitorsRequest( + regionCode: 29, // Красноярский край → город Красноярск, слаг krasnoyarsk + examples: [], + aboutSelf: ['ломбард', 'moy-lombard.ru'], // ниша + ВАШ сайт (другой — себя НЕ вычитаем) + includeFederal: true, + maxCompetitors: 20, + )); + + $names = array_column($res->competitors, 'name'); + expect($names)->toContain('КрасЛомбард'); // найден живым поиском по нише + + $kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард'); + expect($kl['site_url'])->toBe('http://kraslombard24.ru'); + // склейка одной фирмы из 2ГИС + Яндекс: в «где нашли» обе ссылки справочников + expect(collect($kl['directory_urls'])->contains(fn (string $u): bool => str_contains($u, '/firm/')))->toBeTrue(); + expect(collect($kl['directory_urls'])->contains(fn (string $u): bool => str_contains($u, '/maps/org/')))->toBeTrue(); +}); + +it('свой сайт вычитается из конкурентов', function () { + $res = liveEngine()->find(new FindCompetitorsRequest( + regionCode: 29, + examples: [], + aboutSelf: ['ломбард', 'kraslombard24.ru'], // теперь КрасЛомбард = это «мы» + includeFederal: true, + maxCompetitors: 20, + )); + + expect(array_column($res->competitors, 'name'))->not->toContain('КрасЛомбард'); +}); + +it('пустая выдача → пустой список, без падения', function () { + $engine = new LiveFindCompetitors( + stubPages([]), // на всё '' + new SearchResultsParser, + new TwoGisResolver, + new YandexResolver, + new FindCompetitorsAssembler( + new AggregatorFilter(new class implements AggregatorClassifier + { + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool + { + return null; + } + }), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance(new class implements Embedder + { + public function embed(array $texts): array + { + return array_map(fn () => [0.0], $texts); + } + }), + ), + ); + + $res = $engine->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], false, 10)); + expect($res->competitors)->toBe([]); +});