feat(автоподбор): CandidateBuilder — код/подменный/пул/контакты/справочники
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
|
||||
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
|
||||
final class CandidateBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private HtmlPhoneScanner $scanner = new HtmlPhoneScanner,
|
||||
private CalltrackingDetector $detector = new CalltrackingDetector,
|
||||
private AutopodborNormalizer $norm = new AutopodborNormalizer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<FetchedSite> $sites
|
||||
* @param list<DirectoryCard> $cards
|
||||
* @return list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
|
||||
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
|
||||
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
|
||||
|
||||
it('из кода сайта делает кандидатов kind=code с флагом трекера', function () {
|
||||
$site = new FetchedSite(
|
||||
url: 'https://k.ru/',
|
||||
rawHtml: '<a href="tel:+73912920000">x</a><script src="//cdn.roistat.com/x.js"></script>',
|
||||
);
|
||||
$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: '<script src="//cdn.callibri.ru/x.js"></script>'
|
||||
.'<span>8 (391) 111-22-33</span>' // видимый подменный (нет в 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: '<html>пусто</html>',
|
||||
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');
|
||||
});
|
||||
Reference in New Issue
Block a user