From 7746ccc54df3d80cf886434ca2fb0fa22e6895d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 30 Jun 2026 17:07:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B1=D0=BE=D1=80):=20=D1=88=D0=B0=D0=B31=20A2=20=E2=80=94=20T?= =?UTF-8?q?woGisResolver,=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=202=D0=93=D0=98?= =?UTF-8?q?=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбор реальной карточки филиала 2ГИС (/<город>/firm/) в ResolvedCompetitor: имя из , чистый сайт из contact_groups (url, не редирект link.2gis), телефоны type=phone (нормализованы к 7XXXXXXXXXX), город из title, описание из rubrics. Терпим к хвосту ?stat=. Пустая оболочка 2ГИС → null. Фикстура — реальная карточка КрасЛомбарда из живого прогона шага 2 (публичный бизнес-телефон конкурента, не клиентские ПДн); allowlist gitleaks для app/tests/fixtures/. Тесты: A2 3/3; модуль Автоподбора unit 64/64. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .gitleaks.toml | 5 + .../Agent/Resolve/TwoGisResolver.php | 115 ++++++++++++++++++ .../Autopodbor/Resolve/TwoGisResolverTest.php | 40 ++++++ .../autopodbor/2gis-firm-kraslombard.html | 9 ++ 4 files changed, 169 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php create mode 100644 app/tests/Unit/Autopodbor/Resolve/TwoGisResolverTest.php create mode 100644 app/tests/fixtures/autopodbor/2gis-firm-kraslombard.html diff --git a/.gitleaks.toml b/.gitleaks.toml index d57b9ccf..c2707987 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -87,6 +87,11 @@ paths = [ '''app/composer\.lock''', # Pest-тесты с фиктивными data-фикстурами (не реальные ПДн) '''app/tests/.*\.php''', + # Тест-фикстуры (HTML/JSON/CSV) — снятые публичные страницы справочников и + # синтетика для парсеров. Напр. карточка 2ГИС с ПУБЛИЧНЫМ бизнес-телефоном + # конкурента (опубликован в открытом справочнике), не клиентские ПДн. + # Та же категория, что app/tests/*.php выше. + '''app/tests/fixtures/.*''', # Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны) '''app/database/seeders/.*\.php''', # Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН, diff --git a/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php b/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php new file mode 100644 index 00000000..dacd7b0a --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Resolve/TwoGisResolver.php @@ -0,0 +1,115 @@ +<?php + +declare(strict_types=1); + +namespace App\Services\Autopodbor\Agent\Resolve; + +/** + * Разбор карточки филиала 2ГИС (/<город>/firm/<id>) в {@see ResolvedCompetitor}. + * Чистый: на вход — уже отрендеренный HTML карточки, на выход — карточка конкурента + * (имя/сайт/телефоны/город/рубрики) либо null, если данных карточки нет (пустая «оболочка» 2ГИС). + * + * Источники полей в HTML 2ГИС: + * - имя/город — <title> вида «Имя, адрес…, Город — 2ГИС» (первый/последний сегмент); + * - сайт — contact_groups, объект type=website, поле url (ЧИСТЫЙ адрес, НЕ редирект link.2gis в value); + * - телефоны — contact_groups, объекты type=phone, поле value (нормализуем к 7XXXXXXXXXX); + * - описание — rubrics[].name (для эмбеддинг-похожести на следующих под-блоках). + */ +final class TwoGisResolver +{ + public function parse(string $html, string $url): ?ResolvedCompetitor + { + [$name, $city] = $this->nameAndCity($html); + if ($name === null) { + return null; // нет имени фирмы в title — это не карточка филиала + } + + return new ResolvedCompetitor( + name: $name, + siteUrl: $this->website($html), + phones: $this->phones($html), + directoryUrl: $this->cleanUrl($url), + source: '2ГИС', + region: $city, + description: $this->description($html), + isFederal: false, // найдена местная карточка справочника + ); + } + + /** @return array{0: ?string, 1: ?string} [имя, город] из <title>; имя=null если title не похож на карточку */ + private function nameAndCity(string $html): array + { + if (! preg_match('#<title>(.*?)#is', $html, $m)) { + return [null, null]; + } + $title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $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_values(array_filter(array_map('trim', explode(',', $title)), fn ($p) => $p !== '')); + if (count($parts) < 2) { + return [null, null]; // «2gis.ru» и прочие оболочки без «Имя, …, Город» + } + + return [$parts[0], $parts[count($parts) - 1]]; + } + + /** Чистый сайт из contact_groups (url перед type=website), а НЕ редирект link.2gis из value. */ + private function website(string $html): ?string + { + if (preg_match('/"url":"(https?:\/\/[^"]+)"[^{}]*?"type":"website"/i', $html, $m)) { + return $m[1]; + } + + return null; + } + + /** @return list телефоны из contact_groups type=phone, нормализованы к 7XXXXXXXXXX */ + private function phones(string $html): array + { + $out = []; + if (preg_match_all('/"type":"phone","value":"([+0-9]+)"/i', $html, $m)) { + foreach ($m[1] as $raw) { + $digits = preg_replace('/\D+/', '', $raw) ?? ''; + if (strlen($digits) === 11 && $digits[0] === '8') { + $digits = '7'.substr($digits, 1); + } + if ($digits !== '' && ! in_array($digits, $out, true)) { + $out[] = $digits; + } + } + } + + return $out; + } + + /** Описание = названия рубрик карточки (rubrics[].name). */ + private function description(string $html): ?string + { + if (! preg_match('/"rubrics":\s*\[(.*?)\]/s', $html, $rm)) { + return null; + } + if (! preg_match_all('/"name":"([^"]+)"/', $rm[1], $nm)) { + return null; + } + $names = []; + foreach ($nm[1] as $n) { + $n = trim($n); + if ($n !== '' && ! in_array($n, $names, true)) { + $names[] = $n; + } + } + + return $names === [] ? null : implode(', ', $names); + } + + /** Прямая ссылка карточки без хвоста ?stat=… (2ГИС дописывает к firm/). */ + private function cleanUrl(string $url): string + { + $q = strpos($url, '?'); + + return $q === false ? $url : substr($url, 0, $q); + } +} diff --git a/app/tests/Unit/Autopodbor/Resolve/TwoGisResolverTest.php b/app/tests/Unit/Autopodbor/Resolve/TwoGisResolverTest.php new file mode 100644 index 00000000..4e66a423 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Resolve/TwoGisResolverTest.php @@ -0,0 +1,40 @@ +parse(twoGisFixture(), $url); + + expect($c)->not->toBeNull(); + expect($c->name)->toBe('КрасЛомбард'); // из , первый сегмент + expect($c->siteUrl)->toBe('http://kraslombard24.ru'); // чистый url, НЕ редирект link.2gis + expect($c->phones)->toContain('73912920000'); // нормализован к 7XXXXXXXXXX + expect($c->region)->toBe('Красноярск'); // город — последний сегмент title + expect($c->description)->toContain('Ломбарды'); // рубрики + expect($c->source)->toBe('2ГИС'); + expect($c->directoryUrl)->toBe('https://2gis.ru/krasnoyarsk/firm/70000001040792812'); // ?stat срезан + expect($c->isLocal())->toBeTrue(); + expect($c->isFederal)->toBeFalse(); +}); + +it('НЕ берёт редирект link.2gis как сайт конкурента', function () { + $url = 'https://2gis.ru/krasnoyarsk/firm/70000001040792812'; + $c = (new TwoGisResolver)->parse(twoGisFixture(), $url); + + expect($c->siteUrl)->not->toContain('link.2gis.ru'); +}); + +it('возвращает null, когда в HTML нет данных карточки (пустая оболочка 2ГИС)', function () { + $c = (new TwoGisResolver)->parse('<html><head><title>2gis.ru', 'https://2gis.ru/x/firm/1'); + + expect($c)->toBeNull(); +}); diff --git a/app/tests/fixtures/autopodbor/2gis-firm-kraslombard.html b/app/tests/fixtures/autopodbor/2gis-firm-kraslombard.html new file mode 100644 index 00000000..6acba607 --- /dev/null +++ b/app/tests/fixtures/autopodbor/2gis-firm-kraslombard.html @@ -0,0 +1,9 @@ + + +КрасЛомбард, улица 60 лет Октября, 54, Красноярск — 2ГИС + + + +