diff --git a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php index 3b133950..316e549a 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php @@ -27,13 +27,14 @@ class RunAutopodborSearchJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public int $tries = 3; + // ОДИН прогон без авто-ретраев: движок платный (xfetch+ИИ+EXA), и повтор всего прогона при + // таймауте = тройная цена и время. Медленный-но-настоящий ответ теперь ждём большими таймаутами + // стадий (config services.aitunnel/exa), а не гоняем движок трижды. + public int $tries = 1; - public array $backoff = [15, 60, 300]; - - // Живой поиск (2ГИС+Яндекс через антибот, обход карточек) идёт минутами — даём запас, - // иначе фоновое задание убьётся дефолтным 60-сек таймаутом на середине прогона. - public int $timeout = 900; + // Живой поиск (2ГИС+Яндекс через антибот + канал В sonar ×2 + EXA) идёт минутами и латентность + // модели плавает — общий таймаут задания с БОЛЬШИМ запасом, чтобы не убить прогон на середине. + public int $timeout = 1800; public function __construct(public int $runId) { diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index eb01ce9a..c2238506 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -81,7 +81,7 @@ class AutopodborServiceProvider extends ServiceProvider return new LiveFindCompetitors( new AitunnelQueryAnalyzer($http), new CategoryScraper($pages, new CategoryListingParser), - new PlaywrightYandexDirectory, + new PlaywrightYandexDirectory((string) config('autopodbor.node_bin', 'node')), new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser), new ExaSiteFinder($http), $assembler, diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php index 03de280f..f6c14da1 100644 --- a/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php +++ b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\Autopodbor\Agent\ChannelA; +use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher; use App\Services\Autopodbor\Agent\Fetch\PageFetcher; /** @@ -13,7 +14,10 @@ use App\Services\Autopodbor\Agent\Fetch\PageFetcher; * это резко быстрее. Дедуп между страницами и запросами по ссылке карточки. Остановка: пустая * страница / страница без новых фирм / лимит maxPages. * - * Чистый над {@see PageFetcher} (живой — xfetch/firecrawl; в тестах — стаб). + * Загрузка страниц — ПАРАЛЛЕЛЬНАЯ ПОПЕРЁК РУБРИК (страница P всех активных рубрик за один + * {@see BatchPageFetcher::htmlBatch()} вызов). Параллельность живёт внутри htmlBatch (режет на + * порции по services.xfetch.concurrency). Если передан простой PageFetcher (тестовый стаб без + * batch) — деградирует на одиночные html() вызовы, не ломая существующие тесты. */ final class CategoryScraper { @@ -32,19 +36,53 @@ final class CategoryScraper $seen = []; $out = []; + // Нормализуем и фильтруем пустые рубрики + $activeQueries = []; foreach ($queries as $query) { $query = trim((string) $query); - if ($query === '') { - continue; + if ($query !== '') { + $activeQueries[] = $query; + } + } + + if ($activeQueries === []) { + return []; + } + + // Базовые URL для каждой рубрики (страница 1) + $baseUrls = []; + foreach ($activeQueries as $query) { + $baseUrls[$query] = "https://2gis.ru/{$slug}/search/".rawurlencode($query); + } + + // active — список рубрик, которые ещё «живы» (дали новые фирмы на предыдущей странице) + $active = $activeQueries; + + for ($page = 1; $page <= max(1, $this->maxPages); $page++) { + if ($active === []) { + break; } - $base = "https://2gis.ru/{$slug}/search/".rawurlencode($query); + // Строим URL текущей страницы для каждой активной рубрики + $urlByQuery = []; + foreach ($active as $query) { + $base = $baseUrls[$query]; + $urlByQuery[$query] = $page === 1 ? $base : $base."/page/{$page}"; + } + + // Загружаем пачку: BatchPageFetcher — параллельно; простой PageFetcher — по одному + $htmlByUrl = $this->fetchBatch(array_values($urlByQuery)); + + // Разбираем каждую рубрику; те, что дали 0 новых — выбывают + $stillActive = []; + foreach ($active as $query) { + $url = $urlByQuery[$query]; + $html = $htmlByUrl[$url] ?? ''; + $rows = $this->parser->parse($html); - for ($page = 1; $page <= max(1, $this->maxPages); $page++) { - $url = $page === 1 ? $base : $base."/page/{$page}"; - $rows = $this->parser->parse($this->pages->html($url)); if ($rows === []) { - break; // выдача кончилась + // Пустая страница — рубрика выбывает + continue; } $added = 0; @@ -58,12 +96,37 @@ final class CategoryScraper $added++; } - if ($added === 0) { - break; // страница без новых фирм — дальше листать незачем + if ($added > 0) { + $stillActive[] = $query; // есть новые — рубрика живёт } + // $added === 0: страница без новых фирм → рубрика тоже выбывает } + + $active = $stillActive; } return $out; } + + /** + * Загружает HTML нескольких URL. Если fetcher реализует BatchPageFetcher — делегирует + * в htmlBatch() (параллельно, порциями по лимиту сервиса). Иначе — цикл html() по одному + * (сохраняет совместимость с простыми PageFetcher-стабами в тестах). + * + * @param list $urls + * @return array url => html + */ + private function fetchBatch(array $urls): array + { + if ($this->pages instanceof BatchPageFetcher) { + return $this->pages->htmlBatch($urls); + } + + $result = []; + foreach ($urls as $url) { + $result[$url] = $this->pages->html($url); + } + + return $result; + } } diff --git a/app/config/autopodbor.php b/app/config/autopodbor.php index e5443d32..60ff0863 100644 --- a/app/config/autopodbor.php +++ b/app/config/autopodbor.php @@ -12,4 +12,16 @@ return [ | */ 'real_find' => env('AUTOPODBOR_REAL_FIND', false), + + /* + |-------------------------------------------------------------------------- + | Путь к бинарнику node для Playwright-скриптов + |-------------------------------------------------------------------------- + | + | Из-под PHP-процесса (очередь, artisan) PATH может отличаться от интерактивного + | шелла. Если 'node' по PATH не находится — пропишите абсолютный путь в .env: + | AUTOPODBOR_NODE_BIN="C:\Program Files\nodejs\node.exe" + | + */ + 'node_bin' => env('AUTOPODBOR_NODE_BIN', 'node'), ]; diff --git a/app/config/services.php b/app/config/services.php index 3e6410c9..bc06cf35 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -89,15 +89,19 @@ return [ 'base_url' => env('AITUNNEL_BASE_URL', 'https://api.aitunnel.ru/v1'), 'embed_model' => env('AITUNNEL_EMBED_MODEL', 'text-embedding-3-small'), 'chat_model' => env('AITUNNEL_CHAT_MODEL', 'gpt-4o-mini'), - 'timeout_sec' => (int) env('AITUNNEL_TIMEOUT_SEC', 30), + // ИИ-вызовы (анализ рубрик, эмбеддинги) — с ЗАПАСОМ: латентность модели плавает + // (один и тот же запрос то 8с, то 30-40с), а сбор асинхронный и клиент ждёт — режем + // медленный-но-настоящий ответ таймаутом ХУЖЕ, чем ждём. Потому большие таймауты. + 'timeout_sec' => (int) env('AITUNNEL_TIMEOUT_SEC', 120), // Батч отсева агрегаторов: список фирм режем на порции по aggregator_batch_size (иначе // один запрос на ~100 фирм долгий и рискует не успеть) + отдельный долгий таймаут на порцию. 'aggregator_batch_size' => (int) env('AITUNNEL_AGGREGATOR_BATCH_SIZE', 40), - 'aggregator_timeout_sec' => (int) env('AITUNNEL_AGGREGATOR_TIMEOUT_SEC', 90), - // Канал В — генератор имён (ZAFIKSIROVANO §0-БИС): одна рассуждающая модель, ~68 с/вызов, - // потому отдельный долгий таймаут. + 'aggregator_timeout_sec' => (int) env('AITUNNEL_AGGREGATOR_TIMEOUT_SEC', 240), + // Канал В — генератор имён (ZAFIKSIROVANO §0-БИС): одна рассуждающая модель sonar-reasoning-pro, + // в реальном прогоне ~30-70 с/вызов и плавает вверх; 2 прохода. Большой таймаут с запасом — + // лучше дождаться, чем прислать пусто (сбор асинхронный, клиент всё равно ждёт результат). 'research_model' => env('AITUNNEL_RESEARCH_MODEL', 'sonar-reasoning-pro'), - 'research_timeout_sec' => (int) env('AITUNNEL_RESEARCH_TIMEOUT_SEC', 120), + 'research_timeout_sec' => (int) env('AITUNNEL_RESEARCH_TIMEOUT_SEC', 300), ], // EXA — поиск САЙТА по имени для канала В (федералы): у федерала нет карточки в 2ГИС/Яндексе @@ -105,7 +109,7 @@ return [ 'exa' => [ 'key' => env('EXA_API_KEY'), 'base_url' => env('EXA_BASE_URL', 'https://api.exa.ai'), - 'timeout_sec' => (int) env('EXA_TIMEOUT_SEC', 30), + 'timeout_sec' => (int) env('EXA_TIMEOUT_SEC', 90), // Сколько запросов сайта-федерала слать ОДНОВРЕМЕННО (пул EXA). Лимит EXA — на КЛЮЧ, // общий на всех клиентов, поэтому бережно; глобальный потолок между клиентами — очередь. 'concurrency' => (int) env('EXA_CONCURRENCY', 5), @@ -151,9 +155,9 @@ return [ 'xfetch' => [ 'key' => env('XFETCH_API_KEY'), 'endpoint' => env('XFETCH_ENDPOINT', 'https://xf4.ru/fetch'), - // Параллельность пакетной загрузки карточек. Эмпирический предел xf4.ru — ~4 одновременных + // Параллельность пакетной загрузки карточек. Эмпирический предел xf4.ru — ~3 одновременных // (выше идут 429 Too Many Requests; замер 01.07.2026). Меняется без деплоя через .env. - 'concurrency' => (int) env('XFETCH_CONCURRENCY', 4), + 'concurrency' => (int) env('XFETCH_CONCURRENCY', 3), ], ]; diff --git a/app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php b/app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php index 25bfa4ea..6aaedbde 100644 --- a/app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php +++ b/app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php @@ -81,6 +81,43 @@ it('пустой результат: status=empty, без списания', fun ->and((string) $tenant->fresh()->balance_rub)->toBe('100000.00'); }); +it('если почта падает с исключением — прогон всё равно завершается статусом done и конкуренты сохранены', function () { + // Имитируем недоступность SMTP: Mail::to() бросает исключение + Mail::shouldReceive('to')->andThrow(new RuntimeException('smtp down')); + + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00', 'contact_email' => 'client@demo.local']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_max_competitors'], ['value' => '15', 'type' => 'int']); + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'queued', + 'region_code' => 16, 'params' => ['examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true], + ]); + + // handle() НЕ должен бросать — ошибка почты поглощается внутри notifyReady() + expect(fn () => runSearchJob($run->id))->not->toThrow(Throwable::class); + + expect($run->fresh()->status)->toBe('done') + ->and(AutopodborCompetitor::where('search_run_id', $run->id)->count())->toBeGreaterThan(0); +}); + +it('если почта падает при пустом результате — прогон всё равно завершается статусом empty', function () { + Mail::shouldReceive('to')->andThrow(new RuntimeException('smtp down')); + + app()->bind(CompetitorAgent::class, EmptyCompetitorAgent::class); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00', 'contact_email' => 'client@demo.local']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'queued', + 'region_code' => 16, 'params' => ['examples' => [], 'about_self' => [], 'include_federal' => false], + ]); + + expect(fn () => runSearchJob($run->id))->not->toThrow(Throwable::class); + + expect($run->fresh()->status)->toBe('empty'); +}); + it('повторный подбор не дублирует известных конкурентов и не списывает (сквозной дедуп)', function () { $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); DB::statement('SET app.current_tenant_id = '.$tenant->id); diff --git a/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php index faf4e0a6..9a40d935 100644 --- a/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php +++ b/app/tests/Unit/Autopodbor/ChannelA/CategoryScraperTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser; use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper; +use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher; use App\Services\Autopodbor\Agent\Fetch\PageFetcher; // Канал А: по запросу-рубрике скрейпим категорию 2ГИС со СКВОЗНОЙ ПАГИНАЦИЕЙ, берём из списка @@ -27,6 +28,36 @@ final class MapFetcher implements PageFetcher } } +/** + * Batch-стаб: реализует BatchPageFetcher, запоминает какими пачками вызывали htmlBatch. + * Для html() делегирует в ту же карту (совместимость с одиночными вызовами если вдруг). + */ +final class BatchMapFetcher implements BatchPageFetcher +{ + /** @var list> каждый элемент — массив URL одного вызова htmlBatch */ + public array $batchCalls = []; + + /** @param array $map URL (точный) => HTML */ + public function __construct(private readonly array $map) {} + + public function html(string $url): string + { + return $this->map[$url] ?? ''; + } + + /** @param list $urls */ + public function htmlBatch(array $urls): array + { + $this->batchCalls[] = $urls; + $result = []; + foreach ($urls as $url) { + $result[$url] = $this->map[$url] ?? ''; + } + + return $result; + } +} + // listingHtml() — общий хелпер в tests/Pest.php. it('мульти-запрос × пагинация: имя+карточка+сайт со всех страниц, дедуп между запросами', function () { @@ -65,3 +96,63 @@ it('останавливает пагинацию на повторах/maxPages expect($rows)->toHaveCount(1); }); + +// ─── Batch-загрузка (параллельная, по страницам поперёк рубрик) ─────────────── + +it('batch: страницы 1 обеих рубрик загружаются ОДНИМ вызовом htmlBatch', function () { + $slug = 'krasnoyarsk'; + $q1 = 'автоломбард'; + $q2 = 'ломбард'; + + $url1p1 = "https://2gis.ru/{$slug}/search/".rawurlencode($q1); + $url2p1 = "https://2gis.ru/{$slug}/search/".rawurlencode($q2); + + $fetcher = new BatchMapFetcher([ + $url1p1 => listingHtml([[70000000000011, 'БатчФирма1', 'f1.ru']]), + $url2p1 => listingHtml([[70000000000012, 'БатчФирма2', null]]), + ]); + + $rows = (new CategoryScraper($fetcher, new CategoryListingParser, maxPages: 3)) + ->collectTwoGis($slug, [$q1, $q2]); + + // (а) Все фирмы обеих рубрик собраны с дедупом + expect($rows)->toHaveCount(2); + $names = array_column($rows, 'name'); + expect($names)->toContain('БатчФирма1', 'БатчФирма2'); + + // (б) Страницы 1 обеих рубрик забраны ОДНИМ вызовом htmlBatch (не двумя отдельными) + expect($fetcher->batchCalls)->not->toBeEmpty(); + $firstBatch = $fetcher->batchCalls[0]; + expect($firstBatch)->toContain($url1p1) + ->and($firstBatch)->toContain($url2p1); +}); + +it('batch: ранняя остановка — рубрика без новых фирм не листается на страницу 2', function () { + $slug = 'krasnoyarsk'; + $q1 = 'залог'; + $q2 = 'микрозайм'; + + $url1p1 = "https://2gis.ru/{$slug}/search/".rawurlencode($q1); + $url2p1 = "https://2gis.ru/{$slug}/search/".rawurlencode($q2); + $url1p2 = "https://2gis.ru/{$slug}/search/".rawurlencode($q1).'/page/2'; + $url2p2 = "https://2gis.ru/{$slug}/search/".rawurlencode($q2).'/page/2'; + + // q1 стр.1 → 1 фирма; q2 стр.1 → пусто. Страница 2 ни для кого не должна запрашиваться. + $fetcher = new BatchMapFetcher([ + $url1p1 => listingHtml([[70000000000021, 'ЗалогФирма', null]]), + $url2p1 => '', // пустая → рубрика q2 выбывает + $url1p2 => '', // q1 стр.2 тоже пустая → q1 выбывает (не будет нов. фирм) + $url2p2 => listingHtml([[70000000000022, 'НеДолжнаПопасть', null]]), + ]); + + $rows = (new CategoryScraper($fetcher, new CategoryListingParser, maxPages: 5)) + ->collectTwoGis($slug, [$q1, $q2]); + + // Только 1 фирма из q1 стр.1; q2 выбыл на стр.1, q1 выбыл после стр.1 (0 новых на стр.2) + expect($rows)->toHaveCount(1) + ->and($rows[0]['name'])->toBe('ЗалогФирма'); + + // url2p2 (q2 стр.2) никогда не должен попасть в пачку — рубрика выбыла + $allBatchedUrls = array_merge(...($fetcher->batchCalls ?: [[]])); + expect($allBatchedUrls)->not->toContain($url2p2); +});