diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml new file mode 100644 index 00000000..c0917abc --- /dev/null +++ b/.forgejo/workflows/deploy-tradein.yml @@ -0,0 +1,192 @@ +name: Deploy Trade-In + +# Forgejo Actions — отдельный pipeline для подпроекта tradein-mvp/. +# Триггерится только на изменения внутри tradein-mvp/ (или этого workflow), +# не пересекается с основным deploy.yml. +on: + push: + branches: [main] + paths: + - "tradein-mvp/**" + - ".forgejo/workflows/deploy-tradein.yml" + workflow_dispatch: + +concurrency: + group: deploy-tradein-prod + cancel-in-progress: false + +env: + IMAGE_BACKEND: ghcr.io/lekss361/gendesign-tradein-backend + IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-tradein-frontend + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.filter.outputs.backend }} + frontend: ${{ steps.filter.outputs.frontend }} + infra: ${{ steps.filter.outputs.infra }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + backend: + - 'tradein-mvp/backend/**' + frontend: + - 'tradein-mvp/frontend/**' + infra: + - 'tradein-mvp/docker-compose.prod.yml' + - 'tradein-mvp/deploy/**' + - '.forgejo/workflows/deploy-tradein.yml' + + build-backend: + runs-on: ubuntu-latest + needs: changes + if: | + needs.changes.outputs.backend == 'true' || + needs.changes.outputs.infra == 'true' || + github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions) + env: + GHCR_PAT: ${{ secrets.GHCR_PAT }} + run: | + echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build & push tradein-backend + uses: docker/build-push-action@v6 + with: + context: ./tradein-mvp/backend + push: true + cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max + tags: | + ${{ env.IMAGE_BACKEND }}:latest + ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + + build-frontend: + runs-on: ubuntu-latest + needs: changes + if: | + needs.changes.outputs.frontend == 'true' || + needs.changes.outputs.infra == 'true' || + github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions) + env: + GHCR_PAT: ${{ secrets.GHCR_PAT }} + run: | + echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build & push tradein-frontend + uses: docker/build-push-action@v6 + with: + context: ./tradein-mvp/frontend + push: true + # basePath=/trade-in baked-in во время build (Next.js) + build-args: | + NEXT_PUBLIC_BASE_PATH=/trade-in + cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max + tags: | + ${{ env.IMAGE_FRONTEND }}:latest + ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + + deploy: + runs-on: ubuntu-latest + needs: [changes, build-backend, build-frontend] + if: | + always() && + !cancelled() && + needs.build-backend.result != 'failure' && + needs.build-frontend.result != 'failure' + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + env: + IMAGE_TAG: latest + GHCR_PAT: ${{ secrets.GHCR_PAT }} + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT }} + envs: IMAGE_TAG,GHCR_PAT + script: | + set -euo pipefail + cd /opt/gendesign + + # repo уже clone'ен — origin = Forgejo. Подтягиваем последний main. + git fetch origin main + git reset --hard origin/main + + cd tradein-mvp + + # .env.runtime создаётся вручную при первом запуске (см. README-АДМИНУ.md). + # Здесь только подгружаем переменные для docker compose (POSTGRES_PASSWORD). + if [ ! -f .env.runtime ]; then + echo "ERROR: /opt/gendesign/tradein-mvp/.env.runtime отсутствует." + echo "Создай его вручную (см. tradein-mvp/README.md или DEPLOY.md)." + exit 1 + fi + chmod 600 .env.runtime + set -a; source .env.runtime; set +a + + # External network для Caddy (он в основном gendesign-стеке) + docker network inspect gendesign_shared >/dev/null 2>&1 \ + || docker network create gendesign_shared + + # Re-login to GHCR (PAT может быть rotated) + echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + + export IMAGE_TAG="$IMAGE_TAG" + docker compose -p gendesign-tradein -f docker-compose.prod.yml pull + docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d + + # Применяем SQL миграции (если есть data/sql/*.sql) + # Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом + # старте volume. Здесь — для повторных миграций после первого запуска. + sleep 5 + for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do + fname=$(basename "$sql_file") + echo "→ Migration: $fname" + docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ + psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein \ + -v ON_ERROR_STOP=on < "$sql_file" \ + || echo " (skipped — already applied or error: $fname)" + done + + # Caddy reload — основной Caddyfile содержит inline tradein routes + # (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS + # tradein-backend / tradein-frontend (они в gendesign_shared network). + cd /opt/gendesign + docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ + caddy reload --config /etc/caddy/Caddyfile || true + + # Health check + for i in $(seq 1 30); do + docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \ + exec -T backend curl -fsS http://localhost:8000/health && break + sleep 1 + done + + # Cleanup старых образов + for repo in ghcr.io/lekss361/gendesign-tradein-backend \ + ghcr.io/lekss361/gendesign-tradein-frontend; do + docker images "$repo" --format '{{.Repository}}:{{.Tag}}' \ + | grep -v ':latest$' | tail -n +3 \ + | xargs -r docker rmi 2>/dev/null || true + done + docker image prune -af || true diff --git a/Caddyfile b/Caddyfile index 69a36308..d3047c2a 100644 --- a/Caddyfile +++ b/Caddyfile @@ -25,6 +25,24 @@ gendsgn.ru { file_server browse } + # Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack, + # подключен через gendesign_shared network. Routes ДО универсального handle + # потому что Caddy матчит handle-блоки сверху вниз. + handle_path /trade-in/api/* { + # handle_path вырезает префикс /trade-in перед прокси → + # FastAPI получает /api/v1/... + reverse_proxy tradein-backend:8000 + } + + handle /trade-in { + redir /trade-in/ permanent + } + + handle /trade-in/* { + # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL + reverse_proxy tradein-frontend:3000 + } + handle { reverse_proxy frontend:3000 } diff --git a/tradein-mvp/.env.example b/tradein-mvp/.env.example new file mode 100644 index 00000000..1fd4cdf3 --- /dev/null +++ b/tradein-mvp/.env.example @@ -0,0 +1,17 @@ +# Trade-In MVP — environment variables example. +# Скопируй в .env (или экспортируй в shell) для override defaults. + +# === Backend === +DATABASE_URL=postgresql+psycopg://tradein:tradein@postgres:5432/tradein +CORS_ORIGINS=["http://localhost:8080","http://localhost:3000"] +ENVIRONMENT=dev + +# === Frontend (build-time) === +# Backend URL для Next.js rewrites (browser обращается к /api/* — Next.js проксирует на BACKEND_URL/api/*). +# В docker compose это уже выставлено в http://backend:8000 — менять не нужно. +# BACKEND_URL=http://backend:8000 + +# === Postgres (если меняешь — синхронизируй с DATABASE_URL и docker-compose.yml) === +POSTGRES_USER=tradein +POSTGRES_PASSWORD=tradein +POSTGRES_DB=tradein diff --git a/tradein-mvp/.gitignore b/tradein-mvp/.gitignore new file mode 100644 index 00000000..233693bc --- /dev/null +++ b/tradein-mvp/.gitignore @@ -0,0 +1,50 @@ +# Trade-In MVP + +# Backend Python +backend/.venv/ +backend/__pycache__/ +backend/**/__pycache__/ +backend/*.egg-info/ +backend/.pytest_cache/ +backend/.ruff_cache/ +backend/.mypy_cache/ +backend/uv.lock + +# Frontend Node +frontend/node_modules/ +frontend/.next/ +frontend/out/ +frontend/dist/ +frontend/package-lock.json +frontend/next-env.d.ts + +# Docker +postgres-data/ +caddy-data/ +caddy-config/ + +# Env (никогда не коммитим) +.env +.env.local +.env.*.local +backend/.env +backend/.env.runtime +frontend/.env.local + +# Public html mockup (исходник от gendsgn.ru — не наш код, держим в docs) +frontend/public/tradein.html + +# Test PDF outputs +docs/sample-svg-test.pdf +docs/sample-vps-*.pdf +*.tmp.pdf + +# IDE / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db +*.swp + +# Logs +*.log diff --git a/tradein-mvp/DEPLOY.md b/tradein-mvp/DEPLOY.md new file mode 100644 index 00000000..f77bb47d --- /dev/null +++ b/tradein-mvp/DEPLOY.md @@ -0,0 +1,147 @@ +# Trade-In MVP — деплой в gendesign репо + +## Архитектура + +``` +lekss361/-gendesign/ +├── backend/ ← gendesign Python backend (НЕ ТРОГАЕМ) +├── frontend/ ← gendesign Next.js (НЕ ТРОГАЕМ) +├── docker-compose.prod.yml ← gendesign-стек +├── Caddyfile ← основной Caddy → ВКЛЮЧАЕТ tradein-mvp/deploy/Caddyfile.tradein-fragment +├── .github/workflows/ +│ ├── deploy.yml ← существующий gendesign deploy +│ └── deploy-tradein.yml ← НАШ новый, триггерится только на tradein-mvp/** +└── tradein-mvp/ ← НАША подпапка (изолированный subproject) + ├── backend/ + ├── frontend/ + ├── deploy/ + │ ├── Caddyfile.tradein-fragment ← импортится в основной Caddyfile + │ └── cron-scrape.sh + ├── docker-compose.yml ← локальный dev + ├── docker-compose.prod.yml ← production overlay (использует GHCR images) + └── .github/workflows/deploy-tradein.yml +``` + +## Прод URL routes + +| URL | Сервис | +|----------------------------------|-------------------------| +| `gendsgn.ru/trade-in/` | tradein-frontend:3000 | +| `gendsgn.ru/trade-in/api/v1/...` | tradein-backend:8000 | + +Caddy в gendesign-стеке проксирует через `handle_path /trade-in/api/*` (вырезает префикс) и `handle /trade-in/*` (Next.js basePath). + +## Прод docker stacks + +- `docker compose -p gendesign` — основной gendesign (postgres + backend + frontend + caddy + couchdb) +- `docker compose -p gendesign-tradein` — наш (tradein-postgres + tradein-backend + tradein-frontend) + +Оба подключены к `gendesign_shared` external network — туда смотрит Caddy. + +## Что нужно положить в основной Caddyfile gendesign + +В блок `gendsgn.ru { ... }` **ПЕРЕД** универсальным `handle { reverse_proxy frontend:3000 }`: + +```caddyfile +import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment +``` + +Или вставить содержимое фрагмента inline. + +## Что нужно положить в `.env.runtime` (на сервере, НЕ в git) + +```bash +# /opt/gendesign/tradein-mvp/.env.runtime +TRADEIN_POSTGRES_USER=tradein +TRADEIN_POSTGRES_PASSWORD=<сгенерировать openssl rand -hex 32> +TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru +YANDEX_GEOCODER_KEY= # пусто пока, Nominatim fallback работает +``` + +Файл создаётся вручную при первом деплое. + +## GitHub Secrets (уже должны быть от gendesign deploy) + +- `DEPLOY_HOST` — IP сервера (94.228.121.73) +- `DEPLOY_USER` — root +- `DEPLOY_SSH_KEY` — приватный SSH ключ +- `DEPLOY_PORT` — 22 (или другой если меняли) + +## Первый деплой — пошагово + +### 1. На анто́новой машине: подготовить tradein-mvp/ к слиянию + +```bash +# clone gendesign репо (с PAT) +git clone https://@github.com/lekss361/-gendesign.git +cd -gendesign + +# скопировать tradein-mvp/ как подпапку +cp -r /Users/anton/Птица/tradein-mvp ./tradein-mvp +rm -rf ./tradein-mvp/postgres-data # на всякий +rm -rf ./tradein-mvp/.git # не нужен внутри monorepo + +# скопировать workflow в правильное место +mkdir -p .github/workflows +cp ./tradein-mvp/.github/workflows/deploy-tradein.yml .github/workflows/ + +git add tradein-mvp/ .github/workflows/deploy-tradein.yml +git commit -m "feat: add tradein-mvp subproject + deploy workflow" +git push origin main +``` + +### 2. На сервере: первый раз вручную добавить Caddy include + +```bash +ssh -i ~/.ssh/id_bot_server root@94.228.121.73 + +cd /opt/gendesign +# git reset --hard origin/main подтянет tradein-mvp/ автоматически + +# Добавить в Caddyfile (ровно одну строку в блок gendsgn.ru { ... }) +nano Caddyfile +# вставить ПЕРЕД последним handle { ... }: +# import /opt/gendesign/tradein-mvp/deploy/Caddyfile.tradein-fragment + +# Создать .env.runtime +cat > tradein-mvp/.env.runtime </dev/null 2>&1 || docker network create gendesign_shared +cd tradein-mvp +set -a; source .env.runtime; set +a +docker compose -p gendesign-tradein -f docker-compose.prod.yml pull +docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d + +# Reload Caddy +cd /opt/gendesign +docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy caddy reload --config /etc/caddy/Caddyfile + +# Проверить +curl -sS https://gendsgn.ru/trade-in/api/health +curl -sI https://gendsgn.ru/trade-in/ +``` + +### 3. Дальше — автодеплой + +После первого ручного запуска все следующие `git push origin main` с изменениями в `tradein-mvp/**` будут: +1. Триггерить `.github/workflows/deploy-tradein.yml` +2. Билдить и пушить новые образы в `ghcr.io/lekss361/gendesign-tradein-*` +3. SSH в сервер → `docker compose pull` → `up -d` → Caddy reload +4. Health check + +## Откат + +```bash +ssh root@94.228.121.73 +cd /opt/gendesign/tradein-mvp +# Откатиться на предыдущий SHA-тег +docker compose -p gendesign-tradein -f docker-compose.prod.yml down +IMAGE_TAG= docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d +``` diff --git a/tradein-mvp/Makefile b/tradein-mvp/Makefile new file mode 100644 index 00000000..821910b3 --- /dev/null +++ b/tradein-mvp/Makefile @@ -0,0 +1,63 @@ +# Trade-In MVP — удобные команды для локальной разработки. + +.PHONY: help up down rebuild logs ps shell-backend shell-postgres test-estimate clean + +help: + @echo "Trade-In MVP — Makefile commands:" + @echo "" + @echo " make up — собрать и запустить весь стек (caddy + frontend + backend + postgres)" + @echo " make down — остановить стек (volumes остаются)" + @echo " make rebuild — пересобрать образы и перезапустить (нужно при изменении Dockerfile или deps)" + @echo " make logs — tail -f всех контейнеров" + @echo " make logs-backend — tail -f только backend" + @echo " make logs-frontend — tail -f только frontend" + @echo " make ps — статус контейнеров + healthcheck" + @echo " make shell-backend — bash внутри backend контейнера" + @echo " make shell-postgres — psql внутри postgres контейнера" + @echo " make test-estimate — пример POST /api/v1/trade-in/estimate через curl" + @echo " make clean — down + rm volumes (УДАЛИТ данные Postgres!)" + @echo "" + +up: + docker compose up -d --build + @echo "" + @echo "✅ Стек запущен." + @echo " Frontend: http://localhost:8080 (auto-redirect → /trade-in)" + @echo " Mockup preview: http://localhost:8080/preview/tradein.html" + @echo " Backend health: http://localhost:8080/health" + @echo " Backend OpenAPI: http://localhost:8000/docs" + +down: + docker compose down + +rebuild: + docker compose up -d --build --force-recreate + +logs: + docker compose logs -f --tail=100 + +logs-backend: + docker compose logs -f --tail=100 backend + +logs-frontend: + docker compose logs -f --tail=100 frontend + +ps: + docker compose ps + +shell-backend: + docker compose exec backend bash + +shell-postgres: + docker compose exec postgres psql -U tradein -d tradein + +test-estimate: + @echo "POST /api/v1/trade-in/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,"year_built":1985,"house_type":"panel","repair_state":"good","has_balcony":true}' \ + | python3 -m json.tool + +clean: + docker compose down -v + @echo "⚠️ Volumes удалены — Postgres данные стёрты." diff --git a/tradein-mvp/README.md b/tradein-mvp/README.md new file mode 100644 index 00000000..adfa8223 --- /dev/null +++ b/tradein-mvp/README.md @@ -0,0 +1,196 @@ +# Trade-In MVP + +Локальный standalone-форк фичи **Trade-In Estimator** из проекта gendesign — оценка выкупной стоимости квартиры на вторичном рынке по аналогам и реальным сделкам. Layout повторяет PDF-отчёт «Брусника.Обмен» (см. [docs/](docs/)). + +## Что это и откуда взято + +| Источник | Что | Где | +|---|---|---| +| `gendesign/main` PR [#316](https://git.gendsgn.ru/lekss361/gendesign/pulls/316) TI-1 | mock endpoint + Pydantic + SQL migration | `backend/` | +| `gendesign/main` PR [#317](https://git.gendsgn.ru/lekss361/gendesign/pulls/317) TI-3 | Next.js страница + 5 компонентов + hooks | `frontend/` | +| `gendesign/main` PR [#319](https://git.gendsgn.ru/lekss361/gendesign/pulls/319) TI-2 | PDF export 4 страницы (как у Брусники) | `backend/app/services/exporters/` | +| `gendesign/main` PR [#283](https://git.gendsgn.ru/lekss361/gendesign/pulls/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` | + +## Быстрый старт + +```bash +make up # build + up весь стек (caddy + frontend + backend + postgres) +open http://localhost:8080 +``` + +Откроется `/` → автоматически редирект на `/trade-in`. Заполняешь форму (адрес/площадь/комнаты/этаж/...), нажимаешь «Оценить» — backend возвращает mock-оценку, фронт показывает median + диапазон цен + список аналогов. + +Проверка backend напрямую: +```bash +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 документация: + +Для сравнения макет 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` — оценить квартиру. + +Запрос: +```json +{ + "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 +} +``` + +Ответ: +```json +{ + "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](docs/PTITSA_MEETING_2026-05-19.pdf). + +## Как это связано с прод gendesign + +| Аспект | Прод (gendsgn.ru) | Этот MVP | +|---|---|---| +| URL | | | +| 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). diff --git a/tradein-mvp/backend/.dockerignore b/tradein-mvp/backend/.dockerignore new file mode 100644 index 00000000..236db7c7 --- /dev/null +++ b/tradein-mvp/backend/.dockerignore @@ -0,0 +1,13 @@ +.venv +__pycache__ +**/__pycache__ +*.egg-info +.pytest_cache +.ruff_cache +.mypy_cache +.git +.env +.env.local +data/sql +Dockerfile +.dockerignore diff --git a/tradein-mvp/backend/Dockerfile b/tradein-mvp/backend/Dockerfile new file mode 100644 index 00000000..bf781a85 --- /dev/null +++ b/tradein-mvp/backend/Dockerfile @@ -0,0 +1,56 @@ +# Trade-In MVP backend — lean image (FastAPI + WeasyPrint). +# Не содержит Playwright/Chromium/Celery/Redis — только то что нужно для trade-in feature. + +# ---- builder ---- +FROM python:3.12-slim AS builder + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev + +RUN pip install --no-cache-dir uv + +WORKDIR /app + +COPY pyproject.toml ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +COPY app ./app + + +# ---- runner ---- +FROM python:3.12-slim AS runner + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + libcairo2 \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + fonts-dejavu-core \ + curl \ + && useradd --create-home --uid 1000 app + +WORKDIR /app + +COPY --from=builder --chown=app:app /app/.venv /app/.venv +COPY --from=builder --chown=app:app /app/app /app/app + +USER app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/tradein-mvp/backend/app/__init__.py b/tradein-mvp/backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/app/api/__init__.py b/tradein-mvp/backend/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/app/api/v1/__init__.py b/tradein-mvp/backend/app/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py new file mode 100644 index 00000000..89a881a5 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -0,0 +1,151 @@ +"""Admin endpoints — debug + ручной запуск парсеров. + +Пока без auth (Слой 7), потом закроем общим middleware. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.services.geocoder import geocode +from app.services.scrapers.avito import AvitoScraper +from app.services.scrapers.base import save_listings +from app.services.scrapers.cian import CianScraper +from app.services.scrapers.yandex_realty import YandexRealtyScraper + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class ScrapeRequest(BaseModel): + lat: float + lon: float + radius_m: int = Field(default=1000, ge=100, le=20000) + sources: list[Literal["avito", "cian", "yandex"]] = Field(default_factory=lambda: ["avito", "cian", "yandex"]) + multi_room_yandex: bool = False # Если True — скрейп Yandex по 5 сегментам комнат, не одним общим запросом. + deep_yandex: bool = False # Если True — Yandex полный обход 5 rooms × 3 sorts × 2 pages = 30 запросов / ~150s. + multi_room_cian: bool = False # Если True — скрейп Cian по 4 сегментам комнат отдельно (~4× лотов). + + +class ScrapeResult(BaseModel): + source: str + fetched: int + inserted: int + updated: int + + +class ScrapeResponse(BaseModel): + total_fetched: int + total_inserted: int + total_updated: int + by_source: list[ScrapeResult] + + +@router.post("/scrape", response_model=ScrapeResponse) +async def scrape_around( + payload: ScrapeRequest, + db: Annotated[Session, Depends(get_db)], +) -> ScrapeResponse: + """Запустить парсеры для точки (lat, lon) в радиусе radius_m метров. + + Примеры: + curl -X POST /api/v1/admin/scrape \\ + -H 'Content-Type: application/json' \\ + -d '{"lat":56.8332,"lon":60.5944,"radius_m":1000,"sources":["avito"]}' + """ + results: list[ScrapeResult] = [] + + for source in payload.sources: + scraper_cls = { + "avito": AvitoScraper, + "cian": CianScraper, + "yandex": YandexRealtyScraper, + }.get(source) + if scraper_cls is None: + continue + async with scraper_cls() as scraper: + if source == "yandex" and payload.deep_yandex: + lots = await scraper.fetch_around_multi_room( + payload.lat, payload.lon, payload.radius_m, + sorts=("DATE_DESC", "PRICE", "AREA_DESC"), + pages=(0, 1), + ) + elif source == "yandex" and payload.multi_room_yandex: + lots = await scraper.fetch_around_multi_room( + payload.lat, payload.lon, payload.radius_m + ) + elif source == "cian" and payload.multi_room_cian: + lots = await scraper.fetch_around_multi_room( + payload.lat, payload.lon, payload.radius_m + ) + else: + lots = await scraper.fetch_around( + payload.lat, payload.lon, payload.radius_m + ) + inserted, updated = save_listings(db, lots) + results.append( + ScrapeResult(source=source, fetched=len(lots), inserted=inserted, updated=updated) + ) + + return ScrapeResponse( + total_fetched=sum(r.fetched for r in results), + total_inserted=sum(r.inserted for r in results), + total_updated=sum(r.updated for r in results), + by_source=results, + ) + + +@router.post("/geocode-missing") +async def geocode_missing( + db: Annotated[Session, Depends(get_db)], + limit: int = 50, +) -> dict: + """Геокодинг listings у которых нет lat/lon (используя address). + + Запускать после scrape если парсер не достал координаты. + Nominatim rate-limited 1 req/sec — поэтому небольшой batch. + """ + rows = db.execute( + text( + """ + SELECT id, address, raw_payload + FROM listings + WHERE (lat IS NULL OR lon IS NULL) + AND COALESCE(address, '') != '' + ORDER BY scraped_at DESC + LIMIT :limit + """ + ), + {"limit": limit}, + ).mappings().all() + + geocoded = 0 + skipped = 0 + for row in rows: + result = await geocode(row["address"], db) + if result is None: + skipped += 1 + continue + db.execute( + text( + """ + UPDATE listings + SET lat = :lat, lon = :lon + WHERE id = :id + """ + ), + {"lat": result.lat, "lon": result.lon, "id": row["id"]}, + ) + db.commit() + geocoded += 1 + + return {"checked": len(rows), "geocoded": geocoded, "skipped": skipped} diff --git a/tradein-mvp/backend/app/api/v1/brand.py b/tradein-mvp/backend/app/api/v1/brand.py new file mode 100644 index 00000000..325ffac7 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/brand.py @@ -0,0 +1,22 @@ +"""Brand endpoint — frontend дёргает чтобы получить логотип/цвет для white-label.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.services.brand import Brand, get_brand + +router = APIRouter() + + +@router.get("/{slug}", response_model=Brand) +def lookup(slug: str, db: Annotated[Session, Depends(get_db)]) -> Brand: + """GET /api/v1/brand/prinzip → {slug, name, primary_color, ...}. + + Не найден → fallback generic. + """ + return get_brand(slug, db) diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py new file mode 100644 index 00000000..59fbdb9b --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -0,0 +1,70 @@ +"""Geocode endpoints — debug + frontend Suggest proxy.""" + +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.services.geocoder import GeocodeResult, geocode, suggest + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/lookup", response_model=GeocodeResult) +async def lookup( + address: Annotated[str, Query(min_length=3, max_length=500)], + db: Annotated[Session, Depends(get_db)], +) -> GeocodeResult: + """Геокодинг адреса → lat/lon. + + Примеры: + /api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург + /api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург + """ + result = await geocode(address, db) + if result is None: + raise HTTPException(status_code=404, detail=f"Address not found: {address}") + return result + + +class SuggestItem(BaseModel): + label: str + full_address: str + lat: float + lon: float + kind: str + + +class SuggestResponse(BaseModel): + items: list[SuggestItem] + + +@router.get("/suggest", response_model=SuggestResponse) +async def suggest_addresses( + q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")], + limit: Annotated[int, Query(ge=1, le=15)] = 8, +) -> SuggestResponse: + """Автокомплит адресов в пределах ЕКБ. + + Используется в EstimateForm для подсказок пока пользователь печатает. + Bounded viewbox = ЕКБ (lon 60.40-60.85, lat 56.65-56.95). + + Пример: + /api/v1/geocode/suggest?q=Малышева + /api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет + """ + items = await suggest(q, limit=limit) + return SuggestResponse(items=[ + SuggestItem( + label=s.label, + full_address=s.full_address, + lat=s.lat, lon=s.lon, kind=s.kind, + ) for s in items + ]) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py new file mode 100644 index 00000000..b9babe64 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -0,0 +1,411 @@ +"""Trade-In Estimator — endpoints (TI-1 mock + TI-2 PDF). + +MOCK implementation: returns realistic ЕКБ price bands by rooms/floor/repair. +TODO TI-1b: заменить _mock_estimate() на реальный SQL aggregation из +objective_lots + rosreestr_deals после OBJ-1/2 merge. +""" + +from __future__ import annotations + +import json +import logging +import random +from datetime import UTC, datetime, timedelta +from typing import Annotated +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput +from app.services.exporters.trade_in_pdf import generate_trade_in_pdf + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# ЕКБ-адреса для фейковых аналогов (реальные улицы центра) +_EKB_STREETS = [ + "ул. Малышева", + "ул. Куйбышева", + "ул. 8 Марта", + "ул. Белинского", + "пр. Ленина", + "ул. Толмачёва", + "ул. Радищева", + "ул. Мамина-Сибиряка", + "ул. Луначарского", + "ул. Первомайская", +] + +# Базовые ценовые диапазоны по комнатности (ЕКБ, 2026) +_PRICE_BANDS: dict[int, dict[str, int | float | str]] = { + 0: { # студия ~25 м² + "median": 6_500_000, + "low": 5_800_000, + "high": 7_500_000, + "ppm2": 260_000, + "ref_area": 25.0, + }, + 1: { # 1к ~40 м² + "median": 9_000_000, + "low": 8_000_000, + "high": 10_500_000, + "ppm2": 225_000, + "ref_area": 40.0, + }, + 2: { # 2к ~60 м² + "median": 12_500_000, + "low": 11_000_000, + "high": 14_000_000, + "ppm2": 208_000, + "ref_area": 60.0, + }, + 3: { # 3к ~80 м² + "median": 17_000_000, + "low": 15_000_000, + "high": 19_000_000, + "ppm2": 213_000, + "ref_area": 80.0, + }, +} + + +def _floor_factor(floor: int, total_floors: int) -> float: + """±5% поправка за этаж: 1й и последний этаж снижают цену.""" + if floor == 1: + return 0.95 + if floor == total_floors: + return 0.97 + return 1.0 + + +def _repair_factor(repair_state: str | None) -> float: + """±10% поправка за состояние отделки.""" + factors = { + "needs_repair": 0.90, + "standard": 1.00, + "good": 1.05, + "excellent": 1.10, + } + return factors.get(repair_state or "standard", 1.0) + + +def _confidence(rooms: int) -> str: + if 1 <= rooms <= 3: + return "high" + return "medium" + + +def _gen_analogs( + rooms: int, + area_m2: float, + base_ppm2: int, + n: int, + *, + is_listing: bool, +) -> list[AnalogLot]: + """Генерирует список фейковых аналогов (объявления или сделки).""" + rng = random.Random(42 + rooms + n) + result: list[AnalogLot] = [] + today = datetime.now(tz=UTC).date() + for i in range(n): + street = _EKB_STREETS[i % len(_EKB_STREETS)] + building_no = rng.randint(1, 120) + apt_no = rng.randint(1, 300) + addr = f"г. Екатеринбург, {street}, {building_no}, кв. {apt_no}" + + area_jitter = area_m2 * rng.uniform(0.85, 1.15) + ppm2_jitter = int(base_ppm2 * rng.uniform(0.90, 1.10)) + price = int(area_jitter * ppm2_jitter) + + floor_val = rng.randint(2, 16) + total_fl = rng.randint(floor_val, 20) + + if is_listing: + dom = rng.randint(5, 120) + listing_dt = today - timedelta(days=dom) + else: + dom = rng.randint(10, 60) + listing_dt = today - timedelta(days=rng.randint(30, 365)) + + result.append( + AnalogLot( + address=addr, + area_m2=round(area_jitter, 1), + rooms=rooms if rooms > 0 else 0, + floor=floor_val, + total_floors=total_fl, + price_rub=price, + price_per_m2=ppm2_jitter, + listing_date=listing_dt, + days_on_market=dom, + photo_url=None, + ) + ) + return result + + +def _mock_estimate(payload: TradeInEstimateInput) -> AggregatedEstimate: + """Возвращает mock-оценку на основе диапазонов ЕКБ 2026. + + Логика: + - Берём базовый band по rooms (0-3+). + - Масштабируем на фактическую площадь относительно референсной. + - Применяем поправку за этаж (±5%) и отделку (±10%). + - Генерируем 7-10 аналогов (листинги) и 3-5 actual_deals. + """ + rooms_key = min(payload.rooms, 3) # 4к+ → диапазон 3к + band = _PRICE_BANDS[rooms_key] + + # Масштаб по площади + ref_area: float = band["ref_area"] # type: ignore[assignment] + area_scale = payload.area_m2 / ref_area + + ff = _floor_factor(payload.floor, payload.total_floors) + rf = _repair_factor(payload.repair_state) + combined = area_scale * ff * rf + + median = int(band["median"] * combined) # type: ignore[operator] + low = int(band["low"] * combined) # type: ignore[operator] + high = int(band["high"] * combined) # type: ignore[operator] + ppm2 = int(band["ppm2"] * ff * rf) # type: ignore[operator] + + n_analogs = random.randint(7, 10) + n_deals = random.randint(3, 5) + + analogs = _gen_analogs(rooms_key, payload.area_m2, ppm2, n_analogs, is_listing=True) + actual_deals = _gen_analogs( + rooms_key, payload.area_m2, int(ppm2 * 0.93), n_deals, is_listing=False + ) + + now = datetime.now(tz=UTC) + return AggregatedEstimate( + estimate_id=uuid4(), + median_price_rub=median, + range_low_rub=low, + range_high_rub=high, + median_price_per_m2=ppm2, + confidence=_confidence(payload.rooms), + n_analogs=n_analogs + n_deals, + period_months=24, + analogs=analogs, + actual_deals=actual_deals, + expires_at=now + timedelta(hours=24), + ) + + +@router.post("/estimate", response_model=AggregatedEstimate) +async def estimate( + payload: TradeInEstimateInput, + db: Annotated[Session, Depends(get_db)], +) -> AggregatedEstimate: + """Реальная оценка через SQL aggregation поверх listings + deals. + + 1. Geocode address → lat/lon + 2. PostGIS ST_DWithin радиус 800м (или 2км fallback) + 3. Tukey IQR outlier filter + 4. Median + Q1 + Q3 + confidence с explanation + """ + from app.services.estimator import estimate_quality + return await estimate_quality(payload, db) + + +# OLD MOCK PATH — оставлен как fallback для отладки; не вызывается из router. +def _estimate_legacy_mock( + payload: TradeInEstimateInput, + db: Annotated[Session, Depends(get_db)], +) -> AggregatedEstimate: + """Старая mock-реализация. НЕ используется в роутере, удалить когда стабилизируем новый estimator.""" + result = _mock_estimate(payload) + + analogs_json = json.dumps( + [a.model_dump(mode="json") for a in result.analogs], + ensure_ascii=False, + ) + deals_json = json.dumps( + [a.model_dump(mode="json") for a in result.actual_deals], + ensure_ascii=False, + ) + + db.execute( + text( + """ + INSERT INTO trade_in_estimates ( + id, address, area_m2, rooms, floor, total_floors, + year_built, house_type, repair_state, has_balcony, + median_price, range_low, range_high, median_price_per_m2, + confidence, n_analogs, analogs, actual_deals, expires_at + ) VALUES ( + CAST(:id AS uuid), + :address, :area_m2, :rooms, :floor, :total_floors, + :year_built, :house_type, :repair_state, :has_balcony, + :median_price, :range_low, :range_high, :median_price_per_m2, + :confidence, :n_analogs, + CAST(:analogs AS jsonb), + CAST(:actual_deals AS jsonb), + :expires_at + ) + """ + ), + { + "id": str(result.estimate_id), + "address": payload.address, + "area_m2": payload.area_m2, + "rooms": payload.rooms, + "floor": payload.floor, + "total_floors": payload.total_floors, + "year_built": payload.year_built, + "house_type": payload.house_type, + "repair_state": payload.repair_state, + "has_balcony": payload.has_balcony, + "median_price": result.median_price_rub, + "range_low": result.range_low_rub, + "range_high": result.range_high_rub, + "median_price_per_m2": result.median_price_per_m2, + "confidence": result.confidence, + "n_analogs": result.n_analogs, + "analogs": analogs_json, + "actual_deals": deals_json, + "expires_at": result.expires_at, + }, + ) + db.commit() + + logger.info( + "trade_in estimate saved id=%s rooms=%d area=%.1f confidence=%s", + result.estimate_id, + payload.rooms, + payload.area_m2, + result.confidence, + ) + return result + + +@router.get("/estimate/{estimate_id}", response_model=AggregatedEstimate) +def get_estimate( + estimate_id: UUID, + db: Annotated[Session, Depends(get_db)], +) -> AggregatedEstimate: + """Получить сохранённую оценку по UUID (для генерации PDF). + + Возвращает 404 если оценка не найдена или TTL истёк. + """ + row = db.execute( + text( + """ + SELECT id, median_price, range_low, range_high, median_price_per_m2, + confidence, n_analogs, analogs, actual_deals, expires_at, + address, area_m2, rooms + FROM trade_in_estimates + WHERE id = CAST(:id AS uuid) + AND expires_at > NOW() + """ + ), + {"id": str(estimate_id)}, + ).fetchone() + + if row is None: + raise HTTPException(status_code=404, detail="estimate not found or expired") + + analogs = [AnalogLot(**a) for a in (row.analogs or [])] + actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] + + return AggregatedEstimate( + estimate_id=row.id, + median_price_rub=row.median_price, + range_low_rub=row.range_low, + range_high_rub=row.range_high, + median_price_per_m2=row.median_price_per_m2, + confidence=row.confidence, + n_analogs=row.n_analogs, + period_months=24, + analogs=analogs, + actual_deals=actual_deals, + expires_at=row.expires_at, + ) + + +@router.get("/estimate/{estimate_id}/pdf") +def estimate_pdf( + estimate_id: UUID, + db: Annotated[Session, Depends(get_db)], + brand: str | None = None, +) -> Response: + """Скачать 4-страничный PDF-отчёт для оценки trade-in. + + Возвращает application/pdf attachment. + 404 — оценка не найдена. + 410 — оценка просрочена (TTL 24ч). + """ + row = db.execute( + text( + """ + SELECT id, median_price, range_low, range_high, median_price_per_m2, + confidence, confidence_explanation, n_analogs, + analogs, actual_deals, sources_used, data_freshness_minutes, + expires_at, + address, lat, lon, area_m2, rooms, floor, total_floors, + year_built, house_type, repair_state, has_balcony + FROM trade_in_estimates + WHERE id = CAST(:id AS uuid) + """ + ), + {"id": str(estimate_id)}, + ).fetchone() + + if row is None: + raise HTTPException(status_code=404, detail="estimate not found") + + if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC): + raise HTTPException(status_code=410, detail="estimate expired (24h TTL)") + + analogs = [AnalogLot(**a) for a in (row.analogs or [])] + actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] + + estimate = AggregatedEstimate( + estimate_id=row.id, + median_price_rub=row.median_price, + range_low_rub=row.range_low, + range_high_rub=row.range_high, + median_price_per_m2=row.median_price_per_m2, + confidence=row.confidence, + confidence_explanation=row.confidence_explanation, + n_analogs=row.n_analogs, + period_months=24, + analogs=analogs, + actual_deals=actual_deals, + expires_at=row.expires_at, + target_address=row.address, + target_lat=row.lat, + target_lon=row.lon, + sources_used=row.sources_used or [], + data_freshness_minutes=row.data_freshness_minutes, + ) + input_snapshot = { + "address": row.address, + "area_m2": row.area_m2, + "rooms": row.rooms, + "floor": row.floor, + "total_floors": row.total_floors, + "year_built": row.year_built, + "house_type": row.house_type, + "repair_state": row.repair_state, + "has_balcony": row.has_balcony, + } + + from app.services.brand import get_brand as _resolve_brand + brand_obj = _resolve_brand(brand, db) + pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj) + filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf" + logger.info( + "PDF generated estimate_id=%s brand=%s size=%d", + estimate_id, brand_obj.slug, len(pdf_bytes), + ) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/tradein-mvp/backend/app/core/__init__.py b/tradein-mvp/backend/app/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py new file mode 100644 index 00000000..711797e7 --- /dev/null +++ b/tradein-mvp/backend/app/core/config.py @@ -0,0 +1,22 @@ +"""Минимальный settings для standalone trade-in MVP.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + database_url: str = "postgresql+psycopg://tradein:tradein@postgres:5432/tradein" + cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"] + environment: str = "dev" + + # Geocoder + yandex_geocoder_key: str | None = None # 25K req/day free после регистрации + yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend) + contact_email: str = "erginrajpopxbe@outlook.com" # для User-Agent в Nominatim (Nominatim Usage Policy) + + # Public URL — для QR-кода в PDF, shareable links, etc. + public_url: str = "http://127.0.0.1:8080" + + +settings = Settings() diff --git a/tradein-mvp/backend/app/core/db.py b/tradein-mvp/backend/app/core/db.py new file mode 100644 index 00000000..7853cad9 --- /dev/null +++ b/tradein-mvp/backend/app/core/db.py @@ -0,0 +1,21 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.core.config import settings + +engine = create_engine(settings.database_url, pool_pre_ping=True, future=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py new file mode 100644 index 00000000..c886cff8 --- /dev/null +++ b/tradein-mvp/backend/app/main.py @@ -0,0 +1,45 @@ +"""Trade-In MVP — FastAPI entry point. + +Standalone версия, выделена из основного gendesign repo для локальной разработки. +Только trade-in фича; никаких других роутеров (concepts/parcels/analytics/etc) нет. +""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1 import admin, brand, geocode, trade_in +from app.core.config import settings + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) + +app = FastAPI( + title="Trade-In MVP API", + description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok", "environment": settings.environment} + + +app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) +app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) +app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) +app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) diff --git a/tradein-mvp/backend/app/schemas/__init__.py b/tradein-mvp/backend/app/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py new file mode 100644 index 00000000..4aeebd88 --- /dev/null +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -0,0 +1,62 @@ +"""Pydantic schemas for Trade-In Estimator. + +POST /api/v1/trade-in/estimate → AggregatedEstimate +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + + +class TradeInEstimateInput(BaseModel): + address: str = Field(min_length=3, max_length=500) + area_m2: float = Field(gt=10, lt=500) + rooms: int = Field(ge=0, le=10) # 0 = студия + floor: int = Field(ge=1, le=100) + total_floors: int = Field(ge=1, le=100) + year_built: int | None = Field(default=None, ge=1800, le=2100) + house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None + repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None + has_balcony: bool | None = None + + +class AnalogLot(BaseModel): + address: str + area_m2: float + rooms: int + floor: int | None + total_floors: int | None + price_rub: int + price_per_m2: int + listing_date: date | None + days_on_market: int | None + photo_url: str | None = None + # ── Новые поля (Слой 5.2 — clickable links) ── + source: str | None = None # 'avito' / 'cian' / 'domklik' / 'rosreestr' + source_url: str | None = None # ссылка на оригинальное объявление / сделку + distance_m: int | None = None # расстояние до целевой квартиры в метрах + + +class AggregatedEstimate(BaseModel): + estimate_id: UUID + median_price_rub: int + range_low_rub: int + range_high_rub: int + median_price_per_m2: int + confidence: Literal["low", "medium", "high"] + confidence_explanation: str | None = None # «Найдено 15 аналогов, разброс ±7%» + n_analogs: int + period_months: int # 24 + analogs: list[AnalogLot] # top 5-10 listings + actual_deals: list[AnalogLot] # реальные продажи last 12 mo + expires_at: datetime + # ── Дополнительные метаданные ── + target_address: str | None = None # geocoded full address + target_lat: float | None = None + target_lon: float | None = None + sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr'] + data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг diff --git a/tradein-mvp/backend/app/services/__init__.py b/tradein-mvp/backend/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/app/services/brand.py b/tradein-mvp/backend/app/services/brand.py new file mode 100644 index 00000000..370ff9f7 --- /dev/null +++ b/tradein-mvp/backend/app/services/brand.py @@ -0,0 +1,66 @@ +"""Brand resolver — multi-tenant white-label. + +Читает бренд по slug из `brands` Postgres-таблицы. +Используется PDF exporter + frontend (через /api/v1/brand/{slug} endpoint). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class Brand: + slug: str + name: str + logo_url: str | None + primary_color: str + accent_color: str + footer_text: str | None + pdf_disclaimer: str | None + + +_DEFAULT = Brand( + slug="generic", + name="Trade-In Estimator", + logo_url=None, + primary_color="#1d4ed8", + accent_color="#f59e0b", + footer_text=None, + pdf_disclaimer=None, +) + + +def get_brand(slug: str | None, db: Session) -> Brand: + """Возвращает Brand по slug. Fallback на 'generic' если не найден.""" + if not slug: + return _DEFAULT + row = db.execute( + text( + """ + SELECT slug, name, logo_url, primary_color, accent_color, + footer_text, pdf_disclaimer + FROM brands + WHERE slug = :slug + """ + ), + {"slug": slug.lower()}, + ).fetchone() + if row is None: + logger.info("brand not found: %s — using generic", slug) + return _DEFAULT + return Brand( + slug=row.slug, + name=row.name, + logo_url=row.logo_url, + primary_color=row.primary_color or _DEFAULT.primary_color, + accent_color=row.accent_color or _DEFAULT.accent_color, + footer_text=row.footer_text, + pdf_disclaimer=row.pdf_disclaimer, + ) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py new file mode 100644 index 00000000..9975723e --- /dev/null +++ b/tradein-mvp/backend/app/services/estimator.py @@ -0,0 +1,462 @@ +"""Trade-In Estimator — реальное SQL aggregation поверх listings + deals. + +Заменяет старый _mock_estimate() из api/v1/trade_in.py. + +Алгоритм: +1. Geocode address → (lat, lon) +2. SELECT listings с фильтрами: + - PostGIS ST_DWithin (geom, point, 800m) — радиус поиска + - rooms = target_rooms (точное совпадение) + - area_m2 BETWEEN target × 0.85 AND target × 1.15 + - scraped_at > NOW() - 14 days (свежие) + - is_active = true +3. Tukey outlier filter (1.5 × IQR rule) +4. Median / Q1 / Q3 / count → confidence +5. То же для deals (period = 12 mo). +6. Сохранить в trade_in_estimates + вернуть AggregatedEstimate +""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput +from app.services.geocoder import GeocodeResult, geocode + +logger = logging.getLogger(__name__) + + +# ── Constants ──────────────────────────────────────────────────────────────── +DEFAULT_RADIUS_M = 800 +FALLBACK_RADIUS_M = 2000 +AREA_TOLERANCE = 0.15 # ±15% площади +LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней +DEALS_PERIOD_MONTHS = 12 # сделки за последний год + + +# ── Public ─────────────────────────────────────────────────────────────────── +async def estimate_quality( + payload: TradeInEstimateInput, db: Session +) -> AggregatedEstimate: + """Главная функция — оценка квартиры по реальным данным. + + Returns: + AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами, сделками. + """ + # 1. Geocode + geo: GeocodeResult | None = None + if payload.address: + geo = await geocode(payload.address, db) + + if geo is None: + # Без координат не можем искать через PostGIS. Возвращаем low confidence. + logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address) + return _empty_estimate(payload, reason="address_not_geocoded") + + # 2. Three-tier fallback: + # a) 800m + ±15% area + # b) 2km + ±15% area (fallback_used = True) + # c) 2km + ±25% area (fallback_used = True, area_widened = True) + listings, fallback_used = _fetch_analogs( + db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, + radius_m=DEFAULT_RADIUS_M, + ) + area_widened = False + + if len(listings) < 5: + listings_wide, _ = _fetch_analogs( + db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, + radius_m=FALLBACK_RADIUS_M, + ) + if len(listings_wide) > len(listings): + listings = listings_wide + fallback_used = True + + # Tier C: если даже на 2км мало — расширяем area tolerance до ±25% + # (актуально для отдалённых районов / новостроек с нестандартной планировкой) + if len(listings) < 3: + listings_widearea, _ = _fetch_analogs( + db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, + radius_m=FALLBACK_RADIUS_M, area_tolerance=0.25, + ) + if len(listings_widearea) > len(listings): + listings = listings_widearea + fallback_used = True + area_widened = True + + # 3. Outlier filter + listings_clean = _filter_outliers(listings) + + # 4. Aggregation + if listings_clean: + prices_ppm2 = sorted(lot["price_per_m2"] for lot in listings_clean if lot["price_per_m2"]) + median_ppm2 = _percentile(prices_ppm2, 0.5) + q1_ppm2 = _percentile(prices_ppm2, 0.25) + q3_ppm2 = _percentile(prices_ppm2, 0.75) + median_price = int(median_ppm2 * payload.area_m2) + range_low = int(q1_ppm2 * payload.area_m2) + range_high = int(q3_ppm2 * payload.area_m2) + n_analogs = len(listings_clean) + else: + median_ppm2 = 0 + median_price = 0 + range_low = 0 + range_high = 0 + n_analogs = 0 + + confidence, explanation = _compute_confidence( + n_analogs, median_ppm2, q1_ppm2 if listings_clean else 0, + q3_ppm2 if listings_clean else 0, fallback_used, area_widened, + ) + + # 5. Deals — фактические сделки за период + deals = _fetch_deals( + db, lat=geo.lat, lon=geo.lon, rooms=payload.rooms, area=payload.area_m2, + radius_m=DEFAULT_RADIUS_M, + ) + + # 6. Сохраняем в trade_in_estimates + estimate_id = uuid4() + now = datetime.now(tz=UTC) + expires_at = now + timedelta(hours=24) + + analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]] + deals_lots = [_deal_to_analog(d) for d in deals[:10]] + + sources_used_pre = sorted({lot.get("source") for lot in listings_clean if lot.get("source")}) + freshness_pre = _compute_freshness_minutes(listings_clean) + db.execute( + text( + """ + INSERT INTO trade_in_estimates ( + id, address, lat, lon, + area_m2, rooms, floor, total_floors, + year_built, house_type, repair_state, has_balcony, + median_price, range_low, range_high, median_price_per_m2, + confidence, confidence_explanation, n_analogs, + analogs, actual_deals, + sources_used, data_freshness_minutes, + expires_at + ) VALUES ( + CAST(:id AS uuid), + :address, :lat, :lon, + :area, :rooms, :floor, :total_floors, + :year_built, :house_type, :repair_state, :has_balcony, + :median_price, :range_low, :range_high, :median_ppm2, + :confidence, :explanation, :n_analogs, + CAST(:analogs_json AS jsonb), + CAST(:deals_json AS jsonb), + CAST(:sources_json AS jsonb), + :freshness, + :expires_at + ) + """ + ), + { + "id": str(estimate_id), + "address": geo.full_address, + "lat": geo.lat, + "lon": geo.lon, + "area": payload.area_m2, + "rooms": payload.rooms, + "floor": payload.floor, + "total_floors": payload.total_floors, + "year_built": payload.year_built, + "house_type": payload.house_type, + "repair_state": payload.repair_state, + "has_balcony": payload.has_balcony, + "median_price": median_price, + "range_low": range_low, + "range_high": range_high, + "median_ppm2": int(median_ppm2), + "confidence": confidence, + "explanation": explanation, + "n_analogs": n_analogs, + "analogs_json": json.dumps( + [a.model_dump(mode="json") for a in analogs_lots], ensure_ascii=False + ), + "deals_json": json.dumps( + [a.model_dump(mode="json") for a in deals_lots], ensure_ascii=False + ), + "sources_json": json.dumps(sources_used_pre, ensure_ascii=False), + "freshness": freshness_pre, + "expires_at": expires_at, + }, + ) + db.commit() + + logger.info( + "estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)", + estimate_id, + geo.full_address[:60], + payload.rooms, + payload.area_m2, + median_price, + n_analogs, + confidence, + ) + + sources_used = sorted({lot.source for lot in analogs_lots if lot.source}) + freshness_min = _compute_freshness_minutes(listings_clean) + + return AggregatedEstimate( + estimate_id=estimate_id, + median_price_rub=median_price, + range_low_rub=range_low, + range_high_rub=range_high, + median_price_per_m2=int(median_ppm2), + confidence=confidence, + confidence_explanation=explanation, + n_analogs=n_analogs, + period_months=DEALS_PERIOD_MONTHS, + analogs=analogs_lots, + actual_deals=deals_lots, + expires_at=expires_at, + target_address=geo.full_address, + target_lat=geo.lat, + target_lon=geo.lon, + sources_used=sources_used, + data_freshness_minutes=freshness_min, + ) + + +def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None: + """Минут с последнего парсинга — для UI «обновлено N мин назад».""" + if not lots: + return None + from datetime import datetime as _dt + + now = _dt.now(tz=UTC) + scraped = [lot.get("scraped_at") or lot.get("listing_date") for lot in lots] + scraped_dt: list[datetime] = [] + for s in scraped: + if s is None: + continue + # listings rows из mappings — scraped_at это datetime, не date + if hasattr(s, "tzinfo"): + scraped_dt.append(s if s.tzinfo else s.replace(tzinfo=UTC)) + if not scraped_dt: + return None + return int((now - max(scraped_dt)).total_seconds() / 60) + + +# ── Internals ──────────────────────────────────────────────────────────────── +def _fetch_analogs( + db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int, + area_tolerance: float = AREA_TOLERANCE, +) -> tuple[list[dict[str, Any]], bool]: + """SELECT аналогов с PostGIS distance + фильтры. + + Returns: + (list_of_listings_as_dicts, fallback_radius_used_flag) + """ + rows = db.execute( + text( + """ + SELECT + source, source_url, address, lat, lon, + rooms, area_m2, floor, total_floors, + price_rub, price_per_m2, + listing_date, days_on_market, photo_urls, + scraped_at, + ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m + FROM listings + WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) + AND rooms = :rooms + AND area_m2 BETWEEN :area_min AND :area_max + AND is_active = true + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND price_rub > 0 + ORDER BY distance_m + LIMIT 50 + """ + ), + { + "lat": lat, + "lon": lon, + "radius": radius_m, + "rooms": rooms, + "area_min": area * (1 - area_tolerance), + "area_max": area * (1 + area_tolerance), + "fresh_days": LISTINGS_FRESH_DAYS, + }, + ).mappings().all() + + return [dict(r) for r in rows], radius_m > DEFAULT_RADIUS_M + + +def _fetch_deals( + db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int +) -> list[dict[str, Any]]: + rows = db.execute( + text( + """ + SELECT + source, address, lat, lon, + rooms, area_m2, floor, total_floors, + price_rub, price_per_m2, + deal_date, days_on_market, + ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m + FROM deals + WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) + AND rooms = :rooms + AND area_m2 BETWEEN :area_min AND :area_max + AND deal_date > NOW() - (:months || ' months')::interval + AND price_rub > 0 + ORDER BY deal_date DESC + LIMIT 30 + """ + ), + { + "lat": lat, + "lon": lon, + "radius": radius_m, + "rooms": rooms, + "area_min": area * (1 - AREA_TOLERANCE), + "area_max": area * (1 + AREA_TOLERANCE), + "months": DEALS_PERIOD_MONTHS, + }, + ).mappings().all() + + return [dict(r) for r in rows] + + +def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Tukey IQR rule: исключаем точки вне [Q1 - 1.5×IQR, Q3 + 1.5×IQR].""" + if len(lots) < 5: + return lots # на маленькой выборке нечего фильтровать + + prices = sorted(lot["price_per_m2"] for lot in lots if lot.get("price_per_m2")) + if len(prices) < 4: + return lots + + q1 = _percentile(prices, 0.25) + q3 = _percentile(prices, 0.75) + iqr = q3 - q1 + low = q1 - 1.5 * iqr + high = q3 + 1.5 * iqr + + clean = [lot for lot in lots if low <= lot.get("price_per_m2", 0) <= high] + if len(clean) < len(lots): + logger.info("outlier filter: %d → %d (Q1=%d Q3=%d)", len(lots), len(clean), q1, q3) + return clean + + +def _percentile(sorted_values: list[float], p: float) -> float: + """Linear interpolation percentile (не округляем — оставляем float).""" + if not sorted_values: + return 0.0 + if len(sorted_values) == 1: + return float(sorted_values[0]) + n = len(sorted_values) + rank = p * (n - 1) + lo = int(rank) + hi = min(lo + 1, n - 1) + frac = rank - lo + return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac + + +def _compute_confidence( + n_analogs: int, + median_ppm2: float, + q1: float, + q3: float, + fallback_radius_used: bool, + area_widened: bool = False, +) -> tuple[str, str]: + """Confidence + explanation string. + + high — n≥10 AND IQR/median < 0.15 + medium — n≥5 OR IQR/median < 0.25 + low — иначе + """ + if median_ppm2 == 0: + return "low", "Не найдено аналогов — попробуйте уточнить адрес или расширить параметры." + + iqr = q3 - q1 + iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0 + notes = [] + if fallback_radius_used: + notes.append("расширили радиус до 2 км") + if area_widened: + notes.append("расширили допуск по площади до ±25%") + fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else "" + + if n_analogs >= 10 and iqr_pct < 0.15: + return ( + "high", + f"Найдено {n_analogs} аналогов, разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}.", + ) + # medium только если есть достаточно точек ИЛИ узкий разброс при ≥3 точках + if n_analogs >= 5 or (n_analogs >= 3 and iqr_pct < 0.25): + return ( + "medium", + f"Найдено {n_analogs} аналогов, разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}.", + ) + return ( + "low", + f"Только {n_analogs} аналог{'а' if 2 <= n_analogs <= 4 else 'ов' if n_analogs != 1 else ''}, " + f"разброс ±{int(iqr_pct * 100 / 2)}% — рекомендуется ручная проверка{fallback_note}.", + ) + + +def _listing_to_analog(row: dict[str, Any]) -> AnalogLot: + return AnalogLot( + address=row.get("address") or "", + area_m2=float(row.get("area_m2") or 0), + rooms=int(row.get("rooms") or 0), + floor=row.get("floor"), + total_floors=row.get("total_floors"), + price_rub=int(row["price_rub"]), + price_per_m2=int(row.get("price_per_m2") or 0), + listing_date=row.get("listing_date"), + days_on_market=row.get("days_on_market"), + photo_url=(row.get("photo_urls") or [None])[0] if isinstance(row.get("photo_urls"), list) else None, + source=row.get("source"), + source_url=row.get("source_url"), + distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None, + ) + + +def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: + """deals не имеют photo_url — упрощённо.""" + return AnalogLot( + address=row.get("address") or "", + area_m2=float(row.get("area_m2") or 0), + rooms=int(row.get("rooms") or 0), + floor=row.get("floor"), + total_floors=row.get("total_floors"), + price_rub=int(row["price_rub"]), + price_per_m2=int(row.get("price_per_m2") or 0), + listing_date=row.get("deal_date"), + days_on_market=row.get("days_on_market"), + photo_url=None, + source=row.get("source"), + source_url=None, # rosreestr сделки без публичной ссылки + distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None, + ) + + +def _empty_estimate(payload: TradeInEstimateInput, *, reason: str) -> AggregatedEstimate: + """Fallback когда нет данных для оценки.""" + now = datetime.now(tz=UTC) + return AggregatedEstimate( + estimate_id=uuid4(), + median_price_rub=0, + range_low_rub=0, + range_high_rub=0, + median_price_per_m2=0, + confidence="low", + n_analogs=0, + period_months=DEALS_PERIOD_MONTHS, + analogs=[], + actual_deals=[], + expires_at=now + timedelta(hours=24), + ) diff --git a/tradein-mvp/backend/app/services/exporters/__init__.py b/tradein-mvp/backend/app/services/exporters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py new file mode 100644 index 00000000..f7d0ac98 --- /dev/null +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -0,0 +1,979 @@ +"""PDF генератор для Trade-In Estimator — Брусника-style (точное соответствие референсу docs/BRUSNIKA_REFERENCE_EKB-2485.pdf). + +Структура отчёта — 4 страницы: + 1. Cover — № отчёта + параметры объекта + 2 диапазона цен + 3 блока «Что важно» + 2. Listings — рынок квартир-аналогов по объявлениям + таблица примеров + 3. Deals — фактические сделки + таблица примеров + 4. Offer — формирование выкупной стоимости (Trade-In vs самопродажа) + 4 преимущества + +White-label через Brand: PRINZIP / Практика / generic (см. app/services/brand.py). +Расширения над Брусникой (сохранены): source badges + дистанция + кликабельные ссылки + QR-код. +""" + +from __future__ import annotations + +import base64 +import datetime as dt +import html as _html +import io +import logging +from uuid import UUID + +import segno +from PIL import Image, ImageDraw +from weasyprint import CSS, HTML + +from app.core.config import settings +from app.schemas.trade_in import AggregatedEstimate, AnalogLot + + +def _bar_svg_data_url(q_low: float = 0.20, q_high: float = 0.80) -> str: + """SVG диапазон-бара как data URI. WeasyPrint надёжно рендерит SVG (как QR-код).""" + width, height = 600, 32 + x1 = width * q_low + x2 = width * q_high + svg = ( + f'' + f'' + f'' + f'' + ) + return f"data:image/svg+xml;base64,{base64.b64encode(svg.encode('utf-8')).decode('ascii')}" + +logger = logging.getLogger(__name__) + + +# ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ── +_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = { + "avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо) + "cian": ("#0468ff", "#fff"), # Циан фирменный синий + "domklik": ("#1ab248", "#fff"), # ДомКлик зелёный Сбер + "yandex": ("#fc3f1d", "#fff"), # Я.Недвижимость красный + "n1": ("#ff6b00", "#fff"), # N1 оранжевый + "rosreestr": ("#003d82", "#fff"), # Росреестр тёмно-синий + "etazhi": ("#e30613", "#fff"), # Этажи красный +} + +_SOURCE_DISPLAY_NAMES: dict[str, str] = { + "avito": "Avito", + "cian": "Циан", + "domklik": "Домклик · Сбер", + "yandex": "Я.Недвижимость", + "n1": "N1", + "rosreestr": "Росреестр", + "etazhi": "Этажи", +} + + +def _source_logo_pill(source: str) -> str: + """Pill-badge с цветом источника (заменяет реальные логотипы Брусники).""" + bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff")) + name = _SOURCE_DISPLAY_NAMES.get(source, source.title()) + return ( + f"{_html.escape(name)}" + ) + + +def _source_badge_inline(source: str | None) -> str: + """Маленький source badge для table cells (без фона).""" + if not source: + return "" + bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff")) + name = _SOURCE_DISPLAY_NAMES.get(source, source.title()) + return ( + f"{_html.escape(name)}" + ) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _fmt_rub(value: int) -> str: + """12 500 000 ₽""" + return f"{value:,}".replace(",", " ") + " ₽" + + +def _fmt_rub_m(value: int) -> str: + """3.4 млн. руб.""" + m = value / 1_000_000 + return f"{m:.1f}".replace(".", ",") + " млн. руб." + + +def _fmt_ppm2(value: int) -> str: + """101 176""" + return f"{value:,}".replace(",", " ") + + +def _report_number(estimate_id: UUID) -> str: + """Брусника-style № отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код.""" + short = int(estimate_id.int) % 10_000 + long = int(estimate_id.int) % 10_000_000_000 + return f"EKБ-{short:04d}-{long:010d}" + + +def _qr_code_data_url(text: str, size: int = 4) -> str: + """QR-код как SVG data URL.""" + qr = segno.make(text, error="m") + buf = io.BytesIO() + qr.save(buf, kind="svg", scale=size, dark="#1a1d23", light="#ffffff") + return f"data:image/svg+xml;base64,{base64.b64encode(buf.getvalue()).decode('ascii')}" + + +def _conf_label(confidence: str) -> str: + return {"high": "Высокая", "medium": "Средняя", "low": "Низкая"}.get(confidence, confidence) + + +# ── Price range SVG bar (как у Брусники) ──────────────────────────────────── + + +_BAR_PNG_URL = _bar_svg_data_url() + + +def _price_range_bar( + range_low: int, + range_high: int, + *, + label_left: str | None = None, + label_right: str | None = None, + sub_label: str = "Рынок", + days_min: int | None = None, + days_max: int | None = None, + show_days: bool = False, +) -> str: + """Полоска диапазона цен в стиле Брусники — PNG через Pillow.""" + if range_high <= range_low: + return "" + + label_left = label_left or _fmt_rub_m(range_low) + label_right = label_right or _fmt_rub_m(range_high) + + days_overlay = "" + if show_days and days_min is not None and days_max is not None: + days_overlay = ( + f'' + f'' + f'' + f'' + f'' + f'
' + f'{days_min} дней' + f'' + f'{days_max} дней' + f'
' + ) + + # Ц-таблица: 3 ячейки фиксированной ширины с background-color на td. + # Высота через padding (WeasyPrint stable с padding на td). + days_row = "" + if show_days and days_min is not None and days_max is not None: + days_row = ( + f'' + f'' + f'{days_min} дней' + f'' + f'{days_max} дней' + f'' + ) + + return ( + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'{days_row}' + f'
' + f'{_html.escape(sub_label)}' + f'
' + f'{_html.escape(label_left)}' + f'{_html.escape(label_right)}
' + ) + + +# ── Page 1: Cover ──────────────────────────────────────────────────────────── + + +def _build_cover( + estimate: AggregatedEstimate, input_snapshot: dict, brand +) -> str: # type: ignore[no-untyped-def,type-arg] + today = dt.date.today() + expires = today + dt.timedelta(days=30) + report_num = _report_number(estimate.estimate_id) + + # Короткий адрес (для cover): берём первую часть до запятой + full_address = input_snapshot.get("address", "—") + address_short = full_address.split(",")[0:3] + address_short = ", ".join(s.strip() for s in address_short) + address = _html.escape(address_short or full_address) + area = input_snapshot.get("area_m2", 0) + rooms = input_snapshot.get("rooms", 0) + floor = input_snapshot.get("floor", 0) + total_floors = input_snapshot.get("total_floors", 0) + year_built = input_snapshot.get("year_built", "—") + house_type = input_snapshot.get("house_type") + repair_state = input_snapshot.get("repair_state") + has_balcony = input_snapshot.get("has_balcony") + + house_labels = { + "panel": "Панельный", + "brick": "Кирпичный", + "monolith": "Монолитный", + "monolith_brick": "Монолит-кирпич", + "other": "Другое", + } + repair_labels = { + "needs_repair": "Требуется ремонт", + "standard": "Стандартный", + "good": "Хороший", + "excellent": "Евроремонт", + } + + rooms_label = "Студия" if rooms == 0 else f"{rooms} комнат" + ("а" if rooms == 1 else "ы" if 2 <= rooms <= 4 else "") + house_label = house_labels.get(house_type, "—") if house_type else "—" + repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано" + balcony_label = "1" if has_balcony else "0" if has_balcony is False else "—" + + # Active market subband — 4-118 days range (как у Брусники). + # Если есть days_on_market в analogs — берём min/max, иначе фиксированно. + days_min, days_max = _days_on_market_range(estimate.analogs) + + # Deals range — если deals есть, считаем; иначе fallback к listings range + deals_low, deals_high = _deals_range(estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)) + + listings_bar = _price_range_bar( + estimate.range_low_rub, + estimate.range_high_rub, + sub_label="Активный рынок с аналогичным состоянием ремонта", + days_min=days_min, + days_max=days_max, + show_days=True, + ) + + deals_bar = _price_range_bar( + deals_low, + deals_high, + sub_label="Диапазон цен по фактическим сделкам", + ) + + qr_url = _qr_code_data_url( + f"{settings.public_url}/trade-in?id={estimate.estimate_id}", size=3 + ) + + return f""" +
+ + +
+ {_html.escape(brand.name).upper()} +
+ +

+ АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ +

+ + + + + + + + + + + + + + +
№ отчета{report_num}
Дата отчета{today.strftime("%d.%m.%Y")}
Срок действия данныхдо {expires.strftime("%d.%m.%Y")}
Адрес{address}
Год постройки{year_built}
Тип дома{house_label}
Этаж / этажность{floor} / {total_floors}
Площадь{area} м²
Планировка{rooms_label}
Состояние ремонта{repair_label}
Балкон / лоджия{balcony_label}
+ +

+ Диапазон цен в объявлениях + (без учета ремонта) +

+ {listings_bar} + +

+ Диапазон цен по фактическим сделкам +

+ {deals_bar} + + +

+ Что важно при оценке стоимости квартиры: +

+ + + + + + + + + + + + + +
Цены в объявлениях — ожидания собственниковФактические сделки проходят на 5–12% ниже, что подтверждают Росреестр, ДомКлик и продажи агенств недвижимости
Ремонт оценивается по состоянию, а не по вложенным суммамИнвестиции в 400 – 600 тыс. руб. повышают цену объекта всего на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры
Неочевидные расходы при самостоятельной продажеПри самостоятельной продаже суммарные расходы могут достигать до 15% стоимости квартиры (торг, риелтор, нотариус, справки)
+ +

+ Этот отчёт онлайн: {settings.public_url}/trade-in?id={estimate.estimate_id} +

+ +
+""" + + +def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]: + """Min/max days_on_market по аналогам. Fallback 4-118 как у Брусники.""" + days = [lot.days_on_market for lot in lots if lot.days_on_market is not None] + if not days: + return 4, 118 + return min(days), max(days) + + +def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int, int]: + """Min/max price от deals. Fallback к listings range.""" + if not deals: + return fallback + prices = [d.price_rub for d in deals] + return min(prices), max(prices) + + +# ── Page 2: Listings (market) ──────────────────────────────────────────────── + + +def _build_listings_page( + estimate: AggregatedEstimate, input_snapshot: dict, brand +) -> str: # type: ignore[no-untyped-def,type-arg] + n_total = estimate.n_analogs + n_with_repair = max(3, int(n_total * 0.4)) # упрощение: ~40% с указанным ремонтом + + # Source logos (pseudo) — 5 максимальных + sources_to_show = estimate.sources_used or ["avito", "cian", "domklik", "yandex", "rosreestr"] + sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5]) + + # Params правой колонки — параметры поиска (НЕ конкретной квартиры) + area = float(input_snapshot.get("area_m2", 0) or 0) + area_min = round(area * 0.85, 1) + area_max = round(area * 1.15, 1) + rooms = input_snapshot.get("rooms", 0) + year_built = input_snapshot.get("year_built") + year_range = f"{year_built - 2}-{year_built + 2}" if year_built else "—" + house_type = input_snapshot.get("house_type") + house_label = { + "panel": "Эконом до 2000 года", + "brick": "Кирпич до 2000", + "monolith": "Современный (монолит)", + "monolith_brick": "Современный (м-к)", + "other": "Любой", + }.get(house_type, "Любой") if house_type else "Любой" + repair_state = input_snapshot.get("repair_state") + repair_label = { + "needs_repair": "Требуется ремонт", + "standard": "Стандартный", + "good": "Хороший", + "excellent": "Евроремонт", + }.get(repair_state, "Любой") if repair_state else "Любой" + rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты" + + # Полоска диапазона + days_min, days_max = _days_on_market_range(estimate.analogs) + range_bar = _price_range_bar( + estimate.range_low_rub, + estimate.range_high_rub, + sub_label="Рынок", + days_min=days_min, + days_max=days_max, + show_days=True, + ) + + # Топ-5 примеров (отсортированных по distance) + top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5] + examples_rows = _examples_rows(top5) + + return f""" +
+ +
+ + {_html.escape(brand.name).upper()} + +
+ +

+ РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ +

+ + + + + + + +
+ + + + + +
Количество объявлений по аналогичным объектам{n_total} шт.
Количество объявлений по аналогичным объектам
+ (с учётом ремонта)
{n_with_repair} шт.
+
Источники данных
+
{sources_html}
+
+ + + + + + + +
Расположение± 1 км от локации дома
Тип дома{house_label}
Год постройки{year_range}
Диапазон площади{area_min} - {area_max} м²
Планировка{rooms_label}
Состояние ремонта{repair_label}
+
+ +

Диапазон цен в объявлениях

+ {range_bar} + + +

+ Примеры аналогичных объектов с учётом ремонта +

+ + + + + + + + + + + {examples_rows} +
Адрес квартирыИсточникСтоимость 1 м², руб.Стоимость объекта, руб.Срок экспозиции, дней
+ + + +
+""" + + +def _examples_rows(lots: list[AnalogLot]) -> str: + if not lots: + return "Нет данных" + rows = [] + for lot in lots: + addr = _html.escape(lot.address) + if lot.source_url: + addr_cell = f"{addr} ↗" + else: + addr_cell = addr + rows.append( + "" + f"{addr_cell}" + f"{_source_badge_inline(lot.source)}" + f"{_fmt_ppm2(lot.price_per_m2)}" + f"{_fmt_ppm2(lot.price_rub)}" + f"{lot.days_on_market or '—'}" + "" + ) + return "".join(rows) + + +# ── Page 3: Deals ──────────────────────────────────────────────────────────── + + +def _build_deals_page( + estimate: AggregatedEstimate, input_snapshot: dict, brand +) -> str: # type: ignore[no-untyped-def,type-arg] + n_deals = len(estimate.actual_deals) + today = dt.date.today() + period_start = today - dt.timedelta(days=estimate.period_months * 30) + + # Источники для сделок — Этажи / Домклик / Росреестр + deal_sources = ["etazhi", "domklik", "rosreestr"] + sources_html = "".join(_source_logo_pill(s) for s in deal_sources) + + area = float(input_snapshot.get("area_m2", 0) or 0) + area_min = round(area * 0.85, 1) + area_max = round(area * 1.15, 1) + rooms = input_snapshot.get("rooms", 0) + year_built = input_snapshot.get("year_built") + year_range = f"{year_built - 2}–{year_built + 2}" if year_built else "—" + house_type = input_snapshot.get("house_type") + house_label = { + "panel": "Эконом до 2000 года", + "brick": "Кирпич до 2000", + "monolith": "Современный (монолит)", + }.get(house_type, "Любой") if house_type else "Любой" + rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты" + + deals_low, deals_high = _deals_range(estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)) + days_min, days_max = _days_on_market_range(estimate.actual_deals) + range_bar = _price_range_bar( + deals_low, deals_high, sub_label="Рынок", + days_min=days_min, days_max=days_max, show_days=True, + ) + + top5 = estimate.actual_deals[:5] + examples_rows = _examples_rows(top5) + + return f""" +
+ +
+ + {_html.escape(brand.name).upper()} + +
+ +

+ ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ +

+ + + + + + +
+ + + + + +
Количество сделок по аналогичном объектам{n_deals} шт.
Период сделок{period_start.strftime("%m.%Y")} – {today.strftime("%m.%Y")}
+
Источники данных
+
{sources_html}
+
+ + + + + + +
Расположение± 1 км от локации дома
Тип дома{house_label}
Год постройки{year_range}
Диапазон площади{area_min} - {area_max} м²
Планировка{rooms_label}
+
+ +

Диапазон цен по фактическим сделкам

+ {range_bar} + +
+ По данным реальных сделок, квартиры продаются в среднем на 5–12 % дешевле, + чем заявлено в объявлениях +
+ +

+ Примеры аналогичных объектов +

+ + + + + + + + + + + {examples_rows} +
Адрес квартирыИсточникСтоимость 1 м², руб.Стоимость объекта, руб.Срок экспозиции, дней
+ + + +
+""" + + +# ── Page 4: Offer (Trade-In vs Самопродажа) ────────────────────────────────── + + +def _build_offer_page( + estimate: AggregatedEstimate, brand +) -> str: # type: ignore[no-untyped-def] + median = estimate.median_price_rub + + # Расчёт расходов как у Брусники: на основе медианы + listing_price = median # цена в объявлении + torg_pct_low, torg_pct_high = 5, 15 + torg_low = int(listing_price * torg_pct_low / 100) + torg_high = int(listing_price * torg_pct_high / 100) + + # После торга — средний по диапазону для остальных % + sold_price = listing_price - int(listing_price * 0.10) # ~10% торг + + rieltor_pct_low, rieltor_pct_high = 2, 3 + rieltor_low = int(sold_price * rieltor_pct_low / 100) + rieltor_high = int(sold_price * rieltor_pct_high / 100) + + rent_low, rent_high = 90_000, 150_000 # 3 месяца аренды (фикс) + juridical_low = 19_250 # фикс + ads_low, ads_high = 4_000, 36_000 # за 3 месяца + + total_low = torg_low + rieltor_low + rent_low + juridical_low + ads_low + total_high = torg_high + rieltor_high + rent_high + juridical_low + ads_high + + brand_short = _html.escape(brand.name) + trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In" + + return f""" +
+ +
+ + {brand_short.upper()} + +
+ +

+ ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ +

+ +

Структура стоимости сделки

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Продажа через
{trade_in_label} +
+ Самостоятельная продажа, руб. +
+
Торг потенциальных покупателей
+
при стоимости квартиры в объявлении {_fmt_rub(listing_price)}
+
Не применимо + от {_fmt_rub(torg_low)} – {_fmt_rub(torg_high)}
+ от {torg_pct_low}–{torg_pct_high}% +
+
Услуги риэлтора при продаже квартиры
+
с минимальным торгом за {_fmt_rub(sold_price)}
+
бесплатно + {_fmt_rub(rieltor_low)} - {_fmt_rub(rieltor_high)}
+ от {rieltor_pct_low}–{rieltor_pct_high}% +
+
Аренда после сделки
+
однокомнатной квартиры на 3 месяца
+
бесплатно + {_fmt_rub(rent_low)} - {_fmt_rub(rent_high)} +
+
Юридическое сопровождение
+
Проверка документов и подготовка договоров
+
бесплатноот {_fmt_rub(juridical_low)}
+
Расходы на рекламу
+
Ежемесячное базовой продвижение объекта на Циан, Авито, Я.Недвижимости
+
бесплатно + от {_fmt_rub(ads_low)} - {_fmt_rub(ads_high)}
+ за 3 месяца +
Общие финансовые потери + от {_fmt_rub(total_low)} - {_fmt_rub(total_high)} +
+ +
+ Издержки при обмене сопоставимы с личными расходами собственника, которые возникают при + самостоятельной продаже квартиры +
+ +

Преимущества

+
+
+
+ + + +
+
Экономия времени
+
Берём на себя показы, переговоры и поиск покупателей
+
+
+
+ + + +
+
Юридическая безопасность
+
Проверяем документы, исключаем риски, сопровождаем на всех этапах
+
+
+
+ + + +
+
Фиксированная стоимость новостройки
+
Сохраняем цену выбранной планировки в {brand_short}
+
+
+
+ + + +
+
Гарантия цены
+
Снимаем угрозу колебаний рынка и удерживаем договорную стоимость
+
+
+ + + +
+""" + + +# ── CSS ────────────────────────────────────────────────────────────────────── + + +def _build_css(brand=None) -> str: # type: ignore[no-untyped-def] + primary = brand.primary_color if brand else "#1d4ed8" + return f""" +@page {{ + size: A4; + margin: 20mm 18mm 20mm 18mm; +}} +* {{ box-sizing: border-box; }} +body {{ + font-family: 'Helvetica', 'Arial', sans-serif; + font-size: 10pt; + color: #1a1d23; + margin: 0; + padding: 0; + line-height: 1.4; +}} +h1, h2, h3 {{ margin: 0; }} +.page {{ page-break-after: always; position: relative; }} +.page:last-child {{ page-break-after: avoid; }} + +.bold {{ font-weight: 700; }} + +/* Params table (Cover) */ +.params-table td {{ + padding: 2.5pt 4pt; + border-bottom: 1px solid #f3f4f6; + font-size: 9pt; +}} +.params-table td:first-child {{ + color: #6b7280; + width: 40%; +}} +.params-table td:last-child {{ + text-align: right; +}} + +/* Search params (Listings/Deals) */ +.search-params td {{ + padding: 4pt 0; + font-size: 9pt; +}} +.search-params td:first-child {{ + color: #6b7280; +}} +.search-params td:last-child {{ + text-align: right; +}} + +/* Range block */ +.range-block {{ + margin-top: 6pt; +}} + +/* Range bar — linear-gradient на одном div'е. WeasyPrint reliable. */ +.range-bar-strip {{ + width: 100%; + height: 16pt; + line-height: 14pt; + border: 1pt solid #9ca3af; + background: linear-gradient( + to right, + #ffffff 0%, #ffffff 20%, + #dc2626 20%, #dc2626 80%, + #ffffff 80%, #ffffff 100% + ); + margin: 4pt 0; + overflow: hidden; + color: transparent; + font-size: 1pt; +}} +.range-title {{ + font-size: 10pt; + font-weight: 700; + color: #1a1d23; + margin-bottom: 4pt; +}} + +/* Что важно — advice table */ +.advice-table td {{ + padding: 5pt 6pt; + border-bottom: 1px solid #f3f4f6; + vertical-align: top; + font-size: 8.5pt; + line-height: 1.3; +}} +.advice-title {{ + font-weight: 700; + width: 30%; + color: #1a1d23; +}} +.advice-text {{ + color: #4b5563; +}} + +/* Advantages (Offer) */ +.advantage-item {{ + border: 1px solid #e6e8ec; + border-radius: 4pt; + padding: 8pt; + text-align: left; +}} +.advantage-item .adv-ic {{ + display: block; + width: 28pt; + height: 28pt; + border-radius: 50%; + background: #fef2f2; + margin: 0 0 6pt 0; + line-height: 28pt; + text-align: center; +}} +.advantage-item .adv-ic svg {{ + vertical-align: middle; +}} +.advantage-item .adv-title {{ + font-weight: 700; + font-size: 8.5pt; + margin-bottom: 3pt; + color: {primary}; + letter-spacing: -0.01em; +}} +.advantage-item .adv-desc {{ + font-size: 7pt; + color: #5b6066; + line-height: 1.3; +}} +""" + + +# ── Public API ─────────────────────────────────────────────────────────────── + + +def generate_trade_in_pdf( + estimate: AggregatedEstimate, + input_snapshot: dict, # type: ignore[type-arg] + *, + brand=None, # type: ignore[no-untyped-def] +) -> bytes: + """Генерирует 4-страничный WeasyPrint PDF в стиле Брусника.Обмен. + + Pages: + 1. Cover — № отчёта + параметры + 2 диапазона + 3 блока «Что важно» + 2. Listings — рынок аналогов + источники + таблица примеров + 3. Deals — фактические сделки + источники + таблица + 4. Offer — Trade-In vs самопродажа + 4 преимущества + + Args: + estimate: AggregatedEstimate из БД + input_snapshot: ввод пользователя (address, area_m2, ...) + brand: optional Brand для white-label + + Returns: + PDF bytes для Response(media_type="application/pdf") + """ + from app.services.brand import Brand + + if brand is None: + brand = Brand( + slug="generic", + name="Trade-In", + logo_url=None, + primary_color="#1d4ed8", + accent_color="#f59e0b", + footer_text=None, + pdf_disclaimer=None, + ) + + html_str = ( + '' + f'Trade-In — {_html.escape(input_snapshot.get("address", ""))}' + "" + + _build_cover(estimate, input_snapshot, brand) + + _build_listings_page(estimate, input_snapshot, brand) + + _build_deals_page(estimate, input_snapshot, brand) + + _build_offer_page(estimate, brand) + + "" + ) + css_str = _build_css(brand=brand) + # base_url=file:/// чтобы WeasyPrint мог resolve file:// URLs для PNG + pdf_bytes = HTML(string=html_str, base_url="file:///").write_pdf(stylesheets=[CSS(string=css_str)]) + logger.info( + "PDF generated estimate_id=%s brand=%s size=%d (Brusnika-style)", + estimate.estimate_id, brand.slug, len(pdf_bytes), + ) + return pdf_bytes diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py new file mode 100644 index 00000000..2c58e989 --- /dev/null +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -0,0 +1,535 @@ +"""Geocoder service — address → lat/lon. + +Стратегия: +- Cache lookup в `geocode_cache` (Postgres) — TTL 90 дней +- Cache miss → Yandex Geocoder (если есть key) → fallback Nominatim +- Результат сохраняется в кэш для последующих вызовов + +Используется в: +- /api/v1/trade-in/estimate (вход — адрес от пользователя) +- /api/v1/geocode/lookup (debug endpoint) +- scraper jobs (когда нужно по адресу определить координаты) +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Literal + +import httpx +from sqlalchemy import text +from sqlalchemy.orm import Session +from tenacity import retry, stop_after_attempt, wait_exponential + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +# ── Result type ────────────────────────────────────────────────────────────── +@dataclass(frozen=True, slots=True) +class GeocodeResult: + lat: float + lon: float + full_address: str + provider: Literal["nominatim", "yandex", "cache"] + confidence: Literal["exact", "approximate", "locality"] = "approximate" + + +# ── Address normalisation ─────────────────────────────────────────────────── +def normalize_address(address: str) -> str: + """Нормализация для cache lookup: lowercase + trim + collapse whitespace. + + « Ул. МАЛЫШЕВА, 30 » → «ул. малышева, 30» + """ + return " ".join(address.lower().strip().split()) + + +# Согласные, которые часто пишут с одной буквой вместо двух (RU typos). +_DOUBLE_CONSONANTS = "лнмссккттпп" + + +def _typo_variants(query: str, limit: int = 6) -> list[str]: + """Генерация вариантов с удвоением согласных — для случая когда пользователь + написал «Цвилинга» вместо «Цвиллинга», «Толстова» вместо «Толстого» итд. + + Каждая позиция где есть одинокая согласная — кандидат на удвоение. + Возвращаем до `limit` вариантов в порядке вероятности (ближе к началу слова → выше). + """ + if len(query) < 3: + return [] + variants: list[str] = [] + chars = list(query) + for i in range(1, len(chars) - 1): + c = chars[i].lower() + if c not in _DOUBLE_CONSONANTS: + continue + prev_c = chars[i - 1].lower() + next_c = chars[i + 1].lower() + # Удваиваем только если соседи — гласные/'ь'/'ъ' (характерно для русских типо) + if prev_c in "аеёиоуыэюяьъ" and next_c in "аеёиоуыэюяьъ": + variant = query[:i + 1] + chars[i] + query[i + 1:] + if variant != query and variant not in variants: + variants.append(variant) + if len(variants) >= limit: + break + return variants + + +# ── Cache ──────────────────────────────────────────────────────────────────── +def _cache_get(db: Session, address_norm: str) -> GeocodeResult | None: + row = db.execute( + text( + """ + SELECT lat, lon, full_address, provider, confidence + FROM geocode_cache + WHERE address_normalized = :addr + AND expires_at > NOW() + """ + ), + {"addr": address_norm}, + ).fetchone() + if row is None: + return None + return GeocodeResult( + lat=row.lat, + lon=row.lon, + full_address=row.full_address, + provider="cache", + confidence=row.confidence or "approximate", + ) + + +def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None: + db.execute( + text( + """ + INSERT INTO geocode_cache + (address_normalized, lat, lon, full_address, provider, confidence) + VALUES (:addr, :lat, :lon, :full, :provider, :conf) + ON CONFLICT (address_normalized) DO UPDATE + SET lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + full_address = EXCLUDED.full_address, + provider = EXCLUDED.provider, + confidence = EXCLUDED.confidence, + created_at = NOW(), + expires_at = NOW() + interval '90 days' + """ + ), + { + "addr": address_norm, + "lat": result.lat, + "lon": result.lon, + "full": result.full_address, + "provider": result.provider, + "conf": result.confidence, + }, + ) + db.commit() + + +# ── Provider: Nominatim (OSM, без ключа) ──────────────────────────────────── +async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None: + """Single Nominatim search. Возвращает первый item или None. + + ВАЖНО: фильтруем результаты по ЕКБ bbox прямо тут, чтобы при опечатках + не возвращать Пермский край / Челябинск. + """ + response = await client.get( + "https://nominatim.openstreetmap.org/search", + params={ + "q": address, + "format": "json", + "limit": "3", + "countrycodes": "ru", + "addressdetails": "1", + "viewbox": EKB_BBOX["viewbox"], + "bounded": "1", # строго в ЕКБ bbox + }, + ) + response.raise_for_status() + data = response.json() + for item in data: + try: + lat_f = float(item["lat"]) + lon_f = float(item["lon"]) + if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95: + return item + except Exception: # noqa: BLE001 + continue + return None + + +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) +async def _nominatim_lookup(address: str) -> GeocodeResult | None: + """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy. + + Бан-policy: User-Agent с email обязателен. + Tier 1: bounded ЕКБ на оригинальный адрес. + Tier 2: bounded ЕКБ на typo-варианты (Цвилинга → Цвиллинга). + """ + headers = { + "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", + "Accept": "application/json", + "Accept-Language": "ru,en;q=0.8", + "Referer": "https://tradein-mvp.local/", + } + async with httpx.AsyncClient(timeout=10.0, headers=headers) as client: + # Tier 1: оригинал + item = await _nominatim_query(client, address) + + # Tier 2: typo-variants + if item is None: + for variant in _typo_variants(address, limit=4): + await asyncio.sleep(1.0) # Nominatim 1 req/sec policy + item = await _nominatim_query(client, variant) + if item is not None: + logger.info("nominatim typo-fixed: %s → %s", address, variant) + break + + if item is None: + return None + + confidence = "exact" if item.get("class") == "building" else "approximate" + return GeocodeResult( + lat=float(item["lat"]), + lon=float(item["lon"]), + full_address=item.get("display_name", address), + provider="nominatim", + confidence=confidence, + ) + + +# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ───────────── +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) +async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None: + """Yandex Geocoder — 25K req/day free для самопод, лучше РФ. + + Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html + + Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов, + но БЕЗ rspn — чтобы fuzzy matching работал при опечатках. + """ + # Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest) + geocode_query = address if "екатеринбург" in address.lower() else f"Екатеринбург, {address}" + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + "https://geocode-maps.yandex.ru/1.x/", + params={ + "apikey": api_key, + "geocode": geocode_query, + "format": "json", + "results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже + "lang": "ru_RU", + "ll": EKB_BBOX["ll"], + "spn": EKB_BBOX["spn"], + }, + ) + response.raise_for_status() + data = response.json() + + members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) + if not members: + return None + + # Фильтруем top-5 по ЕКБ bbox — игнорируем Челябинск/Уфу/Москву при опечатке + best = None + for m in members: + obj = m.get("GeoObject", {}) + try: + lon_str, lat_str = obj["Point"]["pos"].split() + lat_f, lon_f = float(lat_str), float(lon_str) + if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95: + best = obj + break + except Exception: # noqa: BLE001 + continue + + if best is None: + # Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то) + best = members[0]["GeoObject"] + + lon_str, lat_str = best["Point"]["pos"].split() + precision_raw = ( + best.get("metaDataProperty", {}) + .get("GeocoderMetaData", {}) + .get("precision", "other") + ) + confidence_map = { + "exact": "exact", + "number": "exact", + "near": "approximate", + "range": "approximate", + "street": "approximate", + } + return GeocodeResult( + lat=float(lat_str), + lon=float(lon_str), + full_address=best.get("metaDataProperty", {}) + .get("GeocoderMetaData", {}) + .get("text", address), + provider="yandex", + confidence=confidence_map.get(precision_raw, "approximate"), + ) + + +# ── Suggest (автокомплит) ─────────────────────────────────────────────────── +# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85 +EKB_BBOX = { + "viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE) + "ll": "60.605,56.838", # Yandex center (lon,lat) + "spn": "0.45,0.30", # Yandex span (lon,lat) +} + + +@dataclass(frozen=True, slots=True) +class GeocodeSuggestion: + label: str # формат "Малышева 30, Октябрьский район" + full_address: str # полный из геокодера + lat: float + lon: float + kind: str # 'house' / 'street' / 'locality' + + +def _parse_yandex_members(members: list[dict]) -> list[GeocodeSuggestion]: + """Yandex geocode_members → list[GeocodeSuggestion]. Чистим описание от мусора.""" + out: list[GeocodeSuggestion] = [] + for m in members: + obj = m.get("GeoObject", {}) + try: + lon_str, lat_str = obj["Point"]["pos"].split() + meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) + kind = meta.get("kind", "other") + full = meta.get("text", obj.get("name", "")) + name = obj.get("name", full) + desc = obj.get("description", "") + desc_parts = [ + p.strip() for p in desc.split(",") + if p.strip() and p.strip() not in {"Россия", "Свердловская область"} + ] + label = name if not desc_parts else f"{name} · {', '.join(desc_parts)}" + out.append(GeocodeSuggestion( + label=label, full_address=full, + lat=float(lat_str), lon=float(lon_str), kind=kind, + )) + except Exception: # noqa: BLE001 + continue + return out + + +async def _yandex_geocode_request( + client: httpx.AsyncClient, api_key: str, query: str, limit: int, bounded: bool +) -> list[dict]: + """Single Yandex Geocoder request — bounded=True → строго в ЕКБ через rspn=1.""" + params: dict[str, str] = { + "apikey": api_key, + "geocode": query, + "format": "json", + "results": str(limit), + "lang": "ru_RU", + "ll": EKB_BBOX["ll"], + "spn": EKB_BBOX["spn"], + } + if bounded: + params["rspn"] = "1" + response = await client.get("https://geocode-maps.yandex.ru/1.x/", params=params) + response.raise_for_status() + data = response.json() + return data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) + + +@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) +async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]: + """Yandex Geocoder с авто-fallback на typo-tolerant режим. + + Tier 1: bounded ЕКБ (rspn=1) на оригинальный query. + Tier 2: bounded ЕКБ на typo-variants (удвоение согласных). + Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по ЕКБ bbox. + """ + async with httpx.AsyncClient(timeout=8.0) as client: + # Tier 1: strict bounded на оригинал + members = await _yandex_geocode_request( + client, api_key, f"Екатеринбург, {query}", limit, bounded=True, + ) + results = _parse_yandex_members(members) + if results: + return results + + # Tier 2: bounded на typo-варианты + for variant in _typo_variants(query, limit=4): + members = await _yandex_geocode_request( + client, api_key, f"Екатеринбург, {variant}", limit, bounded=True, + ) + results = _parse_yandex_members(members) + if results: + return results + + # Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox + members = await _yandex_geocode_request( + client, api_key, f"Екатеринбург, {query}", limit, bounded=False, + ) + results = _parse_yandex_members(members) + in_ekb = [ + r for r in results + if 60.40 <= r.lon <= 60.85 and 56.65 <= r.lat <= 56.95 + ] + return in_ekb + + +async def _nominatim_query_multi( + client: httpx.AsyncClient, query: str, limit: int +) -> list[dict]: + """Один Nominatim search с фильтром по ЕКБ bbox. Возвращает up to N items.""" + response = await client.get( + "https://nominatim.openstreetmap.org/search", + params={ + "q": query, + "format": "json", + "limit": str(limit), + "countrycodes": "ru", + "viewbox": EKB_BBOX["viewbox"], + "bounded": "1", + "addressdetails": "1", + }, + ) + response.raise_for_status() + data = response.json() + return data if isinstance(data, list) else [] + + +@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) +async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]: + """Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).""" + headers = { + "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", + "Accept": "application/json", + "Accept-Language": "ru,en;q=0.8", + } + async with httpx.AsyncClient(timeout=8.0, headers=headers) as client: + # Tier 1: оригинальный query + data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit) + + # Tier 2: typo-варианты если оригинал пустой + if not data: + for variant in _typo_variants(query, limit=3): + await asyncio.sleep(1.0) # Nominatim 1 req/sec + data = await _nominatim_query_multi(client, f"{variant}, Екатеринбург", limit) + if data: + logger.info("nominatim suggest typo-fixed: %s → %s", query, variant) + break + + out: list[GeocodeSuggestion] = [] + for item in data: + display = item.get("display_name", "") + addr = item.get("address", {}) or {} + # Компактный лейбл: street + house_number / locality / district + street = addr.get("road") or addr.get("street") or "" + house = addr.get("house_number", "") + district = addr.get("suburb") or addr.get("city_district") or addr.get("borough") or "" + parts = [] + if street: + parts.append(f"{street}{f', {house}' if house else ''}") + elif item.get("name"): + parts.append(item["name"]) + if district: + parts.append(district) + label = " · ".join(parts) if parts else display[:80] + kind = "house" if house else ("street" if street else "locality") + out.append(GeocodeSuggestion( + label=label, full_address=display, + lat=float(item["lat"]), lon=float(item["lon"]), kind=kind, + )) + return out + + +async def suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]: + """Автокомплит адресов в ЕКБ. Yandex → Nominatim → []. + + Без кэша (дешёво, провайдеры толерируют автокомплит-запросы). + """ + if not query or len(query.strip()) < 2: + return [] + + if settings.yandex_geocoder_key: + try: + results = await _yandex_suggest(query, settings.yandex_geocoder_key, limit) + if results: + return results + except Exception: # noqa: BLE001 + logger.exception("yandex suggest failed, falling back to nominatim") + + try: + return await _nominatim_suggest(query, limit) + except Exception: # noqa: BLE001 + logger.exception("nominatim suggest failed") + return [] + + +# ── Public API ─────────────────────────────────────────────────────────────── +async def geocode(address: str, db: Session) -> GeocodeResult | None: + """Геокодинг с кэшем. Yandex → Nominatim → None. + + Args: + address: пользовательский ввод (может быть грязным — нормализуем). + db: сессия Postgres для cache lookup/write. + + Returns: + GeocodeResult или None если ни один провайдер не отвечает. + """ + if not address or len(address.strip()) < 3: + return None + + addr_norm = normalize_address(address) + + # 1. Cache + cached = _cache_get(db, addr_norm) + if cached is not None: + logger.info("geocode cache hit: %s", addr_norm) + return cached + + # 2. Yandex (если есть key) с typo-fallback + if settings.yandex_geocoder_key: + try: + result = await _yandex_lookup(address, settings.yandex_geocoder_key) + # Если результат вне ЕКБ — пробуем typo-варианты + in_ekb = result is not None and 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95 + if result is not None and in_ekb: + _cache_put(db, addr_norm, result) + logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) + return result + # Tier 2: typo-variants + for variant in _typo_variants(address, limit=4): + try: + result = await _yandex_lookup(variant, settings.yandex_geocoder_key) + except Exception: # noqa: BLE001 + continue + if result is None: + continue + if 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95: + _cache_put(db, addr_norm, result) + logger.info( + "geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)", + addr_norm, variant, result.lat, result.lon, + ) + return result + except Exception: # noqa: BLE001 + logger.exception("yandex geocoder failed, falling back to nominatim") + + # 3. Nominatim fallback + try: + result = await _nominatim_lookup(address) + if result is not None: + _cache_put(db, addr_norm, result) + logger.info( + "geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon + ) + # Nominatim rate-limit policy: 1 req/sec — спим после успешного запроса + await asyncio.sleep(1.0) + return result + except Exception: # noqa: BLE001 + logger.exception("nominatim geocoder failed") + + return None diff --git a/tradein-mvp/backend/app/services/scrapers/__init__.py b/tradein-mvp/backend/app/services/scrapers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py new file mode 100644 index 00000000..78a697d8 --- /dev/null +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -0,0 +1,409 @@ +"""Avito.ru scraper — парсинг вторички вокруг точки. + +Стратегия: HTML scrape карточек объявлений + извлечение JSON `__initialState__` +который Avito вставляет в `', re.DOTALL +) +_RE_INITIAL_STATE = re.compile( + r'window\._cianConfig\s*=\s*({.+?});', re.DOTALL +) + + +def _extract_next_data_items(html: str) -> list[dict[str, Any]]: + """Cian Next.js приложение хранит state в `__NEXT_DATA__` JSON.""" + m = _RE_NEXT_DATA.search(html) + if not m: + return [] + try: + data = json.loads(m.group(1)) + except json.JSONDecodeError: + return [] + # Структура у Cian примерно такая: props.pageProps.initialState.serpData.offers + cursor: Any = data + for key in ["props", "pageProps", "initialState"]: + if not isinstance(cursor, dict): + return [] + cursor = cursor.get(key) + if not isinstance(cursor, dict): + return [] + serp = cursor.get("results") or cursor.get("serpData") or cursor.get("data") or {} + offers = serp.get("offersSerialized") or serp.get("offers") or [] + if isinstance(offers, list): + return offers + return [] + + +def _extract_inline_offers(html: str) -> list[dict[str, Any]]: + """Альтернативный поиск offers в любом большом JSON в HTML.""" + # Найти offers: [...] inline + for match in re.finditer(r'"offers":\[(.+?)\](?=,"|\})', html): + chunk = "[" + match.group(1) + "]" + try: + offers = json.loads(chunk) + if isinstance(offers, list) and offers and isinstance(offers[0], dict): + return offers + except json.JSONDecodeError: + continue + return [] + + +# ── Title parsers (Cian-specific format) ──────────────────────────────────── + + +_RE_ROOMS = re.compile(r"(\d)-комн", re.IGNORECASE) +_RE_STUDIO = re.compile(r"студи", re.IGNORECASE) +_RE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м²", re.IGNORECASE) +_RE_FLOOR = re.compile(r"(\d+)\s*/\s*(\d+)\s*эт", re.IGNORECASE) +_RE_PRICE = re.compile(r"([\d\s]+?)\s*₽") + + +def _extract_rooms_from_text(text: str) -> int | None: + if _RE_STUDIO.search(text): + return 0 + m = _RE_ROOMS.search(text) + return int(m.group(1)) if m else None + + +def _extract_area_from_text(text: str) -> float | None: + m = _RE_AREA.search(text) + return float(m.group(1).replace(",", ".")) if m else None + + +def _extract_floor_from_text(text: str) -> tuple[int | None, int | None]: + m = _RE_FLOOR.search(text) + if m: + return int(m.group(1)), int(m.group(2)) + return None, None + + +def _extract_price_rub(text: str) -> int | None: + m = _RE_PRICE.search(text) + if m: + digits = re.sub(r"\D", "", m.group(1)) + return int(digits) if digits else None + return None + + +_RE_YEAR_BUILT = re.compile(r"(?:19[5-9]\d|20[0-2]\d)\s*(?:года?\s*постройки|г\.?\s*постройки)", re.IGNORECASE) + + +def _extract_year_built(text: str) -> int | None: + """Извлечь год постройки из description: '1988 года постройки' / '2005 г. постройки'.""" + m = _RE_YEAR_BUILT.search(text) + if m: + year_m = re.search(r"(19[5-9]\d|20[0-2]\d)", m.group(0)) + if year_m: + return int(year_m.group(1)) + return None diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_realty.py b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py new file mode 100644 index 00000000..c78ffa50 --- /dev/null +++ b/tradein-mvp/backend/app/services/scrapers/yandex_realty.py @@ -0,0 +1,290 @@ +"""Yandex.Недвижимость scraper (realty.yandex.ru). + +Я.Недвижимость отдаёт SSR HTML с `__INITIAL_STATE__` JSON в `', + re.DOTALL, +) + + +def _extract_yandex_state(html: str) -> dict[str, Any] | None: + m = _RE_STATE.search(html) + if not m: + return None + raw = m.group(1) + # Может начинаться с `window.__INITIAL_STATE__=` префикса + raw = re.sub(r'^\s*window\.[\w_]+\s*=\s*', '', raw).rstrip(';').strip() + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +def _items_from_state(state: dict[str, Any]) -> list[dict[str, Any]]: + """Yandex Realty: state.map.offers.points содержит список объявлений (десктоп desktop SSR).""" + for path in [ + ("map", "offers", "points"), # верифицировано на проде + ("search", "offers"), + ("offers", "points"), + ("offers", "items"), + ]: + cursor: Any = state + for key in path: + if not isinstance(cursor, dict): + cursor = None + break + cursor = cursor.get(key) + if isinstance(cursor, list) and cursor: + return cursor + return [] + + +def _map_wall_type(yandex_type: str | None) -> str | None: + if not yandex_type: + return None + mapping = { + "PANEL": "panel", + "BRICK": "brick", + "MONOLITH": "monolith", + "MONOLITH_BRICK": "monolith_brick", + "BLOCK": "panel", + } + return mapping.get(yandex_type.upper(), "other") diff --git a/tradein-mvp/backend/data/sql/001_trade_in_estimates.sql b/tradein-mvp/backend/data/sql/001_trade_in_estimates.sql new file mode 100644 index 00000000..df9e787a --- /dev/null +++ b/tradein-mvp/backend/data/sql/001_trade_in_estimates.sql @@ -0,0 +1,43 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS trade_in_estimates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Input snapshot + address text NOT NULL, + lat double precision, + lon double precision, + area_m2 numeric(8, 2) NOT NULL, + rooms int NOT NULL, + floor int NOT NULL, + total_floors int NOT NULL, + year_built int, + house_type text, + repair_state text, + has_balcony boolean, + + -- Output + median_price bigint NOT NULL, + range_low bigint NOT NULL, + range_high bigint NOT NULL, + median_price_per_m2 int NOT NULL, + confidence text NOT NULL CHECK (confidence IN ('low', 'medium', 'high')), + n_analogs int NOT NULL DEFAULT 0, + analogs jsonb NOT NULL DEFAULT '[]'::jsonb, + actual_deals jsonb NOT NULL DEFAULT '[]'::jsonb, + + -- Metadata + created_at timestamptz NOT NULL DEFAULT NOW(), + expires_at timestamptz NOT NULL DEFAULT NOW() + interval '24 hours' +); + +CREATE INDEX IF NOT EXISTS trade_in_estimates_created_idx + ON trade_in_estimates (created_at DESC); + +CREATE INDEX IF NOT EXISTS trade_in_estimates_expires_idx + ON trade_in_estimates (expires_at); + +COMMENT ON TABLE trade_in_estimates IS + '#314 TradeIn MVP — стор estimates с input snapshot + aggregated output. TTL 24h.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/002_core_tables.sql b/tradein-mvp/backend/data/sql/002_core_tables.sql new file mode 100644 index 00000000..d0841eb2 --- /dev/null +++ b/tradein-mvp/backend/data/sql/002_core_tables.sql @@ -0,0 +1,234 @@ +-- Trade-In MVP — Core data layer (Слой 1.1). +-- Применяется автоматически после 001_trade_in_estimates.sql при первом старте postgres. + +BEGIN; + +-- ── PostGIS для гео-запросов ────────────────────────────────────────────── +CREATE EXTENSION IF NOT EXISTS postgis; + + +-- ───────────────────────────────────────────────────────────────────────── +-- listings — спарсенные объявления (Авито, Циан, ДомКлик, Я.Нед, Н1, Дом.РФ) +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS listings ( + id bigserial PRIMARY KEY, + source text NOT NULL, -- avito / cian / domklik / yandex / n1 / domrf + source_url text NOT NULL, + source_id text, -- внутренний id источника (если есть) + dedup_hash text NOT NULL UNIQUE, -- sha256(source + source_url + price) — дедуп + + -- Локация + address text, + lat double precision, + lon double precision, + geom geometry(Point, 4326), -- PostGIS для ST_DWithin + region_code int, -- 66 = Свердловская обл. + + -- Параметры квартиры + rooms int, -- 0 = студия + area_m2 numeric(8, 2), + floor int, + total_floors int, + year_built int, + house_type text, -- panel / brick / monolith / monolith_brick / other + repair_state text, -- needs_repair / standard / good / excellent + has_balcony boolean, + kadastr_num text, -- если удалось вытянуть кадастр + + -- Цена + price_rub bigint NOT NULL, + price_per_m2 int, + + -- Метаданные источника + listing_date date, -- когда выставили + days_on_market int, + photo_urls jsonb DEFAULT '[]'::jsonb, + raw_payload jsonb, -- сырой ответ для дебага + + -- Quality flags + is_outlier boolean DEFAULT false, -- помечено модели после aggregation + is_active boolean DEFAULT true, -- если объявление пропало с источника + + -- Lifecycle + scraped_at timestamptz NOT NULL DEFAULT NOW(), + last_seen_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS listings_geom_idx ON listings USING GIST (geom); +CREATE INDEX IF NOT EXISTS listings_rooms_area_idx ON listings (rooms, area_m2) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS listings_scraped_idx ON listings (scraped_at DESC) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS listings_source_idx ON listings (source, scraped_at DESC); + +-- Авто-update geom из (lat, lon) +CREATE OR REPLACE FUNCTION listings_set_geom() RETURNS trigger AS $$ +BEGIN + IF NEW.lat IS NOT NULL AND NEW.lon IS NOT NULL THEN + NEW.geom := ST_SetSRID(ST_MakePoint(NEW.lon, NEW.lat), 4326); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS listings_set_geom_trg ON listings; +CREATE TRIGGER listings_set_geom_trg + BEFORE INSERT OR UPDATE OF lat, lon ON listings + FOR EACH ROW EXECUTE FUNCTION listings_set_geom(); + +COMMENT ON TABLE listings IS + 'Спарсенные объявления с площадок. Дедуп по dedup_hash. PostGIS geom для ST_DWithin запросов.'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- deals — фактические сделки (Росреестр + Этажи + ДомКлик исторические) +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS deals ( + id bigserial PRIMARY KEY, + source text NOT NULL, -- rosreestr / etazhi / domklik_history + source_id text, -- идентификатор сделки в источнике + dedup_hash text NOT NULL UNIQUE, + + -- Локация + address text, + lat double precision, + lon double precision, + geom geometry(Point, 4326), + region_code int, + kadastr_num text, + + -- Параметры квартиры + rooms int, + area_m2 numeric(8, 2), + floor int, + total_floors int, + year_built int, + house_type text, + + -- Сделка + deal_date date NOT NULL, + price_rub bigint NOT NULL, + price_per_m2 int, + days_on_market int, -- если знаем когда выставили + + raw_payload jsonb, + scraped_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS deals_geom_idx ON deals USING GIST (geom); +CREATE INDEX IF NOT EXISTS deals_rooms_area_idx ON deals (rooms, area_m2); +CREATE INDEX IF NOT EXISTS deals_date_idx ON deals (deal_date DESC); +CREATE INDEX IF NOT EXISTS deals_source_idx ON deals (source, deal_date DESC); + +DROP TRIGGER IF EXISTS deals_set_geom_trg ON deals; +CREATE TRIGGER deals_set_geom_trg + BEFORE INSERT OR UPDATE OF lat, lon ON deals + FOR EACH ROW EXECUTE FUNCTION listings_set_geom(); -- reuse + +COMMENT ON TABLE deals IS + 'Фактические сделки купли-продажи квартир. Источник #1 — Росреестр (дамп из prod gendesign).'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- geocode_cache — кэш geocoder (Nominatim/Yandex) +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS geocode_cache ( + address_normalized text PRIMARY KEY, -- lowercased trimmed адрес для lookup + lat double precision NOT NULL, + lon double precision NOT NULL, + full_address text, -- что вернул geocoder + provider text NOT NULL, -- nominatim / yandex + confidence text, -- exact / approximate / locality + raw_payload jsonb, + created_at timestamptz NOT NULL DEFAULT NOW(), + -- TTL: 90 дней — потом стоит проверить актуальность (дома сносят редко, но бывает) + expires_at timestamptz NOT NULL DEFAULT NOW() + interval '90 days' +); + +CREATE INDEX IF NOT EXISTS geocode_cache_expires_idx ON geocode_cache (expires_at); + +COMMENT ON TABLE geocode_cache IS + 'Кэш geocoder lookup. Адрес → lat/lon. TTL 90 дней.'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- house_metadata — кэш данных ЕКБ Геопортала (citymap.ekburg.ru) +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS house_metadata ( + id bigserial PRIMARY KEY, + lat double precision NOT NULL, + lon double precision NOT NULL, + geom geometry(Point, 4326), + kadastr_num text UNIQUE, + + -- Что вытянули из ЕКБ Геопортала / DomRF / NSPD + address text, + total_floors int, + year_built int, + house_type text, + total_units int, -- кол-во квартир в доме + construction_status text, -- сдан / строится + developer text, -- застройщик если новостройка + + source text, -- ekb_geoportal / domrf / nspd + raw_payload jsonb, + fetched_at timestamptz NOT NULL DEFAULT NOW(), + -- TTL: 30 дней + expires_at timestamptz NOT NULL DEFAULT NOW() + interval '30 days' +); + +CREATE INDEX IF NOT EXISTS house_metadata_geom_idx ON house_metadata USING GIST (geom); +CREATE INDEX IF NOT EXISTS house_metadata_expires_idx ON house_metadata (expires_at); + +DROP TRIGGER IF EXISTS house_metadata_set_geom_trg ON house_metadata; +CREATE TRIGGER house_metadata_set_geom_trg + BEFORE INSERT OR UPDATE OF lat, lon ON house_metadata + FOR EACH ROW EXECUTE FUNCTION listings_set_geom(); + +COMMENT ON TABLE house_metadata IS + 'Кэш метаданных дома по координатам/кадастру (ЕКБ Геопортал, DomRF, NSPD).'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- audit_log — каждый POST /estimate и GET /estimate/{id}/pdf +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS audit_log ( + id bigserial PRIMARY KEY, + event_type text NOT NULL, -- estimate_request / pdf_download / listing_click + ip_address inet, + user_agent text, + estimate_id uuid, -- FK к trade_in_estimates если применимо + payload jsonb, -- input/output для контекста + created_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS audit_log_created_idx ON audit_log (created_at DESC); +CREATE INDEX IF NOT EXISTS audit_log_event_type_idx ON audit_log (event_type, created_at DESC); + +COMMENT ON TABLE audit_log IS + 'Лог всех значимых событий — для analytics dashboard + rate limiting + debugging.'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- brands — multi-tenant white-label +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS brands ( + slug text PRIMARY KEY, -- prinzip / praktika / generic + name text NOT NULL, + logo_url text, + primary_color text DEFAULT '#1d4ed8', + accent_color text DEFAULT '#f59e0b', + footer_text text, + pdf_disclaimer text, + created_at timestamptz NOT NULL DEFAULT NOW() +); + +INSERT INTO brands (slug, name, primary_color) VALUES + ('generic', 'Trade-In Estimator', '#1d4ed8'), + ('prinzip', 'PRINZIP', '#1a1d23'), + ('praktika', 'Практика', '#e87722') +ON CONFLICT (slug) DO NOTHING; + +COMMENT ON TABLE brands IS + 'White-label бренды. Выбирается через ?brand= на frontend.'; + + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/003_seed_listings.sql b/tradein-mvp/backend/data/sql/003_seed_listings.sql new file mode 100644 index 00000000..1e1c98b4 --- /dev/null +++ b/tradein-mvp/backend/data/sql/003_seed_listings.sql @@ -0,0 +1,147 @@ +-- Trade-In MVP — Seed данные для разработки (v2, фикс распределения). +-- 120 синтетических объявлений + 60 фактических сделок в центре ЕКБ. +-- Использовать пока парсеры не подключены к продакшну. + +BEGIN; + +TRUNCATE listings, deals RESTART IDENTITY CASCADE; + +-- ── Helper: 10 улиц ЕКБ с координатами ───────────────────────────────────── +WITH streets(idx, name, lat, lon) AS (VALUES + (0, 'ул. Малышева', 56.8332::float, 60.5944::float), + (1, 'ул. Куйбышева', 56.8282, 60.6114), + (2, 'ул. 8 Марта', 56.8240, 60.6000), + (3, 'ул. Белинского', 56.8160, 60.6080), + (4, 'пр. Ленина', 56.8400, 60.6100), + (5, 'ул. Толмачёва', 56.8390, 60.6020), + (6, 'ул. Радищева', 56.8300, 60.5890), + (7, 'ул. Мамина-Сибиряка', 56.8420, 60.6190), + (8, 'ул. Луначарского', 56.8350, 60.6280), + (9, 'ул. Первомайская', 56.8440, 60.6240) +), +-- 5 типов квартир (rooms, базовая площадь, базовая цена в руб 2026) +flat_types(idx, rooms, base_area, base_price) AS (VALUES + (0, 0, 28.0::float, 6500000::bigint), + (1, 1, 40.0, 9000000), + (2, 2, 60.0, 12500000), + (3, 3, 80.0, 17000000), + (4, 4, 110.0, 22000000) +), +sources(idx, name) AS (VALUES + (0, 'avito'), + (1, 'cian'), + (2, 'domklik') +), +counts(rooms, n) AS (VALUES + (0, 12), -- студии + (1, 30), -- 1к + (2, 45), -- 2к + (3, 25), -- 3к + (4, 8) -- 4+к +) +INSERT INTO listings ( + source, source_url, dedup_hash, address, lat, lon, region_code, + rooms, area_m2, floor, total_floors, year_built, house_type, + price_rub, price_per_m2, listing_date, days_on_market, scraped_at +) +SELECT + src.name AS source, + 'https://' || src.name || '.example/lot/' || row_num AS source_url, + md5(src.name || row_num::text || street.name || ft.rooms::text) AS dedup_hash, + street.name || ', ' || (10 + (row_num * 7) % 150)::text AS address, + street.lat + ((random() - 0.5) * 0.012) AS lat, -- ≈±650м jitter + street.lon + ((random() - 0.5) * 0.012) AS lon, + 66 AS region_code, + ft.rooms, + ft.base_area + (random() * 6 - 3) AS area_m2, -- ±3м² jitter + 1 + (row_num * 3) % 18 AS floor, + 9 + (row_num * 5) % 14 AS total_floors, + 1960 + (row_num * 11) % 64 AS year_built, + (ARRAY['panel', 'brick', 'monolith', 'monolith_brick'])[1 + (row_num * 7) % 4] AS house_type, + (ft.base_price * (0.85 + random() * 0.30))::bigint AS price_rub, + ((ft.base_price * (0.85 + random() * 0.30)) / (ft.base_area + (random() * 6 - 3)))::int AS price_per_m2, + CURRENT_DATE - ((row_num % 90)::text || ' days')::interval AS listing_date, + row_num % 90 AS days_on_market, + NOW() - ((row_num % 14)::text || ' days')::interval AS scraped_at +FROM ( + -- Раскрываем counts в rows: для rooms=0 12 строк, для rooms=1 30 строк, ... + SELECT + c.rooms, + row_number() OVER (PARTITION BY c.rooms) AS rooms_row, + row_number() OVER () AS row_num + FROM counts c + CROSS JOIN LATERAL generate_series(1, c.n) gs +) AS expanded +JOIN flat_types ft ON ft.rooms = expanded.rooms +JOIN streets street ON street.idx = expanded.row_num % 10 +JOIN sources src ON src.idx = expanded.row_num % 3; + + +-- ── DEALS (60 шт., цены на ~7% ниже листингов) ──────────────────────────── +WITH streets(idx, name, lat, lon) AS (VALUES + (0, 'ул. Малышева', 56.8332::float, 60.5944::float), + (1, 'ул. Куйбышева', 56.8282, 60.6114), + (2, 'ул. 8 Марта', 56.8240, 60.6000), + (3, 'ул. Белинского', 56.8160, 60.6080), + (4, 'пр. Ленина', 56.8400, 60.6100), + (5, 'ул. Толмачёва', 56.8390, 60.6020), + (6, 'ул. Радищева', 56.8300, 60.5890), + (7, 'ул. Мамина-Сибиряка', 56.8420, 60.6190) +), +flat_types(idx, rooms, base_area, base_price) AS (VALUES + (1, 1, 40.0::float, 9000000::bigint), + (2, 2, 60.0, 12500000), + (3, 3, 80.0, 17000000) +), +deal_counts(rooms, n) AS (VALUES + (1, 18), + (2, 28), + (3, 14) +) +INSERT INTO deals ( + source, dedup_hash, address, lat, lon, region_code, + rooms, area_m2, floor, total_floors, year_built, house_type, + deal_date, price_rub, price_per_m2, days_on_market, scraped_at +) +SELECT + 'rosreestr' AS source, + md5('deal:' || row_num::text || street.name || ft.rooms::text) AS dedup_hash, + street.name || ', ' || (10 + (row_num * 5) % 140)::text AS address, + street.lat + ((random() - 0.5) * 0.012) AS lat, + street.lon + ((random() - 0.5) * 0.012) AS lon, + 66, + ft.rooms, + ft.base_area + (random() * 6 - 3) AS area_m2, + 1 + (row_num * 3) % 18 AS floor, + 9 + (row_num * 5) % 14 AS total_floors, + 1960 + (row_num * 11) % 64 AS year_built, + (ARRAY['panel', 'brick', 'monolith'])[1 + (row_num * 7) % 3] AS house_type, + CURRENT_DATE - ((row_num * 6)::text || ' days')::interval AS deal_date, + -- Сделки на 5-12% ниже листингов (вывод PDF Брусники) + (ft.base_price * 0.92 * (0.85 + random() * 0.20))::bigint AS price_rub, + ((ft.base_price * 0.92 * (0.85 + random() * 0.20)) / (ft.base_area + (random() * 6 - 3)))::int AS price_per_m2, + 20 + (row_num % 100) AS days_on_market, + NOW() - ((row_num * 6)::text || ' days')::interval AS scraped_at +FROM ( + SELECT + c.rooms, + row_number() OVER () AS row_num + FROM deal_counts c + CROSS JOIN LATERAL generate_series(1, c.n) gs +) AS expanded +JOIN flat_types ft ON ft.rooms = expanded.rooms +JOIN streets street ON street.idx = expanded.row_num % 8; + + +-- ── Sanity ───────────────────────────────────────────────────────────────── +DO $$ +DECLARE + n_listings int; + n_deals int; +BEGIN + SELECT COUNT(*) INTO n_listings FROM listings; + SELECT COUNT(*) INTO n_deals FROM deals; + RAISE NOTICE 'Seed: % listings, % deals', n_listings, n_deals; +END $$; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/004_extend_trade_in_estimates.sql b/tradein-mvp/backend/data/sql/004_extend_trade_in_estimates.sql new file mode 100644 index 00000000..cdf9e87e --- /dev/null +++ b/tradein-mvp/backend/data/sql/004_extend_trade_in_estimates.sql @@ -0,0 +1,19 @@ +-- Trade-In MVP — добавляем поля для PDF/UI v2 (Слой 5.2 + 6.1). +-- confidence_explanation + sources_used + data_freshness_minutes +-- (target_address, lat, lon уже есть — переименуем при необходимости позже) + +BEGIN; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS confidence_explanation text, + ADD COLUMN IF NOT EXISTS sources_used jsonb DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS data_freshness_minutes int; + +COMMENT ON COLUMN trade_in_estimates.confidence_explanation IS + 'Человекочитаемое объяснение confidence — для PDF/UI'; +COMMENT ON COLUMN trade_in_estimates.sources_used IS + 'Список источников, использованных в этой оценке (jsonb массив строк)'; +COMMENT ON COLUMN trade_in_estimates.data_freshness_minutes IS + 'Минут назад был последний парсинг для самого свежего аналога'; + +COMMIT; diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml new file mode 100644 index 00000000..b74a88fe --- /dev/null +++ b/tradein-mvp/backend/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "tradein-mvp-backend" +version = "0.1.0" +description = "Trade-In Estimator MVP — standalone fork of gendesign trade-in feature" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.30", + "geoalchemy2>=0.15.0", # PostGIS support для SQLAlchemy + "pydantic>=2.7.0", + "pydantic-settings>=2.3.0", + "psycopg[binary]>=3.2.0", + "weasyprint>=62.0", + "jinja2>=3.1.0", + "httpx>=0.27.0", # для geocoder + scrapers + "tenacity>=9.0.0", # retry с exp backoff + "selectolax>=0.3.0", # быстрый HTML парсинг для scrapers + "segno>=1.6.0", # QR-код для PDF shareable URL + "curl-cffi>=0.7.0", # impersonate=chrome120 для Cian/Avito (TLS fingerprint) +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "ruff>=0.5.0", +] + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "N", "RUF"] +ignore = ["RUF001", "RUF002", "RUF003"] diff --git a/tradein-mvp/deploy/Caddyfile b/tradein-mvp/deploy/Caddyfile new file mode 100644 index 00000000..101b6d59 --- /dev/null +++ b/tradein-mvp/deploy/Caddyfile @@ -0,0 +1,29 @@ +# Local Caddyfile — без TLS, всё на http://localhost:8080 +# +# Структура зеркалит прод gendesign: +# - /api/* → backend FastAPI (порт 8000) +# - /health → backend health +# - /preview/* → статические HTML mockups (включая tradein.html) +# - / → frontend Next.js (порт 3000) + +:80 { + encode zstd gzip + + handle /api/* { + reverse_proxy backend:8000 + } + + handle /health { + reverse_proxy backend:8000 + } + + # tradein.html mockup для side-by-side сравнения с реальной /trade-in страницей. + handle_path /preview/* { + root * /srv/preview + file_server browse + } + + handle { + reverse_proxy frontend:3000 + } +} diff --git a/tradein-mvp/deploy/Caddyfile.tradein-fragment b/tradein-mvp/deploy/Caddyfile.tradein-fragment new file mode 100644 index 00000000..73809453 --- /dev/null +++ b/tradein-mvp/deploy/Caddyfile.tradein-fragment @@ -0,0 +1,28 @@ +# Caddy-фрагмент: tradein-mvp под gendsgn.ru/trade-in/* +# +# Этот блок ВКЛЮЧАЕТСЯ в основной gendsgn.ru { ... } блок Caddy +# (см. /opt/gendesign/Caddyfile на bot-server). Размещение — ПЕРЕД +# универсальным `handle { reverse_proxy frontend:3000 }`, потому что +# Caddy матчит handle-блоки сверху вниз. +# +# DNS — отдельный record НЕ нужен; используется тот же gendsgn.ru. +# TLS уже включён в основном блоке (auto через Let's Encrypt). +# +# URL routes: +# gendsgn.ru/trade-in → tradein-frontend:3000 (Next.js basePath=/trade-in) +# gendsgn.ru/trade-in/api/v1/* → tradein-backend:8000 (strip префикса /trade-in) + +handle_path /trade-in/api/* { + # handle_path вырезает префикс /trade-in перед прокси → + # FastAPI получает /api/v1/... + reverse_proxy tradein-backend:8000 +} + +handle /trade-in { + redir /trade-in/ permanent +} + +handle /trade-in/* { + # Next.js basePath="/trade-in" — фронт сам ждёт префикса в URL + reverse_proxy tradein-frontend:3000 +} diff --git a/tradein-mvp/deploy/cron-scrape.sh b/tradein-mvp/deploy/cron-scrape.sh new file mode 100644 index 00000000..e2c43e3e --- /dev/null +++ b/tradein-mvp/deploy/cron-scrape.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Периодический скрейп Yandex+Avito по 5 якорям ЕКБ. +# Запускается из cron. Логи в /var/log/tradein-scrape.log +# +# Usage: ./cron-scrape.sh [yandex|avito|all] [deep|fast] +# +# Примеры в crontab: +# # Yandex deep раз в 3 часа +# 0 */3 * * * /opt/tradein-mvp/deploy/cron-scrape.sh yandex deep +# # Avito быстро каждый час +# 30 * * * * /opt/tradein-mvp/deploy/cron-scrape.sh avito fast + +set -euo pipefail + +SOURCE="${1:-all}" +MODE="${2:-fast}" +API="${API:-http://127.0.0.1:8091}" +LOG="${LOG:-/var/log/tradein-scrape.log}" + +log() { + echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*" | tee -a "$LOG" +} + +# 5 якорей по районам ЕКБ. lat lon name +# Для Yandex DATE_DESC возвращает одинаковую выдачу регардлесс — но Cian/Avito +# реально фильтруют по координатам, так что multi-anchor нужен для них. +ANCHORS=( + "56.8400 60.6050 Центр" + "56.7950 60.5300 ЮЗ" + "56.8970 60.6100 Уралмаш" + "56.7700 60.5500 Академический" + "56.8650 60.6200 Пионерский" +) + +build_payload() { + local lat="$1" lon="$2" src="$3" mode="$4" + local sources_json + case "$src" in + yandex) sources_json='["yandex"]' ;; + avito) sources_json='["avito"]' ;; + cian) sources_json='["cian"]' ;; + all) sources_json='["avito","cian","yandex"]' ;; + *) sources_json='["avito","cian","yandex"]' ;; + esac + if [[ "$mode" == "deep" ]]; then + echo "{\"lat\":$lat,\"lon\":$lon,\"radius_m\":1500,\"sources\":$sources_json,\"multi_room_yandex\":true,\"deep_yandex\":true,\"multi_room_cian\":true}" + else + echo "{\"lat\":$lat,\"lon\":$lon,\"radius_m\":1500,\"sources\":$sources_json,\"multi_room_yandex\":true,\"multi_room_cian\":true}" + fi +} + +log "=== cron-scrape start: source=$SOURCE mode=$MODE ===" + +# Circuit breaker: если Avito IP-rate-limited (3 нуля подряд) — пропускаем оставшиеся. +zero_fetch_streak=0 +for anchor in "${ANCHORS[@]}"; do + if [[ "$zero_fetch_streak" -ge 3 ]]; then + log "⚠ circuit-breaker tripped (3× zero-fetch in a row) — skipping remaining anchors" + break + fi + read -r lat lon name <<< "$anchor" + payload=$(build_payload "$lat" "$lon" "$SOURCE" "$MODE") + log "→ $name ($lat, $lon) payload=$payload" + resp=$(curl -s -X POST "$API/api/v1/admin/scrape" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + -m 300 || echo '{"error":"curl_timeout"}') + log "← $name $resp" + # Парсим total_fetched — если 0, инкремент streak; иначе reset + fetched=$(echo "$resp" | grep -oE '"total_fetched":[0-9]+' | grep -oE '[0-9]+' || echo 0) + if [[ "${fetched:-0}" == "0" ]]; then + zero_fetch_streak=$((zero_fetch_streak + 1)) + else + zero_fetch_streak=0 + fi + sleep 5 +done + +log "=== cron-scrape done ===" diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml new file mode 100644 index 00000000..c3da88f5 --- /dev/null +++ b/tradein-mvp/docker-compose.prod.yml @@ -0,0 +1,75 @@ +# Trade-In MVP — production overlay для деплоя ВНУТРИ gendesign-стека. +# +# Запускается из /opt/gendesign/tradein-mvp/ на bot-server: +# docker compose -p gendesign \ +# -f docker-compose.yml -f docker-compose.prod.yml up -d +# +# Особенности vs локального docker-compose.yml: +# - НЕТ своего Caddy и postgres-данных в bind mount +# (postgres контейнер один на всю машину, но изолированная БД tradein_mvp) +# - Образы тянутся из GHCR (build делает GitHub Actions) +# - Backend/frontend подключены к network gendesign_shared чтобы основной +# Caddy мог проксировать /trade-in/* → tradein-frontend +# - Frontend строится с basePath=/trade-in (см. next.config.ts) + +services: + postgres: + image: postgis/postgis:16-3.4 + container_name: tradein-postgres + environment: + POSTGRES_DB: tradein + POSTGRES_USER: ${TRADEIN_POSTGRES_USER:-tradein} + POSTGRES_PASSWORD: ${TRADEIN_POSTGRES_PASSWORD:?required} + volumes: + - tradein-postgres-data:/var/lib/postgresql/data + - ./backend/data/sql:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${TRADEIN_POSTGRES_USER:-tradein}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: [tradein-net] + + backend: + image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest} + container_name: tradein-backend + environment: + DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" + PUBLIC_URL: "https://gendsgn.ru/trade-in" + CORS_ORIGINS: '["https://gendsgn.ru"]' + ENVIRONMENT: "production" + CONTACT_EMAIL: "${TRADEIN_CONTACT_EMAIL:-tradein@gendsgn.ru}" + YANDEX_GEOCODER_KEY: "${YANDEX_GEOCODER_KEY:-}" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + networks: + - tradein-net + - gendesign_shared # чтобы Caddy gendesign-стека достучался + + frontend: + image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest} + container_name: tradein-frontend + environment: + NODE_ENV: production + # Frontend сам префиксит /trade-in (basePath), а fetch к API делает + # на /trade-in/api/v1/... (см. NEXT_PUBLIC_API_BASE_URL=/trade-in) + NEXT_PUBLIC_API_BASE_URL: "" + BACKEND_URL: "http://backend:8000" # internal SSR + depends_on: [backend] + restart: unless-stopped + networks: + - tradein-net + - gendesign_shared + +volumes: + tradein-postgres-data: + name: tradein-postgres-data + +networks: + tradein-net: + name: tradein-net + gendesign_shared: + external: true diff --git a/tradein-mvp/docker-compose.yml b/tradein-mvp/docker-compose.yml new file mode 100644 index 00000000..86b42825 --- /dev/null +++ b/tradein-mvp/docker-compose.yml @@ -0,0 +1,88 @@ +# Trade-In MVP — локальный стек для разработки. +# +# Архитектура зеркалит прод gendesign (Caddy -> frontend / backend / postgres), +# но всё локально без TLS — открывать http://localhost:8080 +# +# Запуск: +# docker compose up -d --build +# +# Проверки: +# curl http://localhost:8080/health # backend health через Caddy +# open http://localhost:8080 # frontend (auto-redirect → /trade-in) +# open http://localhost:8080/preview/ # tradein.html mockup для сравнения + +name: tradein-mvp + +services: + postgres: + image: postgis/postgis:16-3.4-alpine + container_name: tradein-postgres + restart: unless-stopped + environment: + POSTGRES_USER: tradein + POSTGRES_PASSWORD: tradein + POSTGRES_DB: tradein + volumes: + - postgres-data:/var/lib/postgresql/data + # SQL init scripts: 001_trade_in_estimates.sql создаст таблицу при первом старте. + - ./backend/data/sql:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tradein -d tradein"] + interval: 5s + timeout: 3s + retries: 10 + ports: + - "127.0.0.1:5433:5432" # local port 5433, чтобы не конфликтовать с прод-туннелем 15432 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: tradein-backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql+psycopg://tradein:tradein@postgres:5432/tradein + CORS_ORIGINS: '["http://localhost:8080","http://localhost:3000"]' + ENVIRONMENT: dev + depends_on: + postgres: + condition: service_healthy + volumes: + # Hot-reload: монтируем app/ в runner, uvicorn --reload подхватит изменения. + - ./backend/app:/app/app:ro + ports: + - "127.0.0.1:8000:8000" # для прямого debug-доступа в обход Caddy + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: tradein-frontend + restart: unless-stopped + environment: + BACKEND_URL: http://backend:8000 + NODE_ENV: production + depends_on: + - backend + ports: + - "127.0.0.1:3000:3000" # для прямого debug-доступа в обход Caddy + + caddy: + image: caddy:2-alpine + container_name: tradein-caddy + restart: unless-stopped + ports: + - "127.0.0.1:8080:80" # главный entry point — http://localhost:8080 + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./frontend/public:/srv/preview:ro # для /preview/tradein.html mockup + - caddy-data:/data + - caddy-config:/config + depends_on: + - frontend + - backend + +volumes: + postgres-data: + caddy-data: + caddy-config: diff --git a/tradein-mvp/docs/BRUSNIKA_REFERENCE_EKB-2485.pdf b/tradein-mvp/docs/BRUSNIKA_REFERENCE_EKB-2485.pdf new file mode 100644 index 00000000..873c7bb6 Binary files /dev/null and b/tradein-mvp/docs/BRUSNIKA_REFERENCE_EKB-2485.pdf differ diff --git a/tradein-mvp/docs/PTITSA_MEETING_2026-05-19.pdf b/tradein-mvp/docs/PTITSA_MEETING_2026-05-19.pdf new file mode 100644 index 00000000..1fd72a55 Binary files /dev/null and b/tradein-mvp/docs/PTITSA_MEETING_2026-05-19.pdf differ diff --git a/tradein-mvp/docs/README.md b/tradein-mvp/docs/README.md new file mode 100644 index 00000000..73f8bbaf --- /dev/null +++ b/tradein-mvp/docs/README.md @@ -0,0 +1,35 @@ +# docs/ + +Референсы для Trade-In MVP. + +## `BRUSNIKA_REFERENCE_EKB-2485.pdf` + +4-страничный отчёт «Брусника.Обмен» от 12.05.2026, объект Софьи Перовской 119/368 (Екатеринбург). + +Это **эталон layout-а** который Максим хочет получить (см. протокол Птицы 0:42:04: *«главное, чтобы выходила околореалистичная картинка как у Брусники»*). Структура: + +| Стр. | Что | +|---|---| +| 1 | Hero — параметры объекта + два диапазона цен (объявления / сделки) + блок «что важно при оценке» | +| 2 | Объявления-аналоги — 47 шт., источники Я.Недвижимость + Циан + ДомКлик + Avito + Росреестр, диапазон + табличка top-5 | +| 3 | Фактические сделки — 12 шт. из Этажи + ДомКлик + Росреестр за период 10.2025-05.2026, вывод «сделки на 5-12% ниже объявлений» | +| 4 | Формирование выкупной стоимости — сравнение Брусника.Обмен vs самостоятельная продажа + 4 преимущества | + +PDF-генератор в `backend/app/services/exporters/trade_in_pdf.py` уже повторяет эту структуру через WeasyPrint. + +## `PTITSA_MEETING_2026-05-19.pdf` + +AI-протокол встречи Максима Басова и Антона/Алекса 19.05.2026, 21:12. Это **источник требований** для MVP. + +Топ важных моментов: +- Концепция: сбор данных по квартире → подсос картографии ЕКБ → парсинг 6 агрегаторов → PDF-отчёт с PDF-кнопкой +- MVP-scope: **только актуальные объявления + исторические за квартал**, без ДДУ +- Монетизация: ~1% с каждой подтверждённой сделки +- Срок MVP: **понедельник 25.05.2026**; демо: четверг 28.05.2026 +- Sales engineer обязательно (без него продукт не взлетит) +- Серверы арендуем, не покупаем +- Доступ только Геныч / Загайнов / Паша Глухов. **НЕ Рожкова.** + +## ROADMAP + +Связан с roadmap в основном [README](../README.md). Phase 1 — замена mock-формулы на реальный парсинг. diff --git a/tradein-mvp/docs/ROADMAP.md b/tradein-mvp/docs/ROADMAP.md new file mode 100644 index 00000000..93f53051 --- /dev/null +++ b/tradein-mvp/docs/ROADMAP.md @@ -0,0 +1,439 @@ +# Trade-In MVP — Roadmap «Максимум» + +> Решение 19.05 вечером: убираем привязку к срокам, делаем максимально хорошо. +> План структурирован по архитектурным слоям, не по дням. Катимся сверху-вниз — +> каждый следующий слой требует, чтобы предыдущий был стабилен. + +## Видение продукта + +**Что есть у Брусники:** 4-стр PDF с медианой цен, диапазоном, аналогами и сравнением «их обмен vs самопродажа». Один white-label (Брусника). Один продукт. + +**Что мы делаем поверх:** + +1. **Парсим больше источников** (6 vs ~5 у Брусники): Авито, Циан, Дом.Клик, Я.Недвижимость, Н1, Дом.РФ +2. **Кликабельные ссылки** на каждый аналог — Брусника не даёт +3. **Real-time freshness** — индикатор «обновлено N минут назад» на каждом источнике +4. **Объяснимый confidence** — `high (12 аналогов, разброс ±8%)` вместо «качество данных может быть не очень» +5. **POI окружение** — школы / метро / магазины с decay по расстоянию (переиспользуем код gendesign Site Finder) +6. **Тренд цен** — график за 6/24 мес. в этом районе +7. **Map view** — карта с точками аналогов + heatmap +8. **Multi-tenant white-label** — PRINZIP / Практика / любой бренд через query param +9. **JSON API + iframe widget** — для интеграции в чужие CRM +10. **Похожие сделки в том же доме** — точные совпадения если есть +11. **AI narrative summary** — короткое описание квартиры от LLM +12. **Прогноз срока продажи** — median days_on_market по выборке +13. **Score breakdown explainability** — почему именно эта цена (как Site Finder) +14. **Photo upload** на cover PDF +15. **Shareable URL** + QR-код в PDF + +--- + +## Слой 1 — Core data layer (фундамент) + +Без него ничего не работает. Каждый последующий слой строится поверх. + +### 1.1 Postgres schema +- `listings` — объявления (id, source, source_url, lat, lon, address, rooms, area, floor, total_floors, year_built, house_type, repair_state, price, price_per_m2, photo_urls jsonb, listing_date, scraped_at, dedup_hash, raw_payload jsonb, geom GEOMETRY(Point, 4326)) +- `deals` — фактические сделки (тот же набор + deal_date, source = 'rosreestr' пока) +- `geocode_cache` — address → lat/lon кэш (избегаем повторных Yandex hits) +- `audit_log` — каждый POST /estimate (ip, user_agent, input, estimate_id, ts) +- `house_metadata` — кэш данных ЕКБ Геопортала по координатам/кадастру (этажи, год, материал, кол-во квартир) +- Индексы: GIST на geom, btree на (rooms, scraped_at), unique на dedup_hash + +### 1.2 Geocoder + Suggest +- `app/services/geocoder.py` — Yandex Geocoder через httpx + кэш в `geocode_cache` +- `app/services/suggest.py` — Yandex Suggest для frontend autocomplete (proxy через backend, не светим API key в браузере) +- Fallback к Nominatim если Яндекс упал / лимит + +### 1.3 Scraper framework +- `app/services/scrapers/base.py` — общий BaseScraper +- Retry через `tenacity` (3 попытки, exp backoff) +- Rotation: пул из 5-10 User-Agent + Accept-Language +- Sleep 5-10 sec между запросами (без бана) +- Поддержка Playwright если scrapy не справляется (Циан иногда JS-render требует) +- Дедупликация: `dedup_hash = sha256(source + source_url + price)` уникальный +- Каждый scraper возвращает list[ScrapedLot] — единая Pydantic схема + +--- + +## Слой 2 — Парсеры источников + +### 2.1 Авито (приоритет 1 — стартуем с него) +- URL: `/ekaterinburg/kvartiry/prodam?radius=N&geoCoords=lat,lon&list_view=1` +- Парс HTML через `selectolax` (быстрее BeautifulSoup) +- Поля: address, rooms, area, floor/total_floors, price, listing_date, photo, deeplink +- Edge case: «частный риэлтор» vs «агентство» — фильтруем спам + +### 2.2 Циан ✅ ДЕПЛОЕН +- URL: `https://ekaterinburg.cian.ru/cat.php?...` +- ВАЖНО: Циан банит httpx по TLS fingerprint → `curl_cffi(impersonate="chrome120")` +- 2025+ верстка БЕЗ `__NEXT_DATA__` — парсим DOM по `data-mark` атрибутам: + `OfferTitle`, `MainPrice`, `Description`; адрес из множественных `data-name="GeoLabel"` +- Multi-room scrape: `fetch_around_multi_room()` по rooms=1/2/3/4 → ~108 unique lots +- Cron каждый час: `45 * * * * /opt/tradein-mvp/deploy/cron-scrape.sh cian fast` + +### 2.3 Росреестр сделки +- Дамп из prod gendesign `rosreestr_deals` (6.83M строк, partitioned по кварталам) +- SSH туннель → `pg_dump --table rosreestr_deals --where "region_code = 66"` → restore локально +- Регулярный refresh: weekly Celery task + +### 2.4 Дом.Клик +- API: `https://api.domclick.ru/...` (публичный, без auth, JSON) +- Поля: те же + ипотечные опции (мы пока игнорим) + +### 2.5 Я.Недвижимость ✅ ДЕПЛОЕН +- URL: `https://realty.yandex.ru/ekaterinburg/kupit/kvartira/?rgid=240970&offerType=SELL` +- State в `