From dbc71e898bdb1443f411fbac92f56b6dfb53fbda 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: Wed, 1 Jul 2026 06:24:00 +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=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=D0=B8=D1=82=20=D0=BF=D0=BE=20slu?= =?UTF-8?q?g-=D0=BA=D0=BE=D0=B4=D1=83=20=D1=84=D0=B8=D1=80=D0=BC=D1=8B=20+?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=20=D0=B2=20=D1=81=D0=BA=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Яндекс отдавал по строке на КАЖДЫЙ филиал (дедуп по id точки) — 214 строк, из них хлам-повторы одной сети (Корунд ×15). Новый YandexListParser дедупит по SLUG (код фирмы от источника /maps/org//id): одна фирма = одна строка, разные написания одной фирмы склеены, разные фирмы с разным кодом раздельны. Код справочника (ya:slug) уходит в union-find склейку как опознавательный признак. Живьём 214 -> 94. TDD, 215/215 (unit+feature) зелёные. НЕ прод. Co-Authored-By: Claude Opus 4.8 --- .../ChannelA/PlaywrightYandexDirectory.php | 21 +++----- .../Agent/ChannelA/YandexListParser.php | 48 +++++++++++++++++++ .../Autopodbor/Agent/LiveFindCompetitors.php | 15 ++++-- .../Services/Autopodbor/AutopodborDedup.php | 8 ++++ .../Unit/Autopodbor/AutopodborMergeTest.php | 12 +++++ .../ChannelA/YandexListParserTest.php | 48 +++++++++++++++++++ 6 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php create mode 100644 app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php index 613bc67c..75e9d78b 100644 --- a/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php +++ b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php @@ -24,15 +24,16 @@ final class PlaywrightYandexDirectory implements YandexDirectory private readonly int $timeout = 120, // Не рендерим больше N запросов за подбор (локальный браузер тяжёл при параллельных клиентах). private readonly int $maxQueries = 4, + private readonly YandexListParser $parser = new YandexListParser, ) { $this->script = $script !== '' ? $script : base_path('scripts/render-yandex-list.cjs'); } public function collect(string $city, array $queries): array { - $seen = []; - $out = []; - + // Собираем сырьё по всем рубрикам, дедуп/опознание фирмы — в парсере ПО SLUG (код фирмы от + // Яндекса), а не по id филиала: «Корунд ×15 → один», разные написания одной фирмы склеены. + $orgs = []; foreach (array_slice($queries, 0, max(0, $this->maxQueries)) as $query) { $query = trim((string) $query); if ($query === '') { @@ -41,21 +42,11 @@ final class PlaywrightYandexDirectory implements YandexDirectory $url = 'https://yandex.ru/maps/?text='.rawurlencode(trim($query.' '.$city)); foreach ($this->render($url) as $org) { - $href = (string) ($org['href'] ?? ''); - $name = trim((string) ($org['name'] ?? '')); - if ($name === '' || ! preg_match('#^/maps/org/[a-z0-9_-]+/(\d+)#i', $href, $m)) { - continue; - } - $id = $m[1]; - if (isset($seen[$id])) { - continue; - } - $seen[$id] = true; - $out[] = ['name' => $name, 'card_url' => 'https://yandex.ru'.$href]; + $orgs[] = $org; } } - return $out; + return $this->parser->parse($orgs); } /** @return list> orgs из render-скрипта; ошибка → []. */ diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php b/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php new file mode 100644 index 00000000..f755c281 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php @@ -0,0 +1,48 @@ +/`, а НЕ по id филиала и НЕ по тексту имени. + * + * Почему slug: у всех точек одной сети slug общий (напр. `korund`), а id у каждой точки свой — + * поэтому дедуп по slug честно схлопывает филиалы «Корунд ×15 → один» и склеивает разные написания + * одной фирмы («КрасЛомбард»/«Красломбард» → slug `kraslombard`). При этом две РАЗНЫЕ фирмы с похожим + * именем, которым Яндекс дал разные slug, остаются раздельными. Это опознавательный признак источника, + * а не догадка по строке. Чистый разбор: на вход сырой массив организаций из render, наружу не ходит. + */ +final class YandexListParser +{ + /** + * @param list> $orgs сырые организации из render-скрипта (name, href, ...) + * @return list + */ + public function parse(array $orgs): array + { + $seen = []; + $out = []; + + foreach ($orgs as $org) { + $href = trim((string) ($org['href'] ?? '')); + $name = trim((string) ($org['name'] ?? '')); + if ($name === '' || ! preg_match('#^/maps/org/([a-z0-9_-]+)/(\d+)#i', $href, $m)) { + continue; + } + $slug = mb_strtolower($m[1], 'UTF-8'); + if (isset($seen[$slug])) { + continue; // одна фирма (slug) = одна строка; филиалы схлопнуты + } + $seen[$slug] = true; + $out[] = [ + 'name' => $name, + 'card_url' => 'https://yandex.ru'.$href, + 'slug' => $slug, + ]; + } + + return $out; + } +} diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index ef7344c1..037388b3 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -56,7 +56,12 @@ final class LiveFindCompetitors : []; $aCards = array_merge( array_map(fn (array $row): array => $this->localCard($row['name'], $row['card_url'], $row['site'] ?? null), $twoGisRows), - array_map(fn (array $row): array => $this->localCard($row['name'], $row['card_url'], null), $yandexRows), + array_map(fn (array $row): array => $this->localCard( + $row['name'], + $row['card_url'], + null, + isset($row['slug']) && $row['slug'] !== '' ? 'ya:'.$row['slug'] : null, + ), $yandexRows), ); // 3. Канал В: имена федералов → САЙТ через EXA. Нет сайта = нет якоря → выкидываем. @@ -82,8 +87,11 @@ final class LiveFindCompetitors return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors); } - /** Местная карточка из списка справочника (канал А): 2ГИС (с сайтом) или Яндекс (site=null). */ - private function localCard(string $name, string $cardUrl, ?string $site): array + /** + * Местная карточка из списка справочника (канал А): 2ГИС (с сайтом) или Яндекс (site=null). + * $dirKey — опознавательный код фирмы от справочника (напр. «ya:korund») для склейки по источнику. + */ + private function localCard(string $name, string $cardUrl, ?string $site, ?string $dirKey = null): array { $source = str_contains($cardUrl, 'yandex.ru') ? 'yandex-list' : '2gis-list'; @@ -93,6 +101,7 @@ final class LiveFindCompetitors 'description' => null, 'is_federal' => false, 'directory_urls' => [$cardUrl], + 'directory_keys' => $dirKey !== null ? [$dirKey] : [], 'phones' => [], 'provenance' => ['via' => 'engine', 'source' => $source], ]; diff --git a/app/app/Services/Autopodbor/AutopodborDedup.php b/app/app/Services/Autopodbor/AutopodborDedup.php index 0a71518a..e95c079f 100644 --- a/app/app/Services/Autopodbor/AutopodborDedup.php +++ b/app/app/Services/Autopodbor/AutopodborDedup.php @@ -191,6 +191,14 @@ final class AutopodborDedup $keys[] = 'p:'.$pp; } } + // Код фирмы от справочника (напр. slug Яндекса «ya:korund») — опознавательный признак + // источника: склеивает разные написания одной фирмы, которые ключ имени не поймал. + foreach ($c['directory_keys'] ?? [] as $dk) { + $dk = trim((string) $dk); + if ($dk !== '') { + $keys[] = 'd:'.$dk; + } + } return array_values(array_unique($keys)); } diff --git a/app/tests/Unit/Autopodbor/AutopodborMergeTest.php b/app/tests/Unit/Autopodbor/AutopodborMergeTest.php index d821d85d..5a4b16a5 100644 --- a/app/tests/Unit/Autopodbor/AutopodborMergeTest.php +++ b/app/tests/Unit/Autopodbor/AutopodborMergeTest.php @@ -51,6 +51,18 @@ it('объединяет ссылки справочников и телефон expect($merged[0]['phones'])->toContain('71112223344')->toContain('75556667788'); }); +it('склеивает по общему коду справочника (slug Яндекса), даже при разном написании имени', function () { + // Один и тот же «КрасЛомбард», но имя записано по-разному → по имени НЕ сойдётся. + // Спасает опознавательный код фирмы от Яндекса (slug) — это признак источника, не догадка. + $merged = mergeService()->mergeCompetitors([ + ['name' => 'КрасЛомбард', 'directory_keys' => ['ya:kraslombard'], 'directory_urls' => ['https://yandex.ru/maps/org/kraslombard/333']], + ['name' => 'Красломбард 24 Взлётка', 'directory_keys' => ['ya:kraslombard'], 'directory_urls' => ['https://yandex.ru/maps/org/kraslombard/444']], + ]); + + expect($merged)->toHaveCount(1) + ->and($merged[0]['directory_urls'])->toHaveCount(2); +}); + it('не склеивает разных конкурентов', function () { $merged = mergeService()->mergeCompetitors([ ['name' => 'Окна Комфорт', 'site_url' => 'okna-komfort.ru'], diff --git a/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php b/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php new file mode 100644 index 00000000..2c3703f4 --- /dev/null +++ b/app/tests/Unit/Autopodbor/ChannelA/YandexListParserTest.php @@ -0,0 +1,48 @@ +/), +// а НЕ по id филиала и НЕ по тексту имени. Одна фирма (slug) = одна строка. Чистая логика над сырым +// массивом организаций из render-скрипта — без сети/браузера. + +it('дедуп по slug: филиалы одной фирмы → одна строка (даже при разном написании имени)', function () { + $orgs = [ + ['name' => 'Корунд', 'href' => '/maps/org/korund/111'], + ['name' => 'Корунд', 'href' => '/maps/org/korund/222'], + ['name' => 'Красломбард', 'href' => '/maps/org/kraslombard/333'], + ['name' => 'КрасЛомбард', 'href' => '/maps/org/kraslombard/444'], // другое написание — тот же slug + ]; + + $rows = (new YandexListParser)->parse($orgs); + + expect($rows)->toHaveCount(2) + ->and(array_column($rows, 'slug'))->toContain('korund', 'kraslombard'); + + $kl = collect($rows)->firstWhere('slug', 'kraslombard'); + expect($kl['name'])->toBe('Красломбард') // первое написание сохраняется + ->and($kl['card_url'])->toBe('https://yandex.ru/maps/org/kraslombard/333'); +}); + +it('разные фирмы с похожим именем, но разным slug — остаются раздельными', function () { + // Яндекс дал им РАЗНЫЕ коды → это две разные фирмы, склеивать нельзя. + $orgs = [ + ['name' => 'Автозайм 124', 'href' => '/maps/org/avtozaym_124/1'], + ['name' => 'Автозайм124', 'href' => '/maps/org/avtozaym124/2'], + ]; + + expect((new YandexListParser)->parse($orgs))->toHaveCount(2); +}); + +it('без имени или без валидного href организации — пропускаем', function () { + $orgs = [ + ['name' => '', 'href' => '/maps/org/x/1'], + ['name' => 'Нет ссылки', 'href' => '/foo/bar'], + ['name' => 'ОК', 'href' => '/maps/org/ok/9'], + ]; + + $rows = (new YandexListParser)->parse($orgs); + expect($rows)->toHaveCount(1)->and($rows[0]['name'])->toBe('ОК'); +});