133eb9d0f0
Второй пробел после фикса таймаута рендера: номера, написанные на сайте простым текстом (не кликабельные 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>
172 lines
7.6 KiB
PHP
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;
|
|
}
|
|
}
|