queries; } }; } function fakeResearcher(array $answers): ResearcherClient { return new class($answers) implements ResearcherClient { public function __construct(private array $answers) {} public function research(string $system, string $user): string { static $i = 0; return $this->answers[$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(), ); } 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('слияние 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([]); });