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