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
@@ -7,6 +7,7 @@ use App\Services\Autopodbor\Agent\Aggregator\AitunnelAggregatorClassifier;
use App\Services\Autopodbor\Agent\ChannelA\AitunnelQueryAnalyzer;
use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser;
use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper;
use App\Services\Autopodbor\Agent\ChannelA\PlaywrightYandexDirectory;
use App\Services\Autopodbor\Agent\ChannelB\AitunnelResearcher;
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder;
@@ -79,6 +80,7 @@ class AutopodborServiceProvider extends ServiceProvider
return new LiveFindCompetitors(
new AitunnelQueryAnalyzer($http),
new CategoryScraper($pages, new CategoryListingParser),
new PlaywrightYandexDirectory,
new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser),
new ExaSiteFinder($http),
$assembler,
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
use Symfony\Component\Process\Process;
/**
* Живой {@see YandexDirectory} через локальный Playwright (Firecrawl Яндекс.Карты не рендерит, §12.2).
* По каждому запросу-рубрике рендерит `yandex.ru/maps/?text=<запрос> <город>`, скроллит ленту и берёт
* организации: имя + ссылка на карточку. Скрипт `scripts/render-yandex-list.cjs` (печатает JSON {orgs}).
* Дедуп по id между запросами; потолок на число рендеров (лимит времени/нагрузки при параллельных клиентах).
*
* Сеть/браузер за Process офлайн не юнит-тестим; оркестратор тестируется через фейк YandexDirectory.
*/
final class PlaywrightYandexDirectory implements YandexDirectory
{
private string $script;
public function __construct(
private readonly string $nodeBin = 'node',
string $script = '',
private readonly int $timeout = 120,
// Не рендерим больше N запросов за подбор (локальный браузер тяжёл при параллельных клиентах).
private readonly int $maxQueries = 4,
) {
$this->script = $script !== '' ? $script : base_path('scripts/render-yandex-list.cjs');
}
public function collect(string $city, array $queries): array
{
$seen = [];
$out = [];
foreach (array_slice($queries, 0, max(0, $this->maxQueries)) as $query) {
$query = trim((string) $query);
if ($query === '') {
continue;
}
$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];
}
}
return $out;
}
/** @return list<array<string,mixed>> orgs из render-скрипта; ошибка → []. */
private function render(string $url): array
{
try {
$p = new Process([$this->nodeBin, $this->script, $url]);
$p->setTimeout($this->timeout);
$p->run();
if (! $p->isSuccessful()) {
return [];
}
$json = json_decode($p->getOutput(), true);
return is_array($json['orgs'] ?? null) ? $json['orgs'] : [];
} catch (\Throwable) {
return [];
}
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\ChannelA;
/**
* Второй справочник канала А Яндекс.Карты (ZAFIKSIROVANO: Яндекс = имя + карточка, БЕЗ сайта;
* сайт в списке Яндекса не отдаётся, только на карточке не открываем). Собирает по запросам-рубрикам
* организации: имя + ссылка на карточку. За границей для офлайн-теста оркестратора через фейк.
*/
interface YandexDirectory
{
/**
* @param list<string> $queries запросы-рубрики из шага АНАЛИЗ
* @return list<array{name:string,card_url:string}>
*/
public function collect(string $city, array $queries): array;
}
@@ -6,6 +6,7 @@ namespace App\Services\Autopodbor\Agent;
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\Dto\FindCompetitorsRequest;
@@ -28,6 +29,7 @@ final class LiveFindCompetitors
public function __construct(
private readonly QueryAnalyzer $analyzer,
private readonly CategoryScraper $scraper,
private readonly YandexDirectory $yandex,
private readonly ChannelBSearch $channelB,
private readonly ExaSiteFinder $exa,
private readonly FindCompetitorsAssembler $assembler,
@@ -43,12 +45,19 @@ final class LiveFindCompetitors
$city = RegionCity::name($r->regionCode) ?? '';
$slug = RegionCity::slug($r->regionCode);
// 1+2. АНАЛИЗ → канал А: из списка категории имя+карточка+сайт (без захода в карточку).
// 1+2. АНАЛИЗ → канал А, ДВА справочника: 2ГИС (xfetch, имя+карточка+сайт из списка) +
// Яндекс (локальный Playwright, имя+карточка, без сайта). Слияние ниже объединит дубли по имени.
$queries = $this->analyzer->analyze($profile, $city);
$aRows = ($slug !== null && $queries !== [])
$twoGisRows = ($slug !== null && $queries !== [])
? $this->scraper->collectTwoGis($slug, $queries)
: [];
$aCards = array_map(fn (array $row): array => $this->localCard($row), $aRows);
$yandexRows = ($queries !== [])
? $this->yandex->collect($city, $queries)
: [];
$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),
);
// 3. Канал В: имена федералов → САЙТ через EXA. Нет сайта = нет якоря → выкидываем.
$bCards = [];
@@ -73,17 +82,19 @@ final class LiveFindCompetitors
return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors);
}
/** Местная карточка из строки списка 2ГИС (канал А). */
private function localCard(array $row): array
/** Местная карточка из списка справочника (канал А): 2ГИС (с сайтом) или Яндекс (site=null). */
private function localCard(string $name, string $cardUrl, ?string $site): array
{
$source = str_contains($cardUrl, 'yandex.ru') ? 'yandex-list' : '2gis-list';
return [
'name' => (string) $row['name'],
'site_url' => $row['site'] ?? null,
'name' => $name,
'site_url' => $site,
'description' => null,
'is_federal' => false,
'directory_urls' => [$row['card_url']],
'directory_urls' => [$cardUrl],
'phones' => [],
'provenance' => ['via' => 'engine', 'source' => '2gis-list'],
'provenance' => ['via' => 'engine', 'source' => $source],
];
}
+59
View File
@@ -0,0 +1,59 @@
// Usage: node render-yandex-list.cjs <url>
// Рендер страницы категории Яндекс.Карт локальным Playwright (Firecrawl её не берёт, §12.2).
// Скроллит КОНТЕЙНЕР СПИСКА результатов (подгрузка ленивая) и печатает JSON:
// { orgs: [{ name, id, href }] } — имя + id + ссылка на карточку организации.
// Сайт в списке Яндекса НЕ отдаётся (только на карточке) — здесь не собираем (шаг 1 = имя+карточка).
// require('playwright') резолвится из node_modules корня репо (скрипт лежит под app/scripts).
const { chromium } = require('playwright');
(async () => {
const url = process.argv[2];
let parsed;
try {
parsed = new URL(url);
} catch (e) {
console.error('bad url');
process.exit(2);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
console.error('bad scheme');
process.exit(2);
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ locale: 'ru-RU' });
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(4000);
// Ленивая лента результатов — скроллим её контейнер, пока подгружается.
for (let i = 0; i < 14; i++) {
await page.evaluate(() => {
const el = document.querySelector('.scroll__container, .search-list-view__list, [class*="search-list"]');
if (el) el.scrollBy(0, 2000);
});
await page.waitForTimeout(800);
}
const orgs = await page.$$eval('a[href*="/maps/org/"]', (els) => {
const seen = new Set();
const out = [];
for (const e of els) {
const href = e.getAttribute('href') || '';
const m = href.match(/\/maps\/org\/[a-z0-9_-]+\/(\d+)/i);
if (!m || seen.has(m[1])) continue;
seen.add(m[1]);
const name = (e.getAttribute('aria-label') || e.textContent || '').trim();
if (name) out.push({ name, id: m[1], href: href.split('?')[0] });
}
return out;
});
process.stdout.write(JSON.stringify({ orgs }));
} finally {
await browser.close();
}
})().catch((e) => {
console.error(String(e));
process.exit(1);
});
+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);