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