feat(автоподбор): Яндекс дедупит по slug-коду фирмы + код в склейку

Яндекс отдавал по строке на КАЖДЫЙ филиал (дедуп по id точки) — 214 строк,
из них хлам-повторы одной сети (Корунд ×15). Новый YandexListParser дедупит
по SLUG (код фирмы от источника /maps/org/<slug>/id): одна фирма = одна строка,
разные написания одной фирмы склеены, разные фирмы с разным кодом раздельны.
Код справочника (ya:slug) уходит в union-find склейку как опознавательный
признак. Живьём 214 -> 94. TDD, 215/215 (unit+feature) зелёные. НЕ прод.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-01 06:24:00 +03:00
parent 48acb85df7
commit dbc71e898b
6 changed files with 134 additions and 18 deletions
@@ -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<array<string,mixed>> orgs из render-скрипта; ошибка → []. */
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Разбор списка организаций Яндекс.Карт (канал А). Дедуп по SLUG коду фирмы от самого Яндекса
* из адреса страницы `/maps/org/<slug>/<id>`, а НЕ по id филиала и НЕ по тексту имени.
*
* Почему slug: у всех точек одной сети slug общий (напр. `korund`), а id у каждой точки свой
* поэтому дедуп по slug честно схлопывает филиалы «Корунд ×15 один» и склеивает разные написания
* одной фирмы («КрасЛомбард»/«Красломбард» slug `kraslombard`). При этом две РАЗНЫЕ фирмы с похожим
* именем, которым Яндекс дал разные slug, остаются раздельными. Это опознавательный признак источника,
* а не догадка по строке. Чистый разбор: на вход сырой массив организаций из render, наружу не ходит.
*/
final class YandexListParser
{
/**
* @param list<array<string,mixed>> $orgs сырые организации из render-скрипта (name, href, ...)
* @return list<array{name:string,card_url:string,slug:string}>
*/
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;
}
}
@@ -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],
];
@@ -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));
}
@@ -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'],
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\ChannelA\YandexListParser;
// Канал А, Яндекс: дедуп организаций по slug (коду фирмы от самого Яндекса: /maps/org/<slug>/<id>),
// а НЕ по 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('ОК');
});