1 Commits

Author SHA1 Message Date
Дмитрий a028af4bc8 feat(autopodbor): сторож валидности телефонов на шаге 2 (реестр Россвязи + ДаДата)
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
Экстрактор кода страницы ловил любые 11 цифр (7/8 + телефонная группировка) без
проверки, что это настоящий российский номер — со страниц-агрегаторов пролезал мусор
с несуществующими кодами (227/303/453/613/761) и подписывался «городской».

PhoneValidityGate между CandidateBuilder и SourceAggregator:
- слой 1 (бесплатно, всегда): реестр Россвязи (phone_ranges) — нет в реестре → выкидываем;
- слой 2 (платно, за флагом autopodbor.phone_dadata_enabled, по умолчанию ВЫКЛ):
  DaDataValidityLayer перепроверяет прошедшие реестр номера, снимает qc 2/7,
  под дневным бюджетом + кэш вердикта; платим только за выживших (мусор отсекается ДО ДаДаты).
- fail-open при пустом реестре / сбое БД (иначе внешний catch в SiteOpener съел бы все номера);
- короткие/uncertain номера не трогаем (нельзя сверить).

Закрыты ОБА пути шага 2: обычный (RealCompetitorAgent) и глубокий (SiteOpener).
Миграций нет — реестр уже на проде (453k диапазонов).

Тесты: PhoneValidityGateTest (7), DaDataValidityLayerTest (6), SiteOpenerValidityTest (1);
весь набор автоподбора 519 зелёных, Pint + Larastan чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:34:19 +03:00
10 changed files with 478 additions and 3 deletions
@@ -15,6 +15,7 @@ use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\CompetitorCardEnricher;
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Extract\TwoGisJsonReader;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
@@ -33,6 +34,7 @@ use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
@@ -48,6 +50,7 @@ use App\Services\Autopodbor\Agent\Study\YandexPhoneReverseExpander;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\RunProgressChannel;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\ServiceProvider;
@@ -94,10 +97,20 @@ class AutopodborServiceProvider extends ServiceProvider
directoryFetcher: new XfetchDirectoryFetcher($pages),
);
// Сторож валидности телефонов: слой 1 Россвязь (всегда, бесплатно) + слой 2
// ДаДата за флагом (платно, снимает подтверждённый мусор среди прошедших реестр).
// Общий на оба пути шага 2 (обычный и глубокий через SiteOpener).
$validityGate = new PhoneValidityGate(
new RossvyazPrefixLookup,
config('autopodbor.phone_dadata_enabled')
? $app->make(DaDataValidityLayer::class)
: null,
);
$deep = config('autopodbor.deep_study')
? new DeepStudyCollector(
xfetch: $pages,
opener: new SiteOpener($siteFetcher),
opener: new SiteOpener($siteFetcher, validityGate: $validityGate),
reverse: config('autopodbor.phone_reverse')
? $this->buildPhoneReverse($pages, $reader)
: null,
@@ -115,6 +128,7 @@ class AutopodborServiceProvider extends ServiceProvider
return new RealCompetitorAgent(
$fetcher,
new FakeCompetitorAgent,
validityGate: $validityGate,
liveFind: $liveFind,
deep: $deep,
progress: $app->make(RunProgressChannel::class),
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Support\Facades\DB;
/**
* Сторож валидности телефонов, пойманных при «вскрытии» кода страницы конкурента.
*
* Экстрактор ({@see HtmlPhoneScanner}) ловит любую последовательность из 11 цифр,
* начинающуюся на 7/8 и сгруппированную «по-телефонному» но НЕ проверяет, что это
* реальный российский номер. Со страниц-агрегаторов (отзовики, каталоги) так пролезает
* мусор: ID/коды/чужие блоки цифр с несуществующими кодами города (227, 303, 453, 613, 761).
* Этот гейт отсекает такой мусор ДО показа клиенту (spec §12.4-подбор).
*
* Слой 1 (здесь, бесплатно): реестр нумерации Россвязи (`phone_ranges`, 453k диапазонов на
* проде). Полный номер 7XXXXXXXXXX, которого нет ни в одном диапазоне выкидываем молча.
* Слой 2 (ДаДата, платно, за флагом) {@see DaDataValidityLayer}, добивает сомнительные.
*
* Защита: если реестр пуст (dev/тесты или сбой загрузки) НЕ режем ничего (fail-open),
* чтобы незагруженный справочник не выкосил все номера. Короткие/локальные номера
* (kind=uncertain, код города не определён) не трогаем их нельзя сверить с реестром.
*/
final class PhoneValidityGate
{
private ?bool $registryHasData = null;
public function __construct(
private readonly RossvyazPrefixLookup $rossvyaz = new RossvyazPrefixLookup,
private readonly ?PhoneValidityLayer $dadata = null,
) {}
/**
* @param list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}> $candidates
* @return list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}>
*/
public function filter(array $candidates): array
{
// Реестр не загружен → fail-open: не режем ничего.
if (! $this->registryHasData()) {
return array_values($candidates);
}
return array_values(array_filter($candidates, fn (array $c): bool => $this->keep((string) $c['number'])));
}
private function keep(string $number): bool
{
// Сверяем только полные российские номера. Короткие/локальные (7-значные и т.п.)
// нельзя проверить по реестру — не теряем.
if (preg_match('/^7\d{10}$/', $number) !== 1) {
return true;
}
// Слой 1 — Россвязь (авторитетный полный план нумерации): номера нет ни в одном
// диапазоне → это не настоящий российский номер → мусор, выкидываем молча (бесплатно).
if ($this->rossvyaz->find($number) === null) {
return false;
}
// Слой 2 — ДаДата (платно, за флагом): перепроверяет уже прошедший реестр номер и
// снимает подтверждённый мусор/иностранца (qc 2/7). Платим только за реальных выживших.
if ($this->dadata !== null) {
return $this->dadata->isValid($number);
}
return true;
}
private function registryHasData(): bool
{
if ($this->registryHasData !== null) {
return $this->registryHasData;
}
try {
return $this->registryHasData = DB::connection(RossvyazPrefixLookup::CONNECTION)
->table('phone_ranges')
->limit(1)
->exists();
} catch (\Throwable) {
// Сбой БД/соединения при проверке реестра → fail-open (не фильтруем ничего).
// Иначе внешний catch в SiteOpener::open() принял бы это за «нет номеров».
return $this->registryHasData = false;
}
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Extract;
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
/**
* Второй слой проверки валидности телефона (после реестра Россвязи).
* Реализация {@see DaDataValidityLayer} (платно, за флагом).
*/
interface PhoneValidityLayer
{
/** true — номер годен (оставляем); false — мусор/иностранец (выкидываем). */
public function isValid(string $number): bool;
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Phone;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityLayer;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Второй слой валидности телефона (после реестра Россвязи) через DaData clean/phone.
* Платный, поэтому подключается только за флагом (`autopodbor.phone_dadata_enabled`) и
* под дневным бюджетом {@see DaDataBudgetGuard} тот же кошелёк, что резолв региона лида.
*
* Снимает ТОЛЬКО подтверждённый мусор: qc 2 (не телефон) и qc 7 (иностранный номер).
* Всё остальное (qc 0/3/1, исчерпанный бюджет, сбой сети) оставляет: номер уже прошёл
* авторитетный реестр Россвязи, наказывать его за нашу неуверенность/сбой нельзя.
*
* Вердикт кэшируется по sha256(номер) не платим дважды за один номер между прогонами.
* В кэше/ключе только хэш, без сырого номера (та же ПДн-конвенция, что LeadRegionResolver §7.3).
*/
final class DaDataValidityLayer implements PhoneValidityLayer
{
public function __construct(
private readonly DaDataPhoneClient $client,
private readonly DaDataBudgetGuard $budget,
private readonly CacheRepository $cache,
) {}
public function isValid(string $number): bool
{
$key = 'autopodbor:phone-valid:'.hash('sha256', $number);
$cached = $this->cache->get($key);
if (is_bool($cached)) {
return $cached;
}
// Бюджет исчерпан — не тратим и не режем (номер уже прошёл реестр).
if (! $this->budget->canSpend()) {
return true;
}
try {
$response = $this->client->cleanPhone($number);
$this->budget->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
} catch (DaDataException) {
return true; // сбой ДаДаты — не роняем и не режем
}
$valid = ! in_array($response->qc, [2, 7], true);
$ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
$this->cache->put($key, $valid, now()->addDays($ttlDays));
return $valid;
}
}
@@ -12,6 +12,7 @@ use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Extract\SourceAggregator;
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
use App\Services\Autopodbor\Agent\Study\CompetitorFingerprint;
@@ -26,6 +27,7 @@ final class RealCompetitorAgent implements CompetitorAgent
private Fetcher $fetcher,
private CompetitorAgent $fallback, // для resolve, и для find пока не подключён живой
private CandidateBuilder $builder = new CandidateBuilder,
private PhoneValidityGate $validityGate = new PhoneValidityGate,
private SourceAggregator $aggregator = new SourceAggregator,
private AutopodborNormalizer $norm = new AutopodborNormalizer,
private ?LiveFindCompetitors $liveFind = null, // живой поиск шага 1 (если подключён за флагом)
@@ -108,6 +110,8 @@ final class RealCompetitorAgent implements CompetitorAgent
// конкурента (только если на странице нет полных номеров; см. RegionAreaCode).
$areaCode = RegionAreaCode::forSubject($r->regionCode);
$candidates = $this->builder->build($sites, $cards, $areaCode);
// Отсекаем мусор: пойманные «номера» с несуществующими кодами (не в реестре Россвязи).
$candidates = $this->validityGate->filter($candidates);
$collected = $this->aggregator->aggregate($candidates);
// 3. Маппинг в существующий контракт
@@ -5,17 +5,20 @@ declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Study;
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
/**
* Вскрытие одного сайта фирмы: Fetcher (curl+Playwright) CandidateBuilder (kind номеров)
* список кандидатов, флаг коллтрекинга, HTML в нижнем регистре и «код»-цифры (для якорей).
* сторож валидности (отсев мусора по реестру Россвязи) список кандидатов, флаг коллтрекинга,
* HTML в нижнем регистре и «код»-цифры (для якорей).
*/
final class SiteOpener
{
public function __construct(
private Fetcher $fetcher,
private CandidateBuilder $builder = new CandidateBuilder,
private PhoneValidityGate $validityGate = new PhoneValidityGate,
) {}
public function open(string $domain, ?string $areaCode): OpenedSite
@@ -23,7 +26,8 @@ final class SiteOpener
$url = 'https://'.$this->idn($domain).'/';
try {
$site = $this->fetcher->site($url);
$cands = $this->builder->build([$site], [], $areaCode);
// Отсекаем мусор (номера с несуществующими кодами) ДО якорей/разворота по телефонам.
$cands = $this->validityGate->filter($this->builder->build([$site], [], $areaCode));
$codeDigits = [];
foreach ($cands as $c) {
if ($c['kind'] === 'code' || $c['kind'] === 'contacts') {
+16
View File
@@ -122,4 +122,20 @@ return [
*/
'render_cache' => env('AUTOPODBOR_RENDER_CACHE', false),
'render_cache_ttl_days' => (int) env('AUTOPODBOR_RENDER_CACHE_TTL_DAYS', 5),
/*
|--------------------------------------------------------------------------
| Второй слой отсева телефонов через ДаДату (после реестра Россвязи)
|--------------------------------------------------------------------------
|
| Пойманные при «вскрытии» кода страницы номера всегда чистятся реестром Россвязи
| (бесплатно): номера с несуществующим кодом выкидываются. Этот флаг добавляет
| ВТОРОЙ, ПЛАТНЫЙ слой ДаДата clean/phone (~0,6 /номер) перепроверяет уже прошедшие
| реестр номера и снимает подтверждённый мусор/иностранцев (qc 2/7). Платим только за
| реальных выживших (мусор отсекается бесплатно ДО ДаДаты), под дневным бюджетом
| services.dadata.daily_cap_rub, с кэшем вердикта. По умолчанию ВЫКЛ включается
| осознанно (тратит деньги). Требует services.dadata.api_key/secret.
|
*/
'phone_dadata_enabled' => env('AUTOPODBOR_PHONE_DADATA_ENABLED', false),
];
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
use App\Services\Autopodbor\Agent\Extract\PhoneValidityLayer;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Засеваем один диапазон Россвязи реестр становится непустым.
* По умолчанию: def_code=921, абоненты 5550000..5559999 (МегаФон, СПб).
*
* @param array<string, mixed> $overrides
*/
function seedGateRange(array $overrides = []): void
{
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('b', 64),
'status' => 'completed',
'imported_at' => now(),
]);
DB::table('phone_ranges')->insert(array_merge([
'def_code' => 921,
'from_num' => 5550000,
'to_num' => 5559999,
'operator' => 'МегаФон',
'region' => 'Санкт-Петербург',
'subject_code' => 83,
'imported_at' => now(),
'import_id' => $importId,
], $overrides));
}
/**
* @param array<string, mixed> $overrides
* @return array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}
*/
function gateCandidate(string $number, array $overrides = []): array
{
return array_merge([
'number' => $number,
'kind' => 'text',
'label' => 'в тексте сайта — проверьте',
'url' => 'https://review-group.ru/',
'office' => null,
'tracker' => false,
], $overrides);
}
it('выкидывает номер, чей код города отсутствует в реестре Россвязи', function (): void {
seedGateRange(); // реестр непустой (есть 921)
// 76132306130 — код «613», такого в реестре нет (мусор со страницы-агрегатора).
$out = app(PhoneValidityGate::class)->filter([gateCandidate('76132306130')]);
expect($out)->toBe([]);
});
it('оставляет номер, который есть в реестре Россвязи', function (): void {
seedGateRange(); // def_code=921, 5550000..5559999
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('79215555123', ['kind' => 'code', 'label' => 'в коде сайта']),
]);
expect($out)->toHaveCount(1)
->and($out[0]['number'])->toBe('79215555123');
});
it('фильтрует только мусор, реальные соседи остаются', function (): void {
seedGateRange();
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('76132306130'), // мусор (код 613)
gateCandidate('79215555123', ['kind' => 'code']), // реальный (921)
gateCandidate('72279347817'), // мусор (код 227 — вообще <300, запрещён реестром)
]);
expect($out)->toHaveCount(1)
->and($out[0]['number'])->toBe('79215555123');
});
it('при пустом реестре не режет ничего (fail-open)', function (): void {
// seedGateRange НЕ вызван → phone_ranges пуст (как на dev). Фильтр не должен
// выкосить всё из-за незагруженного реестра.
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('76132306130'),
gateCandidate('79215555123'),
]);
expect($out)->toHaveCount(2);
});
it('никогда не трогает короткие номера «требует проверки» (их нельзя сверить)', function (): void {
seedGateRange();
// 2713333 — локальный 7-значный, код города не определён (kind=uncertain).
// Сверить с реестром нельзя → не теряем.
$out = app(PhoneValidityGate::class)->filter([
gateCandidate('2713333', ['kind' => 'uncertain']),
]);
expect($out)->toHaveCount(1)
->and($out[0]['number'])->toBe('2713333');
});
it('второй слой (ДаДата) снимает мусор, прошедший реестр', function (): void {
seedGateRange(); // 79215555123 есть в реестре
$dropAll = new class implements PhoneValidityLayer
{
public function isValid(string $number): bool
{
return false; // ДаДата сказала «мусор»
}
};
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $dropAll);
$out = $gate->filter([gateCandidate('79215555123', ['kind' => 'code'])]);
expect($out)->toBe([]);
});
it('второй слой НЕ вызывается для номеров вне реестра (мусор отсёк слой 1 бесплатно)', function (): void {
seedGateRange();
$spy = new class implements PhoneValidityLayer
{
public bool $called = false;
public function isValid(string $number): bool
{
$this->called = true;
return true;
}
};
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $spy);
// 76132306130 — код 613, вне реестра → слой 1 выкидывает бесплатно, ДаДата не дёргается.
$out = $gate->filter([gateCandidate('76132306130')]);
expect($out)->toBe([])
->and($spy->called)->toBeFalse();
});
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
use App\Services\Autopodbor\Agent\Study\SiteOpener;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function seedOpenerRange(): void
{
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('c', 64),
'status' => 'completed',
'imported_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => 391,
'from_num' => 2000000,
'to_num' => 2999999,
'operator' => 'Ростелеком',
'region' => 'Красноярский край',
'subject_code' => 24,
'imported_at' => now(),
'import_id' => $importId,
]);
}
/** Fetcher, отдающий фиксированный HTML. */
function openerFetcher(string $html): Fetcher
{
return new class($html) implements Fetcher
{
public function __construct(private string $html) {}
public function site(string $url): FetchedSite
{
return new FetchedSite($url, $this->html);
}
public function directory(string $url): array
{
return [];
}
};
}
it('глубокий путь: мусорный номер (код вне реестра) не попадает в numbers', function (): void {
seedOpenerRange(); // есть только 391 2000000..2999999
// Настоящий 73912920000 (391) + мусор 76132306130 (код 613, вне реестра).
$html = '<a href="tel:+73912920000">звонок</a>'
.' <span>+7 613 230 61 30</span>';
$r = (new SiteOpener(openerFetcher($html)))->open('kraslombard24.ru', '391');
$numbers = collect($r->numbers)->pluck('number');
expect($numbers)->toContain('73912920000')
->and($numbers)->not->toContain('76132306130');
});
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
beforeEach(function (): void {
config(['services.dadata.daily_cap_rub' => 10000]); // бюджет открыт
});
it('qc=2 (мусор) → номер невалиден (выкидываем)', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 2]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeFalse();
});
it('qc=7 (иностранец) → номер невалиден', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 7]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeFalse();
});
it('qc=0 (годный) → валиден', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
});
it('qc=1 (неоднозначный) → оставляем (не режем по сомнению)', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 1]], 200)]);
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
});
it('бюджет исчерпан → не тратим и не режем, ДаДату не дёргаем', function (): void {
config(['services.dadata.daily_cap_rub' => 0]); // касса закрыта
Http::fake();
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
Http::assertNothingSent();
});
it('сбой ДаДаты (сеть) → не роняем и не режем', function (): void {
Http::fake(fn () => throw new ConnectionException('timeout'));
expect(app(DaDataValidityLayer::class)->isValid('79215555123'))->toBeTrue();
});