gendesign/tradein-mvp
bot-backend e4ac0365cf
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 2m53s
fix(tradein/avito): окно ретроспективы под недельный такт + диагноз полного обхода (#2674)
Окно и такт разъехались. Каденс avito_full_load задаёт default_params.interval_days,
глубину обхода — incremental_days (since = today - N, ранняя остановка по listing_date).
Это два независимых литерала, обязанных совпадать: 129 поставил окно 2 при ежедневном
такте (перекрытие было), 206 перевёл такт на 7 суток и окно не тронул. Прогон видит
[D-2, D] = 3 суток из 7; дни D+1..D+4 не попадают ни в один прогон.

Числа с прода (tradein, read-only 2026-08-06). 2026-06-21 — единственный день, когда
оба обхода отработали: инкрементальный run 297 — 2804 unique, exhaustive run 295 —
9992 unique, то есть окно в 2 суток достаёт 28.1% инвентаря. Когорта run 295, не
виденная после 23.06 (listing_date заморожен): полоса [D-2, D] — 189 лотов, полоса
[D-7, D-3] — ещё 427, расширение окна берёт в 3.26 раза больше. Гистограмма
(last_seen_at::date - listing_date) за 20 суток: возраст 0-2 — 594, возраст ровно 7 —
1212 (51% датированных наблюдений) — у Avito недельный авто-подъём, sortTimeStamp
сдвигается кратно 7. Структурный минимум бездырочного покрытия — 6, но 6 режет ровно
по этому пику; 7 = такт, полосы соседних прогонов смыкаются с суточным перехлёстом
под дрейф расписания (замер: last_run 03.08 13:37 -> next_run_at 10.08 14:16).

Цена: страниц примерно втрое больше на прогон, но прогон недельный. До 206 система
платила ~150-370 страниц семь раз в неделю; после правки — ~500-1200 в неделю, всё
ещё примерно вдвое дешевле, чем до 206.

Чиню в двух местах: миграция 215 выводит окно из фактического interval_days строки
(GREATEST — не сужает окно шире такта), scheduler расширяет его на лету и пишет
warning, чтобы расхождение не вернулось следующей правкой каденса.

Полный обход: причина не в площадке. Все пять banned-прогонов
avito_full_load_exhaustive (05.07-02.08) несут один текст —
"browser-sidecar error: browser unavailable (proxy may be down)", то есть 503 от
своего же сайдкара, у которого не поднялся камуфокс. В те же дни avito_city_sweep
(20 done), avito_newbuilding_sweep (21) и avito_detail_backfill (63) работали.
Корень — мёртвый BROWSER_PROXY_AVITO (ard.mobileproxy.space) в env сайдкара, куда
полный обход проваливался, потому что строил свой BrowserFetcher без пула; починено
не здесь, а #2637 (02.08, пул для браузерного пути Авито) и #2616 шаг 2 (05.08,
снос мёртвых env). Прод подтверждает: 0 лотов 05/12/19/26.07, 2334 и 362 в двух
прогонах после 02.08.

Остаток, который чинится кодом, здесь: 503 сайдкара классифицировался как soft-ban и
уходил в бюджет IP-ротации, а ротация снята (#2616 шаг 2, max_rot=0) — условие
rot_done < max_rot ложно всегда, а бюджет коротких backoff-retry стоял в else и был
для soft-ban недостижим. Ни одного ретрая на самую частую ошибку: один блип сайдкара
стоил бакета, четыре подряд — всего прогона. Бюджет backoff теперь общий для обеих
причин; сайдкар на таком 503 сам поднимает фоновый retry launch'а, поэтому повтор
через пару секунд обычно проходит.

Статус banned на такой ошибке остаётся ложью (площадка не банила) — это же чтение
легло в основание 206. Здесь не трогаю: набор status ограничен CHECK-констрейнтом,
а mark_banned в отличие от mark_failed сохраняет чекпоинт done_buckets.
2026-08-06 03:39:37 +05:00
..
backend fix(tradein/avito): окно ретроспективы под недельный такт + диагноз полного обхода (#2674) 2026-08-06 03:39:37 +05:00
browser chore(tradein): удалить мёртвые mobileproxy env-переменные и rotate-ip (#2616 шаги 2-3) (#2650) 2026-08-05 09:35:54 +00:00
deploy fix(tradein/ingest): раз-хардкод rosreestr DKP импорта на всю обл. 66 2026-07-12 18:11:47 +03:00
docs feat(tradein): канонический матч ГАР↔houses (0%→41% yield) + tradein_canon_addr + --match-only (#2063) 2026-06-28 15:50:07 +00:00
frontend fix(tradein): гейт правдоподобия на «медианный торг» — не показывать артефакт пейринга как рыночный факт (#2666) 2026-08-05 23:49:03 +05:00
ops fix(sf): psql stdin-bootstrap пароля gendesign_reader + COALESCE-protect внешних id (review fixup, #976) 2026-06-12 11:26:40 +03:00
packages/scraper-kit fix(tradein/avito): окно ретроспективы под недельный такт + диагноз полного обхода (#2674) 2026-08-06 03:39:37 +05:00
scripts chore(tradein/scrapers): удалить весь legacy scrapers/ каталог — final E (#2397, #2277) 2026-07-04 15:58:15 +03:00
.dockerignore chore(scraper-kit): docker-context wiring + restore backend workspace dep (#2137) 2026-07-02 14:21:38 +00:00
.env.example chore(tradein/geocoder): убрать мёртвую YANDEX_GEOCODER_API_KEY из .env.example (#2593) 2026-07-31 23:36:52 +03:00
.gitignore feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in) 2026-05-21 00:25:39 +03:00
DEPLOY.md chore(tradein/geocoder): удалить остатки скриптов Яндекс-геокодера, часть 3 (#2593) 2026-07-31 23:16:19 +03:00
docker-compose.prod.yml fix(tradein/scraper): не ходить через мёртвый запасной прокси (#2634) 2026-08-04 18:27:49 +00:00
docker-compose.yml chore(scraper-kit): docker-context wiring + restore backend workspace dep (#2137) 2026-07-02 14:21:38 +00:00
Makefile feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in) 2026-05-21 00:25:39 +03:00
pyproject.toml chore(scraper-kit): scaffold internal package + uv workspace, build-safe (#2128) 2026-07-02 14:06:07 +00:00
README.md feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in) 2026-05-21 00:25:39 +03:00
uv.lock feat(tradein/auth): password hashing + session config foundation (#2550) 2026-07-30 09:40:51 +03:00

Trade-In MVP

Локальный standalone-форк фичи Trade-In Estimator из проекта gendesign — оценка выкупной стоимости квартиры на вторичном рынке по аналогам и реальным сделкам. Layout повторяет PDF-отчёт «Брусника.Обмен» (см. docs/).

Что это и откуда взято

Источник Что Где
gendesign/main PR #316 TI-1 mock endpoint + Pydantic + SQL migration backend/
gendesign/main PR #317 TI-3 Next.js страница + 5 компонентов + hooks frontend/
gendesign/main PR #319 TI-2 PDF export 4 страницы (как у Брусники) backend/app/services/exporters/
gendesign/main PR #283 статичный tradein.html mockup (для Геныча) frontend/public/tradein.html
Встреча 19.05.2026 («Птица») требования к MVP оценки вторички docs/PTITSA_MEETING_2026-05-19.pdf
PDF Брусники EКБ-2485 референс layout-а отчёта docs/BRUSNIKA_REFERENCE_EKB-2485.pdf

Быстрый старт

make up              # build + up весь стек (caddy + frontend + backend + postgres)
open http://localhost:8080

Откроется / → автоматически редирект на /trade-in. Заполняешь форму (адрес/площадь/комнаты/этаж/...), нажимаешь «Оценить» — backend возвращает mock-оценку, фронт показывает median + диапазон цен + список аналогов.

Проверка backend напрямую:

make test-estimate
# или вручную:
curl -sS -X POST http://localhost:8080/api/v1/trade-in/estimate \
  -H 'Content-Type: application/json' \
  -d '{"address":"ул. Малышева, 1","area_m2":54,"rooms":2,"floor":5,"total_floors":17}' \
  | python3 -m json.tool

OpenAPI документация: http://localhost:8000/docs

Для сравнения макет vs реальная фича:

Структура

tradein-mvp/
├── docker-compose.yml         # caddy + frontend + backend + postgres
├── Makefile                   # удобные команды (up/down/logs/test-estimate)
├── deploy/
│   └── Caddyfile              # local reverse-proxy на http://localhost:8080
├── backend/                   # FastAPI + WeasyPrint
│   ├── Dockerfile
│   ├── pyproject.toml
│   ├── app/
│   │   ├── main.py            # FastAPI entry — только trade-in router
│   │   ├── core/
│   │   │   ├── config.py      # минимальный pydantic-settings
│   │   │   └── db.py          # SQLAlchemy engine + get_db
│   │   ├── api/v1/
│   │   │   └── trade_in.py    # 3 endpoint'а: POST /estimate, GET /estimate/{id}, GET /estimate/{id}/pdf
│   │   ├── schemas/
│   │   │   └── trade_in.py    # Pydantic: TradeInEstimateInput / AnalogLot / AggregatedEstimate
│   │   └── services/exporters/
│   │       └── trade_in_pdf.py  # WeasyPrint → 4-страничный PDF (cover/listings/deals/offer)
│   └── data/sql/
│       └── 001_trade_in_estimates.sql  # CREATE TABLE; применяется при первом старте postgres
├── frontend/                  # Next.js 15 + React 19 + TanStack Query
│   ├── Dockerfile
│   ├── package.json
│   ├── next.config.ts         # rewrites /api/* → backend
│   ├── tsconfig.json
│   ├── src/
│   │   ├── app/
│   │   │   ├── layout.tsx
│   │   │   ├── page.tsx       # redirect → /trade-in
│   │   │   ├── globals.css
│   │   │   ├── providers.tsx  # QueryClientProvider
│   │   │   └── trade-in/
│   │   │       └── page.tsx
│   │   ├── components/trade-in/
│   │   │   ├── EstimateForm.tsx       # форма ввода (sticky 360px)
│   │   │   ├── EstimateProgress.tsx   # индикатор «Парсим Циан → Авито → ...»
│   │   │   ├── EstimateResult.tsx     # карточка результата
│   │   │   ├── PriceRangeBar.tsx      # визуализация диапазона цен (как у Брусники)
│   │   │   └── AnalogsTable.tsx       # таблица аналогов
│   │   ├── lib/
│   │   │   ├── api.ts                 # apiFetch + HTTPError
│   │   │   ├── sessionId.ts           # X-Session-Id из localStorage
│   │   │   └── trade-in-api.ts        # useEstimateMutation + useEstimate hooks
│   │   └── types/
│   │       └── trade-in.ts            # TS типы зеркалят Pydantic schemas
│   └── public/
│       └── tradein.html              # статичный mockup от 17.05 (для side-by-side review)
└── docs/
    ├── BRUSNIKA_REFERENCE_EKB-2485.pdf  # эталон layout-а
    └── PTITSA_MEETING_2026-05-19.pdf    # AI-протокол встречи с требованиями

API

POST /api/v1/trade-in/estimate — оценить квартиру.

Запрос:

{
  "address": "ул. Малышева, 1, кв. 5, Екатеринбург",
  "area_m2": 54.0,
  "rooms": 2,
  "floor": 5,
  "total_floors": 17,
  "year_built": 1985,
  "house_type": "panel",
  "repair_state": "good",
  "has_balcony": true
}

Ответ:

{
  "estimate_id": "...uuid...",
  "median_price_rub": 13125000,
  "range_low_rub": 11550000,
  "range_high_rub": 14700000,
  "median_price_per_m2": 243056,
  "confidence": "high",
  "n_analogs": 8,
  "period_months": 24,
  "analogs": [ {"address": "...", "area_m2": 56, "price_rub": 12700000, ...} ],
  "actual_deals": [ ... ],
  "expires_at": "2026-05-20T22:48:00Z"
}

GET /api/v1/trade-in/estimate/{id} — получить сохранённую оценку (TTL 24ч) GET /api/v1/trade-in/estimate/{id}/pdf — скачать 4-страничный PDF (cover / listings / deals / offer)

Что внутри _mock_estimate() (текущая реализация)

Формула:

price = base_price_by_rooms × floor_factor × repair_factor
Поле Значения
Базовая цена ЕКБ 2026 студия 6.5M (260K/м²) · 1к 9.0M (225K/м²) · 2к 12.5M (208K/м²) · 3к 17.0M (213K/м²)
floor_factor 1-й этаж = ×0.95, последний = ×0.97, остальные = ×1.00
repair_factor needs_repair = ×0.90, standard = ×1.00, good = ×1.05, excellent = ×1.10
confidence 1-3 комнаты = high, остальные = medium
Улицы аналогов реальные центральные ЕКБ (Малышева, Куйбышева, 8 Марта, Белинского, пр. Ленина, Толмачёва, Радищева, Мамина-Сибиряка, Луначарского, Первомайская)

Каждая оценка сохраняется в trade_in_estimates с TTL 24 часа — UUID можно использовать для shareable links и PDF-экспорта.

Roadmap — что доделать

Phase 1 — заменить mock на реальные данные (TODO TI-1b из gendesign)

Сейчас _mock_estimate() возвращает хардкод. На встрече Птица 19.05 решили:

  • источники: Циан, Авито, Дом.Клик, Я.Недвижимость, Н1, Дом РФ
  • Объектив НЕ использовать на вторичке (он про первичку/ДДУ)
  • Росреестр для исторических сделок (квартал глубины)
  • картография ЕКБ для проверки этажности/года/планировок

Phase 2 — то что обсуждали на встрече

Задача Из протокола Птицы
Парольный вход + учёт пользователей + аналитика 0:23:44, 0:25:54
Доступ только Геныч / Загайнов / Паша (НЕ Рожкова) 0:25:50, 0:22:35
PDF-отчёт под паролем 0:08:39
Real-time парсинг ≥1/час чтобы ловить быстрые продажи 0:40:19
MVP к понедельнику 25.05.2026 0:26:12
Демо для девелопера в четверг 28.05.2026 0:18:08

Phase 3 — следующие продукты (упоминалось на встрече)

  1. Птица — анализ участков + расселение домов (≥20% квартир дома в продаже → подсветить можно расселять)
  2. Расселение как сервис — следствие #1 и Птицы

См. полный протокол: docs/PTITSA_MEETING_2026-05-19.pdf.

Как это связано с прод gendesign

Аспект Прод (gendsgn.ru) Этот MVP
URL https://gendsgn.ru/trade-in http://localhost:8080/trade-in
Backend shared FastAPI /api/v1/trade-in/* то же самое, standalone
Frontend Next.js 15 в большом monorepo тот же код, standalone
Postgres 84 таблицы, 6.83M ДДУ partitioned только trade_in_estimates (1 таблица)
Caddy TLS + 5 доменов + reverse-proxy local :8080 без TLS
Что отрезано site-finder, analytics, generative, scraper, OSM, NSPD, sentry, celery, redis, playwright всё это — кроме trade-in

Важно: эти два инстанса полностью изолированы. Локальный backend пишет в свой Postgres контейнер (порт 5433), не трогает прод. Можно сломать локально что угодно — прод не пострадает.

Лицензия

Internal use only. Forked from gendesign monorepo (private).