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:
@@ -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],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user