diff --git a/docs/superpowers/specs/2026-07-03-avtopodbor-deep-study-step2-design.md b/docs/superpowers/specs/2026-07-03-avtopodbor-deep-study-step2-design.md new file mode 100644 index 00000000..a069c9c2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-avtopodbor-deep-study-step2-design.md @@ -0,0 +1,142 @@ +# Перенос движка «глобального шага 2» песочницы в боевой шаг 2 — дизайн + +**Дата:** 2026-07-03 +**Ветка:** `worktree-avtopodbor` (НЕ прод) +**Прототип-источник:** `app/scripts/sandbox-step2.php` (закоммичен, движок отработан на 5 конкурентах, владельцем принят) + +--- + +## Закон этого переноса + +> **Результат боевого шага 2 = строго один-в-один с песочницей.** +> Никаких новых поведений, «улучшений» или заточки под нишу (займы/ломбарды — только тестовый пример). +> Что песочница показывает на 5 конкурентах — то же выдаёт боевой шаг 2. + +Единственное допустимое расхождение прогон-к-прогону — **конкретный подменный коллтрекинг-номер** (крутится ротацией; принято владельцем). Настоящие номера, состав источников, пометки, оба сайта-близнеца **порознь** — идентичны. + +**Явные НЕ-цели (hard):** +- **НЕ склеивать сайты-близнецы.** `красломбард.рф` и `kraslombard24.ru` — **две отдельные записи-источника**, не одна. Логика близнеца лишь *признаёт* второй сайт «своим» (чтобы не выкинуть как чужой) и выводит его **отдельной строкой**. +- НЕ трогать формат хранения (`autopodbor_sources`), биллинг (900₽/изучение), идемпотентность (`studied_at`). +- НЕ трогать боевой прод. Всё в воркстри на тест-базе; выкат — только по слову владельца. + +--- + +## Цель + +Боевой `RealCompetitorAgent::studyCompetitor()` сейчас изучает конкурента **только по ссылкам из шага 1** (`site_url` + `directory_urls`). Песочница делает шаг 2 **самостоятельным**: от имени+города+сайта сама доищет след и вскроет его глубже. Переносим полный движок песочницы в боевой шаг 2 **за тумблером**, старое поведение остаётся запасным. + +--- + +## Что уже общее (переиспользуем как есть) + +Песочница и боевой шаг 2 уже делят кубики (менять не нужно): +- `CandidateBuilder::build()` — разметка номеров по kind (`code` / `displayed` / `pool` / `contacts` / `uncertain`). +- `SourceAggregator::aggregate()` — дедуп по номеру, сортировка, `phone_kind` / `phone_type` / `confirmations`. +- `CurlPlaywrightFetcher` — вскрытие сайта (curl-код + Playwright видимые/пул/Контакты). +- `XfetchClient`, `XfetchDirectoryFetcher`, `PlaywrightPageFetcher`, `RoutingBatchFetcher` — загрузка. +- `DirectoryParser` — `parseBranchList` / `parse2gisCard` (JSON, с фильтром рекламных кнопок) / `parseYandexCard` / `parseFirmCard`. +- `AutopodborNormalizer` — нормализация телефона/домена. + +**Дельта (чего в боевом нет, а в песочнице есть):** поиск следа через EXA, классификация площадок, вскрытие своих сайтов с якорным фильтром «точно она», 2ГИС-поиск по имени фирмы, добор Яндекса через Playwright, признание близнецов. Именно это и переносим. + +--- + +## Архитектура: разложение движка на проверяемые кубики + +Новый код — пакет `app/app/Services/Autopodbor/Agent/Study/`. Оркестратор дёргает мелкие модули; каждый модуль — одна ответственность, тестируется отдельно. + +### 1. `CompetitorFingerprint` (чистый) +Из записи конкурента строит отпечаток: `name`, `city`, `areaCode`, `gisSlug`, `seedDomain` (из `site_url`, со схемой если её нет — строка 63–68 песочницы), `isFederal`. Регион → `[город, код, слаг 2ГИС]` через `RegionAreaCode`/карту регионов. +**Вход:** competitor-массив + regionCode. **Выход:** DTO `Fingerprint`. + +### 2. `FootprintQueryBuilder` (чистый) +Из отпечатка → список запросов EXA (строки 126–131 песочницы: регионал vs федерал + seed-домен). + +### 3. `FootprintSearch` (I/O, обёртка EXA) +Гоняет запросы через существующий EXA-клиент (переиспользовать канал шага 1, `services.exa`), собирает уникальные URL. Пустой ключ/сбой → пустой список (не падать). + +### 4. `PlatformClassifier` (чистый) +URL-ы → корзины: `own` (сайты фирмы) / `gis` (только `/firm/`) / `yandex` (только `/maps/org/`) / `directory` / `social` / `skip`. Списки `DIRECTORY` / `SOCIAL` / `SKIP` (регуляторы, реестры, агрегаторы, госсайты) — строки 192–225 песочницы. **Универсально, без нишевых слов.** + +### 5. `DomainKinship` (чистый) — БЕЗ СЛИЯНИЯ +`coreOf` (punycode→unicode, без TLD/разделителей) + `translitCore` (кир→лат) + `twinMatch` (один корень содержит другой, мин. длина 5). **Только для решения «признать сайт своим»** — строки 82–118 песочницы. Результат — булево «это тот же бренд». **Слияния источников не делает; оба домена остаются отдельными записями.** + +### 6. `SiteOpener` + `AnchorGate` (I/O + чистый) +- `SiteOpener` вскрывает один сайт через `CurlPlaywrightFetcher` + `CandidateBuilder` (с ретраем на пустой HTML) → номера с kind, флаг коллтрекинга, «код»-цифры (строки 267–294). +- `AnchorGate` (чистый) решает «точно она» для домена: `isSeed` ИЛИ `isTwin` (через `DomainKinship`) ИЛИ на странице встретился якорный телефон ИЛИ ссылка на seed (строки 310–321). Якоря (`anchorDigits`) копятся **только с подтверждённых** сайтов. +- Порядок: seed первым, затем остальные `own` (до 8). Каждый **подтверждённый** домен — **отдельная запись-источник** (сайт) + его номера. + +### 7. `TwoGisByName` (I/O) +2ГИС-поиск по имени: `https://2gis.ru/{slug}/search/{name}` через `xfetch` с ретраем (до 4, флакует) → `parseBranchList` → ссылки `/firm/`; плюс `/firm/`-ссылки из EXA; дедуп; кап 12; каждую карточку — `parse2gisCard` (строки 362–389). + +### 8. `YandexByPlaywright` (I/O) +Яндекс-карточки (`/maps/org/`, кап 5) через `XfetchDirectoryFetcher` на `RoutingBatchFetcher` (Яндекс→Playwright, иначе xfetch) + `parseYandexCard` (строки 391–401). + +### 9. `CardGate` (чистый) +Фильтр карточки справочника «точно она»: сайт карточки содержит seed-домен ИЛИ телефон = якорь (иначе чужая фирма) — строки 343–349. `seedDomain===''` → пропускать всё (как в песочнице). + +### 10. `DeepStudyCollector` (оркестратор) +Собирает всё по последовательности песочницы: отпечаток → EXA → классификация → вскрытие своих сайтов + якоря → 2ГИС-по-имени → Яндекс → карточный фильтр. На выходе — те же кандидаты (сайты + номера с «где нашли»), что и песочница. + +### 11. Маппинг в контракт `StudyCompetitorResult` +Кандидаты (сайты + телефоны) прогоняются через `SourceAggregator` (как уже делает боевой) → строки контракта: +`signal_type` (`site`/`call`), `identifier`, `phone_kind` (`real`/`substitute`/`uncertain`), `phone_type`, `provenance_url/label`, `where_found` (`[{label,url}]`), `office`, `confirmations`. +**Словарь меток «где нашли» должен совпадать с песочницей** (`в коде сайта`, `коллтрекинг`, `страница «Контакты»`, `2ГИС`, `Яндекс.Карты`, домен сайта). Оба сайта-близнеца → две записи `signal_type='site'`. + +--- + +## Поток данных (боевой, за тумблером) + +``` +POST /api/autopodbor/study (competitor_id) + → AutopodborRunService::startStudy (in-flight, priceGate, studied_at) — БЕЗ ИЗМЕНЕНИЙ + → RunAutopodborStudyJob (RLS tenant, идемпотентность) — БЕЗ ИЗМЕНЕНИЙ + → RealCompetitorAgent::studyCompetitor + ├─ ТУМБЛЕР autopodbor.deep_study ВЫКЛ → старый путь (site_url + directory_urls) [как сейчас] + └─ ТУМБЛЕР ВКЛ → DeepStudyCollector (движок песочницы) → кандидаты + → SourceAggregator → StudyCompetitorResult (тот же контракт) + → AutopodborDedup → AutopodborSource::updateOrCreate (box='proposal') — БЕЗ ИЗМЕНЕНИЙ + → chargeForRun (900₽) → competitor.studied_at — БЕЗ ИЗМЕНЕНИЙ +``` + +## Тумблер + +`config('autopodbor.deep_study')` (env `AUTOPODBOR_DEEP_STUDY`). В воркстри/тестах — ВКЛ. На проде — по умолчанию ВЫКЛ до слова владельца (мгновенный откат = ВЫКЛ). По духу тумблера `autopodbor.real_find` шага 1. + +## Обработка ошибок +- EXA пустой ключ/сбой → пустой список, шаг 2 не падает (работает по seed-сайту, если есть). +- Сайт не открылся (пустой HTML) → один ретрай; всё равно пусто → домен помечается «не открылась», не роняет прогон. +- 2ГИС-поиск флакует → ретрай до 4; не нашлось → работаем по EXA-ссылкам 2ГИС. +- Яндекс-карточка упала → лог, пропуск, прогон продолжается. +- Любой необработанный сбой ловится джобой → `status='failed'`, без списания (существующее поведение). + +--- + +## Тестирование (TDD) + +**Юнит (чистые кубики — без сети):** +- `DomainKinship`: `авто-займ-124.рф`↔`автозайм-124.рф` = близнецы; `красломбард.рф`↔`kraslombard24.ru` = близнецы (транслит); `sberbank.ru`↔`vtb.ru` = нет; **и отдельный тест: близнецы дают ДВЕ записи, не сливаются.** +- `PlatformClassifier`: `2gis.ru/.../firm/..`→gis; `2gis.ru/.../search/..`→не gis; `yandex.ru/maps/org/..`→yandex; `cbr.ru`/`banki.ru`/`rusprofile.ru`→skip; `vk.com`→social; `zoon.ru`→directory; сайт фирмы→own. +- `FootprintQueryBuilder`: регионал vs федерал дают разные запросы; seed добавляется. +- `AnchorGate`: seed→точно; близнец→точно; чужой без якоря→нет; чужой с якорным телефоном на странице→точно. +- `CardGate`: сайт-карточки с seed→keep; телефон-якорь→keep; чужой→drop; пустой seed→keep всё. + +**Интеграция (сеть замокана фикстурами HTML):** +- `SiteOpener` на сохранённом HTML → правильные kind (код/подменный/Контакты). +- `TwoGisByName` на фикстуре поиска+карточки → номера, фильтр рекламных кнопок. +- `DeepStudyCollector` end-to-end на фикстурах → ожидаемые кандидаты; **оба сайта-близнеца присутствуют раздельно.** +- Маппинг в `StudyCompetitorResult` → метки «где нашли» совпадают со словарём песочницы. + +**Флаг:** тест «ВЫКЛ → старый путь», «ВКЛ → DeepStudyCollector». + +## Приёмка владельцем +После сборки прогнать **те же 5 конкурентов** (385, 412, 414, 416, 420, tenant 36) через настоящий шаг 2 портала (тумблер ВКЛ, тест-база) и сверить: **номера, настоящие/подменные пометки, состав сайтов (оба близнеца порознь), 2ГИС/Яндекс** — один-в-один с одобренными артефактами `bylo-stanet/`. Расхождение допустимо только по конкретному ротационному подменному номеру. + +--- + +## Структура файлов + +- Создать: `app/app/Services/Autopodbor/Agent/Study/CompetitorFingerprint.php`, `FootprintQueryBuilder.php`, `FootprintSearch.php`, `PlatformClassifier.php`, `DomainKinship.php`, `SiteOpener.php`, `AnchorGate.php`, `TwoGisByName.php`, `YandexByPlaywright.php`, `CardGate.php`, `DeepStudyCollector.php` (+ мелкие DTO по месту). +- Изменить: `app/app/Services/Autopodbor/Agent/RealCompetitorAgent.php` (развилка по тумблеру), `app/config/autopodbor.php` (ключ `deep_study`), DI-привязки в `AutopodborServiceProvider` (собрать `DeepStudyCollector` из зависимостей). +- Тесты: `app/tests/Unit/Autopodbor/Study/*`, `app/tests/Feature/Autopodbor/DeepStudyCollectorTest.php`, фикстуры HTML в `app/tests/Fixtures/Autopodbor/`. +- НЕ трогать: миграции/схему, `AutopodborRunService`, `RunAutopodborStudyJob`, `AutopodborChargeService`, `AutopodborSource`, фронт.