Firecrawl Яндекс.Карты не рендерит (0-2 орг) — по §12.2 Яндекс берём локальным Playwright.
render-yandex-list.cjs скроллит ленту результатов → 113 орг за ~18с (быстрее xfetch-2ГИС).
YandexDirectory (граница) + PlaywrightYandexDirectory (живой, Process→node). Яндекс = имя+карточка
(сайта в списке нет — только на карточке, не открываем). Оркестратор: канал А = 2ГИС(сайт)+Яндекс,
слияние (mergeCompetitors union-find) схлопывает одного конкурента из обоих справочников в одну
карточку с двумя directory_urls; сайт из 2ГИС. Провайдер подключает живой Яндекс. listingHtml →
общий хелпер tests/Pest.php. Модуль 136 unit + 74 feature зелёные. За флагом; на проде не меняется.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Шаг 1 = только имя+сайт+ссылка на справочник (телефоны/описание = шаг 2). Убраны медленные
per-card открытия: со страницы категории 2ГИС берём по фирме имя + ссылку карточки + САЙТ сразу
(CategoryListingParser: сайт из редиректа «Перейти на сайт», firmId в пути = id фирмы; мусор
avito/max.ru/трекеры/иностр.TLD отсеиваем). CategoryScraper отдаёт строки {name,card_url,site}
с пагинацией. LiveFindCompetitors упрощён: канал А из списка (без резолва), канал В — имена →
сайт EXA, без сайта (нет якоря) выкидываем. Живой канал А на новом ключе xfetch: 48 реальных
фирм Красноярска, 17 с сайтом, ~4.5 мин, без per-card. Филиалы схлопывает merge (union-find).
Модуль 135 unit + 74 feature зелёные. За флагом autopodbor.real_find; на проде не меняется.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Замена вырожденного «одна фраза → одна страница» на §12/§11.3 финал:
- Шаг АНАЛИЗ (ChannelA\AitunnelQueryAnalyzer): описание → запросы-рубрики (мелкая модель).
- Канал А (ChannelA\CategoryScraper): скрейп категории 2ГИС с пагинацией → резолв карточек.
- Канал В (ChannelB\*): ОДНА модель sonar-reasoning-pro × 2 прохода → ТОЛЬКО имена
федералов; стоп-лист = имена из А + примеры; сайт федерала через EXA (ExaSiteFinder),
т.к. у федерала нет карточки в 2ГИС/Яндексе на регион.
- Оркестратор LiveFindCompetitors переписан: АНАЛИЗ→А→В→слияние→отсев→дедуп→похожесть→DTO.
- Провайдер перепрошит; config services.php +research_model/exa.
Похожесть — эмбеддер-модель (математически), резолвер/дедуп — без изменений.
Всё за тонкими границами, офлайн-тесты на фикстурах: модуль 130 unit + 74 feature зелёные.
Провайдер за флагом autopodbor.real_find; на проде не меняется.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RunAutopodborSearchJob по завершении (done/empty) шлёт AutopodborReadyMail на contact_email тенанта
(с числом найденных + ссылкой на портал). Письмо не роняет подбор при недоступной почте (try/catch).
Клиент ставит задачу, работает дальше, получает «готово» письмом.
Тесты: SearchJob 4/4 (вкл. Mail::assertSent); Автоподбор unit+feature 183/183; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AitunnelEmbedder (text-embedding-3-small) → косинус-похожесть конкурента на профиль клиента (%).
AitunnelAggregatorClassifier (chat) → «поставщик или площадка?» — авто-отсев Авито/Zoon/Банки.ру.
Оба деградируют сами при пустом ключе (пустые векторы → 0%, null → не выкидываем), поэтому
подключены всегда. Ключ — только в .env (services.aitunnel.key), в гит не попадает.
Тесты: AITUNNEL клиенты 9/9; Автоподбор unit+feature 182/182 (флаг real_find в тестах
принудительно ВЫКЛ через phpunit.xml — иначе findCompetitors ходил бы в сеть); Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RunAutopodborSearchJob.timeout=900 — живой поиск (2ГИС+Яндекс через антибот + обход карточек)
идёт ~4-5 мин; дефолтные 60с убивали задание на середине. Очередь поднимать с --timeout>=900.
Найдено живым прогоном по нише «ломбард/Красноярск»:
1) Яндекс-заголовок «Имя, рубрика, ГОРОД, улица, дом» — город НЕ последний сегмент; брал «дом»
как город → все карточки отбраковывались. Теперь YandexResolver: имя=первый сегмент, город —
по НАЛИЧИЮ в заголовке (DirectoryFields::titleName/titleHasCity). 2ГИС (город последний) — без изменений.
2) ResolvingAgent (ручной resolveByName) теперь берёт ГОРОД центра субъекта (RegionCity), не имя региона.
3) Страница выдачи 2ГИС/Яндекс флакует (капча/пустой layout) → LiveFindCompetitors повторяет поиск,
пока не появятся фирмы (searchRetries).
Тесты: модуль Автоподбора unit 99/99 + live 4/4; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
AggregatorFilter + граница AggregatorClassifier (§12.6): «поставщик услуги или площадка?» —
LLM-рассуждением (за границей), а не мёртвым статик-списком. Консервативно: выкидываем только
при уверенном «агрегатор»; при неуверенности (null) конкурента не теряем.
Тесты: aggregator 3/3; Pint чисто. (живой LLM-классификатор подключается на H/живом прогоне.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CompetitorPhoneEnricher: телефоны карточки → тип (city/mobile/tollfree) + регион + годность (qc=0)
через существующий DaDataPhoneClient (тот же, что резолв региона лида — §12.4, не дубль).
DaData недоступна → деградация: тип по префиксу (PhoneType), регион пуст, негоден — подбор не падает.
Номера не синтезируются, только классифицируются (берём опубликованные фирмой).
Ключ DaData — в config services.dadata (env, прод). Тест офлайн через Http::fake (реальная форма ответа).
Тесты: phone 4/4; модуль Автоподбора unit 91/91; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AutopodborDedup::mergeCompetitors — склейка одного конкурента из 3 каналов под разными
написаниями, доменом-vs-именем или общим телефоном (union-find по корню имени/домена/номеру).
Объединяет ссылки справочников и телефоны, is_federal — местная карточка перевешивает.
Вычитает самого клиента (его имя/сайт не попадают в конкурентов). Усиливает §12.11.
Размещено в существующем backend-дедупе (§7.2 «дедуп на стороне бэкенда»), а не дублем
в движке. Старый dedupCompetitors и его тесты не тронуты.
Нормалайзер: nameKey/domainRoot (alnum-ключ, ё→е) — имя «Драйв займ» сцепляется с «драйвзайм.рф».
Тесты: merge 6/6; модуль Автоподбора unit 81/81; feature dedup 3/3; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A4 CompetitorResolver (§12.3): имя+город → 2ГИС-в-городе (прямая ссылка канала А) →
иначе Яндекс (поиск имя+город → первый org → проверка) → иначе local=false.
is_federal ПО ФАКТУ: нет местной карточки + есть сайт = федерал; есть карточка = местный.
Транспорт за PageFetcher — вся логика офлайн на фикстурах.
A5 ResolvingAgent (§7.2 resolveByName): имя+region_code → кандидат
{name,description,site_url,directory_urls,provenance} из настоящей карточки; нет филиала
→ честный кандидат-заглушка. Свободное сравнение город↔субъект (DirectoryFields::localeMatches).
Хелперы тестов (stubPages/autopodborFixture) вынесены в tests/Pest.php.
Провайдер НЕ флипнут (findCompetitors всё ещё заглушка до под-блока H).
Тесты: Resolve 17/17; модуль Автоподбора unit 75/75; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Разбор реальной org-карточки Яндекс.Карт (/maps/org/<seo>/<id>) в ResolvedCompetitor:
имя/город из <title>, чистый сайт из business-urls (itemprop=url, без utm),
телефоны type=phone (→7XXXXXXXXXX), описание из categories[].name. Отбраковка
чужой фирмы того же имени: имя и город обязаны совпасть с ожидаемыми, иначе null.
Рефактор: общие извлекатели имя+город и телефоны вынесены в DirectoryFields
(используют и 2ГИС, и Яндекс) — без дублирования.
Фикстура — реальная карточка КрасЛомбарда, снята локальным Playwright (render-page.cjs).
Тесты: модуль Автоподбора unit 68/68; Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Разбор реальной карточки филиала 2ГИС (/<город>/firm/<id>) в ResolvedCompetitor:
имя из <title>, чистый сайт из contact_groups (url, не редирект link.2gis),
телефоны type=phone (нормализованы к 7XXXXXXXXXX), город из title, описание из
rubrics. Терпим к хвосту ?stat=. Пустая оболочка 2ГИС → null.
Фикстура — реальная карточка КрасЛомбарда из живого прогона шага 2 (публичный
бизнес-телефон конкурента, не клиентские ПДн); allowlist gitleaks для app/tests/fixtures/.
Тесты: A2 3/3; модуль Автоподбора unit 64/64.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Карточка резолва имени: name/site/phones/directoryUrl/source/region/description/
isFederal + isLocal(). Без ИНН (решение владельца). Офлайн-тест 3/3.
Фундамент под-блока A плана шага 1.
НЕ на проде, воркстри avtopodbor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Живой прогон вскрыл: рендер списка 2ГИС иногда отдаёт непустую «оболочку»
без ссылок на филиалы — ретрай в XfetchClient ловит только пустой ответ,
а тут карточки терялись. XfetchDirectoryFetcher теперь повторяет загрузку
списка, пока не появятся ссылки на филиалы, до 3 раз.
Проверено вживую на kraslombard24.ru: центральный 8(391)292-00-00
подтверждён И кодом сайта, И 2ГИС с адресом «улица Ладо Кецховели, 30»;
where_found/office/confirmations доходят до БД и API. 0 фальшивок.
- тест повтора списка при флаке; бэкенд автоподбора 131/131
НЕ на проде, воркстри avtopodbor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Если на сайте конкурента короткий локальный номер без кода города И на странице
нет ни одного полного номера, движок берёт код по региону конкурента. Основной
путь прежний — код со страницы; региональный код лишь запасной.
- RegionAreaCode: субъект РФ → телефонный код адм. центра, только уверенные
3-значные коды миллионников, ключ по имени из RussianRegions, неизвестный → null
- RealCompetitorAgent резолвит regionCode и пробрасывает в CandidateBuilder→HtmlPhoneScanner
- ограничение задокументировано: код центра, не выдумываем при отсутствии в карте
- тесты карты и сквозной достройки; бэкенд автоподбора 130/130
НЕ на проде, воркстри avtopodbor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Шаг 2 «Конкурентного поля»: один номер встречается в нескольких местах —
код сайта плюс карточки 2ГИС/Яндекс с разными адресами. Раньше хранилось
одно provenance_url/label — список терялся. Теперь сквозной провод
движок→контракт→джоб→БД→API; фронт уже умел показывать кликабельным
списком с подтверждениями.
- autopodbor_sources +3 колонки where_found/office/confirmations
миграция 2026_06_30_120000, идемпотентная, RLS-review APPROVE 7/7
- canon-sync schema.sql v8.59 плюс CHANGELOG, вкл. catch-up phone_type/box 29.06
- тесты бэкенда автоподбора 122/122
НЕ на проде, воркстри avtopodbor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Экран FieldCompetitorScreen:
- «где нашли» теперь кликабельные ссылки на источники (where_found из DTO),
адреса филиалов 2ГИС видны и кликабельны под номером;
- сортировка источников: больше подтверждений — выше, подменные — вниз;
- строка адреса офиса, счётчик подтверждений.
DTO SourceDto расширен полями where_found/confirmations/office (опциональны —
обратная совместимость: без них падаем на старое provenance_label).
Histoire-стори с живыми данными КрасЛомбарда (рендер настоящего компонента).
TDD: +2 теста, autopodbor-экраны 24/24. Бэкенд-провод where_found — отдельно (Plan C).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Два бага, вскрытых живым прогоном по КрасЛомбару (оба в обработке, не в xfetch):
1. Достройка коротких номеров лепила фальшивку из обрезка полного номера:
сайт делит номер на tel:+7 (391) 271 и tel:271-33-33. Обрезок 7391271
(страна+код города) ошибочно достраивался в 73917391271. Теперь обрезок
«7/8 + код города» распознаётся и выкидывается; настоящие локальные
(включая московские 771-..) — целы. Логику вынес в classifyShort/areaCode.
2. parseBranchList брал ссылку филиала только из href — на части прорисовок
2ГИС ссылка лежит в JSON-данных, филиалы терялись. Берём путь /city/firm/<id>
откуда угодно (с дедупом).
TDD: Autopodbor 49/49.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Антибот 2ГИС бьём сервисом xfetch.ru (render:true, timeout:20 —
без timeout страница не дорисовывается). Доказано на живом КрасЛомбаре:
поиск → 12 филиалов → телефон + адрес каждой карточки.
- PageFetcher — граница «достать HTML» (тестируется без сети)
- XfetchClient — POST к xfetch, декод base64; без ключа молча пусто
- XfetchDirectoryFetcher — список→филиалы→карточки через DirectoryParser
- DirectoryParser — чтение списка и карточки 2ГИС (был в хвостах)
- config services.xfetch + .env.example; ключ только в .env (gitignored)
Яндекс.Карты — отдельно (другой формат URL карточек). TDD: Autopodbor 46/46.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Москва 495, Питер 812, Екатеринбург 343 — код города берётся со страницы
конкурента или из города запроса, никакой привязки к Красноярску. Защита
от регрессии. Autopodbor 41/41.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сканер кода сайта выкидывал короткие локальные номера (271-33-33,
2-828-828 и т.п.) как мусор — терялись реальные телефоны филиалов.
Теперь короткий номер из tel:/schema/microdata достраивается кодом
города: сперва по преобладающему коду полных номеров той же страницы,
иначе по коду региона запроса; если код не определить — номер не
теряется, а помечается «требует проверки» (phoneKind=uncertain).
Из тела текста короткие формы не достраиваются (защита от ложных).
TDD: 6 новых тестов, весь Autopodbor 40/40.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CURLOPT_SSL_VERIFYPEER/VERIFYHOST включены
- isSafeUrl: только http/https, блок loopback/приватных/служебных IP
- FOLLOWLOCATION выключен, протоколы ограничены HTTP/HTTPS
- render-page.cjs валидирует схему URL перед навигацией
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Сверка прототипа с реализацией показала расхождения — закрыты по TDD (dev, фронт):
- F1: экран «Предложения» (FieldProposalsScreen) переписан под вид «Поля» —
карточки-плитки field-shared, тип+«предложение», крупная похожесть, Сайт +
Справочник 2ГИС·Яндекс, править/удалять в карточке, массовый перенос; кнопка
«Собрать конкурентов» открывает единое окно сбора 300 ₽ вместо старого autoform.
- F2: новый дружелюбный админ-экран AdminAutopodborPricingView (правка цен
доп.услуг через PUT /api/admin/system-settings/{key} с обоснованием для аудита,
сетка лидов для справки) + маршрут /admin/autopodbor-pricing + пункт меню.
- F3: колонка «когда списывается» в панели доп.услуг биллинга.
- M2: удалён мёртвый экран FieldManualCompetitorScreen (+ спека) — на него не
было переходов; ручное добавление живёт окном на «Поле».
Тесты автоподбор+админ 43/43 зелёные, продакшен-вёрстка eslint-чистая, vite build ✅.
НЕ на проде. M1 (18:00/21:00 МСК) — не баг, реальный инвариант продукта, не трогал.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Фича «Конкурентное поле» на dev до уровня прототипа 2026-06-29-konkurentnoe-pole-proto.html.
Данные: box (proposal|field) на competitors+sources; phone_type city/mobile/tollfree рядом
с phone_kind (вариант C). 3 миграции, дефолты тарифов 300/50.
API (AutopodborController): GET /field (+счётчики), GET /proposals, PATCH/DELETE competitors
и sources с гвардами активного проекта, переключение box, POST /competitors/manual (+directory_urls),
competitor(id) обогащён box+project-статусом; projectStatus отдаёт limit/delivered/days/regions.
Смена источника проекта = PATCH /api/projects/{id} (реальный гвард слепка §14.10).
Фронт: FieldWorkspaceScreen/FieldCompetitorScreen/FieldProposalsScreen/FieldManualCompetitorScreen
+ field-shared.css (Forest) + AutopodborServicesPanel в Биллинге. Дословно по прототипу: подзаголовки,
баннер предложений, баннер правил времени 18:00 МСК, Справочник 2ГИС·Яндекс, статус проекта
5/день·заявки, окна сбора с ценами 300/50 + «что известно», полные формы. Пункт меню «Конкурентное поле».
Тесты: backend автоподбор 80/80, фронт автоподбор 49/49. Движок шага 2 = заглушка FakeCompetitorAgent.
OmegaDemoFieldSeeder — только для визуальной проверки (НЕ на прод).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>