Files
portal/app/app/Services/Autopodbor/Agent/Fetch/XfetchClient.php
T
Дмитрий 76c67e1fc5 feat автоподбор параллельная загрузка карточек справочника пачками по 4
Замер боевого лимита xf4.ru показал безопасный потолок 4 одновременных запроса — выше идут 429 Too Many Requests, плюс есть лимит за окно времени. Сбор до 25 карточек филиалов в XfetchDirectoryFetcher грузился по одной секунды на каждую — узкое место шага 2.

Новый интерфейс BatchPageFetcher + XfetchClient::htmlBatch грузит страницы порциями по services.xfetch.concurrency дефолт 4, параллелит внутри порции через Http::pool, ретраит пустые и 429. XfetchDirectoryFetcher берёт карточки одной пачкой если загрузчик batch-совместимый, иначе по одной. Два справочника шага 2 оставлены последовательно намеренно — иначе 2x4=8 за лимитом. Число выносится в XFETCH_CONCURRENCY без деплоя.

По TDD. Бэкенд автоподбора 258/258, Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:52:18 +03:00

126 lines
4.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Illuminate\Http\Client\Pool;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
/**
* Загрузка страниц через сервис xfetch.ru (обход антибота 2ГИС/Яндекс/Cloudflare).
* POST {url, api_key, render, timeout} → {response_body_base64}. Ключ — из конфига,
* НИКОГДА не в коде/гите. Без ключа клиент молча возвращает пусто (не падает).
*
* Пакетная загрузка {@see htmlBatch()} режет список на порции по $concurrency и параллелит
* внутри порции (эмпирический предел xf4.ru — ~4 одновременных; выше идут 429 Too Many Requests).
*/
final class XfetchClient implements BatchPageFetcher
{
public function __construct(
private ?string $apiKey,
private string $endpoint = 'https://xf4.ru/fetch',
private bool $render = true,
private int $renderTimeout = 20,
private int $httpTimeout = 120,
private int $retries = 3,
private int $concurrency = 4,
) {}
public function html(string $url): string
{
if ($this->apiKey === null || $this->apiKey === '') {
return '';
}
// Рендер 2ГИС/Яндекс флакует: иногда возвращается пустой/ошибочный ответ.
// Повторяем до $retries раз — берём первый непустой результат.
for ($attempt = 1; $attempt <= max(1, $this->retries); $attempt++) {
$html = $this->decode($this->request($url));
if ($html !== '') {
return $html;
}
}
return '';
}
/**
* Пачкой (параллельно, порциями по $concurrency). Карта url => html; пустая строка при неудаче.
*
* @param list<string> $urls
* @return array<string, string>
*/
public function htmlBatch(array $urls): array
{
$urls = array_values(array_unique($urls));
if ($this->apiKey === null || $this->apiKey === '' || $urls === []) {
return array_fill_keys($urls, '');
}
$result = array_fill_keys($urls, '');
foreach (array_chunk($urls, max(1, $this->concurrency)) as $chunk) {
$this->fetchChunkInto($chunk, $result);
}
return $result;
}
/**
* Догружает порцию параллельно, ретраит только оставшиеся пустыми (флак/429) до $retries раз.
*
* @param list<string> $chunk
* @param array<string, string> $result
*/
private function fetchChunkInto(array $chunk, array &$result): void
{
for ($attempt = 1; $attempt <= max(1, $this->retries); $attempt++) {
$pending = array_values(array_filter($chunk, fn (string $u): bool => $result[$u] === ''));
if ($pending === []) {
return;
}
$responses = Http::pool(fn (Pool $pool) => array_map(
fn (string $u) => $pool->as($u)->timeout($this->httpTimeout)->asJson()->post($this->endpoint, [
'url' => $u,
'api_key' => $this->apiKey,
'render' => $this->render,
'timeout' => $this->renderTimeout,
]),
$pending,
));
foreach ($pending as $u) {
$result[$u] = $this->decode($responses[$u] ?? null);
}
}
}
private function request(string $url): Response|\Throwable|null
{
return Http::timeout($this->httpTimeout)->asJson()->post($this->endpoint, [
'url' => $url,
'api_key' => $this->apiKey,
'render' => $this->render,
'timeout' => $this->renderTimeout,
]);
}
private function decode(Response|\Throwable|null $resp): string
{
if (! $resp instanceof Response || ! $resp->successful()) {
return '';
}
$b64 = $resp->json('response_body_base64');
if (! is_string($b64) || $b64 === '') {
return '';
}
$html = base64_decode($b64, true);
return is_string($html) ? $html : '';
}
}