5d40de664e
Шаг 1 = только имя+сайт+ссылка на справочник (телефоны/описание = шаг 2). Убраны медленные
per-card открытия: со страницы категории 2ГИС берём по фирме имя + ссылку карточки + САЙТ сразу
(CategoryListingParser: сайт из редиректа «Перейти на сайт», firmId в пути = id фирмы; мусор
avito/max.ru/трекеры/иностр.TLD отсеиваем). CategoryScraper отдаёт строки {name,card_url,site}
с пагинацией. LiveFindCompetitors упрощён: канал А из списка (без резолва), канал В — имена →
сайт EXA, без сайта (нет якоря) выкидываем. Живой канал А на новом ключе xfetch: 48 реальных
фирм Красноярска, 17 с сайтом, ~4.5 мин, без per-card. Филиалы схлопывает merge (union-find).
Модуль 135 unit + 74 feature зелёные. За флагом autopodbor.real_find; на проде не меняется.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
157 lines
6.8 KiB
PHP
157 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier;
|
|
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
|
|
use App\Services\Autopodbor\Agent\ChannelA\CategoryListingParser;
|
|
use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper;
|
|
use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer;
|
|
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
|
|
use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder;
|
|
use App\Services\Autopodbor\Agent\ChannelB\ResearcherClient;
|
|
use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser;
|
|
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
|
|
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
|
|
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
|
|
use App\Services\Autopodbor\Agent\Similarity\Embedder;
|
|
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
|
|
use App\Services\Autopodbor\AutopodborDedup;
|
|
use App\Services\Autopodbor\AutopodborNormalizer;
|
|
use Illuminate\Http\Client\Factory as HttpFactory;
|
|
use Illuminate\Support\Facades\Config;
|
|
|
|
// Сквозной офлайн-тест ЛЁГКОГО оркестратора: АНАЛИЗ → канал А (из списка 2ГИС имя+карточка+сайт,
|
|
// без захода в карточку) → канал В (имена → сайт EXA, без сайта выкидываем) → сборка.
|
|
// ИИ-границы фейкаем; страницы — stubPages; exa — Http fake. listingHtml() — из CategoryScraperTest.
|
|
|
|
function fakeAnalyzer(array $queries): QueryAnalyzer
|
|
{
|
|
return new class($queries) implements QueryAnalyzer
|
|
{
|
|
public function __construct(private array $queries) {}
|
|
|
|
public function analyze(string $description, string $region): array
|
|
{
|
|
return $this->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 leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, array $queries): LiveFindCompetitors
|
|
{
|
|
return new LiveFindCompetitors(
|
|
fakeAnalyzer($queries),
|
|
new CategoryScraper($pages, new CategoryListingParser, maxPages: 2),
|
|
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('канал В: имя без сайта в 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([]);
|
|
});
|