feat(автоподбор): шаг1 A2 — TwoGisResolver, разбор карточки 2ГИС
Разбор реальной карточки филиала 2ГИС (/<город>/firm/<id>) в ResolvedCompetitor: имя из <title>, чистый сайт из 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>
This commit is contained in:
@@ -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 — генераторы тестовых фикстур (фейковые телефоны/ИНН,
|
||||
|
||||
@@ -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>(.*?)</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<string> телефоны из 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/<id>). */
|
||||
private function cleanUrl(string $url): string
|
||||
{
|
||||
$q = strpos($url, '?');
|
||||
|
||||
return $q === false ? $url : substr($url, 0, $q);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Autopodbor\Agent\Resolve\TwoGisResolver;
|
||||
|
||||
function twoGisFixture(): string
|
||||
{
|
||||
return file_get_contents(base_path('tests/fixtures/autopodbor/2gis-firm-kraslombard.html'));
|
||||
}
|
||||
|
||||
it('разбирает реальную карточку 2ГИС в карточку конкурента', function () {
|
||||
// ?stat=… 2ГИС дописывает к ссылке firm — резолвер должен его терпеть и срезать
|
||||
$url = 'https://2gis.ru/krasnoyarsk/firm/70000001040792812?stat=abc123';
|
||||
$c = (new TwoGisResolver)->parse(twoGisFixture(), $url);
|
||||
|
||||
expect($c)->not->toBeNull();
|
||||
expect($c->name)->toBe('КрасЛомбард'); // из <title>, первый сегмент
|
||||
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</title></head><body></body></html>', 'https://2gis.ru/x/firm/1');
|
||||
|
||||
expect($c)->toBeNull();
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user