perf/fix автоподбор: параллельный сбор 2ГИС по 3 + большие таймауты + безопасное письмо + node из config

Ускорение шага 1: CategoryScraper грузит страницы категории 2ГИС не по одной, а пачками поперёк рубрик
через BatchPageFetcher. На остывшем xfetch 2ГИС упал с ~23 мин до ~5 мин и собрал 139 фирм вместо 12.
Общий лимит одновременных xfetch снижен 4→3 (безопаснее под оконный rate-limit сервиса).

Таймауты ИИ/EXA подняты с запасом (латентность моделей плавает, сбор асинхронный, клиент ждёт):
sonar 120→300с, отсев агрегаторов 90→240с, общий AITUNNEL 30→120с, EXA 30→90с. Джоба: один прогон
без авто-ретраев (tries 3→1, чтобы таймаут не перезапускал платный движок трижды) и общий таймаут 900→1800с.

Фиксы, найденные на живом прогоне:
- Письмо «готово» больше не роняет прогон при недоступной почте (тесты на сбой SMTP при status done/empty).
- Яндекс-загрузчик берёт путь к node из config (autopodbor.node_bin, дефолт node) — из-под PHP node
  теперь находится; на живом прогоне Яндекс дал 78 фирм вместо 0.

По TDD. Бэкенд автоподбора 262/262, Pint чисто. Живой прогон end-to-end: ~8 мин, 14 новых конкурентов,
списание 300 ₽ корректно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-02 05:43:34 +03:00
parent 76c67e1fc5
commit b3f5158e48
7 changed files with 233 additions and 25 deletions
@@ -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)
{
@@ -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,
@@ -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<string> $urls
* @return array<string, string> 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;
}
}
+12
View File
@@ -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'),
];
+12 -8
View File
@@ -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),
],
];
@@ -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);
@@ -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<list<string>> каждый элемент — массив URL одного вызова htmlBatch */
public array $batchCalls = [];
/** @param array<string,string> $map URL (точный) => HTML */
public function __construct(private readonly array $map) {}
public function html(string $url): string
{
return $this->map[$url] ?? '';
}
/** @param list<string> $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);
});