From 5ad50d8b8dae1986b6c56e71d34fe9408fd8d3e5 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 10:42:05 +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):=20CandidateBuilder=20=E2=80=94=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4/=D0=BF=D0=BE=D0=B4=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9/=D0=BF=D1=83=D0=BB/=D0=BA=D0=BE=D0=BD=D1=82=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D1=8B/=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Agent/Extract/CandidateBuilder.php | 106 ++++++++++++++++++ .../Extract/CandidateBuilderTest.php | 66 +++++++++++ 2 files changed, 172 insertions(+) create mode 100644 app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php create mode 100644 app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php diff --git a/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php new file mode 100644 index 00000000..4b254221 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php @@ -0,0 +1,106 @@ + $sites + * @param list $cards + * @return list + */ + public function build(array $sites, array $cards): array + { + $out = []; + foreach ($sites as $site) { + $scan = $this->scanner->scan($site->rawHtml); + $hasTracker = $this->detector->detect($site->rawHtml) !== []; + + foreach (array_keys($scan['code']) as $number) { + $out[] = [ + 'number' => (string) $number, + 'kind' => 'code', + 'label' => 'в коде сайта', + 'url' => $site->url, + 'office' => null, + 'tracker' => $hasTracker, + ]; + } + + $codeNumbers = array_map('strval', array_keys($scan['code'])); + + // видимые отрендеренные номера → displayed (подменный), если их нет в коде + $visible = []; + foreach ($site->visiblePhones as $raw) { + $n = $this->normalize($raw); + if ($n === null) { + continue; + } + $visible[] = $n; + if (! in_array($n, $codeNumbers, true)) { + $out[] = [ + 'number' => $n, 'kind' => 'displayed', 'label' => 'показан на сайте (коллтрекинг)', + 'url' => $site->url, 'office' => null, 'tracker' => $hasTracker, + ]; + } + } + + // пул ротации: номер в теле ≥2 раз, не в коде и не видимый, при активном трекере + if ($hasTracker) { + foreach ($scan['body'] as $number => $count) { + $number = (string) $number; + if ($count >= 2 && ! in_array($number, $codeNumbers, true) && ! in_array($number, $visible, true)) { + $out[] = [ + 'number' => $number, 'kind' => 'pool', 'label' => 'пул подмены', + 'url' => $site->url, 'office' => null, 'tracker' => true, + ]; + } + } + } + + // номера филиалов со страницы «Контакты» (рендер) + foreach ($site->contactsNumbers as $row) { + $n = $this->normalize($row['number']); + if ($n === null) { + continue; + } + $out[] = [ + 'number' => $n, 'kind' => 'contacts', 'label' => 'страница «Контакты»', + 'url' => $site->url, 'office' => $row['office'] ?? null, 'tracker' => $hasTracker, + ]; + } + } + + foreach ($cards as $card) { + $n = $this->normalize($card->number); + if ($n === null) { + continue; + } + $out[] = [ + 'number' => $n, 'kind' => 'directory', 'label' => $card->source, + 'url' => $card->url, 'office' => $card->office, 'tracker' => false, + ]; + } + + return $out; + } + + private function normalize(string $raw): ?string + { + $n = $this->norm->phone($raw); + + return (strlen($n) === 11 && $n[0] === '7') ? $n : null; + } +} diff --git a/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php b/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php new file mode 100644 index 00000000..3b631159 --- /dev/null +++ b/app/tests/Unit/Autopodbor/Extract/CandidateBuilderTest.php @@ -0,0 +1,66 @@ +x', + ); + $cands = (new CandidateBuilder)->build([$site], []); + + $byNum = collect($cands)->keyBy('number'); + expect($byNum)->toHaveKey('73912920000'); + expect($byNum['73912920000']['kind'])->toBe('code'); + expect($byNum['73912920000']['url'])->toBe('https://k.ru/'); + expect($byNum['73912920000']['tracker'])->toBeTrue(); // roistat найден + expect($byNum['73912920000']['label'])->toBe('в коде сайта'); +}); + +it('размечает видимый подменный и скрытый пул при активном трекере', function () { + $site = new FetchedSite( + url: 'https://k.ru/', + rawHtml: '' + .'8 (391) 111-22-33' // видимый подменный (нет в tel:/schema) + .'8(391)700-00-01 8(391)700-00-01', // пул: 2 вхождения, не код, не видимый + visiblePhones: ['8 (391) 111-22-33'], + ); + $cands = (new CandidateBuilder)->build([$site], []); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum['73911112233']['kind'])->toBe('displayed'); + expect($byNum['73917000001']['kind'])->toBe('pool'); +}); + +it('из страницы контактов делает кандидатов с офисом', function () { + $site = new FetchedSite( + url: 'https://k.ru/', + rawHtml: 'пусто', + contactsNumbers: [ + ['number' => '8(391)281-70-70', 'office' => 'Кр. рабочий, 61'], + ['number' => '8(391)250-00-00', 'office' => 'Калинина, 185'], + ], + ); + $cands = (new CandidateBuilder)->build([$site], []); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum['73912817070']['kind'])->toBe('contacts'); + expect($byNum['73912817070']['office'])->toBe('Кр. рабочий, 61'); + expect($byNum['73912817070']['label'])->toBe('страница «Контакты»'); +}); + +it('из карточек справочника делает кандидатов directory', function () { + $cards = [ + new DirectoryCard(number: '8(391)292-00-00', office: 'Единая справочная', url: 'https://2gis.ru/firm/1', source: '2ГИС'), + new DirectoryCard(number: '8(391)250-00-00', office: 'Калинина, 185', url: 'https://yandex.ru/maps/9', source: 'Яндекс.Карты'), + ]; + $cands = (new CandidateBuilder)->build([], $cards); + $byNum = collect($cands)->keyBy('number'); + + expect($byNum['73912920000']['kind'])->toBe('directory'); + expect($byNum['73912920000']['label'])->toBe('2ГИС'); + expect($byNum['73912920000']['url'])->toBe('https://2gis.ru/firm/1'); + expect($byNum['73912500000']['office'])->toBe('Калинина, 185'); +});