fix(autopodbor): якорь по данным клиента на шаге 2 + параллельный разворот

Качество. Привязанные клиентом карточки 2ГИС/Яндекс теперь используются как
«земля» и якорь холдинга (раньше выбрасывались). Слепой поиск по имени берётся
только при наличии якоря; AnchorGate: пустой якорь → drop (было keep-всё — корень
шума). Без сайта чужие фирмы по широкому имени («Притяжение» → стоматология,
салоны, эротический магазин) больше не попадают в карточку конкурента. Доказано на
живых данных (Сибагропромстрой): свежий полный сбор — 0 салонов, только номера
застройщика.

Скорость (за флагом AUTOPODBOR_PHONE_REVERSE_PARALLEL, ВЫКЛ по умолчанию).
ParallelPhoneReverse крутит 2ГИС- и Яндекс-разворот ОДНОВРЕМЕННО (итог по времени
= max, не сумма; оба источника отрабатывают полностью — ничего не режется).
Сверка последовательно vs параллельно: данные один в один, потерь нет. Таймаут
подпроцесса 600с (драйвер process иначе берёт дефолт Symfony 60с и убивал разворот).

Тесты: AnchorGate (пустой якорь → drop; телефон-якорь режет чужое), якорь
привязанной карточки режет салоны, ParallelPhoneReverse (слияние на sync-драйвере).
Весь Autopodbor 464/464, Pint чист.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-06 08:54:48 +03:00
parent 113a3f5aca
commit 0131ecfc47
12 changed files with 418 additions and 12 deletions
@@ -37,6 +37,7 @@ use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
use App\Services\Autopodbor\Agent\Study\CompositePhoneReverse;
use App\Services\Autopodbor\Agent\Study\DeepStudyCollector;
use App\Services\Autopodbor\Agent\Study\ParallelPhoneReverse;
use App\Services\Autopodbor\Agent\Study\PhoneReverse;
use App\Services\Autopodbor\Agent\Study\PhoneReverseExpander;
use App\Services\Autopodbor\Agent\Study\SiteOpener;
@@ -159,7 +160,11 @@ class AutopodborServiceProvider extends ServiceProvider
new PlaywrightYandexCardReader($cardFetcher),
);
return new CompositePhoneReverse([$gis, $yandex]);
// phone_reverse_parallel: 2ГИС и Яндекс крутятся ОДНОВРЕМЕННО (итог = max, оба источника целы).
// Иначе — последовательный композит (как раньше). По умолчанию ВЫКЛ, откат — флаг в false.
return config('autopodbor.phone_reverse_parallel')
? new ParallelPhoneReverse($gis, $yandex)
: new CompositePhoneReverse([$gis, $yandex]);
}
/**
@@ -73,10 +73,12 @@ final class RealCompetitorAgent implements CompetitorAgent
}
$this->progress?->stage($i + 1, $total, 'Изучаю: '.$name);
$sites = is_array($el['sites'] ?? null) ? $el['sites'] : [];
$cards = is_array($el['cards'] ?? null) ? $el['cards'] : [];
$fp = (new CompetitorFingerprint)->build([
'name' => $name,
'site_url' => $sites[0] ?? null,
'is_federal' => $r->competitor['is_federal'] ?? false,
'cards' => $cards,
], $r->regionCode);
foreach ($this->deep->collect($fp) as $row) {
$rows[] = $row;
@@ -44,10 +44,14 @@ final class AnchorGate
*/
public function cardKept(?string $cardSite, string $cardDigits, string $seedDomain, array $anchorDigits): bool
{
if ($seedDomain === '') {
return true;
// Якоря нет вообще (ни seed-домена, ни подтверждённых телефонов) — слепую находку НЕ берём.
// Раньше здесь был keep-всё: без сайта фильтр пропускал чужих (стоматология/салоны по широкому
// имени попадали в карточку конкурента). Клиентские карточки засевают якорь выше по стеку.
if ($seedDomain === '' && $anchorDigits === []) {
return false;
}
$siteHit = $cardSite !== null && $cardSite !== '' && str_contains(mb_strtolower($cardSite, 'UTF-8'), $seedDomain);
// siteHit только при непустом seed: str_contains(..., '') всегда true — пустой seed дал бы ложное совпадение.
$siteHit = $seedDomain !== '' && $cardSite !== null && $cardSite !== '' && str_contains(mb_strtolower($cardSite, 'UTF-8'), $seedDomain);
$phoneHit = $cardDigits !== '' && isset($anchorDigits[$cardDigits]);
return $siteHit || $phoneHit;
@@ -6,7 +6,7 @@ namespace App\Services\Autopodbor\Agent\Study;
final class CompetitorFingerprint
{
/** @param array{name:string,site_url?:?string,is_federal?:bool} $competitor */
/** @param array{name:string,site_url?:?string,is_federal?:bool,cards?:array,directory_urls?:array} $competitor */
public function build(array $competitor, int $regionCode): Fingerprint
{
$geo = CompetitorGeo::for($regionCode);
@@ -17,6 +17,12 @@ final class CompetitorFingerprint
$host = (string) (parse_url($raw, PHP_URL_HOST) ?: '');
$seed = $host !== '' ? mb_strtolower(preg_replace('#^www\.#', '', $host) ?? $host, 'UTF-8') : '';
// Привязанные карточки: у элемента холдинга — ключ 'cards'; у одиночного конкурента — 'directory_urls'.
$cardsRaw = $competitor['cards'] ?? $competitor['directory_urls'] ?? [];
$cards = is_array($cardsRaw)
? array_values(array_filter(array_map(static fn ($u): string => trim((string) $u), $cardsRaw), static fn (string $u): bool => $u !== ''))
: [];
return new Fingerprint(
name: (string) $competitor['name'],
seedDomain: $seed,
@@ -25,6 +31,7 @@ final class CompetitorFingerprint
gisSlug: $geo['gisSlug'] ?? '',
isFederal: (bool) ($competitor['is_federal'] ?? false),
yandexSlug: $geo['yandexSlug'] ?? '',
cards: $cards,
);
}
}
@@ -9,6 +9,7 @@ use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
use App\Services\Autopodbor\Agent\Extract\PhoneType;
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
use App\Services\Autopodbor\Agent\Fetch\PlaywrightPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\RoutingBatchFetcher;
use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
@@ -59,8 +60,13 @@ final class DeepStudyCollector implements SourceCollector
$order = array_values(array_unique($order));
}
// 1) сайты + якоря
// 0) Привязанные клиентом карточки — «земля»: их телефоны берём без фильтра и засеваем якорь
// (клиент подтвердил, что это фирма конкурента). Так безсайтовый элемент даёт свой номер и
// задаёт якорь, по которому ниже отсеиваются чужие из слепого поиска (стоматология/салоны).
$anchorDigits = [];
$this->ingestClientCards($fp, $anchorDigits);
// 1) сайты + якоря
$siteRows = [];
foreach (array_slice($order, 0, 8) as $domain) {
$r = $this->opener->open($domain, $fp->areaCode);
@@ -86,10 +92,15 @@ final class DeepStudyCollector implements SourceCollector
];
}
// 2) 2ГИС по имени + EXA-ссылки
$this->collect2gis($fp, $buckets['gis'], $anchorDigits);
// 3) Яндекс через Playwright
$this->collectYandex($fp, $buckets['yandex'], $anchorDigits);
// Слепое добывание (поиск по имени, EXA-ссылки) — только при наличии якоря (сайт-seed ИЛИ
// подтверждённый клиентом телефон). Нет якоря → не ищем вслепую: лучше пусто, чем чужие фирмы.
$hasAnchor = $seedAscii !== '' || $anchorDigits !== [];
if ($hasAnchor) {
// 2) 2ГИС по имени + EXA-ссылки
$this->collect2gis($fp, $buckets['gis'], $anchorDigits);
// 3) Яндекс через Playwright
$this->collectYandex($fp, $buckets['yandex'], $anchorDigits);
}
// 3.5) Разворот по телефонам (флаг phone_reverse): по личным номерам фирмы вскрываем
// связанные карточки холдинга (проекты застройщика) — их сайты и телефоны. Общие номера
@@ -199,6 +210,47 @@ final class DeepStudyCollector implements SourceCollector
}
}
/**
* Привязанные клиентом карточки: телефоны «земля» (в источники) и якорь ($anchorDigits).
* Карточку берём как есть, без фильтра «наша ли фирма» клиент это уже подтвердил.
*
* @param array<string,bool> $anchorDigits
*/
private function ingestClientCards(Fingerprint $fp, array &$anchorDigits): void
{
foreach ($fp->cards as $cardUrl) {
$cardUrl = (string) $cardUrl;
foreach ($this->openCard($cardUrl) as $card) {
$dig = $card->number !== '' ? $this->norm->phone($card->number) : '';
if (strlen($dig) === 11) {
$anchorDigits[$dig] = true;
$this->addPhone($dig, ['карточка (привязана клиентом)' => $cardUrl]);
}
}
}
}
/**
* Открыть одну карточку справочника в общий контракт: 2ГИС (initialState/HTML) или Яндекс.Карты.
*
* @return list<DirectoryCard>
*/
private function openCard(string $url): array
{
$html = $this->xfetch->html($url);
if ($html === '') {
return [];
}
if (str_contains($url, '2gis')) {
return $this->reader->card($html, $url);
}
if (str_contains($url, 'yandex')) {
return $this->dirParser->parseYandexCard($html, $url);
}
return [];
}
/** @param array<string,bool> $anchorDigits */
private function pushCard(string $source, ?string $number, ?string $site, string $url, string $seedDomain, array $anchorDigits): void
{
@@ -14,5 +14,7 @@ final class Fingerprint
public readonly string $gisSlug, // '' если гео неизвестно
public readonly bool $isFederal,
public readonly string $yandexSlug = '',
/** @var list<string> URL привязанных клиентом карточек 2ГИС/Яндекс — «земля», якорь холдинга. */
public readonly array $cards = [],
) {}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Study;
use Illuminate\Support\Facades\Concurrency;
/**
* Параллельный разворот по телефонам: 2ГИС- и Яндекс-разворот запускаются ОДНОВРЕМЕННО (двумя
* процессами через {@see Concurrency}), а не один за другим. Итог по времени = max(2ГИС, Яндекс),
* а не сумма при этом оба источника отрабатывают ПОЛНОСТЬЮ, ничего не режется (у одних конкурентов
* данные в 2ГИС, у других в Яндексе универсально нужны оба).
*
* Безопасность прокси: доказанная потеря только от НЕСКОЛЬКИХ параллельных лент Яндекса; здесь два
* РАЗНЫХ провайдера, каждый одно-поточный внутри, а виртуалка держит общий потолок (MAX_CONCURRENCY).
* Слияние как {@see CompositePhoneReverse}: сайты дедуп по домену, телефоны по цифрам, 2ГИС (первый)
* выигрывает при совпадении. Драйвер concurrency=sync в тестах даёт детерминизм.
*/
final class ParallelPhoneReverse implements PhoneReverse
{
/**
* @param int $timeoutSeconds ПОТОЛОК на подпроцесс. Драйвер process иначе берёт дефолт Symfony 60с
* и УБИВАЕТ разворот (>60с = наш обычный случай) потеря данных. Разворот
* внутри ограничен (maxCards/maxFirms=40), столько не идёт 600с с запасом.
*/
public function __construct(private PhoneReverse $gis, private PhoneReverse $yandex, private int $timeoutSeconds = 600) {}
public function expand(string $gisSlug, array $seedPhones, array $seedDomains, string $yandexSlug = ''): PhoneExpansionResult
{
$gis = $this->gis;
$yandex = $this->yandex;
/** @var list<PhoneExpansionResult> $results — порядок сохраняется: [2ГИС, Яндекс] */
$results = Concurrency::run([
fn (): PhoneExpansionResult => $gis->expand($gisSlug, $seedPhones, $seedDomains, $yandexSlug),
fn (): PhoneExpansionResult => $yandex->expand($gisSlug, $seedPhones, $seedDomains, $yandexSlug),
], $this->timeoutSeconds);
return $this->merge($results);
}
/** @param list<PhoneExpansionResult> $results */
private function merge(array $results): PhoneExpansionResult
{
$sites = [];
$phones = [];
$seenDom = [];
$seenPhone = [];
foreach ($results as $r) {
foreach ($r->sites as $s) {
$dom = (string) ($s['domain'] ?? '');
if ($dom === '' || isset($seenDom[$dom])) {
continue;
}
$seenDom[$dom] = true;
$sites[] = $s;
}
foreach ($r->phones as $ph) {
$d = (string) ($ph['digits'] ?? '');
if ($d === '' || isset($seenPhone[$d])) {
continue;
}
$seenPhone[$d] = true;
$phones[] = $ph;
}
}
return new PhoneExpansionResult($sites, $phones);
}
}
+14
View File
@@ -62,6 +62,20 @@ return [
*/
'phone_reverse_yandex' => env('AUTOPODBOR_PHONE_REVERSE_YANDEX', false),
/*
|--------------------------------------------------------------------------
| Параллельный разворот: 2ГИС и Яндекс одновременно
|--------------------------------------------------------------------------
|
| true 2ГИС-разворот и Яндекс-разворот запускаются РАЗОМ (двумя процессами,
| Concurrency), итог по времени = max(2ГИС, Яндекс), а не сумма. Оба источника
| отрабатывают полностью ничего не режется. false последовательный композит.
| Безопасно: два разных провайдера, каждый одно-поточный, виртуалка держит потолок.
| Требует phone_reverse_yandex. По умолчанию ВЫКЛ.
|
*/
'phone_reverse_parallel' => env('AUTOPODBOR_PHONE_REVERSE_PARALLEL', false),
/*
|--------------------------------------------------------------------------
| Свой рендер-канал (self-render) вместо платного xf4
@@ -0,0 +1,114 @@
<?php
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
use App\Services\Autopodbor\Agent\Fetch\BatchPageFetcher;
use App\Services\Autopodbor\Agent\Fetch\DirectoryCard;
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
use App\Services\Autopodbor\Agent\Study\DeepStudyCollector;
use App\Services\Autopodbor\Agent\Study\Fingerprint;
use App\Services\Autopodbor\Agent\Study\SiteOpener;
// Земля: клиент привязал карточку 2ГИС с номером 002005 → он якорь и источник. Слепой поиск по
// широкому имени «Притяжение» вернул чужой салон (303303) — он должен отсеяться (не бьётся с якорем).
// Доказано на живых данных (стоматология/салоны попадали в карточку конкурента) — 2026-07-06.
const CLIENT_CARD = 'https://2gis.ru/krasnoyarsk/firm/CLIENT';
const FOREIGN_FIRM = 'https://2gis.ru/krasnoyarsk/firm/FOREIGN';
function fakeXfetch(): BatchPageFetcher
{
return new class implements BatchPageFetcher
{
public function html(string $url): string
{
if ($url === CLIENT_CARD) {
return 'CLIENT';
}
if (str_contains($url, '/search/')) {
return 'SEARCH';
}
if (str_contains($url, 'FOREIGN')) {
return 'FOREIGN';
}
return '';
}
public function htmlBatch(array $urls): array
{
$out = [];
foreach ($urls as $u) {
$out[$u] = $this->html($u);
}
return $out;
}
};
}
function fakeReader(): TwoGisReader
{
return new class implements TwoGisReader
{
public function listing(string $payload): array
{
return [];
}
public function card(string $payload, string $url): array
{
if ($payload === 'CLIENT') {
return [new DirectoryCard(number: '8 (391) 200-20-05', office: null, url: $url, source: '2ГИС', siteUrl: null, description: null)];
}
if ($payload === 'FOREIGN') {
return [new DirectoryCard(number: '8 (391) 230-33-03', office: null, url: $url, source: '2ГИС', siteUrl: 'http://salon.ru', description: 'Парикмахерские')];
}
return [];
}
public function branchList(string $payload): array
{
return $payload === 'SEARCH' ? ['/krasnoyarsk/firm/FOREIGN'] : [];
}
};
}
function emptyOpener(): SiteOpener
{
$fetcher = new class implements Fetcher
{
public function site(string $url): FetchedSite
{
return new FetchedSite($url, '');
}
public function directory(string $url): array
{
return [];
}
};
return new SiteOpener($fetcher);
}
it('привязанная карточка = якорь: её номер в источниках, чужой салон из слепого поиска отсеян', function () {
$collector = new DeepStudyCollector(xfetch: fakeXfetch(), opener: emptyOpener(), reader: fakeReader());
$fp = new Fingerprint('Притяжение', '', 'Красноярск', '391', 'krasnoyarsk', false, '', [CLIENT_CARD]);
$calls = array_map(
fn ($r) => $r['identifier'],
array_values(array_filter($collector->collect($fp), fn ($r) => $r['signal_type'] === 'call')),
);
expect($calls)->toContain('73912002005') // номер с привязанной карточки — есть
->and($calls)->not->toContain('73912303303'); // салон из слепого поиска — отсеян
});
it('нет якоря совсем (без сайта и без карточек) — слепой поиск не берётся, пусто', function () {
$collector = new DeepStudyCollector(xfetch: fakeXfetch(), opener: emptyOpener(), reader: fakeReader());
$fp = new Fingerprint('Притяжение', '', 'Красноярск', '391', 'krasnoyarsk', false, '', []);
expect($collector->collect($fp))->toBe([]);
});
@@ -31,6 +31,14 @@ it('карточка: телефон-якорь — keep', function () {
it('карточка: чужая — drop', function () {
expect(gate()->cardKept('http://alien.ru', '79990000000', 'kraslombard24.ru', ['73912920000' => true]))->toBeFalse();
});
it('карточка: пустой seed — keep всё', function () {
expect(gate()->cardKept(null, '', '', []))->toBeTrue();
it('карточка: якоря нет совсем (ни seed, ни цифр) — drop (слепую находку не берём)', function () {
// Раньше тут был keep-всё — источник шума: без сайта фильтр пропускал стоматологию/салоны.
expect(gate()->cardKept(null, '', '', []))->toBeFalse()
->and(gate()->cardKept('http://alien.ru', '79990000000', '', []))->toBeFalse();
});
it('карточка: якорь только по телефону клиента (сайта нет) — держит своё, режет чужое', function () {
// Элемент без сайта, но клиент привязал карточку с номером 73912002005 → он в якоре.
$anchor = ['73912002005' => true];
expect(gate()->cardKept(null, '73912002005', '', $anchor))->toBeTrue() // своё
->and(gate()->cardKept('http://salon.ru', '73912303303', '', $anchor))->toBeFalse(); // чужой салон
});
@@ -0,0 +1,54 @@
<?php
use App\Services\Autopodbor\Agent\Study\ParallelPhoneReverse;
use App\Services\Autopodbor\Agent\Study\PhoneExpansionResult;
use App\Services\Autopodbor\Agent\Study\PhoneReverse;
// ParallelPhoneReverse крутит 2ГИС- и Яндекс-разворот ОДНОВРЕМЕННО (итог = max, не сумма), затем
// сливает находки: сайты дедуп по домену, телефоны по цифрам, 2ГИС (первый) выигрывает при совпадении.
// В тестах — драйвер concurrency 'sync' (детерминированно, в том же процессе).
function parFakeReverse(array $sites, array $phones): PhoneReverse
{
return new class($sites, $phones) implements PhoneReverse
{
public function __construct(private array $sites, private array $phones) {}
public function expand(string $gisSlug, array $seedPhones, array $seedDomains, string $yandexSlug = ''): PhoneExpansionResult
{
return new PhoneExpansionResult($this->sites, $this->phones);
}
};
}
beforeEach(fn () => config()->set('concurrency.default', 'sync'));
it('сливает находки обоих разворотов: дедуп сайтов и телефонов, 2ГИС выигрывает', function () {
$gis = parFakeReverse(
sites: [['domain' => 'a.ru', 'site_url' => 'https://a.ru', 'card_url' => 'gis/a']],
phones: [['digits' => '73912000001', 'url' => 'gis/1', 'source' => '2ГИС'],
['digits' => '73912000009', 'url' => 'gis/9', 'source' => '2ГИС']],
);
$yandex = parFakeReverse(
sites: [['domain' => 'b.ru', 'site_url' => 'https://b.ru', 'card_url' => 'ya/b'],
['domain' => 'a.ru', 'site_url' => 'https://a.ru', 'card_url' => 'ya/a']], // дубль домена a.ru
phones: [['digits' => '73912000002', 'url' => 'ya/2', 'source' => 'Яндекс'],
['digits' => '73912000009', 'url' => 'ya/9', 'source' => 'Яндекс']], // дубль номера ...009
);
$res = (new ParallelPhoneReverse($gis, $yandex))->expand('krasnoyarsk', ['73912000000'], [], '62/krasnoyarsk');
// сайты: a.ru (от 2ГИС) + b.ru — дубль a.ru от Яндекса отброшен
expect($res->sites)->toHaveCount(2)
->and(array_column($res->sites, 'domain'))->toEqualCanonicalizing(['a.ru', 'b.ru'])
->and(collect($res->sites)->firstWhere('domain', 'a.ru')['card_url'])->toBe('gis/a'); // 2ГИС выиграл
// телефоны: 001, 009 (2ГИС), 002 — дубль 009 от Яндекса отброшен
expect(array_column($res->phones, 'digits'))->toEqualCanonicalizing(['73912000001', '73912000009', '73912000002'])
->and(collect($res->phones)->firstWhere('digits', '73912000009')['source'])->toBe('2ГИС');
});
it('пустые находки — пустой результат', function () {
$res = (new ParallelPhoneReverse(parFakeReverse([], []), parFakeReverse([], [])))->expand('krasnoyarsk', [], [], '');
expect($res->sites)->toBe([])->and($res->phones)->toBe([]);
});
@@ -0,0 +1,72 @@
# Автоподбор шаг 2: якорь по данным клиента вместо слепого поиска — дизайн
**Дата:** 2026-07-06 · **Ветка:** worktree-avtopodbor (НЕ прод) · **Флаг:** deep_study (как сейчас)
## Проблема (доказано на живых данных, конкурент «Сибагропромстрой» id 743)
Шаг 2 для холдинга изучает каждый элемент по имени. У элемента **без сайта** якоря нет, и
фильтр карточек `AnchorGate::cardKept` при `seedDomain===''` возвращает `true` — **берёт все
карточки подряд**. Слепой 2ГИС/Яндекс-поиск по широкому имени («Притяжение») тащит чужие фирмы.
**Факт из базы** (`autopodbor_sources`, id 743): в карточку конкурента попали номера/сайты
**стоматологической клиники** (73912303303, 79232828818 — сейчас в «Предложениях», помечены
«✓ настоящий номер»), **магазина эротических товаров** (lavkasex.ru + номера), хореографической
студии, парикмахерской, детской студии. Все 6 фирм из поиска «Притяжение» — мусор, ни один их
номер не совпадает с номерами, что дал клиент {73912561111, 561818, 002005, 989005}.
**Хорошие** номера застройщика (семейство 561xxx/002xxx/989005 и «по телефону») пришли
**анкорным путём** — от «Метрополиса» (у него есть сайт saps.ru) и разворота его номеров.
Разворот уже гейтится сайтом (`seedDomain !== ''`), поэтому мусора не даёт. Дыра — только в
слепом поиске по имени.
Дополнительно: **привязанные клиентом карточки элемента (`cards`) движок вообще не использует**
в отпечаток идёт только имя + первый сайт. То есть точный адрес фирмы, данный клиентом, теряется.
## Принцип
**Клиент — источник истины.** Что клиент привязал (карточки, сайт) — берём без проверки и делаем
из этого **якорь холдинга**. Проверку «наша ли фирма» применяем ТОЛЬКО к тому, что движок нашёл
сам слепым поиском. Нет якоря вообще — слепые находки не берём (лучше пусто, чем стоматология).
## Изменения
1. **`Fingerprint`** — добавить поле `cards: list<string>` (URL привязанных карточек элемента).
2. **`CompetitorFingerprint::build`** — принимать `cards` из элемента и класть в отпечаток.
3. **`RealCompetitorAgent::studyCompetitor`** (ветка холдинга и одиночная) — прокидывать
`el['cards']` в `build`.
4. **`DeepStudyCollector::collect`** — до слепого поиска:
- открыть привязанные карточки (2ГИС — `reader->card`, Яндекс — `yandexDir/DirectoryParser`);
- каждую добавить в результат как строку-источник (это факт от клиента, без фильтра);
- засеять якорь: телефоны карточек → в `$anchorDigits` (подтверждены клиентом); сайт карточки →
если у отпечатка сайта нет, использовать как seed для гейта.
5. **`AnchorGate::cardKept`** — убрать «пустой seed → keep всё». Новое правило:
- есть привязка (seed-домен ИЛИ якорные цифры) → держим только при `siteHit || phoneHit`;
- якоря нет совсем (ни сайта, ни карточек с телефоном) → `false` (слепую находку не берём).
6. **`collect2gis` / `collectYandex`** — при пустом якоре слепой поиск по имени пропускаем
(нечем валидировать; экономим время и не тащим мусор).
## Покрытие комбинаций
| У элемента | Поведение | Итог |
|---|---|---|
| Сайт (+/− карточка) | сайт-якорь; поиск+разворот с фильтром | полно, чисто (Метрополис) |
| Только карточка, без сайта | карточку берём как источник; её телефон → якорь; слепой поиск фильтруется этим телефоном | карточка сохранена, салоны отсечены (Притяжение 002005) |
| Только имя, ни сайта ни карточки | якоря нет → слепой поиск не берём | пусто вместо мусора |
| Весь конкурент без сайта, только карточки | каждую карточку берём напрямую | находим всё, ноль шума |
| Широкое имя + якорь | слепые чужие не бьются с якорем → вон | стоматология отсекается |
## Тесты (TDD)
- **AnchorGate**: пустой якорь → `false` (заменить текущий «keep всё»); телефон-якорь держит
своё и режет чужой (уже есть — оставить).
- **DeepStudyCollector**: элемент без сайта, но с привязанной 2ГИС-карточкой (телефон P):
- карточка присутствует в результате как источник;
- слепой поиск, вернувший чужую фирму с номером ≠ P, эту фирму **не** добавляет.
- **DeepStudyCollector**: элемент без сайта и без карточек → слепые находки не берутся (пусто).
- Регресс: элемент с сайтом — поведение прежнее (Http-fake сайта, номера собираются).
## Вне scope (отдельно)
- Поиск по имени самого конкурента («Сибагропромстрой») — потенциальный доп-источник, обсудить
после; несёт свой риск шума, не смешивать с этой починкой.
- Защита по таймауту (>5 мин) — отложена ранее, вернуться после.