feat(автоподбор): шаг1 — проводка живого findCompetitors за флагом autopodbor.real_find

LivePageFetcher (2ГИС→xfetch, Яндекс→xfetch+fallback локальный Playwright). Провайдер при
config('autopodbor.real_find')=true собирает LiveFindCompetitors (без ИИ-ключа: null-классификатор
+ нулевой эмбеддер → сырой список без отсева площадок и без %). RealCompetitorAgent.findCompetitors
использует живой движок, если подключён, иначе заглушку. Флаг по умолчанию ВЫКЛ — на проде без изменений.

Тесты: Автоподбор unit+feature 172/172 (флаг выкл — биндинг цел); Pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-30 18:26:38 +03:00
parent 84e769e454
commit 48e65d231c
4 changed files with 135 additions and 8 deletions
@@ -2,24 +2,38 @@
namespace App\Providers;
use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier;
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
use App\Services\Autopodbor\Agent\Fetch\CompositeFetcher;
use App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher;
use App\Services\Autopodbor\Agent\Fetch\LivePageFetcher;
use App\Services\Autopodbor\Agent\Fetch\XfetchClient;
use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
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;
use Illuminate\Support\ServiceProvider;
class AutopodborServiceProvider extends ServiceProvider
{
public function register(): void
{
// Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным
// curl + локальный Playwright (бесплатно), справочники 2ГИС/Яндекс — через антибот
// xfetch.ru. Поиск/резолв (шаг 1) ещё не реальны — RealCompetitorAgent делегирует
// их заглушке FakeCompetitorAgent (fallback). Когда шаг 1 будет готов — заменить
// fallback на настоящую реализацию find/resolve.
// Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным curl +
// локальный Playwright, справочники 2ГИС/Яндекс — через антибот xfetch.ru.
//
// Шаг 1 (поиск конкурентов): при включённом флаге autopodbor.real_find подключается ЖИВОЙ
// движок (ниша → поиск 2ГИС/Яндекс → резолв → сборка). Без ИИ-ключа отсев агрегаторов и
// похожесть-% отключены (null-классификатор + нулевой эмбеддер) — выдаётся сырой список.
// Флаг ВЫКЛ → findCompetitors отдаёт демо-заглушку (как раньше). resolveByName — заглушка.
$this->app->bind(CompetitorAgent::class, function ($app): CompetitorAgent {
$xfetch = new XfetchClient(
apiKey: config('services.xfetch.key'),
@@ -31,7 +45,44 @@ class AutopodborServiceProvider extends ServiceProvider
directoryFetcher: new XfetchDirectoryFetcher($xfetch),
);
return new RealCompetitorAgent($fetcher, new FakeCompetitorAgent);
$liveFind = config('autopodbor.real_find')
? $this->buildLiveFind($xfetch)
: null;
return new RealCompetitorAgent($fetcher, new FakeCompetitorAgent, liveFind: $liveFind);
});
}
/** Живой движок поиска шага 1 (канал А). Без ИИ-ключа — без отсева агрегаторов и без %. */
private function buildLiveFind(XfetchClient $xfetch): 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(static fn (): array => [0.0], $texts);
}
};
$assembler = new FindCompetitorsAssembler(
new AggregatorFilter($nullClassifier),
new AutopodborDedup(new AutopodborNormalizer),
new EmbeddingRelevance($zeroEmbedder),
);
return new LiveFindCompetitors(
new LivePageFetcher($xfetch),
new SearchResultsParser,
new TwoGisResolver,
new YandexResolver,
$assembler,
);
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor\Agent\Fetch;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
/**
* Живая добыча страниц для поиска конкурентов (§12.2 движка v4): 2ГИС через xfetch (обход
* антибота); Яндекс тоже через xfetch, а при пустом ответе fallback на ЛОКАЛЬНЫЙ Playwright
* (бесплатный, проверенный рендер) через scripts/render-page.cjs. Прочие домены не грузим
* (поиск конкурентов ходит только в справочники). Любая ошибка '' (как контракт PageFetcher).
*/
final class LivePageFetcher implements PageFetcher
{
public function __construct(
private readonly XfetchClient $xfetch,
private readonly string $nodeBin = 'node',
private readonly string $renderScript = 'scripts/render-page.cjs',
private readonly int $renderTimeoutSec = 90,
) {}
public function html(string $url): string
{
if (str_contains($url, '2gis.ru')) {
return $this->xfetch->html($url);
}
if (str_contains($url, 'yandex.')) {
$html = $this->xfetch->html($url);
return $html !== '' ? $html : $this->renderLocally($url);
}
return '';
}
/** Локальный Playwright-рендер (бесплатный запас для Яндекса). */
private function renderLocally(string $url): string
{
try {
$process = new Process([$this->nodeBin, base_path($this->renderScript), $url]);
$process->setTimeout($this->renderTimeoutSec);
$process->run();
if (! $process->isSuccessful()) {
return '';
}
$decoded = json_decode($process->getOutput(), true);
return is_array($decoded) && isset($decoded['html']) ? (string) $decoded['html'] : '';
} catch (ProcessTimedOutException|\Throwable) {
return '';
}
}
}
@@ -21,15 +21,17 @@ final class RealCompetitorAgent implements CompetitorAgent
{
public function __construct(
private Fetcher $fetcher,
private CompetitorAgent $fallback, // для find/resolve, пока они не реальны
private CompetitorAgent $fallback, // для resolve, и для find пока не подключён живой
private CandidateBuilder $builder = new CandidateBuilder,
private SourceAggregator $aggregator = new SourceAggregator,
private AutopodborNormalizer $norm = new AutopodborNormalizer,
private ?LiveFindCompetitors $liveFind = null, // живой поиск шага 1 (если подключён за флагом)
) {}
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult
{
return $this->fallback->findCompetitors($r);
// Подключён живой движок поиска — используем его; иначе заглушка (демо-данные).
return $this->liveFind?->find($r) ?? $this->fallback->findCompetitors($r);
}
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult
+15
View File
@@ -0,0 +1,15 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Живой движок поиска конкурентов (шаг 1)
|--------------------------------------------------------------------------
|
| true findCompetitors использует НАСТОЯЩИЙ движок (ниша поиск 2ГИС/Яндекс
| резолв сборка). false демо-заглушка FakeCompetitorAgent. По умолчанию ВЫКЛ
| включается осознанно (локально/за тумблером), т.к. ходит в живые сервисы.
|
*/
'real_find' => env('AUTOPODBOR_REAL_FIND', false),
];