diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index 05e380ac..3927634e 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -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, diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php new file mode 100644 index 00000000..613bc67c --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php @@ -0,0 +1,78 @@ + <город>`, скроллит ленту и берёт + * организации: имя + ссылка на карточку. Скрипт — `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> 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 []; + } + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php b/app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php new file mode 100644 index 00000000..d38f9262 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php @@ -0,0 +1,19 @@ + $queries запросы-рубрики из шага АНАЛИЗ + * @return list + */ + public function collect(string $city, array $queries): array; +} diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index 1ff05d1c..ef7344c1 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -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], ]; } diff --git a/app/scripts/render-yandex-list.cjs b/app/scripts/render-yandex-list.cjs new file mode 100644 index 00000000..1ad64500 --- /dev/null +++ b/app/scripts/render-yandex-list.cjs @@ -0,0 +1,59 @@ +// Usage: node render-yandex-list.cjs +// Рендер страницы категории Яндекс.Карт локальным 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); +}); diff --git a/app/tests/Pest.php b/app/tests/Pest.php index 62859391..0b2ff9d5 100644 --- a/app/tests/Pest.php +++ b/app/tests/Pest.php @@ -100,6 +100,28 @@ function stubPages(array $byNeedle): PageFetcher }; } +/** + * Разметка списка категории 2ГИС для тестов канала А: фирмы [[id,name,site?],...]. + * Общий хелпер (используют CategoryScraperTest и LiveFindCompetitorsTest). + * + * @param array $firms + */ +function listingHtml(array $firms): string +{ + $h = ''; + foreach ($firms as $f) { + $id = $f[0]; + $name = $f[1]; + $site = $f[2] ?? null; + $h .= ''.$name.''; + 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 diff --git a/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php index a7ea8cbc..faf4e0a6 100644 --- a/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php +++ b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php @@ -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 .= ''.$name.''; - 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) diff --git a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php index 41c95602..86678d38 100644 --- a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php +++ b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php @@ -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);