feat(автоподбор шаг2): справочники 2ГИС через xfetch.ru

Антибот 2ГИС бьём сервисом xfetch.ru (render:true, timeout:20 —
без timeout страница не дорисовывается). Доказано на живом КрасЛомбаре:
поиск → 12 филиалов → телефон + адрес каждой карточки.

- PageFetcher — граница «достать HTML» (тестируется без сети)
- XfetchClient — POST к xfetch, декод base64; без ключа молча пусто
- XfetchDirectoryFetcher — список→филиалы→карточки через DirectoryParser
- DirectoryParser — чтение списка и карточки 2ГИС (был в хвостах)
- config services.xfetch + .env.example; ключ только в .env (gitignored)

Яндекс.Карты — отдельно (другой формат URL карточек). TDD: Autopodbor 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-30 12:38:18 +03:00
parent be92afebd3
commit 1b76cfec15
10 changed files with 336 additions and 5 deletions
+3
View File
@@ -96,3 +96,6 @@ VITE_APP_NAME="${APP_NAME}"
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
# Автоподбор шаг2: обход антибота справочников (2ГИС/Яндекс). Ключ — в .env, не в гите.
XFETCH_API_KEY=
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
/**
* Разбор страниц справочников (2ГИС/Яндекс): список филиалов и карточка филиала.
* Чистый: на вход уже отрендеренный HTML, на выход ссылки/карточки.
*/
final class DirectoryParser
{
/**
* Ссылки на карточки филиалов со страницы списка (2ГИС: /city/firm/<id>).
*
* @return list<string>
*/
public function parseBranchList(string $html): array
{
$out = [];
if (preg_match_all('#href="(/[a-z0-9_-]+/firm/\d+)"#i', $html, $m)) {
foreach ($m[1] as $href) {
if (! in_array($href, $out, true)) {
$out[] = $href;
}
}
}
return $out;
}
/**
* Карточка филиала: телефоны из tel:-ссылок + адрес из заголовка страницы.
*
* @return list<DirectoryCard>
*/
public function parseFirmCard(string $html, string $url, string $source): array
{
$office = $this->officeFromTitle($html);
$out = [];
if (preg_match_all('/tel:([+0-9()\s-]{7,})/i', $html, $m)) {
$seen = [];
foreach ($m[1] as $raw) {
$num = trim($raw);
if (isset($seen[$num])) {
continue;
}
$seen[$num] = true;
$out[] = new DirectoryCard(number: $num, office: $office, url: $url, source: $source);
}
}
return $out;
}
/**
* Адрес офиса из <title> вида «Имя, адрес…, Город 2ГИС».
* Берём всё между первой и последней запятой (имя фирмы и город/суффикс отбрасываем).
*/
private function officeFromTitle(string $html): ?string
{
if (! preg_match('#<title>(.*?)</title>#is', $html, $m)) {
return null;
}
$title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
// нормализуем пробелы (в т.ч. неразрывные U+00A0) → одиночные
$title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title;
$title = trim($title);
// отрезаем хвост « — 2ГИС» / « — Яндекс Карты»
$title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title;
$parts = array_map('trim', explode(',', $title));
if (count($parts) < 3) {
return null; // нет адреса между именем и городом
}
// имя фирмы — первый кусок, город — последний; адрес — середина
$address = array_slice($parts, 1, count($parts) - 2);
return implode(', ', $address) ?: null;
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
/**
* Тонкая граница «достать HTML страницы» за ней может стоять xfetch, локальный
* Playwright и т.п. Позволяет тестировать обход справочников без сети.
*/
interface PageFetcher
{
/** Вернуть HTML страницы (пустую строку при неудаче — не кидаем исключений). */
public function html(string $url): string;
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Illuminate\Support\Facades\Http;
/**
* Загрузка страниц через сервис xfetch.ru (обход антибота 2ГИС/Яндекс/Cloudflare).
* POST {url, api_key, render, timeout} {response_body_base64}. Ключ из конфига,
* НИКОГДА не в коде/гите. Без ключа клиент молча возвращает пусто (не падает).
*/
final class XfetchClient implements PageFetcher
{
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,
) {}
public function html(string $url): string
{
if ($this->apiKey === null || $this->apiKey === '') {
return '';
}
$resp = Http::timeout($this->httpTimeout)->asJson()->post($this->endpoint, [
'url' => $url,
'api_key' => $this->apiKey,
'render' => $this->render,
'timeout' => $this->renderTimeout,
]);
if (! $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 : '';
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
/**
* Загрузчик справочников (2ГИС/Яндекс.Карты) через {@see PageFetcher} (обычно xfetch).
* Список филиалов ссылки на карточки телефон+адрес каждой карточки.
* Парсинг в {@see DirectoryParser}; добыча HTML за антибот-границей PageFetcher.
*/
final class XfetchDirectoryFetcher implements Fetcher
{
public function __construct(
private PageFetcher $pages,
private DirectoryParser $parser = new DirectoryParser,
private int $maxBranches = 25,
) {}
public function site(string $url): FetchedSite
{
return new FetchedSite(url: $url, rawHtml: $this->pages->html($url));
}
public function directory(string $url): array
{
$listHtml = $this->pages->html($url);
$links = $this->parser->parseBranchList($listHtml);
if ($links === []) {
return [];
}
$origin = parse_url($url);
$base = ($origin['scheme'] ?? 'https').'://'.($origin['host'] ?? '');
$source = stripos((string) ($origin['host'] ?? ''), 'yandex') !== false ? 'Яндекс.Карты' : '2ГИС';
$cards = [];
foreach (array_slice($links, 0, $this->maxBranches) as $href) {
$firmUrl = str_starts_with($href, 'http') ? $href : $base.$href;
$firmHtml = $this->pages->html($firmUrl);
if ($firmHtml === '') {
continue;
}
foreach ($this->parser->parseFirmCard($firmHtml, $firmUrl, $source) as $card) {
$cards[] = $card;
}
}
return $cards;
}
}
+8
View File
@@ -114,4 +114,12 @@ return [
))),
],
// Автоподбор шаг 2: сервис обхода антибота справочников (2ГИС/Яндекс) — xfetch.ru.
// Ключ — ТОЛЬКО в .env (gitignored), в код/гит не попадает. Без ключа загрузчик
// молча возвращает пусто (поток не падает).
'xfetch' => [
'key' => env('XFETCH_API_KEY'),
'endpoint' => env('XFETCH_ENDPOINT', 'https://xf4.ru/fetch'),
],
];
@@ -0,0 +1,29 @@
<?php
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
it('берёт ссылки на карточки филиалов из списка 2ГИС', function () {
$html = '<a href="/krasnoyarsk/firm/111">Ф1</a><a href="/krasnoyarsk/firm/222">Ф2</a>'
.'<a href="/krasnoyarsk/firm/111">дубль</a><a href="/about">не филиал</a>';
$links = (new DirectoryParser)->parseBranchList($html);
expect($links)->toBe(['/krasnoyarsk/firm/111', '/krasnoyarsk/firm/222']);
});
it('из карточки 2ГИС берёт телефон из tel: и адрес из заголовка', function () {
$html = '<html><head><title>КрасЛомбард, улица Ладо Кецховели, 30, Красноярск — 2ГИС</title></head>'
.'<body><h1>КрасЛомбард</h1><a href="tel:+73912920000">+7 (391) 2...</a></body></html>';
$cards = (new DirectoryParser)->parseFirmCard($html, 'https://2gis.ru/krasnoyarsk/firm/111', '2ГИС');
expect($cards)->toHaveCount(1);
expect($cards[0]->number)->toBe('+73912920000');
expect($cards[0]->office)->toBe('улица Ладо Кецховели, 30');
expect($cards[0]->url)->toBe('https://2gis.ru/krasnoyarsk/firm/111');
expect($cards[0]->source)->toBe('2ГИС');
});
it('схлопывает неразрывные/двойные пробелы в адресе из заголовка', function () {
$html = "<title>КрасЛомбард,\u{00A0}улица Весны,\u{00A0}7а, Красноярск — 2ГИС</title>"
.'<a href="tel:+73912550000">x</a>';
$cards = (new DirectoryParser)->parseFirmCard($html, 'u', '2ГИС');
expect($cards[0]->office)->toBe('улица Весны, 7а');
});
@@ -0,0 +1,31 @@
<?php
use App\Services\Autopodbor\Agent\Fetch\XfetchClient;
use Illuminate\Support\Facades\Http;
it('декодирует HTML из base64-ответа xfetch', function () {
Http::fake([
'xf4.ru/*' => Http::response([
'status_code' => 200,
'response_body_base64' => base64_encode('<html>привет</html>'),
]),
]);
$client = new XfetchClient('test-key');
expect($client->html('https://2gis.ru/x'))->toBe('<html>привет</html>');
});
it('без ключа не ходит в сеть и возвращает пусто', function () {
Http::fake();
$client = new XfetchClient(null);
expect($client->html('https://2gis.ru/x'))->toBe('');
Http::assertNothingSent();
});
it('шлёт url, api_key и render:true на endpoint xfetch', function () {
Http::fake(['xf4.ru/*' => Http::response(['response_body_base64' => base64_encode('ok')])]);
(new XfetchClient('k123'))->html('https://2gis.ru/y');
Http::assertSent(fn ($r) => $r->url() === 'https://xf4.ru/fetch'
&& $r['url'] === 'https://2gis.ru/y'
&& $r['api_key'] === 'k123'
&& $r['render'] === true);
});
@@ -0,0 +1,42 @@
<?php
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
/** @param array<string,string> $pages */
function fakePages(array $pages): PageFetcher
{
return new class($pages) implements PageFetcher
{
/** @param array<string,string> $pages */
public function __construct(private array $pages) {}
public function html(string $url): string
{
return $this->pages[$url] ?? '';
}
};
}
it('обходит список 2ГИС и собирает карточки филиалов с адресом', function () {
$fake = fakePages([
'https://2gis.ru/krasnoyarsk/search/X' => '<a href="/krasnoyarsk/firm/111">ф1</a><a href="/krasnoyarsk/firm/222">ф2</a>',
'https://2gis.ru/krasnoyarsk/firm/111' => '<title>КрасЛомбард, улица Весны, 7, Красноярск — 2ГИС</title><a href="tel:+73912550000">x</a>',
'https://2gis.ru/krasnoyarsk/firm/222' => '<title>КрасЛомбард, проспект Мира, 10, Красноярск — 2ГИС</title><a href="tel:+73912920000">x</a>',
]);
$cards = (new XfetchDirectoryFetcher($fake))->directory('https://2gis.ru/krasnoyarsk/search/X');
expect($cards)->toHaveCount(2);
$byNum = collect($cards)->keyBy('number');
expect($byNum['+73912550000']->office)->toBe('улица Весны, 7');
expect($byNum['+73912550000']->source)->toBe('2ГИС');
expect($byNum['+73912920000']->office)->toBe('проспект Мира, 10');
});
it('site() тянет HTML конкурента через тот же загрузчик', function () {
$fake = fakePages(['https://k.ru' => '<a href="tel:+73912920000">x</a>']);
$site = (new XfetchDirectoryFetcher($fake))->site('https://k.ru');
expect($site->url)->toBe('https://k.ru')
->and($site->rawHtml)->toContain('tel:+73912920000');
});
@@ -94,11 +94,27 @@
не правился (канон — в закоммиченных классах). Файлы: `HtmlPhoneScanner.php`, `CandidateBuilder.php`,
`SourceAggregator.php` + тесты `HtmlPhoneScannerTest`/`CandidateBuilderTest`/`SourceAggregatorTest`.
### 6.2. Справочники 2ГИС/Яндекс блокируют робота
Прямой headless-заход в 2ГИС → пустая 11КБ-заглушка; Яндекс → капча. **Решение уже записано**
(заметки движка шага 1 §12.2): **2ГИС → firecrawl** (антибот/прокси), **Яндекс → свой Playwright**
(рендерит отлично, бесплатно). `DirectoryParser` (чтение карточки) уже готов и проверен на
настоящем 2ГИС-HTML — нужен только правильный загрузчик. Прямой парс 2ГИС руками НЕ делать (стена).
### 6.2. ✅ 2ГИС через xfetch.ru РАБОТАЕТ (30.06); Яндекс — отдельно
**Решение по требованию владельца:** антибот справочников бьём сервисом **xfetch.ru** (НЕ firecrawl).
API: `POST https://xf4.ru/fetch` тело `{"url","api_key","render":true,"timeout":20}` → HTML в base64
(`response_body_base64`). **Без `timeout` 2ГИС не успевает дорисоваться** и отдаёт ошибку браузера —
обязателен `render:true, timeout:20`.
**Доказано на живом 2ГИС (КрасЛомбард, 30.06):** страница поиска → **12 карточек филиалов**;
карточка → `+73912920000`, адрес «улица 60 лет Октября, 54» (прогнал настоящим `DirectoryParser`).
**Собрано и протестировано (46/46):**
- `Agent/Fetch/PageFetcher` — интерфейс «достать HTML» (граница антибота, тестируется без сети).
- `Agent/Fetch/XfetchClient implements PageFetcher` — POST к xfetch, декод base64; **без ключа молча
пусто** (не падает). Ключ — `config('services.xfetch.key')` из `XFETCH_API_KEY` в `app/.env`
(gitignored, в код/гит НЕ попадает; плейсхолдер — в `.env.example`).
- `Agent/Fetch/XfetchDirectoryFetcher implements Fetcher` — список→ссылки→карточки через `DirectoryParser`.
- `DirectoryParser` — закоммичен вместе с этим (был в хвостах §6.4).
**Хвост — Яндекс.Карты:** у Яндекса другой формат URL карточек (`/maps/org/<id>`, НЕ `/city/firm/<id>`),
`DirectoryParser` под него ещё не написан — нужен отдельный парсер по РЕАЛЬНОМУ DOM Яндекса (не выдумывать).
**Провод в движок (замена FakeCompetitorAgent→Real + проброс региона) — Plan C, пока не сделан.**
Прямой парс 2ГИС/Яндекс руками НЕ делать (стена) — только через xfetch.
### 6.3. Нестыковка метки телефона (ось ещё не зафиксирована)
- §14.5 заметок (29.06): показывать **городской / мобильный / 8-800** (через DaData).