docs(автоподбор): дизайн переноса движка «глобального шага 2» в боевой шаг 2

Закон: результат боевого шага 2 строго 1:1 с песочницей. Полный движок за
тумблером autopodbor.deep_study, старый путь — запасной. Явная НЕ-цель:
сайты-близнецы НЕ сливать (красломбард.рф и kraslombard24 — две отдельные
записи). Хранение/биллинг/идемпотентность не трогаем. Приёмка — те же 5
конкурентов через боевой шаг 2 = одобренные артефакты bylo-stanet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-03 22:17:19 +03:00
parent 2b18922db6
commit 07fc46d524
@@ -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` (строки 362389).
### 8. `YandexByPlaywright` (I/O)
Яндекс-карточки (`/maps/org/`, кап 5) через `XfetchDirectoryFetcher` на `RoutingBatchFetcher` (Яндекс→Playwright, иначе xfetch) + `parseYandexCard` (строки 391401).
### 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`, фронт.