feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in) #369
73 changed files with 10087 additions and 0 deletions
192
.forgejo/workflows/deploy-tradein.yml
Normal file
192
.forgejo/workflows/deploy-tradein.yml
Normal 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
|
||||
18
Caddyfile
18
Caddyfile
|
|
@ -25,6 +25,24 @@ gendsgn.ru {
|
|||
file_server browse
|
||||
}
|
||||
|
||||
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
|
||||
# подключен через gendesign_shared network. Routes ДО универсального handle
|
||||
# потому что Caddy матчит handle-блоки сверху вниз.
|
||||
handle_path /trade-in/api/* {
|
||||
# handle_path вырезает префикс /trade-in перед прокси →
|
||||
# FastAPI получает /api/v1/...
|
||||
reverse_proxy tradein-backend:8000
|
||||
}
|
||||
|
||||
handle /trade-in {
|
||||
redir /trade-in/ permanent
|
||||
}
|
||||
|
||||
handle /trade-in/* {
|
||||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||
reverse_proxy tradein-frontend:3000
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
|
|
|
|||
17
tradein-mvp/.env.example
Normal file
17
tradein-mvp/.env.example
Normal 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
50
tradein-mvp/.gitignore
vendored
Normal 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
147
tradein-mvp/DEPLOY.md
Normal 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
63
tradein-mvp/Makefile
Normal 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
196
tradein-mvp/README.md
Normal 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).
|
||||
13
tradein-mvp/backend/.dockerignore
Normal file
13
tradein-mvp/backend/.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.venv
|
||||
__pycache__
|
||||
**/__pycache__
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
data/sql
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
56
tradein-mvp/backend/Dockerfile
Normal file
56
tradein-mvp/backend/Dockerfile
Normal 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"]
|
||||
0
tradein-mvp/backend/app/__init__.py
Normal file
0
tradein-mvp/backend/app/__init__.py
Normal file
0
tradein-mvp/backend/app/api/__init__.py
Normal file
0
tradein-mvp/backend/app/api/__init__.py
Normal file
0
tradein-mvp/backend/app/api/v1/__init__.py
Normal file
0
tradein-mvp/backend/app/api/v1/__init__.py
Normal file
151
tradein-mvp/backend/app/api/v1/admin.py
Normal file
151
tradein-mvp/backend/app/api/v1/admin.py
Normal 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}
|
||||
22
tradein-mvp/backend/app/api/v1/brand.py
Normal file
22
tradein-mvp/backend/app/api/v1/brand.py
Normal 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)
|
||||
70
tradein-mvp/backend/app/api/v1/geocode.py
Normal file
70
tradein-mvp/backend/app/api/v1/geocode.py
Normal 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
|
||||
])
|
||||
411
tradein-mvp/backend/app/api/v1/trade_in.py
Normal file
411
tradein-mvp/backend/app/api/v1/trade_in.py
Normal 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}"'},
|
||||
)
|
||||
0
tradein-mvp/backend/app/core/__init__.py
Normal file
0
tradein-mvp/backend/app/core/__init__.py
Normal file
22
tradein-mvp/backend/app/core/config.py
Normal file
22
tradein-mvp/backend/app/core/config.py
Normal 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()
|
||||
21
tradein-mvp/backend/app/core/db.py
Normal file
21
tradein-mvp/backend/app/core/db.py
Normal 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()
|
||||
45
tradein-mvp/backend/app/main.py
Normal file
45
tradein-mvp/backend/app/main.py
Normal 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"])
|
||||
0
tradein-mvp/backend/app/schemas/__init__.py
Normal file
0
tradein-mvp/backend/app/schemas/__init__.py
Normal file
62
tradein-mvp/backend/app/schemas/trade_in.py
Normal file
62
tradein-mvp/backend/app/schemas/trade_in.py
Normal 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 # сколько минут назад был самый свежий парсинг
|
||||
0
tradein-mvp/backend/app/services/__init__.py
Normal file
0
tradein-mvp/backend/app/services/__init__.py
Normal file
66
tradein-mvp/backend/app/services/brand.py
Normal file
66
tradein-mvp/backend/app/services/brand.py
Normal 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,
|
||||
)
|
||||
462
tradein-mvp/backend/app/services/estimator.py
Normal file
462
tradein-mvp/backend/app/services/estimator.py
Normal 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 — n≥10 AND IQR/median < 0.15
|
||||
medium — n≥5 OR IQR/median < 0.25
|
||||
low — иначе
|
||||
"""
|
||||
if median_ppm2 == 0:
|
||||
return "low", "Не найдено аналогов — попробуйте уточнить адрес или расширить параметры."
|
||||
|
||||
iqr = q3 - q1
|
||||
iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
|
||||
notes = []
|
||||
if fallback_radius_used:
|
||||
notes.append("расширили радиус до 2 км")
|
||||
if area_widened:
|
||||
notes.append("расширили допуск по площади до ±25%")
|
||||
fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""
|
||||
|
||||
if n_analogs >= 10 and iqr_pct < 0.15:
|
||||
return (
|
||||
"high",
|
||||
f"Найдено {n_analogs} аналогов, разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}.",
|
||||
)
|
||||
# medium только если есть достаточно точек ИЛИ узкий разброс при ≥3 точках
|
||||
if n_analogs >= 5 or (n_analogs >= 3 and iqr_pct < 0.25):
|
||||
return (
|
||||
"medium",
|
||||
f"Найдено {n_analogs} аналогов, разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}.",
|
||||
)
|
||||
return (
|
||||
"low",
|
||||
f"Только {n_analogs} аналог{'а' if 2 <= n_analogs <= 4 else 'ов' if n_analogs != 1 else ''}, "
|
||||
f"разброс ±{int(iqr_pct * 100 / 2)}% — рекомендуется ручная проверка{fallback_note}.",
|
||||
)
|
||||
|
||||
|
||||
def _listing_to_analog(row: dict[str, Any]) -> AnalogLot:
|
||||
return AnalogLot(
|
||||
address=row.get("address") or "",
|
||||
area_m2=float(row.get("area_m2") or 0),
|
||||
rooms=int(row.get("rooms") or 0),
|
||||
floor=row.get("floor"),
|
||||
total_floors=row.get("total_floors"),
|
||||
price_rub=int(row["price_rub"]),
|
||||
price_per_m2=int(row.get("price_per_m2") or 0),
|
||||
listing_date=row.get("listing_date"),
|
||||
days_on_market=row.get("days_on_market"),
|
||||
photo_url=(row.get("photo_urls") or [None])[0] if isinstance(row.get("photo_urls"), list) else None,
|
||||
source=row.get("source"),
|
||||
source_url=row.get("source_url"),
|
||||
distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _deal_to_analog(row: dict[str, Any]) -> AnalogLot:
|
||||
"""deals не имеют photo_url — упрощённо."""
|
||||
return AnalogLot(
|
||||
address=row.get("address") or "",
|
||||
area_m2=float(row.get("area_m2") or 0),
|
||||
rooms=int(row.get("rooms") or 0),
|
||||
floor=row.get("floor"),
|
||||
total_floors=row.get("total_floors"),
|
||||
price_rub=int(row["price_rub"]),
|
||||
price_per_m2=int(row.get("price_per_m2") or 0),
|
||||
listing_date=row.get("deal_date"),
|
||||
days_on_market=row.get("days_on_market"),
|
||||
photo_url=None,
|
||||
source=row.get("source"),
|
||||
source_url=None, # rosreestr сделки без публичной ссылки
|
||||
distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _empty_estimate(payload: TradeInEstimateInput, *, reason: str) -> AggregatedEstimate:
|
||||
"""Fallback когда нет данных для оценки."""
|
||||
now = datetime.now(tz=UTC)
|
||||
return AggregatedEstimate(
|
||||
estimate_id=uuid4(),
|
||||
median_price_rub=0,
|
||||
range_low_rub=0,
|
||||
range_high_rub=0,
|
||||
median_price_per_m2=0,
|
||||
confidence="low",
|
||||
n_analogs=0,
|
||||
period_months=DEALS_PERIOD_MONTHS,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
expires_at=now + timedelta(hours=24),
|
||||
)
|
||||
0
tradein-mvp/backend/app/services/exporters/__init__.py
Normal file
0
tradein-mvp/backend/app/services/exporters/__init__.py
Normal file
979
tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
Normal file
979
tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
Normal 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">Фактические сделки проходят на 5–12% ниже, что подтверждают Росреестр, ДомКлик и продажи агенств недвижимости</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;">
|
||||
По данным реальных сделок, квартиры продаются в среднем на 5–12 % дешевле,
|
||||
чем заявлено в объявлениях
|
||||
</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
|
||||
535
tradein-mvp/backend/app/services/geocoder.py
Normal file
535
tradein-mvp/backend/app/services/geocoder.py
Normal 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
|
||||
0
tradein-mvp/backend/app/services/scrapers/__init__.py
Normal file
0
tradein-mvp/backend/app/services/scrapers/__init__.py
Normal file
409
tradein-mvp/backend/app/services/scrapers/avito.py
Normal file
409
tradein-mvp/backend/app/services/scrapers/avito.py
Normal 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 []
|
||||
271
tradein-mvp/backend/app/services/scrapers/base.py
Normal file
271
tradein-mvp/backend/app/services/scrapers/base.py
Normal 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)
|
||||
473
tradein-mvp/backend/app/services/scrapers/cian.py
Normal file
473
tradein-mvp/backend/app/services/scrapers/cian.py
Normal 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
|
||||
290
tradein-mvp/backend/app/services/scrapers/yandex_realty.py
Normal file
290
tradein-mvp/backend/app/services/scrapers/yandex_realty.py
Normal 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")
|
||||
43
tradein-mvp/backend/data/sql/001_trade_in_estimates.sql
Normal file
43
tradein-mvp/backend/data/sql/001_trade_in_estimates.sql
Normal 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;
|
||||
234
tradein-mvp/backend/data/sql/002_core_tables.sql
Normal file
234
tradein-mvp/backend/data/sql/002_core_tables.sql
Normal 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;
|
||||
147
tradein-mvp/backend/data/sql/003_seed_listings.sql
Normal file
147
tradein-mvp/backend/data/sql/003_seed_listings.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
38
tradein-mvp/backend/pyproject.toml
Normal file
38
tradein-mvp/backend/pyproject.toml
Normal 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"]
|
||||
29
tradein-mvp/deploy/Caddyfile
Normal file
29
tradein-mvp/deploy/Caddyfile
Normal 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
|
||||
}
|
||||
}
|
||||
28
tradein-mvp/deploy/Caddyfile.tradein-fragment
Normal file
28
tradein-mvp/deploy/Caddyfile.tradein-fragment
Normal 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
|
||||
}
|
||||
79
tradein-mvp/deploy/cron-scrape.sh
Normal file
79
tradein-mvp/deploy/cron-scrape.sh
Normal 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 ==="
|
||||
75
tradein-mvp/docker-compose.prod.yml
Normal file
75
tradein-mvp/docker-compose.prod.yml
Normal 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
|
||||
88
tradein-mvp/docker-compose.yml
Normal file
88
tradein-mvp/docker-compose.yml
Normal 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:
|
||||
BIN
tradein-mvp/docs/BRUSNIKA_REFERENCE_EKB-2485.pdf
Normal file
BIN
tradein-mvp/docs/BRUSNIKA_REFERENCE_EKB-2485.pdf
Normal file
Binary file not shown.
BIN
tradein-mvp/docs/PTITSA_MEETING_2026-05-19.pdf
Normal file
BIN
tradein-mvp/docs/PTITSA_MEETING_2026-05-19.pdf
Normal file
Binary file not shown.
35
tradein-mvp/docs/README.md
Normal file
35
tradein-mvp/docs/README.md
Normal 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
439
tradein-mvp/docs/ROADMAP.md
Normal 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) — но это позже
|
||||
BIN
tradein-mvp/docs/sample-prinzip.pdf
Normal file
BIN
tradein-mvp/docs/sample-prinzip.pdf
Normal file
Binary file not shown.
BIN
tradein-mvp/docs/sample-report.pdf
Normal file
BIN
tradein-mvp/docs/sample-report.pdf
Normal file
Binary file not shown.
7
tradein-mvp/frontend/.dockerignore
Normal file
7
tradein-mvp/frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*.local
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
41
tradein-mvp/frontend/Dockerfile
Normal file
41
tradein-mvp/frontend/Dockerfile
Normal 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"]
|
||||
37
tradein-mvp/frontend/next.config.ts
Normal file
37
tradein-mvp/frontend/next.config.ts
Normal 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;
|
||||
26
tradein-mvp/frontend/package.json
Normal file
26
tradein-mvp/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
90
tradein-mvp/frontend/src/app/globals.css
Normal file
90
tradein-mvp/frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
23
tradein-mvp/frontend/src/app/layout.tsx
Normal file
23
tradein-mvp/frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
tradein-mvp/frontend/src/app/page.tsx
Normal file
5
tradein-mvp/frontend/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/trade-in");
|
||||
}
|
||||
19
tradein-mvp/frontend/src/app/providers.tsx
Normal file
19
tradein-mvp/frontend/src/app/providers.tsx
Normal 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>;
|
||||
}
|
||||
190
tradein-mvp/frontend/src/app/trade-in/page.tsx
Normal file
190
tradein-mvp/frontend/src/app/trade-in/page.tsx
Normal 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">10–30 сек</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
265
tradein-mvp/frontend/src/components/trade-in/AddressInput.tsx
Normal file
265
tradein-mvp/frontend/src/components/trade-in/AddressInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
Normal file
187
tradein-mvp/frontend/src/components/trade-in/DealsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
350
tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx
Normal file
350
tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
Normal file
255
tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
Normal 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>5–12%</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>
|
||||
);
|
||||
}
|
||||
292
tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx
Normal file
292
tradein-mvp/frontend/src/components/trade-in/ListingsCard.tsx
Normal 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">Диапазон P25–P75</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>
|
||||
);
|
||||
}
|
||||
192
tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx
Normal file
192
tradein-mvp/frontend/src/components/trade-in/OfferCard.tsx
Normal 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">5–15% от цены объявления</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">2–3% от цены сделки</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
208
tradein-mvp/frontend/src/components/trade-in/SourcesProgress.tsx
Normal file
208
tradein-mvp/frontend/src/components/trade-in/SourcesProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
tradein-mvp/frontend/src/components/trade-in/TestPresets.tsx
Normal file
185
tradein-mvp/frontend/src/components/trade-in/TestPresets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1090
tradein-mvp/frontend/src/components/trade-in/trade-in.css
Normal file
1090
tradein-mvp/frontend/src/components/trade-in/trade-in.css
Normal file
File diff suppressed because it is too large
Load diff
92
tradein-mvp/frontend/src/lib/api.ts
Normal file
92
tradein-mvp/frontend/src/lib/api.ts
Normal 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 };
|
||||
}
|
||||
35
tradein-mvp/frontend/src/lib/sessionId.ts
Normal file
35
tradein-mvp/frontend/src/lib/sessionId.ts
Normal 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;
|
||||
}
|
||||
39
tradein-mvp/frontend/src/lib/trade-in-api.ts
Normal file
39
tradein-mvp/frontend/src/lib/trade-in-api.ts
Normal 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
|
||||
});
|
||||
}
|
||||
63
tradein-mvp/frontend/src/types/trade-in.ts
Normal file
63
tradein-mvp/frontend/src/types/trade-in.ts
Normal 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 минут назад»
|
||||
}
|
||||
23
tradein-mvp/frontend/tsconfig.json
Normal file
23
tradein-mvp/frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue