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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 мин) — отложена ранее, вернуться после.
|
||||
Reference in New Issue
Block a user