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:
@@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user