diff --git a/app/.env.example b/app/.env.example index 1f3b5210..0011bb70 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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= diff --git a/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php b/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php new file mode 100644 index 00000000..85ee8dec --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php @@ -0,0 +1,84 @@ +). + * + * @return list + */ + 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 + */ + 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; + } + + /** + * Адрес офиса из вида «Имя, адрес…, Город — 2ГИС». + * Берём всё между первой и последней запятой (имя фирмы и город/суффикс отбрасываем). + */ + private function officeFromTitle(string $html): ?string + { + if (! preg_match('#<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; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Fetch/PageFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/PageFetcher.php new file mode 100644 index 00000000..5dae9121 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/PageFetcher.php @@ -0,0 +1,15 @@ +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 : ''; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php b/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php new file mode 100644 index 00000000..46a567b4 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Fetch/XfetchDirectoryFetcher.php @@ -0,0 +1,53 @@ +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; + } +} diff --git a/app/config/services.php b/app/config/services.php index dd79f9cc..151529cf 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -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'), + ], + ]; diff --git a/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php b/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php new file mode 100644 index 00000000..f68a839f --- /dev/null +++ b/app/tests/Unit/Autopodbor/Extract/DirectoryParserTest.php @@ -0,0 +1,29 @@ +Ф1Ф2' + .'дубльне филиал'; + $links = (new DirectoryParser)->parseBranchList($html); + expect($links)->toBe(['/krasnoyarsk/firm/111', '/krasnoyarsk/firm/222']); +}); + +it('из карточки 2ГИС берёт телефон из tel: и адрес из заголовка', function () { + $html = 'КрасЛомбард, улица Ладо Кецховели, 30, Красноярск — 2ГИС' + .'

КрасЛомбард

+7 (391) 2...'; + $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 = "КрасЛомбард,\u{00A0}улица Весны,\u{00A0}7а, Красноярск — 2ГИС" + .'x'; + $cards = (new DirectoryParser)->parseFirmCard($html, 'u', '2ГИС'); + expect($cards[0]->office)->toBe('улица Весны, 7а'); +}); diff --git a/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php b/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php new file mode 100644 index 00000000..37b37fee --- /dev/null +++ b/app/tests/Unit/Autopodbor/Fetch/XfetchClientTest.php @@ -0,0 +1,31 @@ + Http::response([ + 'status_code' => 200, + 'response_body_base64' => base64_encode('привет'), + ]), + ]); + $client = new XfetchClient('test-key'); + expect($client->html('https://2gis.ru/x'))->toBe('привет'); +}); + +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); +}); diff --git a/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php b/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php new file mode 100644 index 00000000..a755abeb --- /dev/null +++ b/app/tests/Unit/Autopodbor/Fetch/XfetchDirectoryFetcherTest.php @@ -0,0 +1,42 @@ + $pages */ +function fakePages(array $pages): PageFetcher +{ + return new class($pages) implements PageFetcher + { + /** @param array $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' => 'ф1ф2', + 'https://2gis.ru/krasnoyarsk/firm/111' => 'КрасЛомбард, улица Весны, 7, Красноярск — 2ГИСx', + 'https://2gis.ru/krasnoyarsk/firm/222' => 'КрасЛомбард, проспект Мира, 10, Красноярск — 2ГИСx', + ]); + + $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' => 'x']); + $site = (new XfetchDirectoryFetcher($fake))->site('https://k.ru'); + expect($site->url)->toBe('https://k.ru') + ->and($site->rawHtml)->toContain('tel:+73912920000'); +}); diff --git a/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md b/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md index 3740aa20..ccabd492 100644 --- a/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md +++ b/docs/superpowers/findings/2026-06-30-STEP2-SVODKA.md @@ -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/`, НЕ `/city/firm/`), +`DirectoryParser` под него ещё не написан — нужен отдельный парсер по РЕАЛЬНОМУ DOM Яндекса (не выдумывать). +**Провод в движок (замена FakeCompetitorAgent→Real + проброс региона) — Plan C, пока не сделан.** +Прямой парс 2ГИС/Яндекс руками НЕ делать (стена) — только через xfetch. ### 6.3. Нестыковка метки телефона (ось ещё не зафиксирована) - §14.5 заметок (29.06): показывать **городской / мобильный / 8-800** (через DaData).