76c67e1fc5
Замер боевого лимита 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>
126 lines
4.4 KiB
PHP
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 : '';
|
|
}
|
|
}
|