feat(автоподбор): второй справочник канала А — Яндекс.Карты через локальный Playwright + слияние

Firecrawl Яндекс.Карты не рендерит (0-2 орг) — по §12.2 Яндекс берём локальным Playwright.
render-yandex-list.cjs скроллит ленту результатов → 113 орг за ~18с (быстрее xfetch-2ГИС).
YandexDirectory (граница) + PlaywrightYandexDirectory (живой, Process→node). Яндекс = имя+карточка
(сайта в списке нет — только на карточке, не открываем). Оркестратор: канал А = 2ГИС(сайт)+Яндекс,
слияние (mergeCompetitors union-find) схлопывает одного конкурента из обоих справочников в одну
карточку с двумя directory_urls; сайт из 2ГИС. Провайдер подключает живой Яндекс. listingHtml →
общий хелпер tests/Pest.php. Модуль 136 unit + 74 feature зелёные. За флагом; на проде не меняется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-01 04:53:58 +03:00
parent 5d40de664e
commit dee2ebbcf8
8 changed files with 245 additions and 26 deletions
+22
View File
@@ -100,6 +100,28 @@ function stubPages(array $byNeedle): PageFetcher
};
}
/**
* Разметка списка категории 2ГИС для тестов канала А: фирмы [[id,name,site?],...].
* Общий хелпер (используют CategoryScraperTest и LiveFindCompetitorsTest).
*
* @param array<int, array{0:int|string,1:string,2?:?string}> $firms
*/
function listingHtml(array $firms): string
{
$h = '';
foreach ($firms as $f) {
$id = $f[0];
$name = $f[1];
$site = $f[2] ?? null;
$h .= '<a href="/krasnoyarsk/firm/'.$id.'?stat=X" class="_1rehek"><span class="_lvwrwt"><span>'.$name.'</span></span></a>';
if ($site !== null) {
$h .= '{"caption":"Перейти на сайт","url":"https://link.2gis.ru/e/project7/'.$id.'/null/H?http://'.$site.'/"}';
}
}
return $h;
}
/**
* Link a Лидерра-project to a supplier_project via the M:N pivot
* (Plan 1 model). Post-Plan-2 LeadRouter eligibility queries the pivot
@@ -27,22 +27,7 @@ final class MapFetcher implements PageFetcher
}
}
/** Разметка списка категории 2ГИС: фирмы [[id,name,site?],...]. */
function listingHtml(array $firms): string
{
$h = '';
foreach ($firms as $f) {
$id = $f[0];
$name = $f[1];
$site = $f[2] ?? null;
$h .= '<a href="/krasnoyarsk/firm/'.$id.'?stat=X" class="_1rehek"><span class="_lvwrwt"><span>'.$name.'</span></span></a>';
if ($site !== null) {
$h .= '{"caption":"Перейти на сайт","url":"https://link.2gis.ru/e/project7/'.$id.'/null/H?http://'.$site.'/"}';
}
}
return $h;
}
// listingHtml() — общий хелпер в tests/Pest.php.
it('мульти-запрос × пагинация: имя+карточка+сайт со всех страниц, дедуп между запросами', function () {
// id фирм — реалистично длинные (как у 2ГИС: 985690700540003)
@@ -7,6 +7,7 @@ use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser;
use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper;
use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer;
use App\Services\Autopodbor\Agent\ChannelA\YandexDirectory;
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder;
use App\Services\Autopodbor\Agent\ChannelB\ResearcherClient;
@@ -77,11 +78,25 @@ function noAiAssembler(): FindCompetitorsAssembler
);
}
function leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, array $queries): LiveFindCompetitors
function fakeYandex(array $rows): YandexDirectory
{
return new class($rows) implements YandexDirectory
{
public function __construct(private array $rows) {}
public function collect(string $city, array $queries): array
{
return $this->rows;
}
};
}
function leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, array $queries, array $yandexRows = []): LiveFindCompetitors
{
return new LiveFindCompetitors(
fakeAnalyzer($queries),
new CategoryScraper($pages, new CategoryListingParser, maxPages: 2),
fakeYandex($yandexRows),
new ChannelBSearch($researcher, new ResearcherParser),
new ExaSiteFinder($http),
noAiAssembler(),
@@ -118,6 +133,34 @@ it('сквозной: канал А (2ГИС-список: имя+карточк
->and($cm['site_url'])->toBe('carmoney.ru');
});
it('слияние 2ГИС+Яндекс: один конкурент в обоих справочниках → одна карточка с двумя ссылками', function () {
Config::set('services.exa.key', '');
$http = app(HttpFactory::class);
$pages = stubPages([
'2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']]),
]);
// Яндекс дал того же КрасЛомбарда (без сайта) + новую фирму, которой нет в 2ГИС
$yandex = [
['name' => 'КрасЛомбард', 'card_url' => 'https://yandex.ru/maps/org/kraslombard/175852236692'],
['name' => 'ЯрКомиссионка', 'card_url' => 'https://yandex.ru/maps/org/yarkomissionka/226908207223'],
];
$res = leanEngine($pages, fakeResearcher(['[]']), $http, ['ломбард'], $yandex)
->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20));
$names = array_column($res->competitors, 'name');
// КрасЛомбард — один раз (2ГИС+Яндекс слиты), плюс новая из Яндекса
expect(array_count_values($names)['КрасЛомбард'])->toBe(1)
->and($names)->toContain('ЯрКомиссионка');
// у слитого КрасЛомбарда — обе ссылки-справочники и сайт из 2ГИС
$kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард');
expect($kl['site_url'])->toBe('kraslombard24.ru')
->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, '2gis.ru')))->toBeTrue()
->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, 'yandex.ru')))->toBeTrue();
});
it('канал В: имя без сайта в EXA выкидывается (нет якоря)', function () {
Config::set('services.exa.key', 'k');
$http = app(HttpFactory::class);