queries;
}
};
}
function fakeResearcher(array $answers): ResearcherClient
{
return new class($answers) implements ResearcherClient
{
private int $i = 0;
public function __construct(private array $answers) {}
public function research(string $system, string $user): string
{
// Инстанс-счётчик (НЕ static): иначе состояние течёт между тестами и ломает порядок ответов.
return $this->answers[$this->i++] ?? '[]';
}
};
}
function noAiAssembler(): FindCompetitorsAssembler
{
$nullClassifier = new class implements AggregatorClassifier
{
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool
{
return null;
}
};
$zeroEmbedder = new class implements Embedder
{
public function embed(array $texts): array
{
return array_map(fn () => [0.0], $texts);
}
};
return new FindCompetitorsAssembler(
new AggregatorFilter($nullClassifier),
new AutopodborDedup(new AutopodborNormalizer),
new EmbeddingRelevance($zeroEmbedder),
);
}
function fakeYandex(array $rows): YandexDirectory
{
return new class($rows) implements YandexDirectory
{
public function __construct(private array $rows) {}
public function collect(string $city, array $queries): array
{
return $this->rows;
}
};
}
function leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, array $queries, array $yandexRows = []): LiveFindCompetitors
{
return new LiveFindCompetitors(
fakeAnalyzer($queries),
new CategoryScraper($pages, new CategoryListingParser, maxPages: 2),
fakeYandex($yandexRows),
new ChannelBSearch($researcher, new ResearcherParser),
new ExaSiteFinder($http),
noAiAssembler(),
);
}
/** Сборщик с НАСТОЯЩИМ (не нулевым) эмбеддером — чтобы проверить, ЧТО уходит в центр похожести. */
function embedAssembler(Embedder $embedder): FindCompetitorsAssembler
{
$nullClassifier = new class implements AggregatorClassifier
{
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool
{
return null;
}
};
return new FindCompetitorsAssembler(
new AggregatorFilter($nullClassifier),
new AutopodborDedup(new AutopodborNormalizer),
new EmbeddingRelevance($embedder),
);
}
/** Детерминированный эмбеддер по подстроке (нет совпадения → нулевой вектор). */
function needleEmbedder(array $byNeedle): Embedder
{
return new class($byNeedle) implements Embedder
{
public function __construct(private array $map) {}
public function embed(array $texts): array
{
return array_map(function (string $t): array {
foreach ($this->map as $needle => $vec) {
if (str_contains($t, $needle)) {
return $vec;
}
}
return [0.0, 0.0, 0.0];
}, $texts);
}
};
}
function leanEngineEmbed($pages, ResearcherClient $researcher, HttpFactory $http, array $queries, array $yandexRows, Embedder $embedder): LiveFindCompetitors
{
return new LiveFindCompetitors(
fakeAnalyzer($queries),
new CategoryScraper($pages, new CategoryListingParser, maxPages: 2),
fakeYandex($yandexRows),
new ChannelBSearch($researcher, new ResearcherParser),
new ExaSiteFinder($http),
embedAssembler($embedder),
);
}
it('сквозной: канал А (2ГИС-список: имя+карточка+сайт) + канал В (федерал с сайтом EXA)', function () {
Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30]);
$http = app(HttpFactory::class);
$http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]);
$pages = stubPages([
'2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']]),
]);
$res = leanEngine($pages, fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]', '[]']), $http, ['ломбард'])
->find(new FindCompetitorsRequest(
regionCode: 29,
examples: ['automoney-krsk.ru'],
aboutSelf: ['займы под залог авто', 'lkomega.ru'],
includeFederal: true,
maxCompetitors: 20,
));
$kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард');
expect($kl)->not->toBeNull()
->and($kl['site_url'])->toBe('kraslombard24.ru')
->and($kl['is_federal'])->toBeFalse()
->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, '/firm/985690700540003')))->toBeTrue();
$cm = collect($res->competitors)->firstWhere('name', 'CarMoney');
expect($cm)->not->toBeNull()
->and($cm['is_federal'])->toBeTrue()
->and($cm['site_url'])->toBe('carmoney.ru');
});
it('канал В: модель даёт только имена → сайт каждого находит EXA (site/card от модели игнорим)', function () {
Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30]);
$http = app(HttpFactory::class);
// EXA — единственный источник сайта для федерала. Модель могла бы положить свой site/card —
// движок их НЕ использует, берёт сайт из EXA.
$http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://bistrodengi.ru/']]], 200)]);
$pages = stubPages(['2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']])]);
$answer = '[{"name":"Быстроденьги","type":"федеральная","site":"model-ignored.ru","card":"https://2gis.ru/moscow/firm/777"}]';
$res = leanEngine($pages, fakeResearcher([$answer, '[]']), $http, ['ломбард'])
->find(new FindCompetitorsRequest(29, [], ['займы под залог', 'lkomega.ru'], true, 20));
$bd = collect($res->competitors)->firstWhere('name', 'Быстроденьги');
expect($bd)->not->toBeNull()
->and($bd['site_url'])->toBe('bistrodengi.ru') // сайт из EXA, НЕ model-ignored.ru
->and($bd['directory_urls'])->toBe([]); // карточку от модели больше не кладём
});
it('«вскрыть всё»: яндекс-кандидат без сайта получает сайт из карточки → склеивается с 2ГИС-дублем', function () {
Config::set('services.exa.key', '');
$http = app(HttpFactory::class);
$yaCard = '
Автодом24 — Яндекс Карты'
.'';
$pages = stubPages([
'2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'AutoMoney', 'automoney-krsk.ru']]),
'yandex.ru/maps/org/avtodom24' => $yaCard,
]);
$yandex = [['name' => 'Автодом24', 'card_url' => 'https://yandex.ru/maps/org/avtodom24/1']];
$engine = new LiveFindCompetitors(
fakeAnalyzer(['автоломбард']),
new CategoryScraper($pages, new CategoryListingParser, maxPages: 2),
fakeYandex($yandex),
new ChannelBSearch(fakeResearcher(['[]']), new ResearcherParser),
new ExaSiteFinder($http),
noAiAssembler(),
enricher: new CompetitorCardEnricher($pages, new DirectoryParser),
);
$res = $engine->find(new FindCompetitorsRequest(29, [], ['займы под залог', 'lkomega.ru'], false, 20));
// AutoMoney (сайт из 2ГИС-списка) и Автодом24 (сайт вскрыт из яндекс-карточки) — один домен → одна карточка
$withDomain = collect($res->competitors)->filter(fn ($c) => str_contains((string) ($c['site_url'] ?? ''), 'automoney-krsk'));
expect($withDomain)->toHaveCount(1);
});
it('слияние 2ГИС+Яндекс: один конкурент в обоих справочниках → одна карточка с двумя ссылками', function () {
Config::set('services.exa.key', '');
$http = app(HttpFactory::class);
$pages = stubPages([
'2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']]),
]);
// Яндекс дал того же КрасЛомбарда (без сайта) + новую фирму, которой нет в 2ГИС
$yandex = [
['name' => 'КрасЛомбард', 'card_url' => 'https://yandex.ru/maps/org/kraslombard/175852236692'],
['name' => 'ЯрКомиссионка', 'card_url' => 'https://yandex.ru/maps/org/yarkomissionka/226908207223'],
];
$res = leanEngine($pages, fakeResearcher(['[]']), $http, ['ломбард'], $yandex)
->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20));
$names = array_column($res->competitors, 'name');
// КрасЛомбард — один раз (2ГИС+Яндекс слиты), плюс новая из Яндекса
expect(array_count_values($names)['КрасЛомбард'])->toBe(1)
->and($names)->toContain('ЯрКомиссионка');
// у слитого КрасЛомбарда — обе ссылки-справочники и сайт из 2ГИС
$kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард');
expect($kl['site_url'])->toBe('kraslombard24.ru')
->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, '2gis.ru')))->toBeTrue()
->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, 'yandex.ru')))->toBeTrue();
});
it('канал В: имя без сайта в EXA выкидывается (нет якоря)', function () {
Config::set('services.exa.key', 'k');
$http = app(HttpFactory::class);
// EXA не нашла подходящего сайта (только агрегатор) → site=null → имя выкинуто
$http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://zoon.ru/x']]], 200)]);
$pages = stubPages(['2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']])]);
$res = leanEngine($pages, fakeResearcher(['[{"name":"Призрак","type":"федеральная"}]', '[]']), $http, ['ломбард'])
->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], true, 20));
$names = array_column($res->competitors, 'name');
expect($names)->toContain('КрасЛомбард')->and($names)->not->toContain('Призрак');
});
it('includeFederal=false → канал В выключен, остаётся местная А', function () {
Config::set('services.exa.key', 'k');
$http = app(HttpFactory::class);
$http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]);
$pages = stubPages(['2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']])]);
$res = leanEngine($pages, fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]']), $http, ['ломбард'])
->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20));
$names = array_column($res->competitors, 'name');
expect($names)->toContain('КрасЛомбард')->and($names)->not->toContain('CarMoney');
});
it('пусто везде → пустой список, без падения', function () {
Config::set('services.exa.key', '');
$res = leanEngine(stubPages([]), fakeResearcher(['[]', '[]']), app(HttpFactory::class), ['ничего'])
->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], true, 10));
expect($res->competitors)->toBe([]);
});
it('центр похожести берёт описание ниши (а не только домены-примеры)', function () {
Config::set('services.exa.key', '');
$http = app(HttpFactory::class);
// Два конкурента из Яндекса, с рубрикой-описанием. Один — по нише клиента, другой — нет.
$yandex = [
['name' => 'ЗалогЦентр', 'card_url' => 'https://yandex.ru/maps/org/zalogcentr/1', 'description' => 'займ под залог авто'],
['name' => 'Окна Комфорт', 'card_url' => 'https://yandex.ru/maps/org/okna/2', 'description' => 'пластиковые окна'],
];
// Ниша «займы под залог авто» ≡ вектор [1,0,0]; окна — ортогональны.
$embedder = needleEmbedder(['залог авто' => [1.0, 0.0, 0.0], 'пластиковые окна' => [0.0, 1.0, 0.0]]);
// examples ПУСТ — на старом коде центр был бы пуст → все 0%. Ниша в «о себе» должна спасти.
$res = leanEngineEmbed(stubPages([]), fakeResearcher(['[]']), $http, ['ломбард'], $yandex, $embedder)
->find(new FindCompetitorsRequest(29, [], ['займы под залог авто', 'lkomega.ru'], false, 20));
expect(array_column($res->competitors, 'name'))->toBe(['ЗалогЦентр', 'Окна Комфорт']);
$zc = collect($res->competitors)->firstWhere('name', 'ЗалогЦентр');
$ok = collect($res->competitors)->firstWhere('name', 'Окна Комфорт');
expect($zc['relevance_pct'])->toBe(100) // по нише — попадание
->and($ok['relevance_pct'])->toBe(0); // не по нише — мимо
});
it('свой домен клиента в «о себе» НЕ тащится в центр (иначе центр смещён)', function () {
Config::set('services.exa.key', '');
$http = app(HttpFactory::class);
$yandex = [
['name' => 'ЗалогЦентр', 'card_url' => 'https://yandex.ru/maps/org/zalogcentr/1', 'description' => 'займ под залог авто'],
];
// Ниша ≡ [1,0,0]; домен клиента lkomega.ru ≡ [0,1,0] (ортогонален). Если домен попадёт в центр —
// центр станет [0.5,0.5,0], косинус к кандидату [1,0,0] упадёт до ~71%. Значит домен вычитаем.
$embedder = needleEmbedder(['залог авто' => [1.0, 0.0, 0.0], 'lkomega.ru' => [0.0, 1.0, 0.0]]);
$res = leanEngineEmbed(stubPages([]), fakeResearcher(['[]']), $http, ['ломбард'], $yandex, $embedder)
->find(new FindCompetitorsRequest(29, [], ['займы под залог авто', 'lkomega.ru'], false, 20));
$zc = collect($res->competitors)->firstWhere('name', 'ЗалогЦентр');
expect($zc['relevance_pct'])->toBe(100); // домен клиента не сместил центр
});