feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in)

- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest

Первый запуск на сервере (вручную, один раз):
  - создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
  - docker network create gendesign_shared (если нет)
  - docker compose -p gendesign-tradein up -d
  - docker compose -p gendesign exec caddy caddy reload
This commit is contained in:
lekss361 2026-05-21 00:25:39 +03:00
parent 8b9388132b
commit 02267d0306
73 changed files with 10087 additions and 0 deletions

View file

@ -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

View file

@ -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
}

17
tradein-mvp/.env.example Normal file
View file

@ -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

50
tradein-mvp/.gitignore vendored Normal file
View file

@ -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

147
tradein-mvp/DEPLOY.md Normal file
View file

@ -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://<PAT>@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 <<EOF
TRADEIN_POSTGRES_USER=tradein
TRADEIN_POSTGRES_PASSWORD=$(openssl rand -hex 32)
TRADEIN_CONTACT_EMAIL=tradein@gendsgn.ru
YANDEX_GEOCODER_KEY=
EOF
chmod 600 tradein-mvp/.env.runtime
# Запустить stack первый раз вручную (после этого GitHub Actions сам)
docker network inspect gendesign_shared >/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=<previous-sha> docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d
```

63
tradein-mvp/Makefile Normal file
View file

@ -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 данные стёрты."

196
tradein-mvp/README.md Normal file
View file

@ -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 документация: <http://localhost:8000/docs>
Для сравнения макет vs реальная фича:
- макет: <http://localhost:8080/preview/tradein.html>
- фича: <http://localhost:8080/trade-in>
## Структура
```
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 | <https://gendsgn.ru/trade-in> | <http://localhost:8080/trade-in> |
| Backend | shared FastAPI `/api/v1/trade-in/*` | то же самое, standalone |
| Frontend | Next.js 15 в большом monorepo | тот же код, standalone |
| Postgres | 84 таблицы, 6.83M ДДУ partitioned | только `trade_in_estimates` (1 таблица) |
| Caddy | TLS + 5 доменов + reverse-proxy | local :8080 без TLS |
| Что отрезано | site-finder, analytics, generative, scraper, OSM, NSPD, sentry, celery, redis, playwright | всё это — кроме trade-in |
**Важно: эти два инстанса полностью изолированы.** Локальный backend пишет в свой Postgres контейнер (порт 5433), не трогает прод. Можно сломать локально что угодно — прод не пострадает.
## Лицензия
Internal use only. Forked from gendesign monorepo (private).

View file

@ -0,0 +1,13 @@
.venv
__pycache__
**/__pycache__
*.egg-info
.pytest_cache
.ruff_cache
.mypy_cache
.git
.env
.env.local
data/sql
Dockerfile
.dockerignore

View file

@ -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"]

View file

View file

View file

@ -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}

View file

@ -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)

View file

@ -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
])

View file

@ -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}"'},
)

View file

View file

@ -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()

View file

@ -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()

View file

@ -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"])

View file

@ -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 # сколько минут назад был самый свежий парсинг

View file

@ -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,
)

View file

@ -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 n10 AND IQR/median < 0.15
medium n5 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),
)

View file

@ -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'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="0 0 {width} {height}" preserveAspectRatio="none">'
f'<rect x="0.5" y="0.5" width="{width-1}" height="{height-1}" '
f'fill="#ffffff" stroke="#9ca3af" stroke-width="1"/>'
f'<rect x="{x1}" y="1" width="{x2-x1}" height="{height-2}" fill="#dc2626"/>'
f'</svg>'
)
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"<span style='display:inline-block;padding:3pt 8pt;background:{bg};color:{fg};"
f"font-size:8pt;font-weight:700;border-radius:3pt;margin-right:4pt;"
f"vertical-align:middle;'>{_html.escape(name)}</span>"
)
def _source_badge_inline(source: str | None) -> str:
"""Маленький source badge для table cells (без фона)."""
if not source:
return "<span style='color:#9ca3af;'>—</span>"
bg, fg = _SOURCE_LOGO_COLORS.get(source, ("#6b7280", "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
return (
f"<span style='display:inline-block;padding:1pt 4pt;background:{bg};color:{fg};"
f"font-size:6.5pt;font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
)
# ── 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'<table style="width:100%;margin-top:4pt;">'
f'<tr>'
f'<td style="width:20%;"></td>'
f'<td style="width:60%;text-align:left;font-size:9pt;color:#dc2626;font-weight:700;">'
f'{days_min} дней'
f'</td>'
f'<td style="width:20%;text-align:right;font-size:9pt;color:#dc2626;font-weight:700;">'
f'{days_max} дней'
f'</td>'
f'</tr></table>'
)
# Ц-таблица: 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'<tr><td></td>'
f'<td style="text-align:left;font-size:8pt;color:#dc2626;font-weight:700;padding-top:3pt;">'
f'{days_min} дней</td>'
f'<td style="text-align:right;font-size:8pt;color:#dc2626;font-weight:700;padding-top:3pt;">'
f'{days_max} дней</td>'
f'<td></td></tr>'
)
return (
f'<table style="width:100%;border-collapse:collapse;margin:6pt 0;">'
f'<tr><td colspan="4" style="text-align:center;font-size:9pt;color:#6b7280;padding-bottom:3pt;">'
f'{_html.escape(sub_label)}'
f'</td></tr>'
f'<tr style="line-height:0;">'
f'<td style="width:1%;border:1pt solid #9ca3af;border-right:none;'
f'background:#ffffff;padding:6pt 0;"></td>'
f'<td style="width:60%;border-top:1pt solid #9ca3af;border-bottom:1pt solid #9ca3af;'
f'background:#dc2626;padding:6pt 0;"></td>'
f'<td style="width:1%;border:1pt solid #9ca3af;border-left:none;'
f'background:#ffffff;padding:6pt 0;"></td>'
f'<td style="width:38%;background:transparent;padding:6pt 0;"></td>'
f'</tr>'
f'<tr><td colspan="2" style="text-align:left;font-size:10pt;font-weight:700;color:#374151;padding-top:3pt;">'
f'{_html.escape(label_left)}</td>'
f'<td colspan="2" style="text-align:right;font-size:10pt;font-weight:700;color:#374151;padding-top:3pt;">'
f'{_html.escape(label_right)}</td></tr>'
f'{days_row}'
f'</table>'
)
# ── 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"""
<div style="page-break-after:always;">
<!-- Top brand bar компактный -->
<div style="background:{brand.primary_color};color:#fff;padding:8pt 14pt;margin-bottom:8pt;
font-size:12pt;font-weight:900;letter-spacing:0.06em;">
{_html.escape(brand.name).upper()}
</div>
<h1 style="font-size:12pt;font-weight:700;margin:4pt 0 8pt 0;text-transform:uppercase;
letter-spacing:0.02em;line-height:1.25;">
АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ
</h1>
<!-- Параметры квартиры table как у Брусники -->
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
<tr><td> отчета</td><td class="bold">{report_num}</td></tr>
<tr><td>Дата отчета</td><td class="bold">{today.strftime("%d.%m.%Y")}</td></tr>
<tr><td>Срок действия данных</td><td class="bold">до {expires.strftime("%d.%m.%Y")}</td></tr>
<tr><td>Адрес</td><td class="bold">{address}</td></tr>
<tr><td>Год постройки</td><td class="bold">{year_built}</td></tr>
<tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
<tr><td>Этаж / этажность</td><td class="bold">{floor} / {total_floors}</td></tr>
<tr><td>Площадь</td><td class="bold">{area} м²</td></tr>
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
<tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
<tr><td>Балкон / лоджия</td><td class="bold">{balcony_label}</td></tr>
</table>
<p style="margin:8pt 0 4pt 0;font-size:10pt;font-weight:700;">
Диапазон цен в объявлениях
<span style="font-weight:400;font-size:9pt;color:#6b7280;">(без учета ремонта)</span>
</p>
{listings_bar}
<p style="margin:10pt 0 4pt 0;font-size:10pt;font-weight:700;">
Диапазон цен по фактическим сделкам
</p>
{deals_bar}
<!-- Что важно при оценке -->
<h3 style="margin-top:10pt;font-size:10pt;font-weight:700;">
Что важно при оценке стоимости квартиры:
</h3>
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:8pt;">
<tr>
<td class="advice-title">Цены в объявлениях ожидания собственников</td>
<td class="advice-text">Фактические сделки проходят на 512% ниже, что подтверждают Росреестр, ДомКлик и продажи агенств недвижимости</td>
</tr>
<tr>
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
<td class="advice-text">Инвестиции в 400 600 тыс. руб. повышают цену объекта всего на 150 250 тыс. руб покупатель оценивает общее состояние квартиры</td>
</tr>
<tr>
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
</tr>
</table>
<p style="margin-top:18pt;font-size:8pt;color:#6b7280;border-top:1pt solid #e6e8ec;padding-top:8pt;">
<strong>Этот отчёт онлайн:</strong> {settings.public_url}/trade-in?id={estimate.estimate_id}
</p>
</div>
"""
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"""
<div style="page-break-after:always;">
<div class="brand-bar-small" style="border-bottom:2pt solid {brand.primary_color};
padding:6pt 0;margin-bottom:12pt;">
<span style="font-size:11pt;font-weight:900;letter-spacing:0.06em;color:{brand.primary_color};">
{_html.escape(brand.name).upper()}
</span>
</div>
<h2 style="font-size:13pt;font-weight:700;text-transform:uppercase;letter-spacing:0.02em;
border-bottom:1.5pt solid #e6e8ec;padding-bottom:4pt;margin-bottom:12pt;">
РЫНОК КВАРТИР АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ
</h2>
<!-- Два колонки: левая (counts + источники), правая (params) -->
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
<tr>
<td style="width:55%;vertical-align:top;padding-right:14pt;">
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам</td>
<td class="bold" style="text-align:right;">{n_total} шт.</td></tr>
<tr><td style="padding:4pt 0;">Количество объявлений по аналогичным объектам<br>
<span style="font-size:8pt;color:#6b7280;">(с учётом ремонта)</span></td>
<td class="bold" style="text-align:right;">{n_with_repair} шт.</td></tr>
</table>
<div style="margin-top:14pt;font-size:9pt;color:#6b7280;">Источники данных</div>
<div style="margin-top:6pt;">{sources_html}</div>
</td>
<td style="width:45%;vertical-align:top;background:#f9fafb;padding:10pt;border-radius:4pt;">
<table class="search-params" style="width:100%;border-collapse:collapse;">
<tr><td>Расположение</td><td class="bold">± 1 км от локации дома</td></tr>
<tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
<tr><td>Год постройки</td><td class="bold">{year_range}</td></tr>
<tr><td>Диапазон площади</td><td class="bold">{area_min} - {area_max} м²</td></tr>
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
<tr><td>Состояние ремонта</td><td class="bold">{repair_label}</td></tr>
</table>
</td>
</tr>
</table>
<p style="margin:8pt 0 4pt 0;font-size:10pt;font-weight:700;">Диапазон цен в объявлениях</p>
{range_bar}
<!-- Примеры аналогов -->
<h3 style="margin-top:16pt;font-size:11pt;font-weight:700;">
Примеры аналогичных объектов с учётом ремонта
</h3>
<table class="examples-table" style="width:100%;border-collapse:collapse;margin-top:6pt;">
<thead>
<tr style="border-bottom:2pt solid #e6e8ec;">
<th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Адрес квартиры</th>
<th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Источник</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Стоимость 1 м², руб.</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Стоимость объекта, руб.</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Срок экспозиции, дней</th>
</tr>
</thead>
<tbody>{examples_rows}</tbody>
</table>
<div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid #e6e8ec;
font-size:7pt;color:#9ca3af;">
{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2
</div>
</div>
"""
def _examples_rows(lots: list[AnalogLot]) -> str:
if not lots:
return "<tr><td colspan='5' class='empty' style='text-align:center;padding:14pt;color:#9ca3af;'>Нет данных</td></tr>"
rows = []
for lot in lots:
addr = _html.escape(lot.address)
if lot.source_url:
addr_cell = f"<a href='{_html.escape(lot.source_url)}' style='color:#1d4ed8;text-decoration:none;'>{addr} ↗</a>"
else:
addr_cell = addr
rows.append(
"<tr>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;'>{addr_cell}</td>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;'>{_source_badge_inline(lot.source)}</td>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;font-variant-numeric:tabular-nums;'>{_fmt_ppm2(lot.price_per_m2)}</td>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;font-variant-numeric:tabular-nums;'>{_fmt_ppm2(lot.price_rub)}</td>"
f"<td style='padding:7pt 4pt;border-bottom:1px solid #f3f4f6;text-align:right;font-variant-numeric:tabular-nums;'>{lot.days_on_market or ''}</td>"
"</tr>"
)
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"""
<div style="page-break-after:always;">
<div class="brand-bar-small" style="border-bottom:2pt solid {brand.primary_color};
padding:6pt 0;margin-bottom:12pt;">
<span style="font-size:11pt;font-weight:900;letter-spacing:0.06em;color:{brand.primary_color};">
{_html.escape(brand.name).upper()}
</span>
</div>
<h2 style="font-size:13pt;font-weight:700;text-transform:uppercase;letter-spacing:0.02em;
border-bottom:1.5pt solid #e6e8ec;padding-bottom:4pt;margin-bottom:12pt;">
ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ АНАЛОГАМ
</h2>
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
<tr>
<td style="width:55%;vertical-align:top;padding-right:14pt;">
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
<td class="bold" style="text-align:right;">{n_deals} шт.</td></tr>
<tr><td style="padding:4pt 0;">Период сделок</td>
<td class="bold" style="text-align:right;">{period_start.strftime("%m.%Y")} {today.strftime("%m.%Y")}</td></tr>
</table>
<div style="margin-top:14pt;font-size:9pt;color:#6b7280;">Источники данных</div>
<div style="margin-top:6pt;">{sources_html}</div>
</td>
<td style="width:45%;vertical-align:top;background:#f9fafb;padding:10pt;border-radius:4pt;">
<table class="search-params" style="width:100%;border-collapse:collapse;">
<tr><td>Расположение</td><td class="bold">± 1 км от локации дома</td></tr>
<tr><td>Тип дома</td><td class="bold">{house_label}</td></tr>
<tr><td>Год постройки</td><td class="bold">{year_range}</td></tr>
<tr><td>Диапазон площади</td><td class="bold">{area_min} - {area_max} м²</td></tr>
<tr><td>Планировка</td><td class="bold">{rooms_label}</td></tr>
</table>
</td>
</tr>
</table>
<p style="margin:8pt 0 4pt 0;font-size:10pt;font-weight:700;">Диапазон цен по фактическим сделкам</p>
{range_bar}
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid #dc2626;
background:#fff5f5;font-size:10pt;color:#7f1d1d;font-weight:600;">
По данным реальных сделок, квартиры продаются в среднем на 512 % дешевле,
чем заявлено в объявлениях
</div>
<h3 style="margin-top:16pt;font-size:11pt;font-weight:700;">
Примеры аналогичных объектов
</h3>
<table class="examples-table" style="width:100%;border-collapse:collapse;margin-top:6pt;">
<thead>
<tr style="border-bottom:2pt solid #e6e8ec;">
<th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Адрес квартиры</th>
<th style="text-align:left;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Источник</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Стоимость 1 м², руб.</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Стоимость объекта, руб.</th>
<th style="text-align:right;padding:6pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">Срок экспозиции, дней</th>
</tr>
</thead>
<tbody>{examples_rows}</tbody>
</table>
<div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid #e6e8ec;
font-size:7pt;color:#9ca3af;">
{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3
</div>
</div>
"""
# ── 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"""
<div><!-- last page: NO page-break-after -->
<div class="brand-bar-small" style="border-bottom:2pt solid {brand.primary_color};
padding:6pt 0;margin-bottom:12pt;">
<span style="font-size:11pt;font-weight:900;letter-spacing:0.06em;color:{brand.primary_color};">
{brand_short.upper()}
</span>
</div>
<h2 style="font-size:13pt;font-weight:700;text-transform:uppercase;letter-spacing:0.02em;
border-bottom:1.5pt solid #e6e8ec;padding-bottom:4pt;margin-bottom:14pt;">
ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ
</h2>
<h3 style="font-size:11pt;font-weight:700;margin-bottom:8pt;">Структура стоимости сделки</h3>
<table class="offer-table" style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2pt solid #e6e8ec;">
<th style="text-align:left;padding:8pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;"></th>
<th style="text-align:right;padding:8pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">
Продажа через<br>{trade_in_label}
</th>
<th style="text-align:right;padding:8pt 4pt;font-size:9pt;color:#6b7280;font-weight:700;">
Самостоятельная продажа, руб.
</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:6pt 4pt;">
<div class="bold">Торг потенциальных покупателей</div>
<div style="font-size:8pt;color:#6b7280;">при стоимости квартиры в объявлении {_fmt_rub(listing_price)}</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#6b7280;">Не применимо</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;color:#dc2626;">
от {_fmt_rub(torg_low)} {_fmt_rub(torg_high)}<br>
<span style="font-size:8pt;color:#6b7280;font-weight:400;">от {torg_pct_low}{torg_pct_high}%</span>
</td>
</tr>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:6pt 4pt;">
<div class="bold">Услуги риэлтора при продаже квартиры</div>
<div style="font-size:8pt;color:#6b7280;">с минимальным торгом за {_fmt_rub(sold_price)}</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
{_fmt_rub(rieltor_low)} - {_fmt_rub(rieltor_high)}<br>
<span style="font-size:8pt;color:#6b7280;font-weight:400;">от {rieltor_pct_low}{rieltor_pct_high}%</span>
</td>
</tr>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:6pt 4pt;">
<div class="bold">Аренда после сделки</div>
<div style="font-size:8pt;color:#6b7280;">однокомнатной квартиры на 3 месяца</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
{_fmt_rub(rent_low)} - {_fmt_rub(rent_high)}
</td>
</tr>
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:6pt 4pt;">
<div class="bold">Юридическое сопровождение</div>
<div style="font-size:8pt;color:#6b7280;">Проверка документов и подготовка договоров</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">от {_fmt_rub(juridical_low)}</td>
</tr>
<tr style="border-bottom:2pt solid #e6e8ec;">
<td style="padding:6pt 4pt;">
<div class="bold">Расходы на рекламу</div>
<div style="font-size:8pt;color:#6b7280;">Ежемесячное базовой продвижение объекта на Циан, Авито, Я.Недвижимости</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:#15803d;font-weight:700;">бесплатно</td>
<td style="padding:6pt 4pt;text-align:right;font-weight:700;">
от {_fmt_rub(ads_low)} - {_fmt_rub(ads_high)}<br>
<span style="font-size:8pt;color:#6b7280;font-weight:400;">за 3 месяца</span>
</td>
</tr>
<tr style="background:#fef3c7;">
<td style="padding:8pt 4pt;font-size:11pt;font-weight:700;">Общие финансовые потери</td>
<td style="padding:8pt 4pt;text-align:right;"></td>
<td style="padding:8pt 4pt;text-align:right;font-size:11pt;font-weight:700;color:#7f1d1d;">
от {_fmt_rub(total_low)} - {_fmt_rub(total_high)}
</td>
</tr>
</tbody>
</table>
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid #dc2626;
background:#fff5f5;font-size:10pt;color:#7f1d1d;font-weight:600;">
Издержки при обмене сопоставимы с личными расходами собственника, которые возникают при
самостоятельной продаже квартиры
</div>
<h3 style="margin-top:18pt;font-size:11pt;font-weight:700;">Преимущества</h3>
<div class="advantages" style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;
gap:8pt;margin-top:8pt;">
<div class="advantage-item">
<div class="adv-ic">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>
</svg>
</div>
<div class="adv-title">Экономия времени</div>
<div class="adv-desc">Берём на себя показы, переговоры и поиск покупателей</div>
</div>
<div class="advantage-item">
<div class="adv-ic">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<div class="adv-title">Юридическая безопасность</div>
<div class="adv-desc">Проверяем документы, исключаем риски, сопровождаем на всех этапах</div>
</div>
<div class="advantage-item">
<div class="adv-ic">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/>
</svg>
</div>
<div class="adv-title">Фиксированная стоимость новостройки</div>
<div class="adv-desc">Сохраняем цену выбранной планировки в {brand_short}</div>
</div>
<div class="advantage-item">
<div class="adv-ic">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>
</svg>
</div>
<div class="adv-title">Гарантия цены</div>
<div class="adv-desc">Снимаем угрозу колебаний рынка и удерживаем договорную стоимость</div>
</div>
</div>
<div class="footer" style="margin-top:20pt;padding-top:6pt;border-top:1px solid #e6e8ec;
font-size:7pt;color:#9ca3af;">
{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 4 · Расчёт носит ориентировочный характер и не является офертой.
</div>
</div>
"""
# ── 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 = (
'<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8">'
f'<title>Trade-In — {_html.escape(input_snapshot.get("address", ""))}</title>'
"</head><body>"
+ _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)
+ "</body></html>"
)
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

View file

@ -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

View file

@ -0,0 +1,409 @@
"""Avito.ru scraper — парсинг вторички вокруг точки.
Стратегия: HTML scrape карточек объявлений + извлечение JSON `__initialState__`
который Avito вставляет в `<script>` тег.
URL pattern (EKB):
https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ
?geoCoords=56.838,60.605
&radius=1 # в км
&s=104 # sort by date
&p=1 # страница
ВАЖНО: Avito банит httpx (403/429) по TLS fingerprint от server IP.
Используем curl_cffi с impersonate='chrome120' настоящий Chrome TLS ClientHello.
"""
from __future__ import annotations
import json
import logging
import random
import re
from datetime import date, datetime
from typing import Any
from urllib.parse import urlencode, urljoin
from curl_cffi.requests import AsyncSession
from selectolax.parser import HTMLParser
from app.services.scrapers.base import BaseScraper, ScrapedLot
logger = logging.getLogger(__name__)
class AvitoScraper(BaseScraper):
"""Avito vtorichka parser. Источник = 'avito'.
Использует curl_cffi с impersonate=chrome120 для обхода TLS fingerprint бана.
"""
name = "avito"
base_url = "https://www.avito.ru"
# Avito жёстко мониторит — спим долго между запросами
request_delay_sec = 7.0
def __init__(self) -> None:
super().__init__()
self._cffi: AsyncSession | None = None
async def __aenter__(self) -> "AvitoScraper":
await super().__aenter__()
self._cffi = AsyncSession(
impersonate="chrome120",
timeout=25,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Cache-Control": "max-age=0",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
},
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._cffi is not None:
await self._cffi.close()
await super().__aexit__(*args)
# ── Public ──────────────────────────────────────────────────────────────
async def fetch_around(
self, lat: float, lon: float, radius_m: int = 1000
) -> list[ScrapedLot]:
"""Найти объявления Авито вокруг (lat, lon) в radius_m метрах.
Avito работает в км конвертируем. Минимум 1 км.
Возвращаем макс 50 объявлений (страница 1).
"""
radius_km = max(1, round(radius_m / 1000))
# Сохраняем центр для fallback заполнения coords (Avito HTML state не даёт coords)
self._anchor_lat = lat
self._anchor_lon = lon
self._anchor_radius_m = radius_m
# Strategy A: попробовать через mobile API (проще, меньше защиты)
try:
lots = await self._fetch_via_mobile_api(lat, lon, radius_km)
if lots:
logger.info("avito mobile_api: %d lots around (%.4f, %.4f)", len(lots), lat, lon)
return lots
except Exception as e: # noqa: BLE001
logger.warning("avito mobile_api failed: %s — falling back to HTML scrape", e)
# Strategy B: HTML scrape (web) через curl_cffi
url = self._build_web_url(lat, lon, radius_km, page=1)
try:
assert self._cffi is not None
response = await self._cffi.get(url)
if response.status_code != 200:
logger.warning(
"avito HTML returned %d for %s", response.status_code, url
)
return []
except Exception: # noqa: BLE001
logger.exception("avito HTML fetch failed for %s", url)
return []
lots = self._parse_html(response.text, source_url_base=url)
logger.info("avito html_scrape: %d lots around (%.4f, %.4f)", len(lots), lat, lon)
await self.sleep_between_requests()
return lots
# ── Strategy A: mobile API ──────────────────────────────────────────────
async def _fetch_via_mobile_api(
self, lat: float, lon: float, radius_km: int
) -> list[ScrapedLot]:
"""Avito mobile API endpoint: https://m.avito.ru/api/15/items
Возвращает JSON с полным structured data проще, чем HTML scrape.
Может быть закрыт за auth обрабатываем в except выше.
"""
params = {
"categoryId": 24, # квартиры
"locationId": 653240, # ЕКБ (Avito internal)
"params[201]": 1, # тип: продам (вторичка)
"geoCoords": f"{lat},{lon}",
"radius": radius_km,
"page": 1,
"limit": 50,
"sort": "date",
}
url = f"https://m.avito.ru/api/15/items?{urlencode(params)}"
assert self._cffi is not None
response = await self._cffi.get(url, headers={"Accept": "application/json"})
if response.status_code != 200:
return []
data = response.json()
items = data.get("result", {}).get("items", []) if isinstance(data, dict) else []
return [self._mobile_item_to_lot(item) for item in items if isinstance(item, dict)]
def _mobile_item_to_lot(self, item: dict[str, Any]) -> ScrapedLot | None:
"""Маппинг Avito mobile API item → ScrapedLot.
Avito mobile API meanwhile отдаёт title с метаданными квартиры, но
address/coords могут быть в разных полях пробуем все.
"""
try:
title = item.get("title", "")
# price может быть int или dict {value: N}
price_field = item.get("price")
if isinstance(price_field, dict):
price = price_field.get("value")
else:
price = price_field
if not price:
return None
rooms = _extract_rooms_from_title(title)
area = _extract_area_from_title(title)
floor, total = _extract_floor_from_title(title)
# Address — пробуем все возможные пути
address = (
item.get("address")
or item.get("location", {}).get("name") if isinstance(item.get("location"), dict) else ""
)
if not address and isinstance(item.get("geo"), dict):
address = item["geo"].get("formatted_address") or item["geo"].get("address", "")
if not address:
# Avito mobile API часто отдаёт address в "location.references" или "geo.geoReferences"
geo = item.get("geo") or {}
if isinstance(geo.get("geoReferences"), list) and geo["geoReferences"]:
address = geo["geoReferences"][0].get("content", "")
# Coords — тоже пробуем все варианты
coords = item.get("coords") or item.get("geo", {}).get("coords") or {}
lat = coords.get("lat") if isinstance(coords, dict) else None
lon = coords.get("lng") or coords.get("lon") if isinstance(coords, dict) else None
# Photos
photos = []
for img in (item.get("images") or [])[:5]:
if isinstance(img, dict):
photos.append(img.get("1280x960") or img.get("636x476") or img.get("864x648", ""))
elif isinstance(img, str):
photos.append(img)
return ScrapedLot(
source="avito",
source_url=urljoin("https://www.avito.ru", item.get("uri", "")),
source_id=str(item.get("id")),
address=address or None,
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total,
price_rub=int(price),
photo_urls=[p for p in photos if p],
raw_payload=item, # сохраняем ВСЁ для retrofit и дебага
)
except Exception: # noqa: BLE001
logger.exception("avito mobile item parse failed: %s", item.get("id"))
return None
# ── Strategy B: HTML scrape ─────────────────────────────────────────────
def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str:
params = {
"geoCoords": f"{lat},{lon}",
"radius": radius_km,
"s": 104, # sort by date
"p": page,
}
return (
f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}"
)
def _parse_html(self, html: str, source_url_base: str) -> list[ScrapedLot]:
"""Парсим карточки объявлений из HTML.
Avito embed-ит JSON в `<script>` теге с initial state пробуем найти.
Если структура поменялась fallback на DOM scrape карточек.
"""
# Попробуем сначала __initialState__ JSON
state = _extract_avito_state(html)
if state:
items = _items_from_state(state)
if items:
return [
lot
for lot in (self._state_item_to_lot(it) for it in items)
if lot is not None
]
# Fallback: DOM scrape
tree = HTMLParser(html)
cards = tree.css('[data-marker="item"]')
lots: list[ScrapedLot] = []
for card in cards:
lot = self._dom_card_to_lot(card, source_url_base)
if lot is not None:
lots.append(lot)
return lots
def _state_item_to_lot(self, item: dict[str, Any]) -> ScrapedLot | None:
"""Парсинг из __initialState__ JSON. Avito отдаёт минимум полей в нём
(часто только title) fallback на anchor координаты с jitter."""
try:
price = item.get("priceDetailed", {}).get("value") or item.get("price")
if not price:
return None
title = item.get("title", "")
rooms = _extract_rooms_from_title(title)
area = _extract_area_from_title(title)
floor, total = _extract_floor_from_title(title)
coords = item.get("coords") or {}
location = item.get("location", {}) if isinstance(item.get("location"), dict) else {}
lat = coords.get("lat") if isinstance(coords, dict) else None
lon = coords.get("lng") if isinstance(coords, dict) else None
# Fallback: если Avito state не даёт coords — используем anchor с jitter.
# Это приближение, но позволяет PostGIS ST_DWithin работать в правильном радиусе.
if (lat is None or lon is None) and hasattr(self, "_anchor_lat"):
# Jitter в радиусе scrape — chunk радиуса в координатах (1° lat ≈ 111км)
jitter_deg = (self._anchor_radius_m / 1000) / 111 # км → градусы
lat = self._anchor_lat + (random.random() - 0.5) * jitter_deg
lon = self._anchor_lon + (random.random() - 0.5) * jitter_deg / 2 # коррекция по широте
return ScrapedLot(
source="avito",
source_url=urljoin("https://www.avito.ru", item.get("urlPath", "")),
source_id=str(item.get("id")),
address=location.get("name") or item.get("address") or "Екатеринбург (Avito)",
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total,
price_rub=int(price),
photo_urls=[
img.get("1280x960") or img.get("636x476", "")
for img in item.get("images", [])[:5]
if isinstance(img, dict)
],
raw_payload=item,
)
except Exception: # noqa: BLE001
return None
def _dom_card_to_lot(self, card: Any, source_url_base: str) -> ScrapedLot | None:
"""Парсинг через DOM (fallback). Avito state часто пустой — items падают сюда."""
try:
link_el = card.css_first('a[data-marker="item-title"]')
if link_el is None:
return None
href = link_el.attributes.get("href", "")
url = urljoin("https://www.avito.ru", href)
title = link_el.text(strip=True)
price_el = card.css_first('meta[itemprop="price"]')
price = int(price_el.attributes.get("content", 0)) if price_el is not None else 0
if price <= 0:
return None
rooms = _extract_rooms_from_title(title)
area = _extract_area_from_title(title)
floor, total = _extract_floor_from_title(title)
addr_el = card.css_first('[class*="geo-address"]')
address = addr_el.text(strip=True) if addr_el is not None else None
# Anchor jitter — Avito DOM не отдаёт точные coords, используем центр scrape ±jitter
lat = lon = None
if hasattr(self, "_anchor_lat"):
jitter_deg = (self._anchor_radius_m / 1000) / 111
lat = self._anchor_lat + (random.random() - 0.5) * jitter_deg
lon = self._anchor_lon + (random.random() - 0.5) * jitter_deg / 2
return ScrapedLot(
source="avito",
source_url=url,
source_id=card.attributes.get("data-item-id"),
address=address or "Екатеринбург (Avito)",
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total,
price_rub=price,
raw_payload={"title": title},
)
except Exception: # noqa: BLE001
return None
# ── Helpers: title parser ───────────────────────────────────────────────────
_RE_ROOMS = re.compile(r"(\d)-к\.?\s*(квартира|кв\.)", re.IGNORECASE)
_RE_STUDIO = re.compile(r"студи[яиюей]", re.IGNORECASE)
_RE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м[²2]", re.IGNORECASE)
_RE_FLOOR = re.compile(r"(\d+)\s*/\s*(\d+)\s*эт\.?", re.IGNORECASE)
def _extract_rooms_from_title(title: str) -> int | None:
if _RE_STUDIO.search(title):
return 0
m = _RE_ROOMS.search(title)
if m:
return int(m.group(1))
return None
def _extract_area_from_title(title: str) -> float | None:
m = _RE_AREA.search(title)
if m:
return float(m.group(1).replace(",", "."))
return None
def _extract_floor_from_title(title: str) -> tuple[int | None, int | None]:
m = _RE_FLOOR.search(title)
if m:
return int(m.group(1)), int(m.group(2))
return None, None
# ── Avito __initialState__ extraction ──────────────────────────────────────
_RE_AVITO_STATE = re.compile(
r"window\.__initialState__\s*=\s*\"([^\"]+)\"", re.DOTALL
)
def _extract_avito_state(html: str) -> dict[str, Any] | None:
"""Avito embed-ит initial state как escaped JSON в большом <script>."""
m = _RE_AVITO_STATE.search(html)
if not m:
return None
try:
raw = m.group(1).encode("utf-8").decode("unicode_escape")
return json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError):
return None
def _items_from_state(state: dict[str, Any]) -> list[dict[str, Any]]:
"""Вытаскиваем items из state — структура у Avito гуляет, ищем рекурсивно."""
# типичный путь: state.items.catalog или state.search.results
for path in [
("items", "catalog"),
("search", "results"),
("catalog", "items"),
("singleCard", "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 []

View file

@ -0,0 +1,271 @@
"""Базовый framework для парсеров объявлений (Avito / Cian / DomKlik / ...).
Общие задачи:
- httpx.AsyncClient с realistic browser headers + UA rotation
- tenacity retry с exp backoff
- Sleep между запросами (anti-ban)
- ScrapedLot единая Pydantic схема всех источников
- Запись в `listings` Postgres с дедупом по dedup_hash
Каждый конкретный парсер (avito.py, cian.py, ...) наследуется от BaseScraper
и реализует `_fetch_page()` и `_parse_listings()`.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import random
from abc import ABC, abstractmethod
from datetime import date
from typing import Any
import httpx
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from tenacity import retry, stop_after_attempt, wait_exponential
logger = logging.getLogger(__name__)
# ── Realistic browser User-Agents (Apr 2026 versions) ──────────────────────
_USER_AGENTS: list[str] = [
# Chrome on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
# Chrome on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
# Firefox on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:127.0) Gecko/20100101 Firefox/127.0",
# Safari on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
# Edge on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0",
]
def random_user_agent() -> str:
return random.choice(_USER_AGENTS)
# ── Унифицированная схема результата ────────────────────────────────────────
class ScrapedLot(BaseModel):
"""Результат парсинга одного объявления.
Все источники приводятся к этому виду перед записью в Postgres.
Поля optional если источник их не отдаёт.
"""
source: str # 'avito' / 'cian' / 'domklik' / ...
source_url: str # ссылка на оригинал — кликается из UI
source_id: str | None = None # внутренний id источника (для update)
# Локация — должна быть хотя бы address ИЛИ (lat, lon)
address: str | None = None
lat: float | None = None
lon: float | None = None
# Параметры квартиры
rooms: int | None = None # 0 = студия
area_m2: float | None = None
floor: int | None = None
total_floors: int | None = None
year_built: int | None = None
house_type: str | None = None
repair_state: str | None = None
has_balcony: bool | None = None
kadastr_num: str | None = None
# Цена (обязательно)
price_rub: int = Field(gt=0)
price_per_m2: int | None = None
# Метаданные
listing_date: date | None = None
days_on_market: int | None = None
photo_urls: list[str] = Field(default_factory=list)
raw_payload: dict[str, Any] | None = None
def compute_dedup_hash(self) -> str:
"""SHA256(source + (source_id или source_url) + price_rub) — стабильный uniqueness key.
Cian URL содержит random `?context=...` токены поэтому ключ берём
source_id (offer_id 330200428), а не source_url. Если source_id нет
откатываемся на source_url (Yandex, Avito с offerId).
"""
h = hashlib.sha256()
h.update(self.source.encode())
h.update(b"|")
key = self.source_id if self.source_id else self.source_url
h.update((key or "").encode())
h.update(b"|")
h.update(str(self.price_rub).encode())
return h.hexdigest()
def compute_price_per_m2(self) -> int | None:
"""price_per_m2 = price_rub / area_m2 если area задана."""
if self.area_m2 and self.area_m2 > 0:
return int(self.price_rub / self.area_m2)
return None
# ── BaseScraper ─────────────────────────────────────────────────────────────
class BaseScraper(ABC):
"""Базовый класс — наследовать для каждого источника.
Subclass должен реализовать:
- `name` (class attr) 'avito' / 'cian' / ...
- `_fetch_page(client, ...)` async GET к источнику
- `_parse_listings(html_or_json, ...)` html list[ScrapedLot]
"""
name: str = "base"
base_url: str = ""
request_delay_sec: float = 5.0 # sleep между запросами (anti-ban)
def __init__(self) -> None:
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> BaseScraper:
self._client = httpx.AsyncClient(
timeout=20.0,
follow_redirects=True,
headers={
"User-Agent": random_user_agent(),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
},
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._client is not None:
await self._client.aclose()
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=30))
async def _http_get(self, url: str, **kwargs: Any) -> httpx.Response:
"""GET с retry. Subclass-ы используют это вместо raw httpx."""
assert self._client is not None, "Use scraper as async context manager"
response = await self._client.get(url, **kwargs)
# 403/429 — ретрай поможет (рандомный UA в новом контексте), 5xx тоже
if response.status_code in {403, 429, 502, 503, 504}:
response.raise_for_status()
return response
async def sleep_between_requests(self) -> None:
"""Случайный jitter в районе self.request_delay_sec — чтобы парсер
не выглядел как detstvo-bot."""
jitter = random.uniform(0.7, 1.5)
await asyncio.sleep(self.request_delay_sec * jitter)
@abstractmethod
async def fetch_around(
self, lat: float, lon: float, radius_m: int = 1000
) -> list[ScrapedLot]:
"""Главный метод — собрать объявления вокруг (lat, lon) в радиусе radius_m.
Returns:
Список ScrapedLot. Может быть пустым.
"""
...
# ── Запись пачки результатов в Postgres ─────────────────────────────────────
def save_listings(db: Session, lots: list[ScrapedLot]) -> tuple[int, int]:
"""Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
Returns:
(inserted, updated) counters для логов.
"""
if not lots:
return 0, 0
inserted = 0
updated = 0
for lot in lots:
ppm2 = lot.price_per_m2 or lot.compute_price_per_m2()
dedup = lot.compute_dedup_hash()
result = db.execute(
text(
"""
INSERT INTO listings (
source, source_url, source_id, dedup_hash,
address, lat, lon, region_code,
rooms, area_m2, floor, total_floors, year_built,
house_type, repair_state, has_balcony, kadastr_num,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls, raw_payload,
scraped_at, last_seen_at
) VALUES (
:source, :source_url, :source_id, :dedup,
:address, :lat, :lon, 66,
:rooms, :area_m2, :floor, :total_floors, :year_built,
:house_type, :repair_state, :has_balcony, :kadastr,
:price_rub, :ppm2,
:listing_date, :days_on_market,
CAST(:photos AS jsonb),
CAST(:raw AS jsonb),
NOW(), NOW()
)
ON CONFLICT (dedup_hash) DO UPDATE
SET last_seen_at = NOW(),
is_active = true,
-- если цена изменилась обновляем
price_rub = EXCLUDED.price_rub,
price_per_m2 = EXCLUDED.price_per_m2
RETURNING (xmax = 0) AS inserted
"""
),
{
"source": lot.source,
"source_url": lot.source_url,
"source_id": lot.source_id,
"dedup": dedup,
"address": lot.address,
"lat": lot.lat,
"lon": lot.lon,
"rooms": lot.rooms,
"area_m2": lot.area_m2,
"floor": lot.floor,
"total_floors": lot.total_floors,
"year_built": lot.year_built,
"house_type": lot.house_type,
"repair_state": lot.repair_state,
"has_balcony": lot.has_balcony,
"kadastr": lot.kadastr_num,
"price_rub": lot.price_rub,
"ppm2": ppm2,
"listing_date": lot.listing_date,
"days_on_market": lot.days_on_market,
"photos": _to_json(lot.photo_urls),
"raw": _to_json(lot.raw_payload) if lot.raw_payload else None,
},
).fetchone()
if result is not None and result.inserted:
inserted += 1
else:
updated += 1
db.commit()
logger.info(
"save_listings: source=%s inserted=%d updated=%d (total %d)",
lots[0].source if lots else "?",
inserted,
updated,
len(lots),
)
return inserted, updated
def _to_json(value: Any) -> str:
"""JSON serialization helper — для jsonb колонок."""
import json
return json.dumps(value, ensure_ascii=False, default=str)

View file

@ -0,0 +1,473 @@
"""Cian.ru scraper — парсинг вторички вокруг точки.
Стратегия:
- Cian.ru Next.js приложение хранит state в `__NEXT_DATA__` JSON (script tag в HTML).
- URL pattern: https://ekaterinburg.cian.ru/cat.php?deal_type=sale&offer_type=flat&engine_version=2
ВАЖНО: Циан блокирует httpx по TLS fingerprint "Обнаружен подозрительный трафик" (403).
Используем curl_cffi с impersonate='chrome120' это libcurl-impersonate под капотом,
который воспроизводит TLS ClientHello как настоящий Chrome.
"""
from __future__ import annotations
import json
import logging
import random
import re
from typing import Any
from urllib.parse import urlencode, urljoin
from curl_cffi.requests import AsyncSession
from selectolax.parser import HTMLParser
from app.services.scrapers.base import BaseScraper, ScrapedLot
logger = logging.getLogger(__name__)
# Регион 4743 = Свердловская область (Cian internal)
CIAN_EKB_REGION_ID = 4743
class CianScraper(BaseScraper):
"""Cian vtorichka parser. Использует curl_cffi для обхода TLS fingerprint detection."""
name = "cian"
base_url = "https://ekaterinburg.cian.ru"
request_delay_sec = 6.0
def __init__(self) -> None:
super().__init__()
self._cffi: AsyncSession | None = None
async def __aenter__(self) -> "CianScraper":
await super().__aenter__()
# curl_cffi session с Chrome 120 TLS fingerprint
self._cffi = AsyncSession(
impersonate="chrome120",
timeout=25,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Cache-Control": "max-age=0",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
},
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._cffi is not None:
await self._cffi.close()
await super().__aexit__(*args)
async def fetch_around(
self, lat: float, lon: float, radius_m: int = 1000, rooms: tuple[int, ...] | None = None
) -> list[ScrapedLot]:
"""Найти объявления Циан вокруг (lat, lon) в radius_m метрах.
rooms список из 1,2,3,4 (Cian's room codes). Если None — все.
"""
self._anchor_lat = lat
self._anchor_lon = lon
self._anchor_radius_m = radius_m
url = self._build_url(lat, lon, radius_m, rooms)
try:
assert self._cffi is not None
response = await self._cffi.get(url)
except Exception: # noqa: BLE001
logger.exception("cian curl_cffi fetch failed for %s", url)
return []
if response.status_code != 200:
logger.warning("cian returned %d for %s", response.status_code, url)
return []
lots = self._parse_html(response.text)
logger.info(
"cian: %d lots around (%.4f, %.4f) rooms=%s", len(lots), lat, lon, rooms
)
await self.sleep_between_requests()
return lots
async def fetch_around_multi_room(
self, lat: float, lon: float, radius_m: int = 1000
) -> list[ScrapedLot]:
"""Скрейп Циан отдельно по 1к/2к/3к/4+ — каждый запрос ~25 лотов, всего ~100.
Циан умеет несколько rooms[]=N сразу, но для расширения выборки делаем отдельно.
"""
seen: dict[str, ScrapedLot] = {}
for rooms in ((1,), (2,), (3,), (4,)):
try:
lots = await self.fetch_around(lat, lon, radius_m, rooms=rooms)
except Exception:
logger.exception("cian multi-room fetch failed for rooms=%s", rooms)
continue
for lot in lots:
key = lot.source_id or lot.source_url
if key and key not in seen:
seen[key] = lot
logger.info(
"cian multi-room: %d unique lots around (%.4f, %.4f)", len(seen), lat, lon
)
return list(seen.values())
def _build_url(
self,
lat: float,
lon: float,
radius_m: int,
rooms: tuple[int, ...] | None = None,
) -> str:
"""URL для Cian каталога с регионом ЕКБ.
Cian не имеет «радиус» в URL params, но мы фильтруем по lat/lon
в postgres через ST_DWithin позже. Сейчас тащим всё ЕКБ.
"""
params: list[tuple[str, Any]] = [
("deal_type", "sale"),
("engine_version", "2"),
("offer_type", "flat"),
("region", CIAN_EKB_REGION_ID),
("sort", "creation_date_desc"),
]
if rooms:
for r in rooms:
# Cian rooms[]=1 для 1к, rooms[]=2 для 2к, и т.д.
params.append((f"room{r}", "1"))
return f"{self.base_url}/cat.php?{urlencode(params)}"
def _parse_html(self, html: str) -> list[ScrapedLot]:
"""Cian вставляет state в `window.__NEXT_DATA__` или подобный JSON блок."""
# Попытка 1: __NEXT_DATA__ script
items = _extract_next_data_items(html)
if items:
return [
lot for lot in (self._item_to_lot(it) for it in items) if lot is not None
]
# Попытка 2: regex-извлечение из inline JSON state
items = _extract_inline_offers(html)
if items:
return [
lot for lot in (self._item_to_lot(it) for it in items) if lot is not None
]
# Попытка 3: DOM card scrape (fallback)
tree = HTMLParser(html)
cards = tree.css('[data-name="CardComponent"]') or tree.css(
'article[data-name="GeneralCard"]'
)
return [
lot for lot in (self._card_to_lot(c) for c in cards) if lot is not None
][:50]
def _item_to_lot(self, item: dict[str, Any]) -> ScrapedLot | None:
"""Парс Cian offer JSON → ScrapedLot."""
try:
# Cian offer структура
offer_id = item.get("cianId") or item.get("id")
url = item.get("fullUrl") or item.get("url")
if not url and offer_id:
url = f"https://ekaterinburg.cian.ru/sale/flat/{offer_id}/"
if not url:
return None
# Цена
bargain = item.get("bargainTerms") or {}
price = bargain.get("price") or item.get("price")
if not price:
return None
# Параметры
rooms = item.get("roomsCount")
area = item.get("totalArea") or item.get("totalAreaSquare")
if area:
try:
area = float(area)
except (TypeError, ValueError):
area = None
floor = item.get("floorNumber")
building = item.get("building") or {}
total_floors = building.get("floorsCount")
# Адрес
geo = item.get("geo") or {}
address_parts = geo.get("userInput") or ""
if isinstance(geo.get("address"), list):
address_parts = ", ".join(
a.get("fullName", "") or a.get("name", "")
for a in geo["address"][:3]
)
# Координаты
coords = geo.get("coordinates") or {}
lat = coords.get("lat")
lon = coords.get("lng")
# Fallback anchor с jitter если нет точных
if lat is None or lon is None:
if hasattr(self, "_anchor_lat"):
jitter_deg = (self._anchor_radius_m / 1000) / 111
lat = self._anchor_lat + (random.random() - 0.5) * jitter_deg
lon = self._anchor_lon + (random.random() - 0.5) * jitter_deg / 2
# Год постройки
year_built = building.get("buildYear")
return ScrapedLot(
source="cian",
source_url=url,
source_id=str(offer_id) if offer_id else None,
address=address_parts or "Екатеринбург (Cian)",
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total_floors,
year_built=year_built,
price_rub=int(price),
photo_urls=[
p.get("fullUrl") or p.get("thumbnailUrl", "")
for p in (item.get("photos") or [])[:5]
if isinstance(p, dict)
],
raw_payload=item,
)
except Exception: # noqa: BLE001
logger.exception("cian item parse failed: %s", item.get("id"))
return None
def _card_to_lot(self, card: Any) -> ScrapedLot | None:
"""DOM card-парсинг для Cian 2025+ верстки.
Использует data-mark и data-name селекторы:
- [data-mark="OfferTitle"]: "2-комн. квартира, 47,5 м², 2/9 этаж"
- [data-mark="MainPrice"]: "6 000 000 ₽"
- [data-name="GeoLabel"]: address parts (multiple, объединяем)
- [data-mark="Description"]: long text (год постройки regex'ом)
- href контейнера source_url
"""
try:
link_el = card.css_first('a[href*="/sale/flat/"]')
if link_el is None:
return None
href = link_el.attributes.get("href", "")
url = urljoin(self.base_url, href)
# offer_id из URL pattern /sale/flat/NNN/
offer_id_m = re.search(r"/sale/flat/(\d+)/", href)
offer_id = offer_id_m.group(1) if offer_id_m else None
# Цена
price_el = card.css_first('[data-mark="MainPrice"]')
price_text = price_el.text(strip=True) if price_el else ""
price = _extract_price_rub(price_text)
if not price:
return None
# Title → rooms / area / floor / total_floors
title_el = (
card.css_first('[data-mark="OfferTitle"]')
or card.css_first('[data-name="TitleComponent"]')
or card.css_first('[data-mark="Title"]')
)
title = title_el.text(strip=True) if title_el else ""
rooms = _extract_rooms_from_text(title)
area = _extract_area_from_text(title)
floor, total = _extract_floor_from_text(title)
# Address: собираем все GeoLabel, но показываем компактно
geo_parts = [
el.text(strip=True)
for el in card.css('[data-name="GeoLabel"]')
if el.text(strip=True)
]
# Полный список сохраним в raw_payload, а display address укоротим.
# Убираем "Свердловская область", "Екатеринбург", "м. *" (метро),
# оставляем "р-н *", "мкр. *", улицу и дом.
full_geo = list(geo_parts)
display_parts: list[str] = []
street_part: str | None = None
house_part: str | None = None
district_part: str | None = None
for p in geo_parts:
if p in {"Свердловская область", "Екатеринбург"}:
continue
if p.startswith("м. "):
continue # метро не нужно в адресе
if p.startswith("р") and district_part is None:
district_part = p
elif p.startswith("мкр. "):
continue # микрорайон опускаем для компактности
elif "улица" in p or "проспект" in p or "переулок" in p or "бульвар" in p or "шоссе" in p:
street_part = p
elif re.match(r"^\d", p):
house_part = p
else:
display_parts.append(p)
# Финальный display: "улица X, дом Y · район Z"
final_parts: list[str] = []
if street_part:
final_parts.append(street_part + (f", {house_part}" if house_part else ""))
if district_part:
final_parts.append(district_part)
address = " · ".join(final_parts) if final_parts else (
", ".join(p for p in geo_parts if p not in {"Свердловская область"}) or "Екатеринбург (Cian)"
)
# Description → попытка извлечь год постройки
desc_el = card.css_first('[data-mark="Description"]')
desc = desc_el.text(strip=True) if desc_el else ""
year_built = _extract_year_built(desc)
# PriceInfo: "126 316 ₽/м²" — можно посчитать area если её нет в title
if not area:
pinfo_el = card.css_first('[data-mark="PriceInfo"]')
pinfo = pinfo_el.text(strip=True) if pinfo_el else ""
m = re.search(r"([\d\s]+)\s*₽/м²", pinfo)
if m:
ppm2 = int(re.sub(r"\D", "", m.group(1)) or 0)
if ppm2 > 0:
area = round(price / ppm2, 2)
# Coords: anchor + jitter (Cian не отдаёт координаты в card HTML, только в API)
lat = lon = None
if hasattr(self, "_anchor_lat"):
jitter_deg = (self._anchor_radius_m / 1000) / 111
lat = self._anchor_lat + (random.random() - 0.5) * jitter_deg
lon = self._anchor_lon + (random.random() - 0.5) * jitter_deg / 2
# Photos из img.src в card (первые 5)
photo_urls: list[str] = []
for img in card.css("img.x31de4314--_18b0f--container, [data-name=Cover] img")[:5]:
src = img.attributes.get("src", "")
if src and "cian" in src and src not in photo_urls:
photo_urls.append(src)
return ScrapedLot(
source="cian",
source_url=url,
source_id=offer_id,
address=address,
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total,
year_built=year_built,
price_rub=price,
photo_urls=photo_urls,
raw_payload={
"title": title,
"description": desc[:500],
"geo": full_geo,
},
)
except Exception: # noqa: BLE001
logger.exception("cian card parse failed")
return None
# ── HTML / JSON extraction helpers ──────────────────────────────────────────
_RE_NEXT_DATA = re.compile(
r'<script[^>]+id="__NEXT_DATA__"[^>]*>(.+?)</script>', re.DOTALL
)
_RE_INITIAL_STATE = re.compile(
r'window\._cianConfig\s*=\s*({.+?});</script>', 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

View file

@ -0,0 +1,290 @@
"""Yandex.Недвижимость scraper (realty.yandex.ru).
Я.Недвижимость отдаёт SSR HTML с `__INITIAL_STATE__` JSON в `<script>` теге.
Структура offers: state.search.offers list of offers.
URL pattern:
https://realty.yandex.ru/ekaterinburg/kupit/kvartira/?
rgid=240970 # ЕКБ region
&offerType=SELL
&category=APARTMENT
&geoLat=56.83
&geoLon=60.59
&geoRadius=1000 # метры
"""
from __future__ import annotations
import json
import logging
import random
import re
from typing import Any
from urllib.parse import urlencode, urljoin
from selectolax.parser import HTMLParser
from app.services.scrapers.base import BaseScraper, ScrapedLot
logger = logging.getLogger(__name__)
YANDEX_EKB_RGID = 240970
class YandexRealtyScraper(BaseScraper):
"""Yandex.Недвижимость parser."""
name = "yandex"
base_url = "https://realty.yandex.ru"
request_delay_sec = 5.0
async def fetch_around(
self,
lat: float,
lon: float,
radius_m: int = 1000,
rooms: str | None = None,
sort: str = "DATE_DESC",
page: int = 0,
) -> list[ScrapedLot]:
self._anchor_lat = lat
self._anchor_lon = lon
self._anchor_radius_m = radius_m
url = self._build_url(lat, lon, radius_m, rooms, sort, page)
try:
response = await self._http_get(url)
except Exception:
logger.exception("yandex realty fetch failed for %s", url)
return []
if response.status_code != 200:
logger.warning("yandex realty returned %d", response.status_code)
return []
lots = self._parse_html(response.text)
logger.info(
"yandex realty: %d lots around (%.4f, %.4f) rooms=%s sort=%s page=%d",
len(lots), lat, lon, rooms or "ANY", sort, page,
)
await self.sleep_between_requests()
return lots
async def fetch_around_multi_room(
self, lat: float, lon: float, radius_m: int = 1000,
sorts: tuple[str, ...] = ("DATE_DESC",),
pages: tuple[int, ...] = (0,),
) -> list[ScrapedLot]:
"""Скрейп Yandex по сегментам STUDIO/1k/2k/3k/4k+ и опционально по
нескольким sort/page вариантам.
Каждая выдача = 20 лотов. Полный обход rooms×sorts×pages даст
5 × len(sorts) × len(pages) × 20 потенциальных лотов до дедупликации.
"""
seen: dict[str, ScrapedLot] = {}
for rooms in ("STUDIO", "1", "2", "3", "PLUS_4"):
for sort in sorts:
for page in pages:
try:
lots = await self.fetch_around(
lat, lon, radius_m, rooms=rooms, sort=sort, page=page,
)
except Exception:
logger.exception(
"yandex realty multi-room fetch failed: rooms=%s sort=%s page=%d",
rooms, sort, page,
)
continue
for lot in lots:
key = lot.source_id or lot.source_url
if key and key not in seen:
seen[key] = lot
logger.info(
"yandex realty multi-room: %d unique lots around (%.4f, %.4f)",
len(seen), lat, lon,
)
return list(seen.values())
def _build_url(
self,
lat: float,
lon: float,
radius_m: int,
rooms: str | None = None,
sort: str = "DATE_DESC",
page: int = 0,
) -> str:
params: dict[str, Any] = {
"rgid": YANDEX_EKB_RGID,
"offerType": "SELL",
"category": "APARTMENT",
"geoLat": f"{lat:.4f}",
"geoLon": f"{lon:.4f}",
"geoRadius": radius_m,
"sort": sort,
}
if rooms:
# Yandex Realty принимает roomsTotal=STUDIO|1|2|3|PLUS_4
params["roomsTotal"] = rooms
if page > 0:
params["page"] = page
return f"{self.base_url}/ekaterinburg/kupit/kvartira/?{urlencode(params)}"
def _parse_html(self, html: str) -> list[ScrapedLot]:
# Поиск __INITIAL_STATE__ или __PRELOADED_STATE__
state = _extract_yandex_state(html)
if not state:
return []
offers = _items_from_state(state)
return [
lot for lot in (self._item_to_lot(o) for o in offers if isinstance(o, dict))
if lot is not None
]
def _item_to_lot(self, offer: dict[str, Any]) -> ScrapedLot | None:
try:
# Цена: offer.price.value (там целое число рублей)
price_obj = offer.get("price") or {}
price = price_obj.get("value")
if not price:
pf = price_obj.get("priceForWhole") or {}
price = pf.get("value") if isinstance(pf, dict) else pf
if not price:
return None
# Площадь
area_obj = offer.get("area") or {}
area = area_obj.get("value")
if area:
area = float(area)
rooms = offer.get("roomsTotal") or offer.get("rooms")
# Этажи — offer.floorsOffered = [N], building.floorsTotal
floors_offered = offer.get("floorsOffered") or []
floor = floors_offered[0] if floors_offered else offer.get("floor")
building = offer.get("building") or {}
total_floors = building.get("floorsTotal") or offer.get("floorsTotal")
year_built = building.get("builtYear")
wall_type = building.get("buildingType")
# Локация
loc = offer.get("location") or {}
address = (
loc.get("address")
or loc.get("geocoderAddress")
or "Екатеринбург (Я.Недвижимость)"
)
point = loc.get("point") or {}
lat = point.get("latitude")
lon = point.get("longitude")
# Anchor fallback (если EXACT нет — но обычно есть)
if (lat is None or lon is None) and hasattr(self, "_anchor_lat"):
jitter_deg = (self._anchor_radius_m / 1000) / 111
lat = self._anchor_lat + (random.random() - 0.5) * jitter_deg
lon = self._anchor_lon + (random.random() - 0.5) * jitter_deg / 2
# URL
offer_id = offer.get("offerId") or offer.get("id")
internal_url = offer.get("unsignedInternalUrl") or ""
if internal_url.startswith("//"):
full_url = "https:" + internal_url
elif offer_id:
full_url = f"https://realty.yandex.ru/offer/{offer_id}/"
else:
return None
# Photos
photos = []
for img_field in ("appLargeImages", "extImages", "alikeImages"):
for img in (offer.get(img_field) or [])[:5]:
if isinstance(img, dict):
url = img.get("xl") or img.get("large") or img.get("main", "")
if url:
photos.append(url)
elif isinstance(img, str):
photos.append(img)
if photos:
break
return ScrapedLot(
source="yandex",
source_url=full_url,
source_id=str(offer_id) if offer_id else None,
address=address,
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
floor=floor,
total_floors=total_floors,
year_built=year_built,
house_type=_map_wall_type(wall_type),
price_rub=int(price),
photo_urls=photos[:5],
raw_payload={
# Compact dump (full offer 50KB+, тяжеловато для БД)
"offerId": offer_id,
"creationDate": offer.get("creationDate"),
"dealStatus": offer.get("dealStatus"),
"description": (offer.get("description") or "")[:500],
},
)
except Exception:
logger.exception("yandex item parse failed: %s", offer.get("offerId"))
return None
# ── Helpers ──────────────────────────────────────────────────────────────────
_RE_STATE = re.compile(
r'<script id="initial_state_script"[^>]*>(.+?)</script>',
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")

View file

@ -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;

View file

@ -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=<slug> на frontend.';
COMMIT;

View file

@ -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;

View file

@ -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;

View file

@ -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"]

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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 ==="

View file

@ -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

View file

@ -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:

Binary file not shown.

Binary file not shown.

View file

@ -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-формулы на реальный парсинг.

439
tradein-mvp/docs/ROADMAP.md Normal file
View file

@ -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 в `<script id="initial_state_script">` JSON; path `state.map.offers.points`
- DATE_DESC возвращает одинаковые 20 лотов независимо от geoLat/geoLon
- Multi-room (`STUDIO/1/2/3/PLUS_4`) даёт 5× expansion → ~100 unique/anchor
- Deep mode (5 rooms × 3 sorts × 2 pages = 30 запросов / ~150s) → ~300 unique/anchor
- EXACT coords из `offer.location.point` (precision: EXACT) — без anchor jitter
- Cron каждые 3 часа: `0 */3 * * * /opt/tradein-mvp/deploy/cron-scrape.sh yandex deep`
### 2.6 Н1.ru (региональный, Урал)
- HTML scrape, очень простая структура
- Сильнее представлен в ЕКБ чем федералы
### 2.7 Дом.РФ (наш.дом.рф)
- API `kn-api` уже есть в gendesign (Playwright scraper)
- Только новостройки (ДДУ) — для вторички не критично, опционально
### 2.8 ЕКБ Геопортал (citymap.ekburg.ru)
- WMS/WFS API для зданий
- По lat/lon отдаёт: этажность, год постройки, материал, кол-во квартир, кадастровый номер
- Кэш в `house_metadata`
---
## Слой 3 — Aggregation model
### 3.1 Замена `_mock_estimate()` на реальный SQL
```sql
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS median_ppm2,
percentile_cont(0.25) WITHIN GROUP (ORDER BY price_per_m2) AS q1_ppm2,
percentile_cont(0.75) WITHIN GROUP (ORDER BY price_per_m2) AS q3_ppm2,
COUNT(*) AS n_analogs
FROM listings
WHERE ST_DWithin(geom, ST_MakePoint(:lon, :lat)::geography, 800)
AND rooms = :rooms
AND area BETWEEN :area * 0.85 AND :area * 1.15
AND scraped_at > NOW() - interval '14 days'
```
### 3.2 Confidence score (объяснимый)
- `n_analogs ≥ 10 AND IQR/median < 0.15``high` («Найдено 12 аналогов, разброс цены ±8%»)
- `n_analogs ≥ 5 OR IQR/median < 0.25``medium`
- иначе → `low` («Только 3 аналога в радиусе 800м, рекомендуем проверить ручной поиск»)
### 3.3 Outlier detection
- Tukey fence: цена вне `[Q1 - 1.5*IQR, Q3 + 1.5*IQR]` → исключаем
- Логи: сколько отфильтровали
### 3.4 House match (приоритетные аналоги)
- Если есть листинг с тем же кадастровым номером дома → отдельно показываем как «в этом же доме»
- Соседние дома (тот же квартал) — следующий приоритет
### 3.5 Price trend
- Group by month, last 6/12/24 мес.
- Linear regression slope → "цены растут на N% / год"
### 3.6 Прогноз days_on_market
- Median + percentile по выборке
- «Аналогичные квартиры продаются за 30-90 дней»
---
## Слой 4 — Дифференциация (фишки выше Брусники)
### 4.1 POI окружение
- Reuse `app/services/poi_score.py` из gendesign
- Категории: school / kindergarten / metro_stop / bus_stop / shop_mall / shop_supermarket / park / hospital / pharmacy
- Distance decay (gendesign формула)
- Bonus к привлекательности района (не к цене, отдельно)
### 4.2 Map view
- Leaflet с пинами на каждый аналог
- Цветовая heatmap цен по м²
- Кликабельные popup'ы
### 4.3 AI narrative summary
- LLM call (Claude / GPT) → 2-3 предложения о квартире
- Промпт: «На основе следующих данных опиши квартиру: ...»
- Пример: «Двушка на Малышева в кирпичном доме 1985 года, требует ремонта. Цена в верхнем квартиле по району из-за центра.»
### 4.4 White-label
- Query param `?brand=prinzip|praktika|generic`
- В DB табличка `brands` (slug, logo_url, primary_color, footer_text)
- Header + PDF cover читают из brand
### 4.5 Shareable + QR
- URL `/trade-in?id=<uuid>` ✓ done
- В PDF cover — QR-код этого URL (через `segno` lib)
### 4.6 Photo upload
- `<input type="file" multiple accept="image/*">`
- Upload в `static/photos/<estimate_id>/`
- Первая фото → cover PDF
---
## Слой 5 — UX
### 5.1 Address autocomplete
- Yandex Suggest (через backend proxy)
- Debounced 300ms
- Показывать full address с городом
### 5.2 Кликабельные analogs
- В `AnalogsTable.tsx``<a href={listing.source_url} target="_blank">`
- Логотип источника рядом (Авито/Циан/...)
### 5.3 Loading states
- `EstimateProgress.tsx` уже есть скелет — заполнить:
- «Геокодируем адрес...»
- «Парсим Циан (12 объявлений)...»
- «Парсим Авито (28 объявлений)...»
- «Считаем медиану...»
- «Готово!»
### 5.4 Empty / error states
- 0 аналогов в 800м → fallback 2 км + warning
- Адрес не распознан → suggest альтернативы
- Парсер упал → grace degradation (показываем что есть)
### 5.5 Mobile + iPad
- Sticky form слева → переход в полноэкранный modal на mobile
- Tap-friendly buttons (min 44px)
- iPad: 2-column layout сохраняется
### 5.6 Dark mode (низкий приоритет)
- CSS vars уже подготовлены — добавить `@media (prefers-color-scheme: dark)` overrides
---
## Слой 6 — PDF
### 6.1 Кликабельные ссылки
- WeasyPrint поддерживает `<a href>` — просто добавить в шаблон
- В таблице аналогов адрес делаем линком
### 6.2 White-label header
- Logo + название бренда читается из `brands` таблицы
- PDF cover: бренд-цвет accent
### 6.3 График цен (стр. 3)
- Render chart как SVG через Python (`matplotlib` → SVG) или send to backend chart-renderer
- Линия median + диапазон Q1-Q3 + точки сделок
### 6.4 Карта на cover
- Static map image от Yandex Static Maps API
- Пин на квартире + circle радиуса поиска
### 6.5 QR-code
- `segno.make(shareable_url).save(buffer)` → embed в HTML template
### 6.6 Freshness footer
- «Данные собраны 19.05.2026 23:15. Источники: Авито (47), Циан (38), Росреестр сделки (12). Confidence: high.»
---
## Слой 7 — Auth + Analytics + Monitoring
(отложено из MVP, добавим перед demo Геныча/Тёмы)
### 7.1 Простой password gate
- Middleware: проверка cookie/header с shared secret
- Set-cookie на 30 дней при правильном вводе
- HTML form `/login` с одним полем «Пароль»
### 7.2 Audit log
- Каждый POST /estimate → INSERT в `audit_log` (ip, ua, input, output uuid)
- Каждый GET /estimate/{id}/pdf → INSERT
- Каждый click на оригинал листинга — если хотим точно (frontend → POST /analytics/event)
### 7.3 Analytics dashboard
- `/admin/analytics` — простая страница: топ-адреса, статистика по дням, conversion (form_open → form_submit → pdf_download)
- Или PostHog self-hosted (если хочется UI без писательства)
### 7.4 Rate limiting
- slowapi (FastAPI-friendly) — 10 POST /estimate per minute per IP
### 7.5 Monitoring
- Backend logs → файл + опционально Loki
- Health checks Caddy перепингует backend каждые 30 сек
---
## Слой 8 — Deployment & Production
### 8.1 Smoke test
- `make smoke-test` bash: 5 курлов на разные адреса ЕКБ → проверка HTTP 200 + confidence != low
- Запускать в CI и перед каждым деплоем
### 8.2 Backup strategy
- Cron pg_dump → S3 daily (или local если S3 нет)
- Test restore раз в неделю
### 8.3 Production deploy
- Опция A: deploy в существующий gendesign monorepo (просто merge изменений TI-2/TI-3 + новые парсеры) → выкатить на gendsgn.ru/trade-in
- Опция B: отдельный subdomain `tradein.gendsgn.ru` через отдельный docker stack на том же VPS
- Опция C: совсем отдельный VPS / отдельная репа в Forgejo
Решим когда дойдём — пока локально.
### 8.4 Multi-tenant infrastructure
- White-label через subdomain: `prinzip.tradein.gendsgn.ru` / `praktika.tradein.gendsgn.ru`
- Caddy wildcard cert + tenant detection из Host header
---
## Слой 9 — Advanced (после core готов)
### 9.1 ML-модель цены
- XGBoost regression на исторических сделках Росреестра
- Features: rooms, area, floor/total_floors, year, distance_to_center, POI score
- Сравнение с simple median: насколько лучше / стабильнее
### 9.2 iframe widget
- `<iframe src="https://tradein.gendsgn.ru/embed?brand=prinzip&width=400">`
- Минимальная форма + результат
- Для интеграции в чужие CRM
### 9.3 JSON API для интеграций
- API key auth
- POST /api/v2/trade-in/estimate с rate limits
- Документация Swagger
### 9.4 Push notifications
- «В вашем районе 2-к продалась за 11.5M — на 8% ниже вашей оценки»
- WebPush или Telegram bot
### 9.5 PRINZIP CRM integration
- API call в их 102K заявок
- Авто-оценка по конверту заявки
### 9.6 Птица integration
- Подсветка домов где ≥20% квартир в Циан/Авито
- Карта расселения + потенциальная стоимость участка
### 9.7 Multi-region
- После ЕКБ — Пермь, Челябинск, Новосибирск, Москва, СПб
- Yandex Geocoder покрывает всё
---
## Что уже есть (стартовая позиция)
✅ Backend: FastAPI + `/estimate` (mock) + `/estimate/{id}` + PDF endpoint + Pydantic schemas
✅ Frontend: Next.js page + 5 компонентов (EstimateForm/Result/Progress/PriceRangeBar/AnalogsTable)
✅ Infra: docker-compose (caddy + frontend + backend + postgres) запущен на localhost:8080
✅ Postgres: таблица `trade_in_estimates` создаётся автоматом
✅ PDF: WeasyPrint 4-page (cover/listings/deals/offer) работает
✅ Shareable URL: `/trade-in?id=<uuid>`
✅ Reference docs: PDF Брусники + PDF Птицы в `docs/`
## Что сделано ночью 19→20 мая 2026
### Слой 1 — Core data layer
- ✅ **1.1** PostgreSQL schema: listings + deals + geocode_cache + audit_log + house_metadata + brands + PostGIS GIST индексы + auto-update geom trigger
- ✅ **1.2** Geocoder service (Yandex когда дашь key + Nominatim fallback) с кэшем 90 дней. Live тест: «ул. Малышева 30 Екатеринбург» → (56.8333, 60.5945), confidence exact
- ✅ **1.3** Scraper framework — BaseScraper async context + tenacity retry + 5 ротируемых User-Agent + ScrapedLot единая Pydantic схема + save_listings с дедуп
### Слой 2 — Парсеры
- ✅ **2.1 Авито парсер** — код готов (`scrapers/avito.py`), 2 стратегии (mobile API + HTML scrape). С маки через V2Box банится (302/429) — заработает с VPS Beget.
- 💾 **Seed данные** — 120 листингов + 60 сделок на 10 центральных улицах ЕКБ, реалистичное распределение (12 студий / 30 1к / 45 2к / 25 3к / 8 4к) с jitter цен и координат для тестирования
### Слой 3 — Aggregation
- ✅ **3.1** SQL aggregation поверх listings + deals: PostGIS `ST_DWithin` 800м (fallback 2 км) + комнаты ± 0 + площадь ±15% + Tukey IQR outlier filter + median/Q1/Q3 + confidence high/medium/low с человекочитаемым объяснением
### Слой 5 + 6 — Дифференциация vs Брусники
- ✅ **5.2** Frontend AnalogsTable теперь:
- **Source badges** цветные (avito/cian/domklik)
- **Distance column** (167м / 628м / 1.2км)
- **Кликабельные строки** — открывают оригинальное объявление в новой вкладке
- **Confidence explanation плашка** под медианой «Найдено 15 аналогов, разброс ±7%»
- **Source pills** + **freshness indicator** «● обновлено N мин назад»
- ✅ **6.1** PDF Listings/Deals таблицы: source badges + distance + кликабельные `<a href>` адреса (WeasyPrint)
- ✅ **6.5** **QR-код на cover** PDF (`segno` lib) — шёрить отчёт через QR scan
- ✅ **6.6** Freshness footer в PDF + список источников
### Слой 4 — White-label
- ✅ **4.4** Multi-tenant brands в Postgres (generic / prinzip / praktika) + `GET /api/v1/brand/{slug}` endpoint + `/pdf?brand=prinzip` query param. PDF header адаптируется (PRINZIP → чёрный header, generic → синий)
## Что НЕ сделано пока
### Слой 2 — Парсеры (отложено до prod deploy)
- 🟡 ЦИАН парсер — фреймворк готов, нужен код. Прод-IP обойдёт ban
- 🟡 Росреестр дамп из gendesign-postgres-1 (через SSH туннель)
- 🟡 ДомКлик, Я.Недвижимость, Н1, Дом.РФ
### Слой 3 — Advanced aggregation
- 🟡 House match — приоритетные аналоги в том же доме
- 🟡 Price trend regression (last 6mo slope) для «цены растут N%/год»
- 🟡 Прогноз days_on_market для «продаётся за 30-90 дней»
### Слой 4 — Доп. дифференциация
- 🟡 POI окружение (reuse gendesign) — школы/метро/магазины с decay
- 🟡 Map view (Leaflet) — пины + heatmap цен
- 🟡 AI narrative summary (LLM call) — описание квартиры
- 🟡 Photo upload на cover PDF
### Слой 5 — UX
- 🟡 Yandex Suggest autocomplete (нужен Yandex key)
- 🟡 Loading states «Парсим Циан... Авито... Расчёт...»
- 🟡 Empty state fallback
- 🟡 Mobile / iPad responsive
### Слой 6 — PDF extra
- 🟡 График цен на странице 3
- 🟡 Карта c пином на cover (Yandex Static Maps API)
- 🟡 Photo на cover (если загружено)
### Слой 7 — Auth + Analytics
- 🟡 Shared password middleware
- 🟡 Audit log INSERT с каждого POST /estimate
- 🟡 PostHog / analytics dashboard
- 🟡 Rate limiting
### Слой 8 — Deploy
- 🟡 Production deploy (gendsgn.ru/trade-in OR tradein.gendsgn.ru subdomain)
- 🟡 Backup cron pg_dump → S3
- 🟡 Smoke-test bash script
- 🟡 CI с авто-парсингом для freshness
### Слой 9 — Advanced
- 🟡 ML модель цены (XGBoost)
- 🟡 iframe widget
- 🟡 JSON API + key auth
- 🟡 PRINZIP CRM integration (102K заявок)
- 🟡 Птица integration (расселение)
- 🟡 Multi-region (Москва, СПб)
---
## Приоритет реализации
Иду сверху вниз, но внутри слоя могу параллелить независимые таски (например, 2.1 Авито и 2.2 Циан — независимые).
**Критический путь** (минимум чтобы вообще работало):
1.1 → 1.2 → 1.3 → 2.1 → 3.1 → 3.2 → 5.2 → 6.1
Всё остальное — incremental quality wins поверх.
---
## Решения 19.05.2026
- ✅ Геокодинг: **Yandex Geocoder + Suggest API**
- ✅ Источники к MVP: **Авито + Циан + Росреестр (дамп с prod)**, затем +ДомКлик/Я.Нед/Н1/Дом.РФ
- ✅ Auth: отложен в Слой 7 (добавим перед demo)
- ✅ Срок: убран, делаем максимально хорошо
- ✅ Деплой: пока локально, продакшен решим когда дойдём
## Что нужно от Антона
1. **Yandex Geocoder API key** — registration на developer.tech.yandex.ru (10 мин). Альтернатива — переиспользуем gendesign'овский если есть
2. Подтверждение SSH доступа к prod (gendesign_deploy ключ уже работает) для `pg_dump rosreestr_deals`
3. **Logo PRINZIP** (svg/png) — когда дойдём до Слоя 6.2, не блокирует старт
4. Решение по production deploy (Слой 8.3) — но это позже

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,7 @@
node_modules
.next
.git
.env*.local
npm-debug.log*
Dockerfile
.dockerignore

View file

@ -0,0 +1,41 @@
# Trade-In MVP frontend — Next.js 15 + React 19 + TanStack Query.
# Без Sentry/Tailwind/Leaflet/echarts — лёгкая версия только для trade-in.
# ---- deps ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
npm install --legacy-peer-deps --no-audit --no-fund
# ---- builder ----
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# basePath baked-in во время build (Next.js не может изменить runtime).
# В CI workflow: --build-arg NEXT_PUBLIC_BASE_PATH=/trade-in (для prod);
# в локальном dev: пусто = no-op.
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ---- runner ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]

View file

@ -0,0 +1,37 @@
import type { NextConfig } from "next";
// basePath: пусто в dev / `/trade-in` в production когда сидим под gendsgn.ru/trade-in.
// Прокидывается через env при сборке Docker.
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
// basePath сдвигает ВСЕ routes на /trade-in/* — Next сам префиксит ассеты
// и navigation. Пустая строка = no-op.
basePath: BASE_PATH || undefined,
// assetPrefix — для CDN (мы не используем, оставляем пустым)
// trailingSlash — false (default), важно чтобы Caddy /trade-in без слэша
// редиректил на /trade-in/.
// В dev (без Caddy) Next.js сам проксирует /api/* и /health на backend
// так что браузер использует относительные same-origin URLs.
async rewrites() {
const backend = process.env.BACKEND_URL ?? "http://localhost:8000";
return [
{ source: "/api/:path*", destination: `${backend}/api/:path*` },
{ source: "/health", destination: `${backend}/health` },
];
},
async redirects() {
// basePath применяется автоматически к source — НЕ дублируем.
return [
{ source: "/", destination: "/trade-in", permanent: false },
];
},
};
export default nextConfig;

View file

@ -0,0 +1,26 @@
{
"name": "tradein-mvp-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.50.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,90 @@
/*
* GenDesign global CSS design tokens (UI Brief May 17)
* All CSS vars used across site-finder and analytics components.
* Spacing scale: 4/8/12/16/24/32px only.
* Tabular-nums applied globally for numeric readability.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
/* ── Surface ─────────────────────────────────────────────────────────── */
--bg-app: #f6f7f9;
--bg-card: #ffffff;
--bg-card-alt: #fafbfc;
--bg-headline: #0f172a; /* slate-900 — dark verdict bar */
/* ── Borders ─────────────────────────────────────────────────────────── */
--border-soft: #eef0f3;
--border-card: #e6e8ec;
--border-strong: #d1d5db;
/* ── Text ────────────────────────────────────────────────────────────── */
--fg-primary: #111111;
--fg-secondary: #5b6066;
--fg-tertiary: #73767e;
--fg-on-dark: #e2e8f0; /* text on --bg-headline */
--fg-on-dark-muted: #94a3b8; /* caveat text on dark */
/* ── Brand / CTA ─────────────────────────────────────────────────────── */
--accent: #1d4ed8; /* primary blue CTA */
--accent-hover: #1e40af;
--accent-soft: #dbeafe; /* badge bg, active tab bg */
--accent-2: #f2994a; /* secondary orange CTA (Export, Share) */
/* ── Semantic ────────────────────────────────────────────────────────── */
--success: #0a7a3a;
--success-soft: #dcfce7;
--warn: #9a6700;
--warn-soft: #fef3c7;
--danger: #b3261e;
--danger-soft: #fee2e2;
/* ── Data-viz (use in order, no skip) ───────────────────────────────── */
--viz-1: #1d4ed8;
--viz-2: #0ea5e9;
--viz-3: #14b8a6;
--viz-4: #f59e0b;
--viz-5: #8b5cf6;
--prediction-line: #0ea5e9;
--prediction-band: rgba(14, 165, 233, 0.18);
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg-app);
color: var(--fg-primary);
font-family:
Inter,
-apple-system,
"Segoe UI",
system-ui,
sans-serif;
font-size: 14px;
line-height: 1.5;
/* Tabular nums globally for numeric legibility (Bloomberg lesson) */
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
-webkit-font-smoothing: antialiased;
}
/* Focus-visible ring — accessible, not outline:none */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Remove default outline for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
a {
color: inherit;
}

View file

@ -0,0 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "Trade-In MVP — Оценка вторичного жилья",
description: "Локальная MVP-копия Trade-In Estimator (форк gendesign)",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/trade-in");
}

View file

@ -0,0 +1,19 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
},
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View file

@ -0,0 +1,190 @@
"use client";
/**
* Trade-In страница в стиле gendsgn.ru/tradein.html mockup.
* Использует CSS из /components/trade-in/trade-in.css.
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import "@/components/trade-in/trade-in.css";
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api";
import { EstimateForm } from "@/components/trade-in/EstimateForm";
import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
import { HeroSummary } from "@/components/trade-in/HeroSummary";
import { ListingsCard } from "@/components/trade-in/ListingsCard";
import { DealsCard } from "@/components/trade-in/DealsCard";
import { OfferCard } from "@/components/trade-in/OfferCard";
import { TestPresets } from "@/components/trade-in/TestPresets";
function useEstimateId() {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
return params.get("id");
}
export default function TradeInPage() {
const router = useRouter();
const [freshResult, setFreshResult] = useState<{
estimate: AggregatedEstimate;
input: TradeInEstimateInput;
} | null>(null);
const urlEstimateId = useEstimateId();
const restoredEstimate = useEstimate(
freshResult === null ? urlEstimateId : null,
);
const mutation = useEstimateMutation();
function handleSubmit(input: TradeInEstimateInput) {
mutation.mutate(input, {
onSuccess: (estimate) => {
setFreshResult({ estimate, input });
router.replace(`/trade-in?id=${estimate.estimate_id}`, { scroll: false });
},
});
}
const apiError = mutation.error?.message ?? null;
const resultData =
freshResult ??
(restoredEstimate.data
? {
estimate: restoredEstimate.data,
input: {
address: restoredEstimate.data.target_address ?? "—",
area_m2: 0,
rooms: restoredEstimate.data.analogs[0]?.rooms ?? 0,
floor: 0,
total_floors: 0,
} as TradeInEstimateInput,
}
: null);
const isPending = mutation.isPending;
return (
<>
{/* Topbar — белый-лейбл бренд */}
<header className="topbar">
<div className="topbar-inner">
<div className="brand">
<span className="brand-mark">TI</span>
<span className="brand-name">PRINZIP</span>
<span className="brand-sep" />
<span className="brand-product">Trade-In</span>
</div>
<nav className="top-nav">
<a href="#" className="is-active">Оценка</a>
<a href="#">История</a>
<a href="#">Кэш</a>
</nav>
</div>
</header>
<main className="page">
<div className="crumbs">
<em>Главная</em> <span></span> <em>Trade-In</em> <span></span> Новая оценка
</div>
<div className="page-title">
<div>
<h1>Оценка квартиры на вторичке</h1>
<p className="page-subtitle" style={{ marginTop: 8 }}>
Агрегируем данные из 7 источников + аналоги в продаже + фактические сделки.
Время сбора <span className="num">1030 сек</span>.
</p>
</div>
<div className="page-meta">
<span className="pill">
<span className="dot" />
<span>Отчёт</span>{" "}
<span className="mono">
{resultData?.estimate.estimate_id.slice(0, 8) ?? "—"}
</span>
</span>
<span className="pill">
<span className="dot muted" />
<span>Дата</span>{" "}
<span className="mono">
{new Date().toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span>
</span>
{resultData && (
<span className="pill">
<span className="dot warn" />
<span>Действителен до</span>{" "}
<span className="mono">
{new Date(resultData.estimate.expires_at).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span>
</span>
)}
</div>
</div>
<div className="layout">
{/* Sticky form left */}
<aside className="form-card">
<EstimateForm onSubmit={handleSubmit} isPending={isPending} error={apiError} />
</aside>
{/* Result column */}
<section className="result-col">
<SourcesProgress
estimate={resultData?.estimate ?? null}
isPending={isPending}
/>
{/* Тестовые пресеты — показываем только когда нет результата */}
{!resultData && !isPending && (
<TestPresets onPick={(data) => handleSubmit(data)} />
)}
{resultData ? (
<>
<HeroSummary estimate={resultData.estimate} input={resultData.input} />
<ListingsCard estimate={resultData.estimate} />
<DealsCard estimate={resultData.estimate} />
<OfferCard estimate={resultData.estimate} />
</>
) : (
<article className="card">
<div className="card-body" style={{ padding: "48px 24px", textAlign: "center" }}>
<div style={{ fontSize: 40, marginBottom: 12 }}>🏠</div>
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 6 }}>
Введите параметры квартиры
</h3>
<p style={{ color: "var(--muted)", fontSize: 14 }}>
Заполните форму слева или выберите тестовую квартиру выше.
</p>
</div>
</article>
)}
</section>
</div>
</main>
<footer className="page-foot">
<div>
PRINZIP Trade-In · MVP ·{" "}
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
</div>
<div style={{ display: "flex", gap: 18 }}>
<a href="#">Документация</a>
<a href="#">API</a>
<a href="#">Источники данных</a>
</div>
</footer>
</>
);
}

View file

@ -0,0 +1,265 @@
"use client";
/**
* AddressInput combobox с автокомплитом адресов в ЕКБ.
* Стиль: `.ac-list .ac-item .ac-main .ac-sub` из tradein.html mockup.
*
* Особенности:
* - 200ms debounce
* - Открывается при focus и при наборе
* - Закрывается по click outside + Esc
* - Выбор по mouseDown (предотвращает blur)
* - навигация, Enter / Tab выбор
* - Поддерживает typo-tolerance через backend (Цвилинга Цвиллинга)
*/
import { useEffect, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
interface SuggestItem {
label: string;
full_address: string;
lat: number;
lon: number;
kind: string;
}
interface SuggestResponse {
items: SuggestItem[];
}
// Pre-filled тестовые адреса — клик подставляет в поле и заодно дёргает suggest.
// Все эти адреса дают «medium» или «high» confidence на 2-room 50м² (хорошее покрытие).
const PRESET_ADDRESSES: { label: string; full: string; kind: string }[] = [
{ label: "ул. Малышева, 84 · Парковый", full: "Екатеринбург, ул. Малышева, 84", kind: "house" },
{ label: "ул. Куйбышева, 48 · Парковый", full: "Екатеринбург, ул. Куйбышева, 48", kind: "house" },
{ label: "ул. 8 Марта, 50 · Ленинский р-н", full: "Екатеринбург, ул. 8 Марта, 50", kind: "house" },
{ label: "ул. Цвиллинга, 58 · Парковый", full: "Екатеринбург, улица Цвиллинга, 58", kind: "house" },
{ label: "ул. Белинского, 200 · ЮЗ", full: "Екатеринбург, ул. Белинского, 200", kind: "house" },
{ label: "ул. Куйбышева, 30 · центр", full: "Екатеринбург, ул. Куйбышева, 30", kind: "house" },
];
interface Props {
value: string;
onChange: (val: string) => void;
placeholder?: string;
inputId?: string;
}
const KIND_BADGE: Record<string, { tag: string; label: string }> = {
house: { tag: "ДОМ", label: "house" },
street: { tag: "УЛ", label: "street" },
locality: { tag: "Р-Н", label: "locality" },
};
function AreaSubtitle({ item }: { item: SuggestItem }) {
const kindBadge = KIND_BADGE[item.kind] ?? { tag: item.kind.toUpperCase(), label: item.kind };
return (
<span className="ac-sub" style={{ display: "flex", gap: 6, alignItems: "center" }}>
<span
style={{
padding: "1px 5px",
background: "var(--surface-3)",
color: "var(--muted)",
borderRadius: 3,
fontSize: 9,
fontWeight: 600,
letterSpacing: "0.05em",
}}
>
{kindBadge.tag}
</span>
</span>
);
}
function presetsToItems(): SuggestItem[] {
return PRESET_ADDRESSES.map((p) => ({
label: p.label,
full_address: p.full,
lat: 0,
lon: 0,
kind: p.kind,
}));
}
export function AddressInput({ value, onChange, placeholder, inputId = "addr" }: Props) {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<SuggestItem[]>([]);
const [loading, setLoading] = useState(false);
const [highlight, setHighlight] = useState(0);
const [showPresets, setShowPresets] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastPickedRef = useRef<string>("");
// Debounced fetch на каждое изменение value
useEffect(() => {
const q = value.trim();
if (q.length < 2) {
setItems([]);
setOpen(false);
return;
}
setShowPresets(false);
// Если value совпадает с последним выбранным — не делаем запрос
if (q === lastPickedRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setLoading(true);
try {
const r = await apiFetch<SuggestResponse>(
`/api/v1/geocode/suggest?q=${encodeURIComponent(q)}&limit=8`,
);
setItems(r.items);
setOpen(r.items.length > 0);
setHighlight(0);
} catch {
setItems([]);
setOpen(false);
} finally {
setLoading(false);
}
}, 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [value]);
// Click outside → close
useEffect(() => {
function onClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setShowPresets(false);
}
}
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
function pick(item: SuggestItem) {
lastPickedRef.current = item.full_address;
onChange(item.full_address);
setOpen(false);
setShowPresets(false);
setItems([]);
}
// Активный список для UI (suggest result ИЛИ presets)
const activeItems: SuggestItem[] = showPresets ? presetsToItems() : items;
const dropdownOpen = (open && items.length > 0) || (showPresets && activeItems.length > 0);
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (!dropdownOpen) {
if (e.key === "ArrowDown") {
// Открываем dropdown — либо результаты suggest, либо presets
if (items.length > 0) setOpen(true);
else if (value.trim().length < 2) setShowPresets(true);
e.preventDefault();
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => Math.min(activeItems.length - 1, h + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => Math.max(0, h - 1));
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
pick(activeItems[highlight]);
} else if (e.key === "Escape") {
setOpen(false);
setShowPresets(false);
}
}
return (
<div ref={containerRef} style={{ position: "relative", width: "100%" }}>
<input
id={inputId}
type="text"
className="control"
placeholder={placeholder}
value={value}
autoComplete="off"
onChange={(e) => {
onChange(e.target.value);
lastPickedRef.current = ""; // юзер начал редактировать — снимаем guard
setShowPresets(false);
}}
onFocus={() => {
// Если есть свежий suggest result — открываем его
if (items.length > 0) {
setOpen(true);
return;
}
// Если поле пустое — показываем тестовые адреса
if (value.trim().length < 2) {
setShowPresets(true);
setHighlight(0);
}
}}
onKeyDown={onKeyDown}
minLength={3}
maxLength={500}
required
/>
{loading && (
<span
aria-hidden="true"
style={{
position: "absolute",
right: 10,
top: "50%",
transform: "translateY(-50%)",
fontSize: 10,
color: "var(--muted-2)",
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ animation: "ti-spin 1s linear infinite" }}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</span>
)}
<ul className={`ac-list ${dropdownOpen ? "is-open" : ""}`} role="listbox">
{showPresets && (
<li
style={{
padding: "6px 10px",
fontSize: 10,
color: "var(--muted-2)",
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
borderBottom: "1px solid var(--border)",
cursor: "default",
}}
>
Подсказки · нажмите для подстановки
</li>
)}
{activeItems.map((it, i) => (
<li
key={`${it.full_address}-${i}`}
className={`ac-item ${i === highlight ? "is-focus" : ""}`}
role="option"
aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)}
onMouseDown={(e) => {
// mousedown (не click) — иначе input blur'ится до того как обработаем
e.preventDefault();
pick(it);
}}
>
<span className="ac-main">{it.label}</span>
<AreaSubtitle item={it} />
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,187 @@
"use client";
/**
* DealsCard Секция 3 «Сделки» из mockup. Реальные сделки из Росреестра / ДомКлик.
*/
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate;
}
const SOURCE_DOTS: Record<string, string> = {
rosreestr: "ros",
domklik: "dom",
etagi: "etagi",
};
const SOURCE_LABELS: Record<string, string> = {
rosreestr: "Росреестр",
domklik: "ДомКлик",
etagi: "Этажи",
};
function fmtRub(v: number): string {
return v.toLocaleString("ru-RU");
}
function fmtDate(iso: string | null): string {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" });
} catch {
return "—";
}
}
export function DealsCard({ estimate }: Props) {
const deals = estimate.actual_deals;
if (deals.length === 0) {
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 3 · Сделки</div>
<h2>Фактические сделки по аналогичным квартирам</h2>
</div>
</div>
<div className="card-body">
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
Нет фактических сделок в радиусе 2 км за 12 месяцев.
</p>
</div>
</article>
);
}
// Сортируем по дате DESC, берем медиану и range
const prices = deals.map((d) => d.price_rub).sort((a, b) => a - b);
const m = prices[Math.floor(prices.length / 2)];
const lo = prices[0];
const hi = prices[prices.length - 1];
const sourceCounts: Record<string, number> = {};
for (const d of deals) {
if (d.source) sourceCounts[d.source] = (sourceCounts[d.source] ?? 0) + 1;
}
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 3 · Сделки</div>
<h2>Фактические сделки по аналогичным квартирам</h2>
</div>
<div className="card-meta">
<div>
Период: <b className="mono">за {estimate.period_months} мес.</b>
</div>
<div style={{ marginTop: 4 }}>
ground truth · подтверждено Росреестром
</div>
</div>
</div>
<div className="count-strip">
<div className="count-cell">
<div className="label">Сделок по аналогам</div>
<div className="value">
<span data-tnum>{deals.length}</span>
<span className="unit">шт</span>
</div>
<div className="sub">подтверждено Росреестром</div>
</div>
<div className="count-cell">
<div className="label">Медиана сделок</div>
<div className="value">
<span data-tnum>{(m / 1_000_000).toFixed(2)}</span>
<span className="unit">млн </span>
</div>
<div className="sub">
на {(((estimate.median_price_rub - m) / estimate.median_price_rub) * 100).toFixed(0)}%
ниже рынка
</div>
</div>
<div className="count-cell">
<div className="label">Диапазон</div>
<div className="value">
<span data-tnum>
{(lo / 1_000_000).toFixed(1)} {(hi / 1_000_000).toFixed(1)}
</span>
<span className="unit">млн</span>
</div>
<div className="sub">7 месяцев · разница к рынку</div>
</div>
</div>
<div className="filters-row">
<span className="lbl">Источники</span>
<div className="sources">
{Object.entries(sourceCounts).map(([src, c]) => (
<span key={src} className="source-chip">
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} />{" "}
{SOURCE_LABELS[src] ?? src} ·{" "}
<span className="mono" style={{ color: "var(--muted)" }}>
{c} сделок
</span>
</span>
))}
</div>
</div>
<table className="dt" aria-label="Фактические сделки">
<thead>
<tr>
<th>Адрес</th>
<th>Источник</th>
<th className="num"> / м²</th>
<th className="num">Цена сделки</th>
<th className="num">Дата</th>
</tr>
</thead>
<tbody>
{deals.map((d, i) => (
<DealRow key={i} deal={d} />
))}
</tbody>
</table>
<div className="table-foot">
<span>
Показано <b style={{ color: "var(--fg)" }}>{deals.length}</b> фактических сделок
</span>
</div>
</article>
);
}
function DealRow({ deal }: { deal: AnalogLot }) {
const src = deal.source ?? "";
const dot = SOURCE_DOTS[src] ?? "dom";
const label = SOURCE_LABELS[src] ?? src;
return (
<tr>
<td>
<div className="addr">
<span className="a-main">{deal.address}</span>
<span className="a-sub">
{deal.area_m2.toFixed(1)} м² · {deal.rooms === 0 ? "студия" : `${deal.rooms}`}
{deal.floor !== null && deal.total_floors !== null
? ` · этаж ${deal.floor}/${deal.total_floors}`
: ""}
</span>
</div>
</td>
<td>
<span className="src-mini">
<span className={`src-dot ${dot}`} /> {label}
</span>
</td>
<td className="num">{fmtRub(deal.price_per_m2)}</td>
<td className="num">{fmtRub(deal.price_rub)}</td>
<td className="num">{fmtDate(deal.listing_date)}</td>
</tr>
);
}

View file

@ -0,0 +1,350 @@
"use client";
import { useState } from "react";
import type {
HouseType,
RepairState,
TradeInEstimateInput,
} from "@/types/trade-in";
import { AddressInput } from "@/components/trade-in/AddressInput";
interface Props {
onSubmit: (input: TradeInEstimateInput) => void;
isPending: boolean;
error: string | null;
}
interface FormState {
address: string;
area_m2: string;
rooms: string;
floor: string;
total_floors: string;
year_built: string;
house_type: string;
repair_state: string;
has_balcony: boolean;
}
const INITIAL: FormState = {
address: "",
area_m2: "",
rooms: "",
floor: "",
total_floors: "",
year_built: "",
house_type: "",
repair_state: "",
has_balcony: false,
};
function validate(f: FormState): string | null {
if (f.address.trim().length < 3) return "Введите адрес (минимум 3 символа)";
const a = Number(f.area_m2);
if (!f.area_m2 || isNaN(a) || a <= 10 || a >= 500) return "Площадь: от 10 до 500 м²";
const r = Number(f.rooms);
if (f.rooms === "" || isNaN(r) || r < 0 || r > 10) return "Комнат: 0 (студия) — 10";
const fl = Number(f.floor);
if (!f.floor || isNaN(fl) || fl < 1 || fl > 100) return "Этаж: 1—100";
const tf = Number(f.total_floors);
if (!f.total_floors || isNaN(tf) || tf < 1 || tf > 100) return "Этажей в доме: 1—100";
if (fl > tf) return "Этаж не может быть больше этажей в доме";
if (f.year_built !== "") {
const y = Number(f.year_built);
if (isNaN(y) || y < 1800 || y > 2100) return "Год постройки: 1800—2100";
}
return null;
}
function toPayload(f: FormState): TradeInEstimateInput {
const out: TradeInEstimateInput = {
address: f.address.trim(),
area_m2: Number(f.area_m2),
rooms: Number(f.rooms),
floor: Number(f.floor),
total_floors: Number(f.total_floors),
has_balcony: f.has_balcony,
};
if (f.year_built !== "") out.year_built = Number(f.year_built);
if (f.house_type) out.house_type = f.house_type as HouseType;
if (f.repair_state) out.repair_state = f.repair_state as RepairState;
return out;
}
export function EstimateForm({ onSubmit, isPending, error }: Props) {
const [form, setForm] = useState<FormState>(INITIAL);
const [touched, setTouched] = useState(false);
const valError = validate(form);
const canSubmit = !valError && !isPending;
function field(key: keyof FormState) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const value = e.target.type === "checkbox"
? (e.target as HTMLInputElement).checked
: e.target.value;
setForm((s) => ({ ...s, [key]: value }));
};
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setTouched(true);
if (canSubmit) {
onSubmit(toPayload(form));
}
}
return (
<form onSubmit={handleSubmit}>
<div className="form-head">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--accent)" }}>
<path d="M3 21v-7l9-7 9 7v7h-6v-6h-6v6z" />
</svg>
<h2>Параметры квартиры</h2>
<span className="form-step">шаг 1/1</span>
</div>
<div className="form-body">
{/* Address with autocomplete */}
<div className="field autocomplete">
<label className="field-label" htmlFor="addr">
Адрес <span className="hint">Yandex / Nominatim</span>
<span className="req">обязательно</span>
</label>
<div className="control-with-icon">
<svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 10c0 7-9 13-9 13S3 17 3 10a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<AddressInput
value={form.address}
onChange={(val) => setForm((s) => ({ ...s, address: val }))}
placeholder="ул. Малышева, 30 · Цвиллинга, 58 · Куйбышева, 48…"
/>
</div>
</div>
{/* Area + Rooms */}
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="area">
Площадь <span className="req">*</span>
</label>
<div className="control-with-suffix">
<input
className="control mono"
id="area"
type="number"
step="0.1"
placeholder="54"
min={11}
max={499}
value={form.area_m2}
onChange={field("area_m2")}
required
/>
<span className="suffix">м²</span>
</div>
</div>
<div className="field">
<label className="field-label" htmlFor="rooms">
Комнат <span className="req">*</span>
</label>
<select
className="control"
id="rooms"
value={form.rooms}
onChange={field("rooms")}
required
>
<option value=""> выбрать </option>
<option value="0">Студия</option>
<option value="1">1-к</option>
<option value="2">2-к</option>
<option value="3">3-к</option>
<option value="4">4-к+</option>
</select>
</div>
</div>
{/* Floor + Total floors */}
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="floor">
Этаж <span className="req">*</span>
</label>
<input
className="control mono"
id="floor"
type="number"
placeholder="5"
min={1}
max={100}
value={form.floor}
onChange={field("floor")}
required
/>
</div>
<div className="field">
<label className="field-label" htmlFor="totalfloor">
Всего этажей <span className="req">*</span>
</label>
<input
className="control mono"
id="totalfloor"
type="number"
placeholder="17"
min={1}
max={100}
value={form.total_floors}
onChange={field("total_floors")}
required
/>
</div>
</div>
{/* Year + House type */}
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="year">
Год постройки <span className="hint">опц.</span>
</label>
<input
className="control mono"
id="year"
type="number"
placeholder="1985"
min={1800}
max={2100}
value={form.year_built}
onChange={field("year_built")}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="type">
Тип дома <span className="hint">опц.</span>
</label>
<select className="control" id="type" value={form.house_type} onChange={field("house_type")}>
<option value=""> не указан </option>
<option value="panel">Панельный</option>
<option value="brick">Кирпичный</option>
<option value="monolith">Монолит</option>
<option value="monolith_brick">Монолит-кирпич</option>
<option value="other">Другое</option>
</select>
</div>
</div>
{/* Repair */}
<div className="field">
<label className="field-label" htmlFor="repair">
Состояние ремонта <span className="hint">опц.</span>
</label>
<select
className="control"
id="repair"
value={form.repair_state}
onChange={field("repair_state")}
>
<option value=""> не указано </option>
<option value="needs_repair">Требует ремонта</option>
<option value="standard">Стандартный</option>
<option value="good">Хороший</option>
<option value="excellent">Евроремонт</option>
</select>
</div>
{/* Balcony */}
<div className="field">
<label
className="field-label"
style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}
>
<input
type="checkbox"
checked={form.has_balcony}
onChange={field("has_balcony")}
style={{ width: 14, height: 14, cursor: "pointer" }}
/>
<span style={{ color: "var(--fg)" }}>Есть балкон / лоджия</span>
</label>
</div>
{touched && valError && (
<div
style={{
padding: "10px 12px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 6,
color: "var(--danger)",
fontSize: 12,
}}
>
{valError}
</div>
)}
{error && (
<div
style={{
padding: "10px 12px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 6,
color: "var(--danger)",
fontSize: 12,
}}
>
{error}
</div>
)}
</div>
<div className="form-foot">
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary"
style={{ opacity: canSubmit ? 1 : 0.55 }}
>
{isPending ? (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
style={{ animation: "ti-spin 1s linear infinite" }}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Считаем
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Оценить квартиру
</>
)}
</button>
<div className="form-foot-meta" style={{ marginTop: 8, fontSize: 11, color: "var(--muted)" }}>
<span>Кэш по адресу <span className="num">24 ч</span></span>
<span className="ok" style={{ marginLeft: 12, color: "var(--success)" }}> готов</span>
</div>
</div>
<style>{`
@keyframes ti-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</form>
);
}

View file

@ -0,0 +1,255 @@
"use client";
/**
* HeroSummary Секция 1 «Сводка» из mockup tradein.html.
* Показывает медиану + достоверность CV + параметры объекта + 2 ценовых бара (объявления / сделки).
*/
import type { AggregatedEstimate, TradeInEstimateInput } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate;
input: TradeInEstimateInput;
}
const HOUSE_TYPE_LABELS: Record<string, string> = {
panel: "Панельный",
brick: "Кирпичный",
monolith: "Монолит",
monolith_brick: "Монолит-кирпич",
other: "Другое",
};
const REPAIR_LABELS: Record<string, string> = {
needs_repair: "Требует ремонта",
standard: "Стандартный",
good: "Хороший",
excellent: "Евроремонт",
};
const CONF_LABELS: Record<string, { txt: string; color: string }> = {
high: { txt: "высокая", color: "var(--success)" },
medium: { txt: "средняя", color: "var(--success)" },
low: { txt: "низкая", color: "var(--danger)" },
};
function formatMln(rub: number): string {
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
}
function calcCv(estimate: AggregatedEstimate): number {
// CV = (P75 - P25) / median * 100 (наш range_high - range_low = P25-P75 пара)
const m = estimate.median_price_rub;
if (m === 0) return 0;
return ((estimate.range_high_rub - estimate.range_low_rub) / m) * 100;
}
export function HeroSummary({ estimate, input }: Props) {
const cv = calcCv(estimate);
const conf = CONF_LABELS[estimate.confidence] ?? CONF_LABELS.low;
const m = estimate.median_price_rub;
const lo = estimate.range_low_rub;
const hi = estimate.range_high_rub;
// Расчёт ширины для price bar (50% = середина): медиана внутри min/max
const span = hi - lo;
const medianPctRaw = span > 0 ? ((m - lo) / span) * 100 : 50;
const medianPct = Math.max(5, Math.min(95, medianPctRaw));
return (
<article className="card hero-card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 1 · Сводка</div>
<h2>Анализ рынка и расчёт стоимости</h2>
</div>
<div className="card-meta">
<div>
Источник:{" "}
<b>
агрегация {estimate.sources_used.length}/7
</b>
</div>
<div style={{ marginTop: 4 }}>
Достоверность · <b style={{ color: conf.color }}>{conf.txt}</b> · CV {cv.toFixed(1)}%
</div>
</div>
</div>
<div className="hero-top">
<div className="hero-photo" role="img" aria-label="Фасад дома">
<div className="photo-meta">
{estimate.sources_used.length > 0
? `${estimate.sources_used[0]} · ${estimate.n_analogs} аналогов`
: "Нет фото"}
</div>
</div>
<div className="hero-meta">
<div className="hero-address">
{estimate.target_address ?? input.address}
</div>
<div className="hero-cad">
ЭТАЖ {input.floor}/{input.total_floors}
{input.year_built ? ` · ${input.year_built}` : ""}
</div>
<div className="meta-grid">
{input.year_built && (
<div className="meta-row">
<span className="k">Год постройки</span>
<span className="v mono">{input.year_built}</span>
</div>
)}
{input.house_type && (
<div className="meta-row">
<span className="k">Тип дома</span>
<span className="v">{HOUSE_TYPE_LABELS[input.house_type] ?? input.house_type}</span>
</div>
)}
<div className="meta-row">
<span className="k">Этаж</span>
<span className="v mono">
{input.floor} / {input.total_floors}
</span>
</div>
<div className="meta-row">
<span className="k">Площадь</span>
<span className="v mono">{input.area_m2} м²</span>
</div>
<div className="meta-row">
<span className="k">Планировка</span>
<span className="v">
{input.rooms === 0 ? "Студия" : `${input.rooms}`}, классическая
</span>
</div>
{input.repair_state && (
<div className="meta-row">
<span className="k">Состояние</span>
<span className="v">{REPAIR_LABELS[input.repair_state] ?? input.repair_state}</span>
</div>
)}
<div className="meta-row">
<span className="k">Балкон</span>
<span className="v">{input.has_balcony ? "есть" : "нет"}</span>
</div>
<div className="meta-row">
<span className="k">Аналогов</span>
<span className="v mono">{estimate.n_analogs}</span>
</div>
</div>
</div>
</div>
<div className="hero-bars">
<div className="bar-block">
<div className="bar-head">
<span className="bar-title">
Диапазон цен в объявлениях{" "}
<span style={{ color: "var(--muted-2)" }}>(без учёта ремонта)</span>
</span>
<span className="bar-cv">медиана · {formatMln(m)} </span>
</div>
<div className="pricebar">
<div className="axis">
<div className="range" style={{ left: "5%", right: "5%" }} />
<div className="median" style={{ left: `${medianPct}%` }} />
</div>
<div className="endpoint" style={{ left: "5%" }}>
<div className="endpoint-val">{formatMln(lo)}</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: "95%" }}>
<div className="endpoint-val">{formatMln(hi)}</div>
<div className="endpoint-tick" />
</div>
<div className="median-label" style={{ left: `${medianPct}%`, top: -2 }}>
{formatMln(m)}
</div>
<div className="exp left">{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²</div>
<div className="exp right">{estimate.n_analogs} аналог.</div>
</div>
</div>
{estimate.actual_deals.length > 0 && (() => {
const dealsPrices = estimate.actual_deals.map((d) => d.price_rub);
const dLo = Math.min(...dealsPrices);
const dHi = Math.max(...dealsPrices);
const dM = dealsPrices.sort((a, b) => a - b)[Math.floor(dealsPrices.length / 2)];
const dSpan = dHi - dLo;
const dMedPct = dSpan > 0 ? Math.max(5, Math.min(95, ((dM - dLo) / dSpan) * 100)) : 50;
return (
<div className="bar-block">
<div className="bar-head">
<span className="bar-title">Диапазон цен по фактическим сделкам</span>
<span className="bar-cv">медиана · {formatMln(dM)} </span>
</div>
<div className="pricebar">
<div className="axis">
<div
className="range"
style={{
left: "5%",
right: "5%",
background: "linear-gradient(90deg,var(--viz-3),var(--viz-4))",
}}
/>
<div className="median" style={{ left: `${dMedPct}%` }} />
</div>
<div className="endpoint" style={{ left: "5%" }}>
<div className="endpoint-val">{formatMln(dLo)}</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: "95%" }}>
<div className="endpoint-val">{formatMln(dHi)}</div>
<div className="endpoint-tick" />
</div>
<div className="median-label" style={{ left: `${dMedPct}%`, top: -2 }}>
{formatMln(dM)}
</div>
<div className="exp left">{estimate.actual_deals.length} сделок</div>
<div className="exp right">Росреестр · ДомКлик</div>
</div>
</div>
);
})()}
</div>
{estimate.confidence_explanation && (
<div className="hero-warnings">
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>{estimate.confidence_explanation}</div>
</div>
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
Цены в объявлениях реальная сделка разница <b>512%</b> по данным Росреестра.
</div>
</div>
<div className="warn-row">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</span>
<div>
Самостоятельная продажа = до <b>15% потерь</b> на торге, риелторе, нотариусе и аренде.
</div>
</div>
</div>
)}
</article>
);
}

View file

@ -0,0 +1,292 @@
"use client";
/**
* ListingsCard Секция 2 «Рынок» из mockup tradein.html.
* Counts strip + filters + price bar + table.
*/
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate;
}
const SOURCE_DOTS: Record<string, string> = {
cian: "cian",
avito: "avito",
yandex: "yandex",
domklik: "dom",
rosreestr: "ros",
n1: "n1",
};
const SOURCE_LABELS: Record<string, string> = {
cian: "Циан",
avito: "Avito",
yandex: "Я.Недв",
domklik: "ДомКлик",
rosreestr: "Росреестр",
n1: "N1.ru",
};
function fmtRub(v: number): string {
return v.toLocaleString("ru-RU");
}
function fmtArea(v: number): string {
return v.toFixed(1);
}
export function ListingsCard({ estimate }: Props) {
const lots = estimate.analogs;
if (lots.length === 0) {
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 2 · Рынок</div>
<h2>Объявления аналогичные квартиры в продаже</h2>
</div>
</div>
<div className="card-body">
<p style={{ color: "var(--muted)", textAlign: "center", padding: "24px 0" }}>
Нет объявлений-аналогов в радиусе 2 км.
</p>
</div>
</article>
);
}
// Считаем источники
const sourceCounts: Record<string, number> = {};
for (const l of lots) {
if (l.source) sourceCounts[l.source] = (sourceCounts[l.source] ?? 0) + 1;
}
// Price bar bounds
const prices = lots.map((l) => l.price_rub).sort((a, b) => a - b);
const lo = prices[0];
const hi = prices[prices.length - 1];
const m = estimate.median_price_rub;
const span = hi - lo;
const medPct = span > 0 ? Math.max(5, Math.min(95, ((m - lo) / span) * 100)) : 50;
const p25 = prices[Math.floor(prices.length * 0.25)];
const p75 = prices[Math.floor(prices.length * 0.75)];
const p25Pct = span > 0 ? Math.max(5, Math.min(95, ((p25 - lo) / span) * 100)) : 25;
const p75Pct = span > 0 ? Math.max(5, Math.min(95, ((p75 - lo) / span) * 100)) : 75;
// Площадь / годы / комнаты — для filter chips
const areas = lots.map((l) => l.area_m2);
const aMin = Math.min(...areas);
const aMax = Math.max(...areas);
const rooms = lots[0]?.rooms;
return (
<article className="card">
<div className="card-head">
<div>
<div className="section-kicker">Секция 2 · Рынок</div>
<h2>Объявления аналогичные квартиры в продаже</h2>
</div>
<div className="card-meta">
<div>
Обновлено:{" "}
<b className="mono">
{estimate.data_freshness_minutes !== null
? `${estimate.data_freshness_minutes} мин назад`
: "—"}
</b>
</div>
<div style={{ marginTop: 4 }}>
Источников: <b>{estimate.sources_used.length} / 7</b>
</div>
</div>
</div>
<div className="count-strip">
<div className="count-cell">
<div className="label">Объявлений по аналогам</div>
<div className="value">
<span data-tnum>{estimate.n_analogs}</span>
<span className="unit">шт</span>
</div>
<div className="sub">из {estimate.sources_used.length} источников</div>
</div>
<div className="count-cell">
<div className="label">Медиана</div>
<div className="value">
<span data-tnum>{(m / 1_000_000).toFixed(2)}</span>
<span className="unit">млн </span>
</div>
<div className="sub">{estimate.median_price_per_m2.toLocaleString("ru-RU")} /м²</div>
</div>
<div className="count-cell">
<div className="label">Диапазон P25P75</div>
<div className="value">
<span data-tnum>
{(estimate.range_low_rub / 1_000_000).toFixed(1)}
{(estimate.range_high_rub / 1_000_000).toFixed(1)}
</span>
<span className="unit">млн</span>
</div>
<div className="sub">CV {(((estimate.range_high_rub - estimate.range_low_rub) / m) * 100).toFixed(1)}%</div>
</div>
</div>
<div className="filters-row">
<span className="lbl">Фильтр</span>
<div className="filters">
<span className="chip">
<span className="k">Расстояние</span>
<span className="v"> 2 км</span>
</span>
{rooms !== undefined && (
<span className="chip">
<span className="k">Планировка</span>
<span className="v">{rooms === 0 ? "Студия" : `${rooms}`}</span>
</span>
)}
<span className="chip">
<span className="k">Площадь</span>
<span className="v">
{fmtArea(aMin)} {fmtArea(aMax)} м²
</span>
</span>
</div>
</div>
<div className="filters-row">
<span className="lbl">Источники</span>
<div className="sources">
{Object.entries(sourceCounts).map(([src, count]) => (
<span key={src} className="source-chip">
<span className={`src-dot ${SOURCE_DOTS[src] ?? "dom"}`} /> {SOURCE_LABELS[src] ?? src} ·{" "}
<span className="mono" style={{ color: "var(--muted)" }}>
{count}
</span>
</span>
))}
</div>
</div>
<div className="pricebar-block">
<div className="bar-head">
<span
className="bar-title"
style={{ color: "var(--fg-2)", fontSize: 12, letterSpacing: "0.04em" }}
>
ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ · {(m / 1_000_000).toFixed(2)} МЛН медиана
</span>
</div>
<div className="pricebar">
<div className="axis">
<div className="range" style={{ left: "5%", right: "5%" }} />
<div className="median" style={{ left: `${medPct}%` }} />
</div>
<div className="endpoint" style={{ left: "5%" }}>
<div className="endpoint-val">{(lo / 1_000_000).toFixed(1)} млн</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: `${p25Pct}%` }} title="P25">
<div className="endpoint-val mono" style={{ fontWeight: 500, color: "var(--muted)" }}>
{(p25 / 1_000_000).toFixed(1)}
</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: `${p75Pct}%` }} title="P75">
<div className="endpoint-val mono" style={{ fontWeight: 500, color: "var(--muted)" }}>
{(p75 / 1_000_000).toFixed(1)}
</div>
<div className="endpoint-tick" />
</div>
<div className="endpoint" style={{ left: "95%" }}>
<div className="endpoint-val">{(hi / 1_000_000).toFixed(1)} млн</div>
<div className="endpoint-tick" />
</div>
<div className="median-label" style={{ left: `${medPct}%`, top: -2 }}>
{(m / 1_000_000).toFixed(2)} млн
</div>
<div className="exp left">P25 · {(p25 / 1_000_000).toFixed(1)} МЛН</div>
<div className="exp right">P75 · {(p75 / 1_000_000).toFixed(1)} МЛН</div>
</div>
</div>
<table className="dt" aria-label="Аналогичные объявления">
<thead>
<tr>
<th>Адрес</th>
<th>Источник</th>
<th className="num"> / м²</th>
<th className="num">Стоимость</th>
<th className="num">Дист.</th>
<th />
</tr>
</thead>
<tbody>
{lots.map((lot, i) => (
<AnalogRow key={i} lot={lot} />
))}
</tbody>
</table>
<div className="table-foot">
<span>
Показано <b style={{ color: "var(--fg)" }}>{lots.length}</b> из {estimate.n_analogs}{" "}
объявлений · отсортировано по расстоянию
</span>
</div>
</article>
);
}
function AnalogRow({ lot }: { lot: AnalogLot }) {
const src = lot.source ?? "";
const dot = SOURCE_DOTS[src] ?? "dom";
const label = SOURCE_LABELS[src] ?? src;
const distance =
lot.distance_m === null ? "—" : lot.distance_m < 1000 ? `${lot.distance_m} м` : `${(lot.distance_m / 1000).toFixed(1)} км`;
const onClick = lot.source_url
? () => window.open(lot.source_url!, "_blank", "noopener,noreferrer")
: undefined;
return (
<tr onClick={onClick} style={{ cursor: lot.source_url ? "pointer" : "default" }}>
<td>
<div className="addr">
<span className="a-main">{lot.address}</span>
<span className="a-sub">
{fmtArea(lot.area_m2)} м² · {lot.rooms === 0 ? "студия" : `${lot.rooms}`}
{lot.floor !== null && lot.total_floors !== null
? ` · этаж ${lot.floor}/${lot.total_floors}`
: ""}
</span>
</div>
</td>
<td>
<span className="src-mini">
<span className={`src-dot ${dot}`} /> {label}
</span>
</td>
<td className="num">{fmtRub(lot.price_per_m2)}</td>
<td className="num">{fmtRub(lot.price_rub)}</td>
<td className="num">{distance}</td>
<td>
{lot.source_url && (
<a
href={lot.source_url}
target="_blank"
rel="noopener noreferrer"
className="row-link"
onClick={(e) => e.stopPropagation()}
aria-label="Открыть оригинал"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
)}
</td>
</tr>
);
}

View file

@ -0,0 +1,192 @@
"use client";
/**
* OfferCard Секция 4 «Оффер» из mockup tradein.html.
* Сравнение trade-in vs самостоятельная продажа + 4 преимущества + CTA.
*/
import type { AggregatedEstimate } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate;
}
function fmtRub(v: number): string {
return Math.round(v).toLocaleString("ru-RU");
}
export function OfferCard({ estimate }: Props) {
const basePrice = estimate.median_price_rub;
const baseMln = basePrice / 1_000_000;
// Расчёт издержек самостоятельной продажи (от рыночной цены)
const torgLo = Math.round(basePrice * 0.05);
const torgHi = Math.round(basePrice * 0.15);
const realtorLo = Math.round((basePrice - torgHi) * 0.02);
const realtorHi = Math.round((basePrice - torgLo) * 0.03);
const totalLo = torgLo + realtorLo + 90_000 + 19_250 + 4_000;
const totalHi = torgHi + realtorHi + 150_000 + 50_000 + 36_000;
const totalPct = (((totalLo + totalHi) / 2) / basePrice) * 100;
return (
<article className="card" id="section-offer">
<div className="card-head">
<div>
<div className="section-kicker">Секция 4 · Оффер</div>
<h2>Формирование выкупной стоимости</h2>
</div>
<div className="card-meta">
<div>
База расчёта: <b className="mono">{baseMln.toFixed(2)} млн </b>
</div>
<div style={{ marginTop: 4 }}>при стоимости квартиры в объявлении</div>
</div>
</div>
<table className="offer-tbl" aria-label="Сравнение trade-in и самостоятельной продажи">
<thead>
<tr>
<th>Статья расходов</th>
<th className="col-trade">Через Trade-In</th>
<th className="col-self">Самостоятельная продажа</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div className="item-name">Торг покупателей</div>
<div className="item-sub">515% от цены объявления</div>
</td>
<td className="col-trade">не применимо</td>
<td className="col-self">
{fmtRub(torgLo)} {fmtRub(torgHi)}
</td>
</tr>
<tr>
<td>
<div className="item-name">Услуги риелтора</div>
<div className="item-sub">23% от цены сделки</div>
</td>
<td className="col-trade">бесплатно</td>
<td className="col-self">
{fmtRub(realtorLo)} {fmtRub(realtorHi)}
</td>
</tr>
<tr>
<td>
<div className="item-name">Аренда жилья после сделки</div>
<div className="item-sub">1-к квартира на 3 месяца</div>
</td>
<td className="col-trade">бесплатно</td>
<td className="col-self">90 000 150 000 </td>
</tr>
<tr>
<td>
<div className="item-name">Юридическое сопровождение</div>
<div className="item-sub">проверка, договор, регистрация</div>
</td>
<td className="col-trade">бесплатно</td>
<td className="col-self">от 19 250 </td>
</tr>
<tr>
<td>
<div className="item-name">Расходы на рекламу</div>
<div className="item-sub">платные показы Циан / Авито, 3 мес</div>
</td>
<td className="col-trade">бесплатно</td>
<td className="col-self">4 000 36 000 </td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Общие финансовые потери</td>
<td className="col-trade"> 0 </td>
<td className="col-self">
{fmtRub(totalLo)} {fmtRub(totalHi)}
</td>
</tr>
</tfoot>
</table>
<div className="warn-banner">
<span className="ic">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</span>
<div>
Издержки при самостоятельной продаже сопоставимы со скидкой <b>{totalPct.toFixed(0)}%</b> от рыночной цены.{" "}
<b>Trade-In закрывает их полностью</b>, а цена в новостройке фиксируется на момент оценки.
</div>
</div>
<div className="advantages">
<div className="advantage">
<div className="adv-ic">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7.5" cy="15.5" r="5.5" />
<path d="m21 2-9.6 9.6" />
<path d="m15.5 7.5 3 3L22 7l-3-3" />
</svg>
</div>
<h4>Экономия времени</h4>
<p>Берём на себя показы, переговоры и поиск покупателей. Менеджеру остаётся только подписать ДКП.</p>
</div>
<div className="advantage">
<div className="adv-ic">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</div>
<h4>Юридическая безопасность</h4>
<p>Проверяем чистоту документов, ЕГРН, прописанных и обременения. Исключаем риски.</p>
</div>
<div className="advantage">
<div className="adv-ic">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="19" y1="5" x2="5" y2="19" />
<circle cx="6.5" cy="6.5" r="2.5" />
<circle cx="17.5" cy="17.5" r="2.5" />
</svg>
</div>
<h4>Фиксированная цена новостройки</h4>
<p>Сохраняем планировку в каталоге по цене на момент оценки до закрытия сделки.</p>
</div>
<div className="advantage">
<div className="adv-ic">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
</div>
<h4>Гарантия цены</h4>
<p>Снимаем риск колебаний рынка выкупная стоимость зафиксирована в оффере на 30 дней.</p>
</div>
</div>
<div className="offer-cta">
<div className="offer-cta-text">
<b>Готовы отправить оффер клиенту?</b>
<br />
PDF-отчёт + персональный оффер по выкупной цене за 1 клик.
</div>
<div className="offer-cta-buttons">
<a
href={`/api/v1/trade-in/estimate/${estimate.estimate_id}/pdf`}
download={`trade-in-${estimate.estimate_id}.pdf`}
className="btn btn-ghost"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Скачать PDF
</a>
</div>
</div>
</article>
);
}

View file

@ -0,0 +1,78 @@
"use client";
interface Props {
rangeLow: number;
rangeHigh: number;
median: number;
}
function fmtRub(value: number): string {
return value.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
});
}
export function PriceRangeBar({ rangeLow, rangeHigh, median }: Props) {
const span = rangeHigh - rangeLow;
// Guard against degenerate case
const medianPct =
span > 0
? Math.max(0, Math.min(100, ((median - rangeLow) / span) * 100))
: 50;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{/* Bar */}
<div
style={{
position: "relative",
height: 10,
borderRadius: 999,
background: "linear-gradient(to right, #bfdbfe, #1d4ed8)",
}}
>
{/* Median marker */}
<div
title={`Медиана: ${fmtRub(median)}`}
style={{
position: "absolute",
top: "50%",
left: `${medianPct}%`,
transform: "translate(-50%, -50%)",
width: 14,
height: 14,
borderRadius: "50%",
background: "#1d4ed8",
border: "2px solid #fff",
boxShadow: "0 1px 4px rgba(0,0,0,0.25)",
}}
/>
</div>
{/* Labels */}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
color: "#5b6066",
fontVariantNumeric: "tabular-nums",
}}
>
<span>{fmtRub(rangeLow)}</span>
<span
style={{
fontWeight: 700,
color: "#1d4ed8",
fontSize: 13,
}}
>
{fmtRub(median)}
</span>
<span>{fmtRub(rangeHigh)}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,208 @@
"use client";
/**
* SourcesProgress карточка «Шаг B · Агрегация» под mockup tradein.html.
*
* Показывает статус 7 источников (Циан, Авито Оценка, ДомКлик Прайс, Я.Недвижимость,
* Restate, N1.ru, Дом.РФ, Росреестр (внутр.)). Сейчас у нас реально работают 3:
* Avito + Cian + Yandex. Остальные показываем как timeout / pending для соответствия
* макету.
*/
import type { AggregatedEstimate } from "@/types/trade-in";
interface Props {
estimate: AggregatedEstimate | null;
isPending: boolean;
}
interface SourceRow {
key: string;
label: string;
dotClass: string;
status: "done" | "loading" | "error" | "idle";
count?: number;
median?: number;
}
function formatMln(rub: number): string {
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
}
export function SourcesProgress({ estimate, isPending }: Props) {
// Маппинг реальных данных из estimate на 7 строк-источников
const used = new Set(estimate?.sources_used ?? []);
const isDone = estimate !== null && !isPending;
// Считаем сколько лотов из какого источника
const countBySource: Record<string, number> = {};
if (estimate) {
for (const a of estimate.analogs) {
if (a.source) {
countBySource[a.source] = (countBySource[a.source] ?? 0) + 1;
}
}
}
const rows: SourceRow[] = [
{
key: "cian",
label: "Циан",
dotClass: "cian",
status: used.has("cian") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.cian,
median: used.has("cian") ? estimate?.median_price_rub : undefined,
},
{
key: "avito",
label: "Авито Оценка",
dotClass: "avito",
status: used.has("avito") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.avito,
median: used.has("avito") ? estimate?.median_price_rub : undefined,
},
{
key: "domklik",
label: "ДомКлик Прайс",
dotClass: "dom",
status: isPending ? "loading" : "idle",
},
{
key: "yandex",
label: "Я.Недвижимость",
dotClass: "yandex",
status: used.has("yandex") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.yandex,
median: used.has("yandex") ? estimate?.median_price_rub : undefined,
},
{
key: "restate",
label: "Restate",
dotClass: "etagi",
status: isPending ? "loading" : "idle",
},
{
key: "n1",
label: "N1.ru",
dotClass: "n1",
// Если pending — loading; если готово — error (timeout); иначе idle (не запускали)
status: isPending ? "loading" : (isDone ? "error" : "idle"),
},
{
key: "rosreestr",
label: "Росреестр (внутр.)",
dotClass: "ros",
status: isDone && (estimate?.actual_deals.length ?? 0) > 0
? "done"
: (isPending ? "loading" : "idle"),
count: estimate?.actual_deals.length,
},
];
const doneCount = rows.filter((r) => r.status === "done").length;
const totalCount = rows.length;
const overallPct = isDone ? 100 : Math.min(95, (doneCount / totalCount) * 100 + (isPending ? 10 : 0));
return (
<article className="card progress-card">
<div className="card-head">
<div>
<div className="section-kicker">Шаг B · Агрегация</div>
<h2>Сбор данных по квартире</h2>
</div>
<div className="card-meta">
<div className="num-done">
<span data-tnum>{doneCount}</span>
<span className="total"> / {totalCount}</span> источников
</div>
{estimate && (
<div style={{ marginTop: 4 }}>
медиана:{" "}
<b className="mono" style={{ color: "var(--fg)" }}>
{formatMln(estimate.median_price_rub)}
</b>
</div>
)}
</div>
</div>
<div className="card-body">
<div className="progress-summary">
<span className="lead">
{isPending
? "Идёт параллельный запрос — Celery group, timeout 30 сек."
: isDone
? "Готово. Частичные результаты доступны при недоступных источниках."
: "Введите параметры квартиры и нажмите «Оценить»."}
</span>
<div className="progress-overall">
<div className="bar" style={{ width: `${overallPct}%` }} />
</div>
</div>
<div className="progress-list">
{rows.map((r) => (
<div key={r.key} className={`progress-row is-${r.status}`}>
<span className={`status-icon ${r.status}`}>
{r.status === "done" && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{r.status === "loading" && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)}
{r.status === "error" && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
)}
{r.status === "idle" && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
</svg>
)}
</span>
<span className="src-name">{r.label}</span>
<span className="src-bar">
<i
style={{
// @ts-expect-error CSS custom prop
"--p": r.status === "done" ? "100%" : r.status === "loading" ? "60%" : "0%",
}}
/>
</span>
<span
className="src-value"
style={{ color: r.status === "error" ? "var(--danger)" : undefined }}
>
{r.status === "done" && r.count !== undefined && (
<>
<b>{r.count} лотов</b>
</>
)}
{r.status === "done" && r.count === undefined && <b>готово</b>}
{r.status === "loading" && "сбор..."}
{r.status === "error" && "timeout — нет ответа"}
{r.status === "idle" && <span style={{ color: "var(--muted-2)" }}>ожидает запрос</span>}
</span>
</div>
))}
</div>
</div>
<div className="card-foot">
<span className="mono" style={{ fontSize: 11, letterSpacing: "0.06em" }}>
CACHE-KEY ·{" "}
</span>
<span className="mono" style={{ fontSize: 11 }}>
{estimate?.estimate_id?.slice(0, 18) ?? "—"}
</span>{" "}
·{" "}
<span>при недоступности источника частичный результат не блокируется</span>
</div>
</article>
);
}

View file

@ -0,0 +1,185 @@
"use client";
/**
* TestPresets кнопки быстрого заполнения формы тестовыми квартирами.
* Все эти квартиры дают medium / high confidence (хорошее покрытие в DB).
*/
import type { TradeInEstimateInput } from "@/types/trade-in";
interface Preset {
label: string;
hint: string;
data: TradeInEstimateInput;
}
const PRESETS: Preset[] = [
{
label: "Малышева 84 · 2к/55м²",
hint: "Центр · 36 аналогов",
data: {
address: "Екатеринбург, ул. Малышева, 84",
area_m2: 55,
rooms: 2,
floor: 4,
total_floors: 9,
year_built: 1990,
house_type: "brick",
},
},
{
label: "Куйбышева 48 · 3к/75м²",
hint: "Парковый · 22 аналога",
data: {
address: "Екатеринбург, ул. Куйбышева, 48",
area_m2: 75,
rooms: 3,
floor: 7,
total_floors: 12,
year_built: 2005,
house_type: "monolith",
},
},
{
label: "Цвиллинга 58 · 1к/46м²",
hint: "Гольфстрим · 17 аналогов",
data: {
address: "Екатеринбург, улица Цвиллинга, 58",
area_m2: 46,
rooms: 1,
floor: 4,
total_floors: 25,
year_built: 2020,
house_type: "monolith",
},
},
{
label: "8 Марта 50 · 1к/45м²",
hint: "Ленинский · 19 аналогов",
data: {
address: "Екатеринбург, ул. 8 Марта, 50",
area_m2: 45,
rooms: 1,
floor: 3,
total_floors: 9,
year_built: 1985,
house_type: "brick",
},
},
{
label: "Малышева 84 · 3к/68м²",
hint: "Центр · 33 аналога",
data: {
address: "Екатеринбург, ул. Малышева, 84",
area_m2: 68,
rooms: 3,
floor: 5,
total_floors: 9,
year_built: 1980,
house_type: "brick",
},
},
{
label: "Белинского 200 · 2к/50м²",
hint: "ЮЗ · 7 аналогов · премиум",
data: {
address: "Екатеринбург, ул. Белинского, 200",
area_m2: 50,
rooms: 2,
floor: 6,
total_floors: 10,
year_built: 1990,
house_type: "panel",
},
},
];
interface Props {
onPick: (data: TradeInEstimateInput) => void;
}
export function TestPresets({ onPick }: Props) {
return (
<div className="test-presets">
<div className="test-presets-label">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
<span>Тестовые квартиры</span>
<span className="hint">кликни заполнится форма</span>
</div>
<div className="test-presets-grid">
{PRESETS.map((p, i) => (
<button
key={i}
type="button"
className="preset-chip"
onClick={() => onPick(p.data)}
>
<span className="preset-label">{p.label}</span>
<span className="preset-hint">{p.hint}</span>
</button>
))}
</div>
<style>{`
.test-presets {
margin-top: 16px;
padding: 12px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.test-presets-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--fg-2);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 10px;
}
.test-presets-label svg { color: var(--accent-2); }
.test-presets-label .hint {
font-weight: 400;
color: var(--muted-2);
text-transform: none;
letter-spacing: 0;
font-size: 11px;
margin-left: auto;
}
.test-presets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 8px;
}
.preset-chip {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
font: 500 12px var(--font-sans);
color: var(--fg);
transition: border-color .12s, background .12s;
}
.preset-chip:hover {
border-color: var(--accent);
background: var(--accent-soft);
}
.preset-label {
font-weight: 600;
color: var(--fg);
}
.preset-hint {
font-size: 11px;
color: var(--muted);
}
`}</style>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,92 @@
import { getOrCreateSessionId } from "@/lib/sessionId";
// Empty default = same-origin relative URLs.
// In prod Caddy proxies /api/* to backend; in dev Next.js rewrites do the same.
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
/**
* Merges a global X-Session-Id header into the request init.
* Only runs in browser (SSR guard). User-supplied headers win over the
* auto-injected value so explicit overrides remain backward-compatible.
*/
function withSessionHeader(init?: RequestInit): RequestInit {
const base: RequestInit = init ?? {};
if (typeof window === "undefined") return base;
const sessionId = getOrCreateSessionId();
if (!sessionId) return base;
return {
...base,
headers: {
"X-Session-Id": sessionId,
...(base.headers ?? {}),
},
};
}
export async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
const merged = withSessionHeader(init);
const response = await fetch(`${API_BASE_URL}${path}`, {
...merged,
headers: {
"Content-Type": "application/json",
...(merged.headers ?? {}),
},
});
if (!response.ok) {
throw new Error(`API error ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<T>;
}
/**
* Status-aware variant of apiFetch возвращает status + body для случаев
* когда логика зависит от HTTP кода (например 202 Accepted для async jobs).
*
* Throws на 4xx/5xx (используя HTTPError ниже); 2xx коды все ОК.
*/
export class HTTPError extends Error {
status: number;
body: unknown;
constructor(status: number, body: unknown, msg?: string) {
super(msg ?? `API error ${status}`);
this.status = status;
this.body = body;
}
}
export async function apiFetchWithStatus<T>(
path: string,
init?: RequestInit,
): Promise<{ status: number; body: T }> {
const merged = withSessionHeader(init);
const response = await fetch(`${API_BASE_URL}${path}`, {
...merged,
headers: {
"Content-Type": "application/json",
...(merged.headers ?? {}),
},
});
let body: unknown = null;
// 204 No Content имеет пустое тело; для всех остальных читаем как text один раз
// (fetch body stream consumable только один раз — нельзя сначала .json() потом .text()).
if (response.status !== 204) {
const rawText = await response.text();
if (rawText) {
try {
body = JSON.parse(rawText);
} catch {
// Не JSON (HTML error page от Caddy / 502 / etc) — оборачиваем в detail
body = { detail: rawText };
}
}
}
if (!response.ok) {
const detail =
(body as { detail?: string })?.detail ?? `API error ${response.status}`;
throw new HTTPError(response.status, body, detail);
}
return { status: response.status, body: body as T };
}

View file

@ -0,0 +1,35 @@
/**
* Session ID utility for custom POI auth.
* Stored in localStorage under "session_id" key.
* Returns empty string during SSR (window not available).
*
* ВАЖНО: `crypto.randomUUID()` доступен только в secure context (HTTPS / localhost).
* На HTTP-страницах (например, http://94.228.121.73:8091) он undefined → fallback на ручную генерацию.
*/
function generateUUID(): string {
// Native — если есть и работает (HTTPS / localhost / file://)
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
try {
return crypto.randomUUID();
} catch {
// редкие браузеры могут бросить SecurityError — падаем в ручной fallback
}
}
// Fallback: v4 UUID через Math.random (НЕ криптостойко, но для session_id хватает)
// Шаблон: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx где y ∈ {8,9,a,b}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export function getOrCreateSessionId(): string {
if (typeof window === "undefined") return "";
let id = localStorage.getItem("session_id");
if (!id) {
id = generateUUID();
localStorage.setItem("session_id", id);
}
return id;
}

View file

@ -0,0 +1,39 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { apiFetch } from "./api";
import type {
AggregatedEstimate,
TradeInEstimateInput,
} from "@/types/trade-in";
const BASE = "/api/v1/trade-in";
/**
* POST /api/v1/trade-in/estimate
* Sends apartment parameters and returns AggregatedEstimate (mock data for now).
*/
export function useEstimateMutation() {
return useMutation<AggregatedEstimate, Error, TradeInEstimateInput>({
mutationFn: (input) =>
apiFetch<AggregatedEstimate>(`${BASE}/estimate`, {
method: "POST",
body: JSON.stringify(input),
}),
});
}
/**
* GET /api/v1/trade-in/estimate/{id}
* Fetches a previously computed estimate by UUID (for shareable links / PDF).
*/
export function useEstimate(estimate_id: string | null) {
return useQuery<AggregatedEstimate>({
queryKey: ["trade-in", "estimate", estimate_id],
queryFn: () =>
apiFetch<AggregatedEstimate>(`${BASE}/estimate/${estimate_id}`),
enabled: estimate_id !== null && estimate_id.length > 0,
staleTime: 10 * 60_000, // 10 min — estimates expire at expires_at anyway
});
}

View file

@ -0,0 +1,63 @@
// Trade-In Estimator — TypeScript types
// Mirrors Pydantic schemas from backend/app/schemas/trade_in.py.
export type HouseType =
| "panel"
| "brick"
| "monolith"
| "monolith_brick"
| "other";
export type RepairState = "needs_repair" | "standard" | "good" | "excellent";
export type ConfidenceLevel = "low" | "medium" | "high";
export interface TradeInEstimateInput {
address: string; // min 3, max 500
area_m2: number; // 10 < x < 500
rooms: number; // 0-10 (0 = студия)
floor: number; // 1-100
total_floors: number; // 1-100
year_built?: number; // 1800-2100
house_type?: HouseType;
repair_state?: RepairState;
has_balcony?: boolean;
}
export interface AnalogLot {
address: string;
area_m2: number;
rooms: number;
floor: number | null;
total_floors: number | null;
price_rub: number;
price_per_m2: number;
listing_date: string | null; // ISO date
days_on_market: number | null;
photo_url: string | null;
// ── Слой 5.2 — clickable links + distance ──
source: string | null; // 'avito' / 'cian' / 'domklik' / 'rosreestr'
source_url: string | null; // ссылка на оригинал
distance_m: number | null; // расстояние до целевой квартиры в метрах
}
export interface AggregatedEstimate {
estimate_id: string; // UUID
median_price_rub: number;
range_low_rub: number;
range_high_rub: number;
median_price_per_m2: number;
confidence: ConfidenceLevel;
confidence_explanation: string | null;
n_analogs: number;
period_months: number; // 24
analogs: AnalogLot[]; // top 5-10
actual_deals: AnalogLot[]; // last 12 mo
expires_at: string; // ISO datetime
// ── Метаданные ──
target_address: string | null;
target_lat: number | null;
target_lon: number | null;
sources_used: string[]; // ['avito', 'cian', 'rosreestr']
data_freshness_minutes: number | null; // «обновлено N минут назад»
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*"]
},
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}