Files
portal/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php
T
Дмитрий 133eb9d0f0 feat(автоподбор): текстовые номера с сайта → «на сайте, проверьте» (unverified)
Второй пробел после фикса таймаута рендера: номера, написанные на сайте простым
текстом (не кликабельные tel:/schema, без коллтрекинга), терялись. Пример Центрофинанс:
8 800 200-00-10 («Телефон:» у юр. адреса), +7 931 106-54-50 (подвал) — движок брал
только tel:-ссылки.

- HtmlPhoneScanner: тело сканируем БЕЗ <script>/<style> (меньше шума из конфигов)
- CandidateBuilder: без трекера body-номера (не в code/visible) → kind 'text' «проверьте»
- SourceAggregator + DeepStudyCollector: text-only → phone_kind 'unverified' (не теряем,
  но и за 100% настоящий не выдаём); code/справочник перебивают → 'real'
- фронт: бейдж «⚠ на сайте — проверьте» (amber), сортировка ниже настоящих, легенда
- 7 тестов (scanner/builder/aggregator/DeepStudy); вся ветка 493/493, build ок

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:23:37 +03:00

172 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
final class HtmlPhoneScanner
{
/**
* @param ?string $defaultAreaCode код города из запроса (напр. «391») — запасной для
* достройки коротких локальных номеров, если на странице нет полных
* @return array{code: array<string,list<string>>, body: array<string,int>, emails: list<string>, uncertain: list<string>}
*/
public function scan(string $html, ?string $defaultAreaCode = null): array
{
$code = [];
$uncertain = [];
// 1. Собираем сырые значения из ЯВНЫХ телефонных контекстов (tel/schema/microdata).
// Короткие локальные формы (без кода города) здесь безопасны — это точно телефоны.
$rawCandidates = [];
$patterns = [
['/tel:([+0-9()\s-]{6,})/i', 'tel'],
['/"telephone"\s*:\s*"([^"]+)"/i', 'schema'],
['/itemprop=["\']telephone["\'][^>]*content=["\']([^"\']+)/i', 'microdata'],
];
foreach ($patterns as [$re, $slot]) {
if (preg_match_all($re, $html, $m)) {
foreach ($m[1] as $x) {
$rawCandidates[] = ['raw' => $x, 'slot' => $slot];
}
}
}
// 2. Из полных номеров берём национальные части (10 цифр) — по ним определяем код города.
$national = [];
foreach ($rawCandidates as $c) {
$n = $this->normalizeMaybe($c['raw']);
if ($n !== null) {
$national[] = substr($n, 1);
}
}
$add = function (string $n, string $slot) use (&$code): void {
$code[$n] ??= [];
if (! in_array($slot, $code[$n], true)) {
$code[$n][] = $slot;
}
};
// 3. Разносим: полные — как есть; короткие локальные — достраиваем кодом города.
foreach ($rawCandidates as $c) {
$n = $this->normalizeMaybe($c['raw']);
if ($n !== null) {
$add($n, $c['slot']);
continue;
}
[$status, $value] = $this->classifyShort($c['raw'], $national, $defaultAreaCode);
if ($status === 'built') {
$add((string) $value, $c['slot']);
} elseif ($status === 'uncertain' && ! in_array($value, $uncertain, true)) {
$uncertain[] = (string) $value; // код города не определить — не теряем, к проверке
}
// 'fragment' (обрезок полного номера) и 'skip' (не телефон) — игнорируем
}
// Номера в теле берём из ВИДИМОГО текста: вырезаем <script>/<style>, чтобы не тянуть
// технический мусор из конфигов/аналитики. Реальные номера из JSON-LD уже пойманы выше (schema).
$bodyHtml = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', ' ', $html) ?? $html;
$body = [];
if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $bodyHtml, $m)) {
foreach ($m[0] as $x) {
$n = $this->normalizeMaybe($x);
if ($n !== null) {
$body[$n] = ($body[$n] ?? 0) + 1;
}
}
}
$emails = [];
if (preg_match_all('/([a-z0-9._%+-]+)@[a-z0-9.-]+\.[a-z]{2,}/i', $html, $m)) {
foreach ($m[1] as $local) {
$d = preg_replace('/\D+/', '', $local) ?? '';
if (strlen($d) >= 7) {
$emails[] = $d;
}
}
}
return ['code' => $code, 'body' => $body, 'emails' => $emails, 'uncertain' => $uncertain];
}
private function normalizeMaybe(string $raw): ?string
{
$digits = preg_replace('/\D+/', '', $raw) ?? '';
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
return '7'.substr($digits, 1);
}
if (strlen($digits) === 10) {
return '7'.$digits;
}
return null;
}
/**
* Классифицирует короткую (6–7 цифр) форму номера из явного телефонного контекста.
* Возвращает [статус, значение]:
* - 'built' + 7XXXXXXXXXX — локальный номер достроен кодом города;
* - 'fragment' + null — обрезок полного номера (страна+код), выкидываем;
* - 'uncertain' + цифры — код города не определить, не теряем, помечаем к проверке;
* - 'skip' + null — не 6–7-значная форма, не наше.
* Пример: «271-33-33» при «+7 (391) …» → ['built','73912713333'];
* обрезок «+7 (391) 271» (7391271) → ['fragment', null].
*
* @param list<string> $national 10-значные национальные части полных номеров страницы
* @param ?string $defaultAreaCode запасной код города из запроса (если полных нет)
* @return array{0:string,1:?string}
*/
private function classifyShort(string $raw, array $national, ?string $defaultAreaCode): array
{
$digits = preg_replace('/\D+/', '', $raw) ?? '';
$len = strlen($digits);
if ($len < 6 || $len > 7) {
return ['skip', null];
}
$area = $this->areaCode($national, 10 - $len, $defaultAreaCode);
if ($area === null) {
return ['uncertain', $digits]; // код города не определить
}
// Обрезок полного номера: уже содержит код страны 7/8 + код города (напр. 7391271 = 7·391·271).
// Настоящий локальный (2713333, или московский 7712233 при коде 495) этим не задевается.
if (str_starts_with($digits, '7'.$area) || str_starts_with($digits, '8'.$area)) {
return ['fragment', null];
}
return ['built', '7'.$area.$digits];
}
/**
* Код города нужной длины: самый частый префикс среди полных номеров страницы,
* иначе запасной из запроса. null — если не определить.
*
* @param list<string> $national
*/
private function areaCode(array $national, int $prefixLen, ?string $defaultAreaCode): ?string
{
if ($national !== []) {
$counts = [];
foreach ($national as $nat) {
$p = substr($nat, 0, $prefixLen);
$counts[$p] = ($counts[$p] ?? 0) + 1;
}
arsort($counts);
$prefix = (string) array_key_first($counts);
if (strlen($prefix) === $prefixLen) {
return $prefix;
}
}
if ($defaultAreaCode !== null && strlen($defaultAreaCode) === $prefixLen) {
return $defaultAreaCode;
}
return null;
}
}