diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index 87696a7f..14ef1c6d 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -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]); } /** diff --git a/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php index c5e95fb5..ed1c2b93 100644 --- a/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php +++ b/app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php @@ -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; diff --git a/app/app/Services/Autopodbor/Agent/Study/AnchorGate.php b/app/app/Services/Autopodbor/Agent/Study/AnchorGate.php index f8a4c537..7e35bd0d 100644 --- a/app/app/Services/Autopodbor/Agent/Study/AnchorGate.php +++ b/app/app/Services/Autopodbor/Agent/Study/AnchorGate.php @@ -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; diff --git a/app/app/Services/Autopodbor/Agent/Study/CompetitorFingerprint.php b/app/app/Services/Autopodbor/Agent/Study/CompetitorFingerprint.php index 50ee3939..4a347275 100644 --- a/app/app/Services/Autopodbor/Agent/Study/CompetitorFingerprint.php +++ b/app/app/Services/Autopodbor/Agent/Study/CompetitorFingerprint.php @@ -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, ); } } diff --git a/app/app/Services/Autopodbor/Agent/Study/DeepStudyCollector.php b/app/app/Services/Autopodbor/Agent/Study/DeepStudyCollector.php index b42464a5..f6db32da 100644 --- a/app/app/Services/Autopodbor/Agent/Study/DeepStudyCollector.php +++ b/app/app/Services/Autopodbor/Agent/Study/DeepStudyCollector.php @@ -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 $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 + */ + 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 $anchorDigits */ private function pushCard(string $source, ?string $number, ?string $site, string $url, string $seedDomain, array $anchorDigits): void { diff --git a/app/app/Services/Autopodbor/Agent/Study/Fingerprint.php b/app/app/Services/Autopodbor/Agent/Study/Fingerprint.php index ec8ba712..5a8dfd21 100644 --- a/app/app/Services/Autopodbor/Agent/Study/Fingerprint.php +++ b/app/app/Services/Autopodbor/Agent/Study/Fingerprint.php @@ -14,5 +14,7 @@ final class Fingerprint public readonly string $gisSlug, // '' если гео неизвестно public readonly bool $isFederal, public readonly string $yandexSlug = '', + /** @var list URL привязанных клиентом карточек 2ГИС/Яндекс — «земля», якорь холдинга. */ + public readonly array $cards = [], ) {} } diff --git a/app/app/Services/Autopodbor/Agent/Study/ParallelPhoneReverse.php b/app/app/Services/Autopodbor/Agent/Study/ParallelPhoneReverse.php new file mode 100644 index 00000000..0133d99b --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Study/ParallelPhoneReverse.php @@ -0,0 +1,72 @@ +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 $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 $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); + } +} diff --git a/app/config/autopodbor.php b/app/config/autopodbor.php index 947de3fb..9be022fe 100644 --- a/app/config/autopodbor.php +++ b/app/config/autopodbor.php @@ -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 diff --git a/app/tests/Feature/Autopodbor/DeepStudyClientCardAnchorTest.php b/app/tests/Feature/Autopodbor/DeepStudyClientCardAnchorTest.php new file mode 100644 index 00000000..905c4796 --- /dev/null +++ b/app/tests/Feature/Autopodbor/DeepStudyClientCardAnchorTest.php @@ -0,0 +1,114 @@ +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([]); +}); diff --git a/app/tests/Unit/Autopodbor/Study/AnchorGateTest.php b/app/tests/Unit/Autopodbor/Study/AnchorGateTest.php index ac3c8488..45f84d0b 100644 --- a/app/tests/Unit/Autopodbor/Study/AnchorGateTest.php +++ b/app/tests/Unit/Autopodbor/Study/AnchorGateTest.php @@ -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(); // чужой салон }); diff --git a/app/tests/Unit/Autopodbor/Study/ParallelPhoneReverseTest.php b/app/tests/Unit/Autopodbor/Study/ParallelPhoneReverseTest.php new file mode 100644 index 00000000..896b2e0b --- /dev/null +++ b/app/tests/Unit/Autopodbor/Study/ParallelPhoneReverseTest.php @@ -0,0 +1,54 @@ +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([]); +}); diff --git a/docs/superpowers/specs/2026-07-06-avtopodbor-site-anchor-search-fix.md b/docs/superpowers/specs/2026-07-06-avtopodbor-site-anchor-search-fix.md new file mode 100644 index 00000000..e76e70e1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-avtopodbor-site-anchor-search-fix.md @@ -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` (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 мин) — отложена ранее, вернуться после.