808 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fb38d657ad |
fix(tradein/deactivate-stale): пол переобхода — LATERAL вместо equality-join по дате
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
Правка меняет две вещи разом, обе намеренно. 1. Равенство по дате -> «последняя строка не позже якоря». listing_source_snapshots переходит на модель «строка на изменение». В ней equality-join теряет 95.6% пар (на 2026-08-20 изменениями являются 4458 строк из 101795): выборка для percentile_disc схлопывается до n=3-4, квантиль вырождается в максимум из трёх чисел, TTL обваливается — domklik 28->14 (под снятие сразу 128 активных строк), yandex 44->30, avito 13->10. Дыры в суточной истории ломали equality-join и до перехода: на проде 03-14.06 (12 суток подряд), 03-04.07, 12.07, 26.07, 30-31.07, 01.08 — там n_pairs=0, floor_days=NULL и TTL молча оставался как задан. 2. Глобальный max(snapshot_date) -> максимум внутри источника. Старый подзапрос брал максимум по ВСЕЙ таблице, не скоупленный по listing_source_id: источник, чья история короче общей, выпадал из выборки целиком. LATERAL ищет предшественника по строке. Замер на живом проде 2026-08-23 (health_window_days=3): обе формы дают побитово одинаковый результат на всех четырёх источниках — avito n=765 пол=49.7, cian n=1226 пол=81.6, domklik n=565 пол=35.7, yandex n=3856 пол=87.3. Сегодня суточная джоба пишет строку для каждого источника каждый день, поэтому глобальный максимум совпадает с максимумом каждого источника, и пункт 2 — no-op на текущих данных. Расхождение проявится только на дырах и после перехода на change-only. Плюс floor_n_pairs в counters — наблюдательность для будущего гейта деградации пола, сейчас ничего не блокирует. FROM..WHERE вынесен в _revisit_floor_from_where_sql, чтобы count(*) и percentile_disc гарантированно шли по одному срезу. |
||
|
|
5b447ec33d |
feat(tradein/estimate): внешние оценки не ждутся в запросе — 9 секунд превращаются в 1
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m42s
Замер на проде 2026-08-22, разбивка одного расчёта по логам:
10.659 старт
10.827 дом найден 0.17 с
11.565 аналоги, 49 кандидатов 0.74 с
11.579 ДКП-коридор 0.01 с
17.576 yandex_valuation ← 6.0 с
19.159 cian_valuation ← 1.5 с
19.252 готово итого 8.6 с
Семь с половиной секунд из восьми с половиной — ожидание чужих HTTP. Наша база
и сам расчёт укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с,
по новому — 8-12.4 с. Геокодинг ни при чём: с готовыми координатами те же 7.5-10.8.
Публикация в РБК 30.08 приведёт аудиторию на НОВЫЕ адреса, то есть мимо кэша.
Масштабирование контейнеров тут не помогает: время уходит на ожидание чужого
ответа, а не на наши вычисления.
Что сделано: у обоих источников появился режим «только кэш» (fetch_on_miss).
При включённом ESTIMATE_EXTERNAL_SOURCES_BACKGROUND запрос делает лишь чтение
кэша (локальный запрос на миллисекунды), а свежая загрузка уходит в фон и
наполняет кэш к следующему обращению по тому же адресу.
Почему это безопасно: деградация источника в None — НЕ новое состояние ответа.
Ровно так же ведёт себя таймаут estimate_*_valuation_timeout_s, и этот путь
работает в проде сегодня. Контракт API не меняется.
Фоновая задача берёт СВОЮ сессию: сессия запроса закрывается вместе с ответом,
а обе функции источников делают внутри себя db.commit() — переиспользование
чужой сессии зафиксировало бы её незавершённую работу. По той же причине
отвергнут наивный asyncio.gather двух источников на одной сессии.
Очередь догрузки ограничена восемью задачами. Без потолка всплеск по новым
адресам — ровно тот случай, ради которого режим и сделан — породил бы сотни
параллельных задач с сессиями и HTTP-клиентами при max_connections 100 и
mem_limit 768m у backend, то есть отказ вместо ускорения.
Дефолт в коде False: поведение других окружений не меняется. На проде режим
включён через docker-compose.prod.yml у сервиса backend.
Отдельно НЕ сделано, хотя предлагалось: снижение таймаутов до 4 с. Замер
показал, что свежий запрос к Яндексу занимает 6 с — таймаут 4 обрывал бы его
почти всегда, кэш бы не наполнялся, и источник оказался бы тихо отключён.
Таймаут здесь страховка от патологии, а не регулятор задержки.
Тесты: 7 штук на режим «только кэш», собственную сессию, гашение ошибок,
удержание ссылки на задачу и потолок очереди. Фальсифицированы — на неизменённом
коде падают 5 из 7 (проходит только сторож неизменности дефолта). Смежные
тесты оценщика (29 штук: бюджет ЦИАН, клиентские координаты, аудит) зелёные.
|
||
|
|
fa1399d59e |
fix(tradein/avito): бэкфилл простаивал 23 часа из 24 — каденс был суточным
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m47s
Замер 2026-08-22, уже после починки транспорта (#3049): обогащение работает, но очередь не разбирается. За сутки 238 карточек при 9 951 активном объявлении. Причина в планировщике, а не в скрапинге. `compute_next_run_at` имеет суточную гранулярность по построению — `interval_days = max(1, int(...))`, целевая дата `now + interval_days`. Меньше суток не выражается. Бэкфилл при этом умирает по бану через 17-83 минуты, то есть работал около часа в сутки, а остальное время расписание ждало следующего дня. Механизм sub-hourly каденса уже был написан — `reschedule_after_minutes` (#2162, сделан для proxy_healthcheck). Его просто не подключили к бэкфиллу. Хук ставит next_run_at = now() + interval_minutes сразу после claim и сам себя тормозит: пока прогон идёт, has_running_run в _claim_run возвращает None. 180 минут — осознанно консервативная отправная точка, НЕ найденный оптимум. Данных для подбора нет, и имеющиеся два прогона противоречат наивному ожиданию: 4562 дал 175 карточек за 83 минуты, а 4586 через 4.3 часа — когда пул прокси был давно чист — умер за 17 минут с 42 карточками. Значит память Авито длиннее часов, и учащение может ухудшить выход. Отдельно держать в голове при подборе: те же 4 прокси обслуживают SERP-свипы, то есть первичный сбор. Сжечь их на обогащении хуже, чем медленно обогащать. Двигать интервал вниз только по замеру нескольких суток, глядя и на свипы. Подбор — через default_params расписания, правка кода для этого не нужна. Тесты закрепляют подключённость хука и коридор дефолта, а не конкретное значение: 180 будет двигаться, а вот утрата хука вернёт суточный простой молча. Проверено фальсификацией — на неизменённом коде все три падают. |
||
|
|
b61123d7a6 |
fix(tradein/avito): detail_backfill ходил curl'ом, который не решает PoW-челлендж
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s
Замер на проде 2026-08-21: Авито за QRATOR отдаёт JavaScript proof-of-work
челлендж (startPow → кука pow_solved → self-reload через window.location).
curl_cffi не исполняет JS и пройти его не может в принципе.
Что это давало в цифрах (counters прогонов avito_detail_backfill):
run 4508: enriched 3 / attempted 46
run 4394: enriched 2 / attempted 47
run 4348: enriched 4 / attempted 53
~6% успеха. Браузерный путь на том же проде — ~74% (17/23 в инлайн-обогащении
свипа, 2/3 в точечной проверке после #3048). То есть задание работало почти
вхолостую, а поля дома, которые чинил #3048, до карточек просто не доходили.
Исходное обоснование AVITO_DETAIL_BACKFILL_USE_CURL=true (browser открывает
десятки коннектов и превышает cap прокси-аккаунта auv) больше не действует:
браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect
SCRAPER_PROXY_URL, что и curl-путь.
Переключение сделано в docker-compose.prod.yml (environment перекрывает
env_file), а не правкой рантайм-env на хосте — чтобы изменение осталось в
репозитории и пережило deploy без дрейфа конфигурации. Дефолт в config.py
оставлен True: другие окружения не трогаем вслепую, но устаревшее обоснование
там помечено.
|
||
|
|
fe628a5e17 |
fix(tradein/scraper-kit): actualize avito fingerprint - impersonate/Sec-Fetch-Site/referer/cookie-check
Живой замер браузера 2026-08-21 разошёлся с тем, что шлёт прогретая сессия:
- impersonate="chrome120" был захардкожен в 9+ местах (avito/{serp,detail,imv,
houses}.py, pipeline.py x4, cian/valuation.py, yandex/valuation.py) вместо
единой DEFAULT_IMPERSONATE (providers/_base.py). Обновление до chrome146
(макс. доступный профиль curl_cffi 0.15.0; alias "chrome" НЕ используется -
едет сам при апгрейде библиотеки без ревью) теперь меняется в одном месте.
Guard-тест backend/tests/test_impersonate_single_source.py грепает всё
дерево scraper_kit на литерал "chrome120".
- DOCUMENT_HEADERS ставил Sec-Fetch-Site="none" на уровне сессии, а Referer
добавлялся per-request (curl_cffi мёржит per-request headers поверх
session-level) - живой Referer с "none" рядом не бывает у настоящего
Chrome. Добавлен referer_headers() (Sec-Fetch-Site="cross-site") для
caller'ов с чужедоменным Referer; avito warm-up (yandex/ya.ru -> avito)
теперь его использует. Внутренний avito-search -> avito-detail Referer
(fetch_detail, same-origin) НЕ тронут - отдельный явный комментарий почему.
- Referer прогрева заменён с yandex.ru на ya.ru (живой переход из выдачи
даёт короткий домен). ysclid сознательно не добавлен - значение выпускает
Яндекс, подделка хуже отсутствия.
- warm_up_session/research_in_session считали прогрев успешным по HTTP-
статусу и отсутствию firewall-маркеров, не проверяя антибот-cookies
(__zzatw-*/cfidsw-*, сняты с живого браузера) - detail-батч на такой
"прогретой" сессии сжигал прокси на обречённых 403. Теперь поднимают
AvitoWarmupCookiesMissingError (наследник AvitoBlockedError - существующие
except-блоки/ban_kind/proxy-ротация в pipeline и backfill ловят без
изменений).
Полный backend pytest suite зелёный (4672 passed), ruff check чист.
Refs #3034
|
||
| cdf493f345 |
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
|
|||
| 507c8e0611 |
fix(tradein): неудача резолва yandex_jk_slug помечает дом — очередь не упирается в одни и те же пять (#2924)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Failing after 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m25s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
(полное описание — в PR) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 6cdf56820d |
fix(tradein): апсерт не переписывает неизменившуюся строку — гейт IS DISTINCT FROM + МСК-день (#2992)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m15s
(полное описание — в PR) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 865e64b783 |
feat(tradein/estimator): штраф за неизвестный год/тип дома — флагом, default OFF (#2936)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m14s
В SQL-формуле relevance_score кандидат без year_built получает штраф 0 — столько же, сколько точное попадание в год, и лучше, чем кандидат с известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие данных выигрывает у знания и возвышает источник с худшей полнотой. Флаг estimate_unknown_attr_penalty_enabled (default OFF): кандидат с NULL получает МЕДИАННЫЙ по пулу штраф того же признака среди тех, у кого он известен — не наказание и не награда; считается по тем же термам, что SQL (abs(Δyear)/12.0, 1.5 за несовпадение типа). Лежит в Python-слое после SQL, рядом с kitchen/ceiling (#2012), по тому же контракту: включать — только по бэктесту. Без чего флаг был бы мёртв (и был в первой редакции): _ANALOG_SELECT_COLS не выбирал year_built/house_type — SQL считал по ним CASE, но в словарь кандидата колонки не попадали, Python-слой видел None у ВСЕХ и не штрафовал никого по построению. Probe-лог в прод-оверлее: pool=30 null_year=30. Добавлены в _ANALOG_SELECT_COLS, во внешние SELECT тиров H/W и во внутренний base Tier W (он строится явным списком). Контроль: флаг OFF с колонками и без — метрики бэктеста идентичны до сотых. На этот инвариант стоит тест по исходнику запросов. Живой A/B (бэктест в прод-контейнере, оверлей /tmp/ab, 300 сделок ЕКБ Q2 2026, одна и та же выборка в обоих прогонах — проверено по deal_id): состав топ-50: сменилось 96 слотов из 5 915 (1.6 %), 27 сделок из 300 источники: avito 55.7→55.3 %, cian 24.5→24.8 %, yandex 12.7→12.6 % цена: MAPE 16.70→16.70, bias −4.52→−4.52, coverage 84.46→84.46 — идентично до сотых по всем срезам (сегменты, комнатность). Нижний Тагил (300 сделок, пулы 21/p90 40): 0 из 5 666 слотов сменилось. Почему эффект в разы меньше замера задачи (×0.20 avito): тот замер шёл по SQL тира H без стратификации. В боевом пути 54.9 % слотов топ-50 — гарантированная квота MIN_ANALOGS_PER_SOURCE=5, раздаётся ДО сортировки остатка; и у avito в топ-50 NULL-год лишь у 25.7 % (задача мерила 56 % по всем активным объявлениям). Обе величины измерены по фикстурам A/B. Что это значит: артефакт в формуле есть, флаг его корректно лечит, но на итоговую цену он не влияет измеримо. Включать по умолчанию оснований нет — и это и есть ответ, ради которого флаг заводился вместо правки. pytest tradein-mvp/backend: test_2936 7 passed; гейт фикстуры и roundtrip 8 passed; -k "estimat or analog" 735 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| a003fdd37c |
feat(tradein): ручка «дома, уехавшие от собственных объявлений» (#2996)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m27s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 2m10s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
Часть Находки 3 из #2996. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| fab0bc84c1 |
feat(tradein): ручка «дома, уехавшие от собственных объявлений» (#2996)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m49s
Находка 3 задачи («23 дома лежат вне области 66, гарда на приёме нет») подтвердилась, и к ней добавились масштаб и причина. Масштаб: к этим домам привязано 591 объявление. Адреса екатеринбургские («Ул. 8 Марта», «Крауля», «Амундсена»), координаты — Варшава, Белград, Таллин, Владивосток, Ижевск. Причина: 22 из 23 несут в raw_payload след разового бэкфилла `full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без резолва города. Живой путь при этом ЧИСТ — 0 записей вне региона в geocode_cache из 10 448 и 2 из 88 544 у listings; скрипта в репозитории нет. Это исторический осадок, а не текущая утечка, и гард нужен не столько живому пути, сколько следующему разовому скрипту. 17 из 22 помечены геокодером `precision: "exact"`. Точность отвечает на «нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть не может — поэтому проверяется ПРИНАДЛЕЖНОСТЬ. Инвариант нарочно не географический: «дом рядом со своими объявлениями», а не «дом внутри рамки области». Рамка сломалась бы при выходе в Москву — ровно то, ради чего заведена #2996. Он же строго сильнее: ловит 25 домов против 23 у рамки, и оба лишних проверены («Ул. Белинского/Фурманова» за 207 км, 34 объявления). Порог 100 км не подобран на глаз. Замер по 9 052 домам: ближе 1 км — 8 914 (98.5 %), 25-100 км — 9 настоящих пригородов (Сарапулка, Кедровка, Чусовское Озеро, Ревда, Первоуральск; самый дальний 46.7 км), дальше 100 км — 25 (ближайший 119.2, максимум 5 079). Между 46.7 и 119.2 км нет НИ ОДНОГО дома: порог лежит в середине пустого промежутка. Первая редакция клала счётчик полем в /scraper/data-quality. Замер это остановил: запрос стоит ~445 мс на тёплом кэше, а обе существующие выборки той ручки вместе — 27 мс, при опросе фронтом каждые 120 с. То есть 17-кратное удорожание ради числа, которое меняется раз в месяцы. Проверка вынесена в отдельную ручку по требованию, и на возврат в горячий путь поставлен контроль-тест. Ручка отдаёт не только счётчик, но и масштаб (список домов + сколько объявлений привязано) — по одному числу «25» решение об очистке не принять. Двусторонне: против origin/main три теста красные, краснота везде по значению — ни одного ImportError/AttributeError. Контроль test_check_stays_out_of_the_polled_endpoint зелёный с обеих сторон. pytest tradein-mvp/backend — 4642 passed, 23 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| b9cfdaa040 |
feat(tradein): secondary_only — параметр расписания, выброшенное считается (#1781)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m27s
`run_cian_full_load` передавал `secondary_only=True` жёстко, поэтому включить новостройки в полный обход можно было только деплоем. Теперь это параметр с ТЕМ ЖЕ дефолтом `True` — поведение прода не меняется ни на строку, но решение становится правкой одной ячейки `scrape_schedules.default_params`, а не выкаткой кода. Откат — тем же движением. Почему это важно именно здесь. Новостройки НЕ пропускаются при запросе: они скачиваются, разбираются и выбрасываются последним шагом (`cian/serp.py`), потому что SERP-параметр `object_type=1` у Cian ненадёжен (~5 % выдачи) и фильтруют по authoritative `listing_segment` после парсинга. Проба бакета берёт `totalOffers` из Redux-состояния SERP и считает `pages_needed = ceil(totalOffers / offers_per_page)`, а `totalOffers` включает ОБЕ категории — то есть страницы с новостройками уже скачаны, лимит страниц и антибан-бюджет за них уже заплачены. Включение стоит ноль дополнительных запросов. Заодно `dropped_novostroyki` сохраняется в counters прогона. Счётчик логировался (`dropped_nb=`), но не персистился, и ответить «сколько инвентаря выбрасывает полный обход» задним числом было нечем: логи за 17.08 уже ротировались — `docker logs --since 120h` не находит ни строки «cian:» ни в одном контейнере. Тот же довод, по которому рядом заведён `partial_buckets`. Копится в атрибуте инстанса, а не аргументом `on_bucket`: у колбэка есть внешние реализации, менять его сигнатуру ради счётчика нельзя. Сброс на каждый прогон — инстанс переиспользуется. Замер, ради которого это делается (прод 21.08): месячный охват свипа cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и avito/novostroyki; 11 993 активные строки, медианный возраст 81 сутки, 10 585 старше 30 суток. Подробности и оговорки — в #1781 и #2994. Двусторонне: против origin/main пять тестов красные, и краснота везде по значению, а не по отсутствию символа — ни одного KeyError. Сообщения перечисляют фактическое состояние («параметра нет в сигнатуре; параметры: [...]», «поля нет в запросе; поля: [...]»). Контроли зелёные с обеих сторон: дефолт остаётся True (иначе правка тихо включила бы сбор новостроек на проде — это отдельное решение с замером); фильтр при `secondary_only=True` остаётся на месте и по-прежнему зависит от флага. pytest tradein-mvp/backend — 4644 passed, 23 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 3b40ba09f7 |
fix(tradein/geocoder): темп Nominatim сдерживается перед запросом, а не после успеха (#2953) (#2954)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m32s
Deploy Trade-In / build-backend (push) Successful in 1m20s
Deploy Trade-In / deploy (push) Successful in 1m36s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
|
|||
| cf48e6d6c8 |
feat(tradein): позиция квартиры внутри когорты аналогов — перцентиль (#2899) (#2926)
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m54s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 10s
|
|||
| d0071c57bc |
fix(tradein): фильтр выдачи Яндекса мёртв — slug ЖК ищем по странице объекта (#2860) (#2923)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 1m41s
Deploy Trade-In / deploy (push) Successful in 1m42s
|
|||
| 215b78a348 |
Merge pull request 'fix(tradein/estimate): залипший anchor_tier больше не глушит четыре механизма разом' (#2920) from fix/2656-anchor-tier-reset into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m45s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 1m57s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
42b0ebe338 |
fix(tradein/estimate): залипший anchor_tier молча глушил IMV-blend и quarter-index
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s
Флаг anchor_tier оставался равным anchor_tier_fetched ("A"/"C"), когда якорь
фактически НЕ строился — сброс делал только low-conf гейт (#audit-1), но не
Tier C corridor-гейт (#1795) и не сама _compute_same_building_anchor, когда
она отклоняет кандидата (комплов меньше estimate_sb_min_comps). Дальше по коду
залипший флаг читается как «headline построил якорь» и молча глушит IMV/Yandex
blend (#651, гейт `anchor_tier is None`) и quarter-index correction (#764
Guard-1a) — притом что радиусный headline их не получал.
Замер: 154 из 996 сделок теряют tier-флаг этой правкой, и у всех 154 изменение
цены ровно 0.000% — чинится именно залипший ФЛАГ, не ценообразование (баланс
метрик бэктеста подтверждает: единственная дельта в baseline — новая канарейка
unrecorded_lookup_calls, все остальные метрики побитово те же).
- estimator.py: сброс `anchor_tier = None` единой веткой `if anchor is None`
после всех трёх гейтов (Tier C / low-conf / _compute_same_building_anchor);
display-only IMV-карточка больше не гейтится по `anchor_tier is not None`
(иначе терялась в щели «тир добыт, якорь не построен, headline подавлен»).
- backtest_estimator.py: quarter_index_lookup/quarter_indexes_lookup в реплее
отвечают «промах» (None/{}), если сброс флага открыл путь, которого не было
в замороженной фикстуре, вместо падения с RuntimeError; счётчик таких промахов
уходит в baseline как unrecorded_lookup_calls (точное целое, канарейка на
расхождение реплея с захватом). Заодно пиннится estimate_dedup_analogs_enabled
= False внутри replay_fixture (было только в самом гейте) — иначе штатная
регенерация baseline (--from-fixture --update-baseline) писала baseline,
который тест не совпадал бы никогда.
- backtest_baseline.json: перегенерирован штатным путём, unrecorded_lookup_calls=0.
Выделено из #2656/PR #2661 — фильтры свежести (scraped_at) в якоре дома и в
знаменателе коэффициента выкупа остаются в исходном PR как отдельная, более
спорная правка (двигает деньги: знаменатель просаживается на ~1.3% по бакетам).
|
||
|
|
c66047683c |
Merge remote-tracking branch 'forgejo/main' into local/pr2836
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m36s
|
||
|
|
73c4c487ed |
fix(mera/b2c): житель Серова получал «вы вне области», а подсказки игнорировали выбранный город
All checks were successful
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m6s
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Successful in 48s
Два дефекта, найденных прогоном сценария глазами посетителя на живом домене. ## 1. Город предлагали выбрать, но отвечать по нему не умели Дропдаун на сайте (`OBLAST_CITIES`, city-registry.ts) и списки покрытия (`COVERAGE_GREEN/YELLOW_CITIES`, trade_in.py) — одно множество, записанное в двух местах. Они разошлись в обе стороны: предлагали, но не отвечали: Серов отвечали, но не предлагали: Берёзовский, Среднеуральск, Ревда Житель Серова выбирал СВОЙ город из НАШЕГО дропдауна и получал: «Этот адрес вне области, по которой мы собираем данные. Сейчас это Свердловская область: Екатеринбург целиком и ещё несколько городов вокруг.» Про город в той же самой области. Серов при этом покрыт данными: 363 активных объявления в радиусе 15 км, все свежие (замер по проде). Поэтому добавлен в жёлтый тир, а не убран из дропдаунa; три недостающих города добавлены на фронт. Шапка city-registry.ts этот риск прямо предсказывала — «перед добавлением 7-го города сверить оба списка вручную, теста на это пока нет». Теперь тест есть: бэкендовый сьют читает TS-реестр и требует РАВЕНСТВА множеств. Плюс проверка, что у каждого города с порогом есть центроид, — иначе порог мёртвый, город по координатам не резолвится. ## 2. Подсказки не слушались выбранного города `city_hint` доезжает до геокодера, но на выдачу не влияет: его смотрит только екатеринбургский кадастровый тир (как признак «речь не про ЕКБ, тир пропускаем»), а DaData-тир ограничен регионом целиком и хинта не принимает. Замер: выбран Серов, введено «Ленина 1» → первой подсказкой «Невьянский р-н, пгт Верх-Нейвинский». Человек выбирает верхний вариант и считает чужой дом — ровно баг #2576, ради которого город и спрашивают. Публичная ручка теперь подставляет город в саму строку запроса. Проверено на проде: «Серов Ленина 1» даёт серовскую выдачу целиком. Для Екатеринбурга подстановка безвредна — три разных адреса дали тот же результат с префиксом и без, поэтому правило одно на все города, без исключения для основного трафика. Чинится в публичной ручке, а не в геокодере: там от `city_hint` зависит поведение закрытого контура (`target_city_ambiguous`). ## Фикстура теста `_FAR_AWAY_CITY` стояла в 21 км от центра Серова и работала как «далеко от всех» лишь потому, что Серов не был поддержан. Переехала в Тавду — 271 км до ближайшего центроида. ## Мутации убрать Серов из покрытия (состояние прода) → падает сверка списков не подставлять город в строку → падает проверка ручки откат → 21 passed Плюс backend 75 passed, vitest 56 passed, tsc, lint, build, isolation guard. `city-registry.ts` добавлен в paths-фильтр БЭКЕНДОВОГО лэйна: сверку списков делает бэкендовый тест, и без этой строки правка одного лишь дропдауна её бы не запускала — то есть ровно тот путь, которым списки и разошлись. |
||
|
|
56194c606a |
fix(mera/b2c): семь дефектов публичного периметра, найденных состязательным ревью
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 46s
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 4m42s
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.
## Caddy: открытый редирект и потерянные ссылки
Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.
Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).
Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).
`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.
## Гейты, которых не было
`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.
Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.
## Бюджет внешнего геокодера
Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.
Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.
## «Адрес нигде не сохраняется» — теперь правда целиком
Две утечки, обе воспроизведены:
1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
вызов, прод пишет stdout в persistent journald — адрес ложился на диск
рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
время публичного запроса (contextvar, переживает await и to_thread).
Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
Закрыто `scrub_public_address`.
Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.
## Фронт
- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
не наступала вовсе, если человек переставал печатать: ответ по старой строке
долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.
## Проверено
Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.
Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
|
||
|
|
b1fb7bb055 |
Merge remote-tracking branch 'forgejo/main' into feat/mera-public-api
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Successful in 46s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m45s
# Conflicts: # tradein-mvp/backend/app/core/rbac.py |
||
|
|
7424c283d5 |
feat(mera/b2c): отдельный экран оценки на meraocenka.ru и короткие адреса
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 44s
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
## Экран проверки — /estimate Проверка квартиры вынесена на собственный адрес: там у автокомплита есть место под список подсказок, а у результата — место рядом с полями. Форма в герое лэндинга осталась входной точкой и уводит сюда, донося набранное через sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом с IP посетителя, а мы на той же странице обещаем ничего не хранить). Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и покрыты тестами: подпись под возрастом обязана говорить «объявление», а не «продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что поменять, — директива «никогда не блокировать вывод». ## Короткие адреса Человек больше не видит /trade-in/mera-public/... — только /, /estimate, /oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы один канонический адрес, старые ссылки живы. Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru. Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и href="/estimate" уехал бы на несуществующий /trade-in/estimate. ## Три дефекта, найденных на живом сайте 1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна --b2c-* переменная не объявлялась, каскад молча пропускал такие декларации — лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест: каждая использованная в CSS переменная обязана быть объявлена. 2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой. Ровно туда вела «Главная» в подвале. 3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря, которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны. ## Периметр и приватность Подсказки переведены на POST: access-лог публичного домена пишет URI целиком, то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает. Метод запинен тестом — это часть обещания, а не стиль. П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно — передаётся, используется однократно, в базах не сохраняется. Последнее проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до платного шага (бесплатная проба не хранит ничего, платный расчёт хранит). ## Проверено vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard 40 файлов, backend 75 passed, caddy validate = Valid configuration. Мутации структурных гейтов: убрать --b2c-accent-text из b2cVars → падает тест палитры убрать /estimate из @meraPages → падает тест маршрутов добавить импорт next/link → падает тест basePath откат → 14 passed Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов, и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт. Refs #2894, #2895 |
||
| 22a1b3aff4 |
Merge pull request 'feat(tradein/ops): алерты GlitchTip доезжают в Telegram' (#2915) from feat/tradein-glitchtip-telegram-alerts into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-worker (push) Successful in 43s
Deploy / build-frontend (push) Successful in 42s
Deploy / build-backend (push) Successful in 44s
Deploy / deploy (push) Successful in 1m11s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / test (push) Successful in 3m48s
Deploy Trade-In / build-backend (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 1m38s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
b649caec2d |
feat(tradein/ops): приёмник GlitchTip webhook-алертов в Telegram-тему
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
Мониторинг GlitchTip сейчас нем (alerts_projectalert/alerts_alertrecipient пусты, EMAIL_URL=consolemail:// печатает письма в stdout, аудит на проде 2026-08-15). GlitchTip умеет получателя типа webhook, но шлёт свой Slack- совместимый JSON без каких-либо заголовков — Telegram Bot API его не понимает, нужен адаптер. - app/api/v1/glitchtip.py: POST /api/v1/trade-in/ops/glitchtip-webhook — принимает issue- и uptime-алерты (структурно одинаковый payload у GlitchTip 6.1.6, см. docstring), форматирует короткое сообщение (проект/заголовок/ссылка/время получения) и шлёт через существующий TelegramClient в отдельную тему алертов. Обрезка под лимит Telegram (4096 симв.), неизвестная форма payload пересылается как есть с пометкой вместо 500. - Auth: GlitchTip не может слать кастомные заголовки (aiohttp.post без headers=) — переиспользуем TRADEIN_INTERNAL_AUTH_SECRET (#2213) как query-параметр `secret`, constant-time compare. В отличие от rbac.py пустой секрет здесь fail-CLOSED (503), это единственный auth-рубеж пути. - config.py: TELEGRAM_ALERTS_CHAT_ID / TELEGRAM_ALERTS_TOPIC_ID — намеренно отдельные от TELEGRAM_SUPPORT_*, чтобы алерты не лились в топик клиентов. - rbac.py: путь добавлен в _PUBLIC_PATHS (фиксированный, без секрета в самом пути — секрет только в query). - docker-compose.prod.yml: glitchtip-worker (реально шлёт вебхуки, не glitchtip-web) переведён на networks: [default, shared] — без этого tradein-backend не резолвится с его стороны (общей сети не было вообще). |
||
|
|
d046eaada2 |
feat(mera/b2c): публичный API МЕРЫ — /api/public/mera/{suggest,coverage}
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m49s
Первый шаг к отдельному B2C-интерфейсу оценки на meraocenka.ru: домен получает собственную поверхность бэкенда вместо того, чтобы тянуть куски закрытого контура. ## Отдельный префикс, а не проброс кусков /api/v1/* На meraocenka.ru действует allowlist-by-default. Открыть там API можно было двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под которым по определению не лежит ничего закрытого. Выбран второй: при первом одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает наружу весь v1 — ~20 ручек, включая PDF расчётов, фотографии и админку. Цена ошибки, а не удобство. Добавить сюда приватную ручку теперь нужно СПЕЦИАЛЬНО — положив файл в app/api/public/. Случайно нельзя. ## Ноль записей в БД Обе ручки только читают: /coverage — один SELECT, /suggest — прокси автокомплита. Это условие, при котором публичная форма работает ДО контура согласия 152-ФЗ (#2895: сегодня адрес физлица попадает в trade_in_estimates раньше согласия, а пути удаления в бэкенде нет). Платный расчёт, который писать будет, открывается только вместе с ним. ## Делегирование, а не копии Обе ручки вызывают те же функции, что обслуживают закрытый контур (v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894 показал, чем кончается вторая копия когорты: проба отвечает «данные есть» там, где платный расчёт видит ноль. Публичный ответ переиспользует CoverageProbeResponse — на нём уже стоит гейт «ни одного price-подобного поля». ## Бюджеты Общего 300/60с мало: /suggest через DaData-тир — платный внешний вызов, абуз стоит денег. Свои per-IP окна: 40/мин на подсказки (человек с debounce'ом тратит единицы на адрес), 15/мин на пробу. ## Проверено 11 тестов, из них структурные: набор ручек под /api/public проверяется на РАВЕНСТВО (третья, добавленная без правки теста, роняет сборку) и сверяется с rbac._PUBLIC_PATHS в обе стороны — чтобы не осталось открытого пути-призрака. Рядом висит закрытый маршрут-двойник: без него «аноним получает 200» одинаково зелёный и когда исключение точечное, и когда auth-гейт снят целиком. Мутации: убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed снять бюджет с /coverage → 2 failed / 9 passed откат → 11 passed Плюс 72 passed на связке rbac + coverage + version, `caddy validate` = Valid configuration, ruff чист. Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки отвечают 200 анонимно И /trade-in/api/v1/* на этом домене по-прежнему 404. Зелёная только первая проверка = API открыт целиком, а тест этого не заметил. Refs #2894, #2895 |
||
|
|
a8fa7364ae |
merge(tradein/payments): влить main в feat/tradein-payments-perimeter-hardening
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
Слияние main принесло собственные Sentry-скрубберы (redact_telegram_bot_token + stabilize_retry_error_fingerprint) в app/main.py и app/scheduler_main.py — конфликт разрешён композицией, а не выбором стороны: обработчик перед отправкой в GlitchTip теперь прогоняет событие через всю цепочку в указанном порядке: scrub_payment_request_body → scrub_pii_event → redact_telegram_bot_token → stabilize_retry_error_fingerprint (main.py), и без redact_telegram_bot_token в scheduler_main.py (тот процесс не держит TelegramClient) — оба канала, before_send и before_send_transaction, используют один и тот же обработчик. tests/test_sentry_scrub.py: тесты обеих сторон объединены без потерь — PR-D2 платёжный composed-тест (body-wipe + PII-scrub + token-redaction) и весь блок RetryError fingerprint-стабилизации из main сосуществуют в одном файле. |
||
| 12007f8615 |
feat(tradein): бесплатная проба покрытия POST /coverage (#2894) (#2909)
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m41s
Deploy Trade-In / build-backend (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 1m21s
Deploy Trade-In / deploy-status (push) Successful in 0s
|
|||
|
|
37e738c802 |
fix(tradein/coverage): resolve city by coordinates, not sweep-context city_hint
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m34s
Повторная проверка /coverage закрыла оба MAJOR из #2894, но выявила три новых дефекта: 1. Город больше не резолвится из моды listings.city найденной когорты — эта колонка хранит город SWEEP-контекста скрейпера (миграция 196), не геокод адреса объявления. Замер на проде: 90/90 строк в радиусе 1000м вокруг Берёзовского имеют city='Екатеринбург', 74/74 вокруг Ревды — city='Первоуральск'. Города-спутники из COVERAGE_GREEN/YELLOW_CITIES были физически недостижимы. Город теперь резолвится детерминированно по lat/lon запроса — ближайший центроид из статичной константы (8 городов, рядом с ручкой, не в БД — comment объясняет почему) в пределах 25 км. city_hint остаётся в схеме (фронт его шлёт для соседних ручек), но чисто информационный — на порог/статус не влияет. 2. test_max_age_outlier_days_passed_to_sql проверял подстроку, которая встречается в SQL дважды (count и percentile_cont) — мутация «убрать FILTER у percentile_cont, оставив у count» проходила зелёной. Добавлен живой поведенческий тест (вставляет когорту + выброс days_on_market=4000, проверяет что медиана не сдвигается) — ловит эту мутацию (подтверждено: median 8→9 при мутации). 3. _live_session() вызывался в pytest.mark.skipif на этапе сбора тестов и создавал никогда не закрываемый Session, плюс дублировался в теле теста. Заменено на _live_db_available() (open+close голого connection) для skipif и pytest-фикстуру live_session с гарантированным close/dispose. 4. Nit: пустая когорта в поддерживаемом городе отдавала status=not_covered вместе с ненулевым threshold — противоречило докстрингу CoverageProbeResponse.threshold ("0, когда порог неприменим"). threshold теперь всегда 0 при not_covered, независимо от причины. |
||
|
|
e00dcac177 |
fix(tradein/deactivate): resolve migration 264 renumber collision on merge with main
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m27s
fix/tradein-ttl-effective-cap (PR #2907, merged into main while this branch was in flight) already claimed 264/265 for deactivate_stale_avito_cap_mult / deactivate_stale_yandex_cap_mult. Renumbers this branch's 264_seed_deactivate_stale_null_segment_yandex_cian.sql -> 266_seed_... (git mv + _manifest_applied.txt entry moved after 264/265 + self-references in the migration header and in test_deactivate_stale_listings.py's _MIGRATION_264 constant/test names). Merges main's bool-guard (ttl_days/cap_mult reject bool) + CAP_MULT ceiling + per-source cap_mult calibration with this branch's null_segment_only kwarg (explicit `listing_segment IS NULL` predicate, since ANY(:segments) never matches NULL) -- both features apply to the same deactivate_stale_listings() call site in product_handlers.py and the same function signature/docstring in deactivate_stale_avito.py, so every conflict was signature/docstring-level, not logic-level (git already auto-merged the function body correctly since the two features touch disjoint lines below the signature). Also reconciled the module docstring's "known gap" note (main) to reflect that the NULL-segment slice it measured (cian 211 / yandex 523 rows >60d) is now closed by this migration -- novostroyki stays open, unrelated to this branch. |
||
|
|
08cff706ec |
docs(tradein/deactivate): убрать неверное число из обоснования потолка
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m33s
В шапке модуля, в комментарии миграции 264 и в докстринге теста стояло «23 687 из 44 744 avito-объявлений не подтверждались >7 суток» под заголовком «ЗАМЕР НА ПРОДЕ». Число реальное, но приписано не тому. Перепроверено запросом 2026-08-15: это ВСЕ источники вместе, и две трети — новостройки, которых оценщик не берёт (он фильтрует listing_segment IS NULL OR = 'vtorichka'). У самого avito просроченных строк ноль: 8 663 активных, максимальный возраст 10 суток. Оставлять это в коде нельзя: следующий человек прочитает «avito раздут вдвое», проверит и не найдёт — а заодно потеряет доверие к остальным числам в том же абзаце, которые верны и сверены с scrape_runs.counters. Заодно явно записано, чего потолок НЕ делает: он не сжимает пул (0 деактиваций замерено на всех четырёх джобах), а защищает от разгона пола и от опечатки в расписании. Настоящий раздутый срез — строки с пустым сегментом, они чинятся отдельной джобой. |
||
|
|
3e9af2fdef |
fix(tradein/coverage): sync cohort with paid estimator, honest age medians
Independent review found two MAJOR defects in POST /api/v1/trade-in/coverage: MAJOR-1: the probe cohort WHERE clause was missing three predicates present in estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city', price_rub > 0) — the free probe could answer "ok" at points where the paid estimator's own 1000m radius tier sees zero real analogs. Prod example: 56.868904/60.837955, 2 rooms, 50 m2 gave n_listings=22/status=ok while the estimator's cohort at the same radius was 0 (all 54 rows were novostroyki). Added the three predicates verbatim from estimator.py, plus both a static SQL-text regression test and a real-Postgres integration test (skip_allowlist.txt, same _live_session() pattern as test_gar_flats_loader) that inserts novostroyka/geo_precision=city/price=0 rows and asserts they are not counted. MAJOR-2: median_listing_age_days was computed from days_on_market, which on prod is populated almost exclusively by one source (yandex) — thin cohorts produced a "median" over 1-2 listings. Added n_with_age to the response (honest count of listings the median is based on); median is now null below COVERAGE_MIN_AGE_SAMPLES=5, and values above COVERAGE_MAX_AGE_DAYS=365 (near -certainly dead listings, per prod: 15% of fresh yandex rows exceed 365d, max 4261d) are excluded as outliers before the percentile is computed. MINOR: city_hint was trusted at face value and echoed back verbatim — a client could pass city_hint="Екатеринбург" with coordinates in Серов and get threshold=8/status=ok. _resolve_coverage_city now prioritizes the SQL cohort's mode city (ground truth) over the client hint, falling back to hint only when the cohort is empty (where status is forced not_covered anyway). Unmatched cities no longer echo the raw client string in the city field. |
||
|
|
cfb4c159ab |
fix(tradein/scraper): deactivate stale yandex/cian listings with NULL segment
544 yandex + 224 cian active rows carry listing_segment=NULL (legacy rows predating migration 011, plus a small trickle that can never self-heal since the upsert ON CONFLICT never rewrites listing_segment on re-scrape). 94-97% of them are frozen at ~86 days old, yet the estimator's Tier A (same-building) and Tier C (micro-radius) anchor queries filter only is_active=true -- no freshness column -- so these stale asking prices anchor live valuations. deactivate_stale_yandex/_cian (migration 115) already run at TTL=30 but scope segments=['vtorichka'] only: `= ANY(CAST(:segments AS text[]))` never matches NULL, so the NULL bucket was invisible to both existing jobs and to avito/domklik/n1 (which are source-blanket or vtorichka-only respectively). Adds null_segment_only kwarg to deactivate_stale_listings() building an explicit `listing_segment IS NULL` predicate (confirmations/revisit-floor builders extended in parallel for correctness, though both gates are kept off for this slice -- population too small for thresholds calibrated on a full vtorichka sweep, would permanently skip as unhealthy). Two new scrape_schedules rows (migration 264) run it per source, untouched novostroyki/vtorichka jobs unaffected. TTL=60d (vs 30d for vtorichka): revisit-floor is not computable here (rows out of dedicated sweep scope have no revisit-gap history), so the margin is folded into the TTL directly -- 2.26x/1.4x over the measured p99 revisit gaps already on file for cian/yandex vtorichka (26.6d/43.0d). Bimodal age distribution means this costs almost nothing in coverage (30d vs 60d: 744 vs 734 deactivated). First run: 734 of 768 NULL rows deactivated (211 cian, 523 yandex), 34 remain (fresher than 60d, still incidentally re-touched). Comparable pool (is_active AND segment IN (NULL, vtorichka)) after: cian -2.7% (7739->7528), yandex -10.2% (5133->4610) -- yandex crosses the 10% flag threshold. All 734 removed rows already had scraped_at frozen >60d, i.e. already excluded from Tier S/H (which do filter freshness, 14-60d window) -- the drop is real for is_active headcount but zero-impact there; it only prunes Tier A/C, where it removes stale prices rather than live comps. Separate finding (not fixed here): base.py's ON CONFLICT DO UPDATE omits listing_segment from SET entirely, so a legacy NULL row can never heal even though cian/yandex SERP always compute segment deterministically on re-scrape. |
||
|
|
19c9da8119 |
fix(tradein/deactivate): bool guard hole + unpinned test + yandex cap_mult gap (TTL-CAP round 3)
Три остатка ревью TTL-CAP: 1. cap_mult < 1 пропускал bool: jsonb true -> True < 1 ложно -> потолок = ttl_days*True = ttl_days -> пол молча отключается без ValueError. Тот же класс дыры возможен и через ttl_days=true (TTL молча = 1). Оба параметра теперь явно отклоняют bool ДО числового сравнения; воспроизведено на HEAD и закрыто тестами (True/False на обоих параметрах). 2. test_avito_prod_floor_is_capped_by_calibrated_cap_mult хардкодил cap_mult=6 как вход -- мутация миграции 264 (6 -> 2) оставляла набор зелёным. Тест теперь читает cap_mult ИЗ ФАЙЛА миграции regex'ом, ожидаемый результат (потолок 60) остаётся зафиксированным числом -- дрейф калибровки в SQL теперь ломает тест. 3. Текст миграции 264 утверждал "yandex 43.0 -> потолок 60, запас есть" по статическому p99. Живые полы из scrape_runs.counters (08-10..08-15: 75/75/75/39/52/54) и live-замер сегодня (79.2, n=1961) выше потолка 60 -- тот же false-kill класс, что у avito. Откалибровал yandex отдельной миграцией 265 (cap_mult=3 -> потолок 90, тот же запас ~14%, что у avito), поправил таблицу в 264 на живые числа и пиннящий тест по образцу avito. Численный эффект (live-замер 2026-08-15, до и после): next-run deactivated=0 на всех четырёх джобах что до, что после -- ветка по-прежнему НЕ сжимает пул (avito/cian: живой пол уже ниже потолка, cap не участвует; yandex: 0 активных строк старше 39 суток вообще, калибровка убирает будущий риск, не текущее число; domklik: блокирован гейтом здоровья, confirmations 94 < 200). Ветка остаётся тем, чем и была: защита от опечатки в расписании + калибровка, не сжатие пула. 4508 backend-тестов зелёные (uv run pytest tests/), ruff чист на изменённых файлах. |
||
|
|
8fff174337 | Merge remote-tracking branch 'forgejo/main' into fix/tradein-ttl-effective-cap | ||
|
|
d0105470f4 |
feat(tradein/coverage): бесплатная проба покрытия для лендинга МЕРА (#2894)
POST /api/v1/trade-in/coverage — до оплаты пользователь видит только n похожих объявлений в радиусе 1000м и медианный возраст листинга, без единой цены. Один SQL (радиус GIST + rooms + area ±15% + freshness 14д + тот же дедуп/cap- канон, что у estimator._fetch_analogs), ноль внешних вызовов, ноль записей. Пороги ok/thin/not_covered — константы рядом с ручкой (зелёные города >=8, жёлтые >=12, остальные всегда not_covered). Поле median_listing_age_days (не "срок продажи" — возраст активного объявления, цензурированная выборка). RBAC не тронут — путь остаётся закрытым, открытие анонимного периметра вынесено в #2895. |
||
|
|
349494a9df |
fix(tradein/observability): close RetryError-half of GlitchTip noise fix (round 2)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m30s
CI / openapi-codegen-check (pull_request) Has been skipped
Ревью round 1 подтвердил basic_auth-часть, но нашёл 4 факта в RetryError-части: 1. reraise=True в geocoder.py не убирает шум, а переименовывает: наружу летит httpx.HTTPStatusError, чей str() содержит ПОЛНЫЙ request URL с query string (`for url '...search?q=<адрес>&...'`) — воспроизведено эмпирически. Тот же per-address issue-explosion, просто под другим типом исключения. Фикс: _HTTPX_ERROR_URL_QUERY_RE в sentry_scrub.scrub_pii_event режет query string из httpx-style "for url '...'" сообщений — стабилизирует ТЕКСТ, не только тип, независимо от того, уважает ли GlitchTip fingerprint-поле. 2. stabilize_retry_error_fingerprint затирал fingerprint целиком по (типу причины) — RetryError из НЕСВЯЗАННЫХ подсистем с одинаковым типом причины схлопнулись бы в один issue (geocoder vs scraper_kit оба ловят httpx-типы). Фикс: culprit = event["logger"] (LoggingIntegration ставит его = имя модуля-источника logger.exception) идёт первым компонентом fingerprint — разные подсистемы больше не сливаются. 3. Второй живой источник RetryError, пропущенный round 1 (грепали литерал "RetryError", не producers): BaseScraper._http_get в packages/scraper-kit — @retry БЕЗ reraise=True, живой путь через YandexDetailScraper.fetch_detail (yandex/serp.py и valuation.py переопределяют _http_get без retry — не затронуты). Оставлен на fingerprint-хук намеренно: detail-URL варьируются в ПУТИ (offer id), не в query — _HTTPX_ERROR_URL_QUERY_RE их не покрывает, а добавление reraise=True туда воспроизвело бы ту же проблему через HTTPStatusError с variable path вместо query. 4. type(exc).__name__ == "RetryError" (string-compare) → isinstance(exc, RetryError) с прямым импортом tenacity.RetryError — не матчит посторонние классы с тем же __name__, не промахивается мимо подклассов. Полный backend suite (4479 passed, 21 skipped) + geocoder/scheduler/alerts подмножества — без регрессий (reraise=True уже влит в main). Не тронуто (вне scope round 2, подтверждено ревьюером как верное): ops/glitchtip-auth-forwarder/* (basic_auth 401 дроп), массовая чистка накопленных issue. |
||
|
|
772ae116b5 |
fix(tradein/deactivate): validate cap_mult, calibrate avito, document scope gap
Round-2 review (MAJOR) left three items open: 1. cap_mult was threaded through as a jsonb default_params parameter but never validated, reproducing the exact ttl_days<=0 hole the earlier guard closed. Verified live: cap_mult=0 -> effective_ttl=0 -> whole active pool of the source would deactivate; cap_mult=0.5 pushes the ceiling BELOW the operator- configured ttl_days. Added `if cap_mult < 1: raise ValueError` next to the ttl_days guard (same fail-fast contract, before any SQL). Non-numeric values (e.g. a stringly-typed "6" from a typo in default_params) already fail safe via TypeError on the comparison, caught by the same except-block -> mark_failed. Covered with 5 new tests (zero/negative/<1/non-numeric/mark_failed routing). 2. The mechanical part of cap_mult (parameter + wiring) was merged but never calibrated for avito on prod -- no migration shipped, so prod default_params for deactivate_stale_avito still lacked "cap_mult" and ran with the module default (CAP_MULT=2, ceiling=20d), which is BELOW avito's own p99 revisit gap (42.1d) and below the observed prod peak (floor=52, three runs 08-10..08-12). Added data/sql/264_deactivate_stale_avito_cap_mult.sql (idempotent, same pattern as 219) setting cap_mult=6 for deactivate_stale_avito only (ceiling 60d, matching the order of magnitude already used for cian/yandex). cian/ yandex/domklik keep the CAP_MULT=2 default -- their p99 gaps (26.6/43.0/3.1) sit comfortably under their default ceilings (60/60/28), no override needed. Pinned the calibration with a dedicated test (test_avito_prod_floor_is_capped_by_calibrated_cap_mult) instead of leaving the avito slice skipped in the false-kill coverage test. 3. Confirmed (SSH read-only, prod counts): active rows aged >60d that this PR cannot touch regardless of cap_mult -- cian/novostroyki 9483, cian/NULL 211, yandex/NULL 523 (0 inside the jobs' actual scope: cian/vtorichka, yandex/vtorichka). deactivate_stale_cian/_yandex are scoped to segments=['vtorichka'] by a deliberate, documented DECISION (blanket TTL on novostroyki risks killing live inventory cian/yandex don't fully sweep). Widening that scope is a separate, riskier investigation and is out of scope here -- documented the gap directly in the module docstring next to the existing DECISION so it isn't lost. Verification (SSH read-only against prod, 2026-08-15): recomputed the exact per-source formula the next scheduled run will use. In-scope next-run deactivation is currently 0 for all four sources -- the active pool has already self-corrected to be consistent with each source's own recent effective TTL (yesterday's yandex run used effective=54, so no active row is older than that yet). This matches the round-2 reviewer's own conclusion: the cap is a preventative guardrail, not a retroactive cleanup, and isn't expected to fire on the exact day it's calibrated. It is not idle, though -- live recompute of yandex/vtorichka's raw (uncapped) floor right now is 78.2d, already above its 60d ceiling; the trailing 6-day counters show the identical loop (floor=75, deactivated=0, three days straight) already recurred twice without this cap in place. The mechanism will bind the moment the pool ages past the ceiling, which is exactly the recurrence it exists to stop. Tests: 106 passed (test_deactivate_stale_ttl_cap.py, test_deactivate_stale_revisit_floor.py, test_deactivate_stale_health_gate.py, test_deactivate_stale_listings.py, test_migrations_manifest.py). ruff clean. scripts/check-migration-lock-timeout.py: pass (UPDATE-only migration, no blocking DDL, no SET LOCAL needed). |
||
|
|
662720e1c8 | Merge remote-tracking branch 'forgejo/main' into _cont-tradein-glitchtip-noise | ||
|
|
bef2a05f60 | Merge remote-tracking branch 'forgejo/main' into local/fix-ttl-effective-cap-review2 | ||
| 1a391caae2 |
Merge pull request 'fix(health): HEAD на /health в обоих бэкендах — аптайм проверял то, что всегда отвечает 405' (#2893) from fix/tradein-uptime-honest-green into main
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 3m4s
Deploy Trade-In / test (push) Successful in 3m51s
Deploy / build-worker (push) Successful in 5m3s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / deploy (push) Successful in 8m1s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
cb0f42d1b1 |
fix(health): не тащить HEAD-пробу в OpenAPI-схему
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI / changes (pull_request) Successful in 16s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m53s
CI Trade-In / backend-tests (pull_request) Successful in 5m16s
CI / backend-tests (pull_request) Successful in 17m1s
Джоба openapi-codegen-check покраснела на этой ветке: она дампит app.openapi(), регенерирует frontend/src/types/api-types.ts и падает на расхождении. Добавленный HEAD /health попал в схему и потребовал правки сгенерированного файла. Регенерировать типы ради маршрута, который фронт никогда не вызывает, — лишний шум в generated-коде. HEAD-проба это инфраструктура для uptime-монитора, а не часть контракта, по которому фронт строит типы, поэтому include_in_schema=False здесь и по смыслу верно, а не только удобно. Флаг ставим в обоих бэкендах симметрично: у trade-in codegen-джобы пока нет, но расхождение схем между двумя бэкендами потом само станет источником вопросов. |
||
| 85414dabd2 |
Merge pull request 'fix(tradein/scrapers): прогон больше не рапортует «done» поверх провала и нуля' (#2892) from fix/tradein-honest-run-status into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Successful in 3m0s
Deploy Trade-In / test (push) Successful in 4m22s
Deploy Trade-In / build-browser (push) Successful in 4m50s
Deploy Trade-In / build-backend (push) Successful in 1m51s
Deploy Trade-In / deploy (push) Successful in 2m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
|
|||
|
|
fa84705ec7 |
fix(tradein/geocoder): stop apt number leaking into house + houses bbox/sibling guard
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m0s
Review round 2 on #2626 (local houses fallback) found two HIGH-severity bugs verified live against prod data: 1. _extract_local_house_token took the LAST digit-like token in the raw address, so "...Педагогическая, д 15, кв 11" resolved house=11 (apartment number) instead of 15 -- confidently returning a stranger's building with confidence='exact', written to geocode_cache. Fixed by stripping the apartment/office/floor/entrance tail (кв/оф/пом/подъезд/этаж -- NOT корп/к, which is part of the house number) before extracting the token. Fixes the exact prod case from the review plus the corpus+apartment combo ("д 26 к 1, кв 41" -> 26к1, not 41). 2. houses is not an EKB-only table (21% of rows with coords are outside the metro, some as far as another city) -- "улица Маяковского, 7" in houses resolves to Серов, not Екатеринбург, and use_local_ekb only gates the user's query text, not the source row. Added an is_within_ekb_bbox_wide check on every candidate row before it can become a match. Also addressed two MEDIUM findings from the same review: 3. The "<номер> -> <номер>к1" corpus guess only checked uniqueness among к1-labelled rows, so real multi-building addresses (Онуфриева 24: к1/к2/к3, 250-400m apart) resolved confidently to к1 anyway. Guess is now skipped when any other corpus/slash variant of the same base number exists among the street's candidates. 4. Houses-fallback results are no longer cached in geocode_cache -- the source (scraped listings) is less reliable than geoportal/cadastral/ Nominatim, and the lookup is cheap/local, so caching only extended the lifetime of a possible bad match. Side benefit: address_refined now survives every repeat request of the same raw address, not just the first. Also added ORDER BY address, id to the underlying query so the coordinate dedup picks a deterministic row (LOW finding #5). 14 new/updated tests in test_geocoder_local_houses_fallback.py cover all five findings against real prod address/houses-row fixtures. Full geocoder + dadata + estimator/pdf regression suite (402 tests) green. |
||
|
|
e9ca744e85 |
fix(tradein/scrapers): не путать rows_inserted/processed с честным результатным ключом
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Ревью честного run-status нашло, что _RESULT_COUNTER_KEYS ловил не только целевой yandex_newbuilding_sweep, но и rosreestr_dkp_import (rows_inserted, 66 из 67 прод- прогонов = здоровый ноль догнавшего инкрементального импорта) и newbuilding_enrich (processed — счётчик попыток, ==limit даже при частичном провале). Первое завело бы практически непрерываемый ложный zero-стрик у здорового источника, второе маскировало бы реальные отказы под measured-N. Проверено по прод-БД (2026-08-15): "succeeded" пишут ТОЛЬКО yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich (65/90д) — ни разу rosreestr_dkp_import; у yandex_newbuilding_sweep succeeded численно совпадает с rows_inserted на всех 42/42 прогонах. Заменил "rows_inserted"+"processed" на "succeeded" в _RESULT_COUNTER_KEYS (app-копия и byte-эквивалентная kit-копия) — цель (b) исходной правки сохранена, ложный стрик у rosreestr_dkp_import снят, попутно newbuilding_enrich получает честное измерение вместо счётчика попыток. Также поправлены докстринги test_backfill_honest_status.py — два кейса (76%/72% отказов -> 'done') проверяют только выбор финализатора mark_backfill_finished (mark_done там замокан); реальный mark_done с honest-run-status переквалифицирует их в 'failed' через _failed_ratio_too_high — это не документировалось явно. |
||
|
|
cb79c67bfc |
fix(tradein/deactivate): make TTL-cap multiplier configurable per source
Review of
|
||
|
|
3f5f099392 |
fix(health): HEAD /health на верном бэкенде (Site Finder) + честные заголовки
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 2m34s
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
CI / backend-tests (pull_request) Successful in 16m49s
Review-разбор ветки fix/tradein-uptime-honest-green:
1. [HIGH] Прод-симптом `HEAD gendsgn.ru/health -> 405` обслуживает Site
Finder (Caddyfile:60 `handle /health { reverse_proxy backend:8000 }`),
а предыдущий коммит правил только tradein-mvp/backend, чей /health наружу
не проксируется вообще. Добавлен @app.head("/health") в backend/app/main.py
рядом с существующим @app.get — эмпирически подтверждено (uv run pytest):
HEAD было 405, стало 200. tradein-mvp фикс не откачен (безвреден, годится
для будущего internal-caller), но обвязан комментарием, что реальный
прод-путь чинится не там.
2. [LOW] Response(status_code=200) без media_type отдавал HEAD без
Content-Type, тогда как GET отдаёт application/json — расходится с
заявленным в комментарии RFC 9110 §9.3.2. Добавлен media_type в обоих
бэкендах; Content-Length сознательно не подгоняем под байты GET-ответа
(payload header field, RFC разрешает опускать для HEAD) — не дублируем
сборку payload ради байт-в-байт соответствия.
Тесты: test_health_head_ok_no_body добавлен в backend/tests/test_health.py
(Site Finder) — RED-check (git stash app/main.py) воспроизводит прод-баг
1:1: assert 405 == 200. tradein-mvp/backend/tests/test_health_endpoint.py
дополнен проверкой Content-Type. uv run pytest — все зелёные.
|
||
|
|
c75206c348 |
fix(tradein/geocoder): local houses fallback + downgrade DaData CLEAN-disabled noise
28/1084 прод-оценок имели lat IS NULL — гарантированный ноль аналогов, клиент не получал оценку вовсе. Дом уже был в houses (скрейпленные листинги), но не резолвился ни geoportal/cad_buildings, ни Nominatim: разговорное/усечённое имя улицы («Онуфриева» вместо ГАР-каноничного «Начдива Онуфриева») или отсутствующий в вводе корпус («49» вместо реального «49к1»). Добавлен последний тир geocode() с двумя defensive-допущениями (суффиксный матч улицы + опциональная догадка «номер+к1») — при любой неоднозначности возвращает None, а не гадает; проверено живыми прод-адресами (Онуфриева/Хрустальногорская резолвятся, Крестинского корректно остаётся неоднозначным — два разных дома в houses под одним номером). Отдельно: HTTP 403 «услуга CLEAN выключена на аккаунте» логировался как ERROR на каждый /estimate (164 события) — это статичная конфигурация аккаунта, а не сбой; понижено до WARNING (первый раз за процесс) + DEBUG на повторы, чтобы ERROR продолжал значить настоящую проблему. |
||
|
|
8fcec9f12e |
fix(tradein/observability): stop basic_auth 401 and RetryError GlitchTip noise
83% of tracker issues (7460 total) were pure noise drowning real signal:
- basic_auth 401 (3738 issues, 2019 distinct titles) — ops/glitchtip-auth-
forwarder sent EVERY 401 from bots scanning gendsgn.ru (GET /wp-admin/
install.php etc.) as an individual GlitchTip event, remote_ip baked into
message/tags inflated cardinality. Not an application error — expected
bot-scan traffic against a basic_auth-protected site.
- RetryError (2462 issues) — geocoder.py's three tenacity @retry-wrapped
Nominatim helpers (lookup/suggest/reverse) raised tenacity.RetryError on
exhaustion without reraise=True; RetryError.__str__() embeds a Future
repr() with a memory address that differs every call, so GlitchTip
grouped each exhausted retry as a distinct issue instead of one.
Fix at the source, not post-hoc issue cleanup:
- forwarder.py: before_send drops events tagged event_type in
{basic_auth_failed, basic_auth_storm}; forwarder's own capture_exception
(real script bugs) carries no such tag and passes through untouched.
- geocoder.py: reraise=True on all three @retry decorators — propagates
the real underlying exception (stable type + stacktrace) instead of the
unstable RetryError wrapper.
- sentry_scrub.stabilize_retry_error_fingerprint: belt-and-suspenders
before_send hook, composed into both app/main.py and scheduler_main.py
(geocoder runs in both processes — FastAPI request path and the
overnight geocode_missing_listings batch). Collapses any RetryError that
still slips through into one persistent issue per cause-exception type
name only — never IP/address/listing-id.
Content-ful categories (OperationalError, city-sweep, harvest_quarter,
cian/avito/yandex sweep failures, scrape_freshness_check — ~700 issues)
are untouched: filters key off event_type tag / exception type name only.
|
||
|
|
885031420e |
fix(tradein/scrapers): honest run status — стоп 'done' поверх провала и нуля
Три прод-факта, где status='done' врал о реальном исходе прогона:
- avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1}
-> 'done'. mark_backfill_finished звал mark_done, потому что produced=6 (>0);
ни _sweep_run_did_nothing (нет anchors_total/errors_count у backfill'ов), ни
_phase_totally_failed (голые "attempted"/"failed" без фазового префикса) эту
форму counters не ловили. Новый _failed_ratio_too_high внутри mark_done:
failed/attempted >= 0.5 -> 'failed', >= 0.15 -> тоже 'failed' (другая
формулировка причины в error-тексте) — 'partial' статусом не заведён: это
потребовало бы DROP+ADD CHECK constraint (051_scrape_runs_extend.sql) и
дообучения ещё 4 мест (Literal-фильтр admin API, статусы фронта, оба
IN-списка сторожей) — тот же класс проводки, что и у ban_kind (#2686/#2764),
который сознательно не стал новым статусом.
- yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при
processed=5 succeeded=0 rows_inserted=0 failed_resolve=4-5 — сторож нулевого
результата (_alert_if_consecutive_zero_results) не видел ни один результатный
ключ этого sweep'а и молчал навсегда. _RESULT_COUNTER_KEYS дополнен
rows_inserted/processed (именно в этом порядке — rows_inserted это результат,
processed это попытки; иначе "5 обработано, 0 записано" замаскировалось бы
под measured-5).
- admin-витрина показывала new_count=0 у трёх подряд cian_full_load при реально
сохранённых saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count',
ни 'lots_inserted'. _column_counts дополнен saved_inserted/rows_inserted.
Правки продублированы в scraper_kit/orchestration/runs.py (byte-эквивалент
app.services.scrape_runs, см. докстринг модуля) для параллели: единственный
текущий писатель "attempted"/"failed" (mark_backfill_finished) живёт только в
app-копии, но приоритет ключей/константы держим синхронными на будущее.
Не тронуто: сознательно пустые sweep'ы (errors_count=0, honest empty) и малые
батчи (attempted < 3) — доля отказов на них не считается диагнозом.
Tests: tests/test_honest_run_status_failed_ratio.py (41 кейс, оба модуля,
включая точные прод-числа из трёх фактов выше) + regression-прогон 609 тестов
по всем файлам, трогающим scrape_runs/orchestration.runs — 0 регрессий.
|
||
|
|
24b70e5c58 |
fix(tradein): HEAD /health отвечает 200 вместо 405
@app.get("/health") в FastAPI/Starlette не добавляет HEAD-обработчик
автоматически (в отличие от низкоуровневого Route(methods=["GET"])) —
внешний uptime-monитор (GlitchTip PING-тип шлёт HEAD) получал 405 и не
мог отличить "жив" от "мёртв" по статусу. Добавлен явный
@app.head("/health") — 200 без тела (RFC 9110 §9.3.2), GET не тронут.
Тест test_health_endpoint.py фиксирует оба метода; RED до фикса
(HEAD → 405), GREEN после (проверено git stash + повторный прогон).
|