feat(автоподбор): шаг1 — живой findCompetitors (канал А: ниша → 2ГИС+Яндекс → резолв → сборка)

LiveFindCompetitors: ниша из формы → поиск 2ГИС (по слагу города) + Яндекс (по «ниша город») →
парсер выдачи → резолв каждой карточки напрямую → FindCompetitorsAssembler (фильтр/слияние/
похожесть → DTO §7.2). Добыча за PageFetcher — обход тестируется офлайн на фикстурах.
RegionCity — слаг/имя города центра субъекта (как RegionAreaCode, только уверенные).

Тесты: live 3/3 (склейка 2ГИС+Яндекс одной фирмы, вычет своего сайта, пустая выдача);
модуль Автоподбора unit 99/99; Pint чисто. Провайдер ещё не флипнут — следующий шаг.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-30 18:23:29 +03:00
parent 0636a3c1b4
commit 84e769e454
3 changed files with 297 additions and 0 deletions
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
use App\Services\Autopodbor\Agent\Fetch\PageFetcher;
use App\Services\Autopodbor\Agent\Resolve\ResolvedCompetitor;
use App\Services\Autopodbor\Agent\Resolve\TwoGisResolver;
use App\Services\Autopodbor\Agent\Resolve\YandexResolver;
use App\Services\Autopodbor\Agent\Search\SearchResultsParser;
use App\Support\RegionCity;
/**
* Живой канал А (§12.1 движка v4): ниша клиента поиск 2ГИС+Яндекс резолв каждой карточки
* сборка ядра (фильтр/слияние/похожесть DTO §7.2) через {@see FindCompetitorsAssembler}.
*
* Запрос для поиска это сама «ниша» из формы (about_self[0]); ИИ-«анализ» профиля не обязателен.
* Добыча страниц за {@see PageFetcher} (живая: 2ГИС через xfetch, Яндекс через локальный
* Playwright), поэтому весь обход тестируется офлайн на фикстурах. Резолв идёт ПРЯМО по ссылке
* из выдачи (карточка уже найдена), без повторного поиска.
*/
final class LiveFindCompetitors
{
/** Предел резолвов на источник — ограничивает число живых запросов/трат за один подбор. */
private const PER_SOURCE_CAP = 20;
public function __construct(
private readonly PageFetcher $pages,
private readonly SearchResultsParser $parser,
private readonly TwoGisResolver $twoGis,
private readonly YandexResolver $yandex,
private readonly FindCompetitorsAssembler $assembler,
) {}
public function find(FindCompetitorsRequest $r): FindCompetitorsResult
{
$niche = trim((string) ($r->aboutSelf[0] ?? ''));
$city = RegionCity::name($r->regionCode) ?? '';
$candidates = [];
if ($niche !== '') {
$candidates = array_merge(
$this->fromTwoGis($niche, $r->regionCode),
$this->fromYandex($niche, $city),
);
}
// Свои сайт/имя (about_self) — для вычета себя из конкурентов.
$clientKeys = array_values(array_filter(array_map(
static fn ($v): string => (string) $v,
$r->aboutSelf,
), static fn (string $v): bool => $v !== ''));
// examples — для похожести (если подключён живой эмбеддер); без него ранжирование = 0.
$examples = array_values(array_filter(array_map(
static fn ($v): string => (string) $v,
$r->examples,
), static fn (string $v): bool => $v !== ''));
return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors);
}
/** 2ГИС: /<город>/search/<ниша> → карточки firm (только если знаем слаг города). */
private function fromTwoGis(string $niche, int $regionCode): array
{
$slug = RegionCity::slug($regionCode);
if ($slug === null) {
return []; // нет уверенного слага — пропускаем 2ГИС, остаётся Яндекс
}
$searchUrl = "https://2gis.ru/{$slug}/search/".rawurlencode($niche);
$items = array_slice($this->parser->twoGis($this->pages->html($searchUrl)), 0, self::PER_SOURCE_CAP);
$out = [];
foreach ($items as $it) {
$url = 'https://2gis.ru'.$it['path'];
$card = $this->twoGis->parse($this->pages->html($url), $url);
if ($card !== null) {
$out[] = $this->toArray($card);
}
}
return $out;
}
/** Яндекс: поиск «ниша город» → карточки org (имя/город проверяет резолвер). */
private function fromYandex(string $niche, string $city): array
{
$searchUrl = 'https://yandex.ru/maps/?text='.rawurlencode(trim($niche.' '.$city));
$items = array_slice($this->parser->yandex($this->pages->html($searchUrl)), 0, self::PER_SOURCE_CAP);
$out = [];
foreach ($items as $it) {
$url = 'https://yandex.ru'.$it['url'];
$card = $this->yandex->parse($this->pages->html($url), $url, $it['name'], $city);
if ($card !== null) {
$out[] = $this->toArray($card);
}
}
return $out;
}
/** Карточка резолвера → строка-кандидат для сборщика. */
private function toArray(ResolvedCompetitor $c): array
{
return [
'name' => $c->name,
'site_url' => $c->siteUrl,
'description' => $c->description,
'is_federal' => $c->isFederal,
'directory_urls' => $c->directoryUrl !== null ? [$c->directoryUrl] : [],
'phones' => $c->phones,
'provenance' => ['via' => 'engine', 'source' => $c->source],
];
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Город (слаг 2ГИС + отображаемое имя) административного центра субъекта РФ (1..89,
* см. {@see RussianRegions}). Нужен живому поиску шага 1: 2ГИС ищет по URL вида
* `2gis.ru/<citySlug>/search/<запрос>`, Яндекс по тексту «<ниша> <город>».
*
* Как и {@see RegionAreaCode}: внесены ТОЛЬКО уверенные центры (ключ каноничное имя
* субъекта из RussianRegions::CODE_TO_NAME); неизвестный субъект null, и движок не
* выдумывает город (для 2ГИС просто пропустит этот источник, останется Яндекс по имени субъекта).
* Карта расширяема.
*/
final class RegionCity
{
/** @var array<string, array{slug:string, name:string}> имя субъекта => {слаг 2ГИС, имя города} */
private const BY_NAME = [
'Москва' => ['slug' => 'moscow', 'name' => 'Москва'],
'Санкт-Петербург' => ['slug' => 'spb', 'name' => 'Санкт-Петербург'],
'Красноярский край' => ['slug' => 'krasnoyarsk', 'name' => 'Красноярск'],
'Новосибирская область' => ['slug' => 'novosibirsk', 'name' => 'Новосибирск'],
'Свердловская область' => ['slug' => 'ekaterinburg', 'name' => 'Екатеринбург'],
'Республика Татарстан' => ['slug' => 'kazan', 'name' => 'Казань'],
'Нижегородская область' => ['slug' => 'n_novgorod', 'name' => 'Нижний Новгород'],
'Самарская область' => ['slug' => 'samara', 'name' => 'Самара'],
'Ростовская область' => ['slug' => 'rostov', 'name' => 'Ростов-на-Дону'],
'Воронежская область' => ['slug' => 'voronezh', 'name' => 'Воронеж'],
'Пермский край' => ['slug' => 'perm', 'name' => 'Пермь'],
'Приморский край' => ['slug' => 'vladivostok', 'name' => 'Владивосток'],
'Республика Башкортостан' => ['slug' => 'ufa', 'name' => 'Уфа'],
'Челябинская область' => ['slug' => 'chelyabinsk', 'name' => 'Челябинск'],
'Краснодарский край' => ['slug' => 'krasnodar', 'name' => 'Краснодар'],
];
/** Слаг города для URL 2ГИС (`krasnoyarsk`) или null, если уверенного нет. */
public static function slug(int $subjectCode): ?string
{
return self::entry($subjectCode)['slug'] ?? null;
}
/** Имя города («Красноярск») или имя субъекта как запасной вариант (для запроса Яндекса). */
public static function name(int $subjectCode): ?string
{
$name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null;
return self::entry($subjectCode)['name'] ?? $name;
}
/** @return array{slug:string, name:string}|array{} */
private static function entry(int $subjectCode): array
{
$name = RussianRegions::CODE_TO_NAME[$subjectCode] ?? null;
if ($name === null) {
return [];
}
return self::BY_NAME[$name] ?? [];
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier;
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
use App\Services\Autopodbor\Agent\Resolve\TwoGisResolver;
use App\Services\Autopodbor\Agent\Resolve\YandexResolver;
use App\Services\Autopodbor\Agent\Search\SearchResultsParser;
use App\Services\Autopodbor\Agent\Similarity\Embedder;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
// stubPages()/autopodborFixture() — глобальные хелперы из tests/Pest.php.
function liveEngine(): LiveFindCompetitors
{
$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);
}
};
$assembler = new FindCompetitorsAssembler(
new AggregatorFilter($nullClassifier),
new AutopodborDedup(new AutopodborNormalizer),
new EmbeddingRelevance($zeroEmbedder),
);
return new LiveFindCompetitors(
stubPages([
// выдача
'2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'),
'maps/?text=' => autopodborFixture('yandex-search-kraslombard.html'),
// карточки
'/firm/' => autopodborFixture('2gis-firm-kraslombard.html'),
'/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'),
]),
new SearchResultsParser,
new TwoGisResolver,
new YandexResolver,
$assembler,
);
}
it('живой поиск по нише → настоящий конкурент, склеенный из 2ГИС и Яндекса', function () {
$res = liveEngine()->find(new FindCompetitorsRequest(
regionCode: 29, // Красноярский край → город Красноярск, слаг krasnoyarsk
examples: [],
aboutSelf: ['ломбард', 'moy-lombard.ru'], // ниша + ВАШ сайт (другой — себя НЕ вычитаем)
includeFederal: true,
maxCompetitors: 20,
));
$names = array_column($res->competitors, 'name');
expect($names)->toContain('КрасЛомбард'); // найден живым поиском по нише
$kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард');
expect($kl['site_url'])->toBe('http://kraslombard24.ru');
// склейка одной фирмы из 2ГИС + Яндекс: в «где нашли» обе ссылки справочников
expect(collect($kl['directory_urls'])->contains(fn (string $u): bool => str_contains($u, '/firm/')))->toBeTrue();
expect(collect($kl['directory_urls'])->contains(fn (string $u): bool => str_contains($u, '/maps/org/')))->toBeTrue();
});
it('свой сайт вычитается из конкурентов', function () {
$res = liveEngine()->find(new FindCompetitorsRequest(
regionCode: 29,
examples: [],
aboutSelf: ['ломбард', 'kraslombard24.ru'], // теперь КрасЛомбард = это «мы»
includeFederal: true,
maxCompetitors: 20,
));
expect(array_column($res->competitors, 'name'))->not->toContain('КрасЛомбард');
});
it('пустая выдача → пустой список, без падения', function () {
$engine = new LiveFindCompetitors(
stubPages([]), // на всё ''
new SearchResultsParser,
new TwoGisResolver,
new YandexResolver,
new FindCompetitorsAssembler(
new AggregatorFilter(new class implements AggregatorClassifier
{
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool
{
return null;
}
}),
new AutopodborDedup(new AutopodborNormalizer),
new EmbeddingRelevance(new class implements Embedder
{
public function embed(array $texts): array
{
return array_map(fn () => [0.0], $texts);
}
}),
),
);
$res = $engine->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], false, 10));
expect($res->competitors)->toBe([]);
});