Шаг 1 «Конкурентное поле» — чекпоинт:
- Финальный проход «Найти и объединить дубли» на поле и предложениях; клиент решает по каждой группе, ничего не склеиваем молча.
- Тихая склейка только по сайту и коду справочника; телефон и людный номер у более чем 4 фирм — на решение клиента.
- Колонка phones jsonb; телефон-дубли видны уже на «Предложениях».
- Sonar даёт только имя и тип, сайт всегда через EXA; вскрываем все карточки без гейта «есть сайт».
- Яндекс-карточки через локальный Playwright параллельно; рубрика из заголовка идёт в описание.
- Фикс рекламных номеров: 2ГИС-парсер разбирает телефоны пообъектно и выкидывает рекламные кнопки с platforms/caption чужих фирм.
Тесты: автоподбор 224/224 unit, 105/105 feature; фронт-спеки зелёные. Времянки app/scripts/diag-*/probe-* в гит не входят.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Создавать проекты можно всегда; баланс проверяется в момент ЗАПУСКА
(создать-и-запустить / запустить / возобновить одиночно и пачкой / автоподбор)
под замком на клиента (без гонок). Не хватает — проект остаётся на паузе с
меткой preflight_blocked_at, клиенту сообщение в рублях (сколько пополнить).
Групповой запуск «сколько влезло». Нет активного тарифа на дату → запуск
запрещён (fail-closed). Гейт реквизитов первого проекта добавлен и в автоподбор.
- LaunchBalanceGate — единый гейт вместо 3 копий preflight (ProjectController
store/update, AutopodborController), под DB::transaction + lockForUpdate(Tenant).
- ProjectService::create($launch) + новый setActive(); bulk resume «сколько влезло».
- AutopodborProjectCreator: пачка в транзакции через общий ProjectService::create.
- Идемпотентность box/phone_type миграций автоподбора (Schema::hasColumn guard).
- Тест-инфра: afterRefreshingDatabase восстанавливает месячные партиции.
Тесты фичи 40/40 зелёные. Спека и план — docs/superpowers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ускорение шага 1: CategoryScraper грузит страницы категории 2ГИС не по одной, а пачками поперёк рубрик
через BatchPageFetcher. На остывшем xfetch 2ГИС упал с ~23 мин до ~5 мин и собрал 139 фирм вместо 12.
Общий лимит одновременных xfetch снижен 4→3 (безопаснее под оконный rate-limit сервиса).
Таймауты ИИ/EXA подняты с запасом (латентность моделей плавает, сбор асинхронный, клиент ждёт):
sonar 120→300с, отсев агрегаторов 90→240с, общий AITUNNEL 30→120с, EXA 30→90с. Джоба: один прогон
без авто-ретраев (tries 3→1, чтобы таймаут не перезапускал платный движок трижды) и общий таймаут 900→1800с.
Фиксы, найденные на живом прогоне:
- Письмо «готово» больше не роняет прогон при недоступной почте (тесты на сбой SMTP при status done/empty).
- Яндекс-загрузчик берёт путь к node из config (autopodbor.node_bin, дефолт node) — из-под PHP node
теперь находится; на живом прогоне Яндекс дал 78 фирм вместо 0.
По TDD. Бэкенд автоподбора 262/262, Pint чисто. Живой прогон end-to-end: ~8 мин, 14 новых конкурентов,
списание 300 ₽ корректно.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Замер боевого лимита xf4.ru показал безопасный потолок 4 одновременных запроса — выше идут 429 Too Many Requests, плюс есть лимит за окно времени. Сбор до 25 карточек филиалов в XfetchDirectoryFetcher грузился по одной секунды на каждую — узкое место шага 2.
Новый интерфейс BatchPageFetcher + XfetchClient::htmlBatch грузит страницы порциями по services.xfetch.concurrency дефолт 4, параллелит внутри порции через Http::pool, ретраит пустые и 429. XfetchDirectoryFetcher берёт карточки одной пачкой если загрузчик batch-совместимый, иначе по одной. Два справочника шага 2 оставлены последовательно намеренно — иначе 2x4=8 за лимитом. Число выносится в XFETCH_CONCURRENCY без деплоя.
По TDD. Бэкенд автоподбора 258/258, Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 1: опознавалка фирмы по сайту/карточкам, ProposalClassifier new/actualize/archived/hidden, box=archived, bulk удалить всех ранее удалённых. Backend 249/249, front autopodbor 61/61.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Отсев агрегаторов шёл по СЫРЬЮ до склейки — gpt-4o-mini залетало 227 позиций
с дублями (Ломбардико ×6). Порядок в FindCompetitorsAssembler: сначала
склейка/дедуп, потом отсев — классифицируем ~90 уникальных, а не 227.
Плюс файл состояния сессии (что сделано/осталось) + промт восстановления
контекста для компакта. TDD, 234/234 зелёные. НЕ прод.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Масштаб (лимиты внешних сервисов — на КЛЮЧ, общий на всех клиентов):
- EXA: параллельный пул findSites (concurrency=5) вместо 40 запросов по одному.
- Агрегатор: батч вместо ~90 запросов по одному + чанкинг по 40 и длинный
таймаут 90с (большой список gpt-4o-mini не успевал в 30с).
- Очередь autopodbor + WithoutOverlapping на всех 3 джобах — глобальный
потолок = число воркеров; assertNoInFlight держит один подбор на клиента.
Чистота выдачи (универсально, без зашитых ниш):
- Анализатор: узкие рубрики, запрет зонтичных слов («финансовые услуги» тащила
юрфирмы/банки).
- Дедуп: ключ имени минус слова из СЛОВАРЯ рубрик прогона («Яричъ Ломбард»→«Яричъ»);
слово-категория из данных, не из списка. Гард: склейка только если остаётся
ровно один слово-токен (генерики вроде «займы под залог» не трогаем).
TDD, 233/233 (unit+feature) зелёные. НЕ прод.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Playwright тянет рубрику фирмы прямо из СПИСКА Яндекса («Ломбард, автоломбард»,
«Микрофинансовая организация», «Банк») без захода в карточку — быстро и бесплатно.
Рубрика идёт в описание конкурента → в похожесть-эмбеддинги (раньше меряли по
голому имени, хлам и целевое сбивались). Промпт анализатора обезличен: примеры
из РАЗНЫХ отраслей (стоматология/автосервис/доставка) + «подбери под описание
клиента» — движок универсален, не подточен под нишу займов Омеги. TDD 216/216, НЕ прод.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Яндекс отдавал по строке на КАЖДЫЙ филиал (дедуп по id точки) — 214 строк,
из них хлам-повторы одной сети (Корунд ×15). Новый YandexListParser дедупит
по SLUG (код фирмы от источника /maps/org/<slug>/id): одна фирма = одна строка,
разные написания одной фирмы склеены, разные фирмы с разным кодом раздельны.
Код справочника (ya:slug) уходит в union-find склейку как опознавательный
признак. Живьём 214 -> 94. TDD, 215/215 (unit+feature) зелёные. НЕ прод.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Стоп-лист известных для ИИ канала В дедуплицировался только в проверке
видели/не видели, а в промпт уходил с повторами (Корунд ×27, КрасЛомбард ×19).
Теперь чистим и сам список по ключу имени — ИИ видит каждое имя один раз:
короче, дешевле по токенам, точнее. TDD, 137/137 unit зелёные.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Найдено живым прогоном по нише «ломбард/Красноярск»:
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>
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>
Два бага, вскрытых живым прогоном по КрасЛомбару (оба в обработке, не в 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>
AutopodborNormalizer: domainHead (схема/www/путь/порт → голова),
phone (через PhoneNormalizer::normalize → 7xxxxxxxxxx без плюса),
sourceKey и competitorKey для дедупликации конкурентов и источников.
4 теста, 9 assertions, все GREEN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>