Merge branch 'main' into fix/report-data-1953
This commit is contained in:
commit
d5e51ca809
34 changed files with 3862 additions and 1464 deletions
|
|
@ -44,7 +44,7 @@ Write-Host "✓ PAT belongs to $($me.login) — analyst persona ready"
|
||||||
|
|
||||||
Следую правилам из `.claude/agents/auto-analyst.md` + `.claude/agents/_autonomous_pickup.md`.
|
Следую правилам из `.claude/agents/auto-analyst.md` + `.claude/agents/_autonomous_pickup.md`.
|
||||||
|
|
||||||
**Что делаю каждый /loop tick (30m):**
|
**Что делаю каждый /loop tick (15m):**
|
||||||
|
|
||||||
1. Kill-switch check (label `pause-bots` на repo)
|
1. Kill-switch check (label `pause-bots` на repo)
|
||||||
2. Read новые commits + vault inbox + closed-since-last-tick Forgejo issues
|
2. Read новые commits + vault inbox + closed-since-last-tick Forgejo issues
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ curl -sH "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_URL/api/v1/user/tokens"
|
||||||
|
|
||||||
Следую правилам из `.claude/agents/auto-code-reviewer.md` + `_autonomous_pickup.md` + `.claude/agents/code-reviewer.md` + `.claude/rules/git-pr.md`.
|
Следую правилам из `.claude/agents/auto-code-reviewer.md` + `_autonomous_pickup.md` + `.claude/agents/code-reviewer.md` + `.claude/rules/git-pr.md`.
|
||||||
|
|
||||||
**Per-tick workflow (5m):**
|
**Per-tick workflow (2m):**
|
||||||
|
|
||||||
1. Kill-switch check
|
1. Kill-switch check
|
||||||
2. GET pulls `status/review` без approve, oldest first, limit=1
|
2. GET pulls `status/review` без approve, oldest first, limit=1
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
---
|
---
|
||||||
paths: backend/**/*.py
|
paths:
|
||||||
|
- backend/**/*.py
|
||||||
|
- tradein-mvp/backend/**/*.py
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend conventions — Python 3.12 / FastAPI
|
# Backend conventions — Python 3.12 / FastAPI
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,11 @@ paths:
|
||||||
|
|
||||||
Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod 500 на by-bbox, потом 400 на analyze, потом TypeError на poi-score, потом UI overlap. Каждое ловилось бы playwright smoke по `/site-finder/analysis/{cad}`.
|
Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod 500 на by-bbox, потом 400 на analyze, потом TypeError на poi-score, потом UI overlap. Каждое ловилось бы playwright smoke по `/site-finder/analysis/{cad}`.
|
||||||
|
|
||||||
## GHA path triggers
|
## Path triggers (Forgejo Actions, `.forgejo/workflows/`)
|
||||||
|
|
||||||
- `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/*.sql` → `deploy.yml` (main stack)
|
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack)
|
||||||
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh` → `deploy-obsidian.yml`
|
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
|
||||||
|
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh` → `.github/workflows/deploy-obsidian.yml`
|
||||||
- `docs/**` alone → НЕ триггерит деплой
|
- `docs/**` alone → НЕ триггерит деплой
|
||||||
|
|
||||||
## После изменения .env на VPS
|
## После изменения .env на VPS
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
---
|
---
|
||||||
paths: frontend/**/*.{ts,tsx,jsx,js}
|
paths:
|
||||||
|
- frontend/**/*.{ts,tsx,jsx,js}
|
||||||
|
- tradein-mvp/frontend/**/*.{ts,tsx,jsx,js}
|
||||||
---
|
---
|
||||||
|
|
||||||
# Frontend conventions — Next.js 15 / React 19 / TypeScript 5
|
# Frontend conventions — Next.js 15 / React 19 / TypeScript 5
|
||||||
|
|
@ -30,7 +32,7 @@ Reference: vault `Bug_RenderMarkdown_JavascriptUrl_May14` (`renderMarkdown.ts:sa
|
||||||
cd frontend && npm run codegen
|
cd frontend && npm run codegen
|
||||||
```
|
```
|
||||||
|
|
||||||
Обновляет `src/types/openapi.ts` из live OpenAPI. Без этого frontend build упадёт на missing types.
|
Обновляет `src/lib/api-types.ts` из live OpenAPI (`npm run codegen` = `openapi-typescript … -o src/lib/api-types.ts`). Без этого frontend build упадёт на missing types.
|
||||||
|
|
||||||
## package.json + lockfile sync (CRITICAL — deploy aborts on mismatch)
|
## package.json + lockfile sync (CRITICAL — deploy aborts on mismatch)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@ paths:
|
||||||
- **Imperative**: "fix crash" не "fixed crash"
|
- **Imperative**: "fix crash" не "fixed crash"
|
||||||
- **Body — почему**, не что (что видно в diff)
|
- **Body — почему**, не что (что видно в diff)
|
||||||
- **NO `Co-Authored-By: Claude ...`** — никогда (~/.claude/CLAUDE.md rule)
|
- **NO `Co-Authored-By: Claude ...`** — никогда (~/.claude/CLAUDE.md rule)
|
||||||
- Workers оставляют staged — main session коммитит
|
- Workers (subagents) оставляют staged — main session коммитит
|
||||||
- **No auto-commit**: написать commit message в чат, user решает когда коммитить
|
- **GenDesign (Mera/Ptica) = full self-service:** main/solo session коммитит → пушит → PR → **merge сам** (см. § Auto-merge policy). `balance_platform` — наоборот: только stage, commit message в чат, не коммитить/пушить/мержить
|
||||||
|
|
||||||
## PR body template
|
## PR body template
|
||||||
|
|
||||||
|
|
@ -85,11 +85,11 @@ Closes #N
|
||||||
|
|
||||||
## Auto-merge policy
|
## Auto-merge policy
|
||||||
|
|
||||||
**Любой scope** — bot мержит при `verdict=approve` + SHA match. Blocked-list снят 2026-05-16 ([Auto-merge any scope] memory rule).
|
**Self-merge разрешён (2026-06-27, Mera/Ptica).** Любая GenDesign-сессия — solo/foreground ИЛИ bot-pipeline — мержит свой PR сама (любой scope), когда checks зелёные. В bot-pipeline review остаётся (reviewer-окно ставит `verdict=approve` + SHA match), но merge-authority больше **не** эксклюзив reviewer'а — worker может смержить approved PR сам. Pre-merge gate: зелёный CI + (в pipeline) approve+SHA match. `balance_platform` — никогда не мержит (stage only).
|
||||||
|
|
||||||
**Жёсткие исключения** (даже при APPROVE — НЕ merge, ping user):
|
**Жёсткие исключения (даже при зелёном — НЕ merge, ping human):**
|
||||||
- Diff содержит литеральный secret/token/password/credential (40-char hex, API keys, JWT, и т.д.) — security tripwire
|
- Diff содержит литеральный secret/token/password/credential (40-char hex, API keys, JWT, и т.д.) — security tripwire.
|
||||||
- PR меняет блок `## Auto-merge policy` в этом файле, `Critical workflow rules` в CLAUDE.md, `_autonomous_pickup.md` (claim/kill-switch/merge-FSM contract), `auto-code-reviewer.md` или любой `work-as-*.md` (self-extending guard, decided 2026-05-24, расширен 2026-05-29 — изменение правил пайплайна всегда через human, предотвращает bot-loop где bot сам расширяет свои merge права)
|
- PR меняет правила самого пайплайна: блок `## Auto-merge policy` здесь, `Critical rules` в CLAUDE.md, `_autonomous_pickup.md` (claim/kill-switch/merge-FSM), `auto-code-reviewer.md` или любой `work-as-*.md` — **self-extending guard** (расширение/снятие собственных merge-прав всегда через human, предотвращает bot-loop).
|
||||||
|
|
||||||
## Sequential PRs
|
## Sequential PRs
|
||||||
|
|
||||||
|
|
@ -122,7 +122,7 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
|
||||||
## Запреты
|
## Запреты
|
||||||
|
|
||||||
- ❌ `git push forgejo main` / direct push в main
|
- ❌ `git push forgejo main` / direct push в main
|
||||||
- ❌ `mcp__forgejo__merge_pull_request` без approval (human "merge it" или bot verdict=approve + SHA match)
|
- ❌ merge PR с литеральным secret в diff ИЛИ PR меняющий правила пайплайна (self-extending guard) — это через human. Иначе self-merge OK (зелёный CI; в pipeline дополнительно approve+SHA)
|
||||||
- ❌ `gh pr *` — bypassed 2026-05-16, используй Forgejo MCP или curl + `$FORGEJO_TOKEN`
|
- ❌ `gh pr *` — bypassed 2026-05-16, используй Forgejo MCP или curl + `$FORGEJO_TOKEN`
|
||||||
- ❌ `--no-verify` / `--amend` / `--no-edit` / `--force` без явного approval
|
- ❌ `--no-verify` / `--amend` / `--no-edit` / `--force` без явного approval
|
||||||
- ❌ `@claude` в PR comments — plain text only (`feedback_no_claude_mentions`)
|
- ❌ `@claude` в PR comments — plain text only (`feedback_no_claude_mentions`)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
---
|
---
|
||||||
paths: data/sql/**/*.sql
|
paths:
|
||||||
|
- data/sql/**/*.sql
|
||||||
|
- tradein-mvp/backend/data/sql/**/*.sql
|
||||||
---
|
---
|
||||||
|
|
||||||
# SQL conventions — PostgreSQL 16 / PostGIS 3.4
|
# SQL conventions — PostgreSQL 16 / PostGIS 3.4
|
||||||
|
|
|
||||||
53
.claude/rules/tradein.md
Normal file
53
.claude/rules/tradein.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
paths:
|
||||||
|
- tradein-mvp/**/*.py
|
||||||
|
- tradein-mvp/**/*.sql
|
||||||
|
- tradein-mvp/frontend/**/*.{ts,tsx}
|
||||||
|
---
|
||||||
|
|
||||||
|
# trade-in (Mera) conventions — `tradein-mvp/`
|
||||||
|
|
||||||
|
Отдельный продукт + отдельный стек от Site Finder. Backend `tradein-mvp/backend/app/**`,
|
||||||
|
SQL `tradein-mvp/backend/data/sql/NN_*.sql`, frontend `tradein-mvp/frontend/`. Backend Python
|
||||||
|
подчиняется `.claude/rules/backend.md` (psycopg v3, CAST, ruff-100), SQL — `.claude/rules/sql.md`
|
||||||
|
(NN naming, idempotency). Ниже — то, что СПЕЦИФИЧНО для trade-in.
|
||||||
|
|
||||||
|
## Две БД — не путай
|
||||||
|
|
||||||
|
- **`postgres-tradein`** (db=tradein) — скрейпленные листинги avito/cian/yandex, estimator,
|
||||||
|
coverage, houses. Для ЛЮБОЙ tradein-задачи метрики/схему бери отсюда (`mcp__postgres-tradein__*`).
|
||||||
|
- **`postgres-gendesign`** (db=gendesign) — Site Finder, НЕ trade-in.
|
||||||
|
|
||||||
|
## Тестировать HTTP только ВНУТРИ контейнера
|
||||||
|
|
||||||
|
SSH-туннель `localhost:8000` → `gendesign-backend` (Site Finder, db=gendesign, старый код),
|
||||||
|
**НЕ** tradein-backend (порт не опубликован на хост). curl на туннель:8000 по trade-in endpoint =
|
||||||
|
мусор / чужая БД (стоило ~2ч). Тест trade-in API только изнутри контейнера:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh gendesign # затем:
|
||||||
|
docker exec tradein-backend curl -s localhost:8000/<route> # админ-роуты: -H "X-Authenticated-User: admin"
|
||||||
|
docker exec tradein-postgres psql -U <user> -d tradein -c "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scheduler крутится в `tradein-scraper`, не `tradein-backend`
|
||||||
|
|
||||||
|
In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`,
|
||||||
|
`SCHEDULER_ENABLE=true`) живёт в контейнере **`tradein-scraper`**; в `tradein-backend` намеренно
|
||||||
|
`false`. Статус scheduled-задач смотри в scraper-контейнере (logs/printenv), не в backend.
|
||||||
|
Ручной smoke: `UPDATE scrape_schedules SET next_run_at=now() WHERE source='X'` → подхват ≤60s.
|
||||||
|
|
||||||
|
## SQL авто-применяется на ПРОД (strict)
|
||||||
|
|
||||||
|
`tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
|
||||||
|
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
|
||||||
|
деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2,
|
||||||
|
`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename,
|
||||||
|
не доверяй `tail`.
|
||||||
|
|
||||||
|
## Rapid-merge trap
|
||||||
|
|
||||||
|
2 tradein-PR мержа за секунды → backend `test`-job cancelled → `build-backend` пропущен →
|
||||||
|
«deploy success» на СТАРОМ образе (нет нового кода/deps). Сверяй `:latest` Created-timestamp vs
|
||||||
|
время мержа + smoke в контейнере; не верь «деплой прошёл». Recovery: ручной `workflow_dispatch`
|
||||||
|
для `deploy-tradein.yml`.
|
||||||
|
|
@ -276,14 +276,17 @@ jobs:
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: uv run python -c "import json; from app.main import app; print(json.dumps(app.openapi()))" > /tmp/openapi.json
|
run: uv run python -c "import json; from app.main import app; print(json.dumps(app.openapi()))" > /tmp/openapi.json
|
||||||
|
|
||||||
- name: Regenerate api-types.ts + format (prettier defaults)
|
- name: Regenerate api-types.ts + format (project-local pinned prettier)
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
# 1) openapi-typescript из дампнутого файла (эквивалент `npm run codegen`,
|
# 1) openapi-typescript из дампнутого файла (эквивалент `npm run codegen`,
|
||||||
# который читает ту же схему по URL). 2) prettier (defaults, как
|
# который читает ту же схему по URL). 2) ./node_modules/.bin/prettier —
|
||||||
# pre-commit hook) → формат совпадает с committed-файлом.
|
# PROJECT-LOCAL, pinned (prettier 3.9.0 в devDependencies). НЕ `npx
|
||||||
|
# prettier` (тот резолвится в плавающий latest и расходится с pre-commit,
|
||||||
|
# ломая этот gate). Pre-commit hook гоняет тот же локальный prettier 3.9.0
|
||||||
|
# → байт-в-байт идентичный формат. См. .pre-commit-config.yaml.
|
||||||
run: |
|
run: |
|
||||||
npx openapi-typescript /tmp/openapi.json -o src/lib/api-types.ts
|
npx openapi-typescript /tmp/openapi.json -o src/lib/api-types.ts
|
||||||
npx prettier --write src/lib/api-types.ts
|
./node_modules/.bin/prettier --write src/lib/api-types.ts
|
||||||
|
|
||||||
- name: Assert api-types.ts is up-to-date
|
- name: Assert api-types.ts is up-to-date
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,17 @@ repos:
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
files: ^(backend|tradein-mvp/backend)/
|
files: ^(backend|tradein-mvp/backend)/
|
||||||
|
|
||||||
# Frontend — prettier on TS/TSX/JSON
|
# Frontend — prettier on TS/TSX/JSON.
|
||||||
|
# additional_dependencies pins the EXACT prettier engine (3.9.0) so this hook
|
||||||
|
# and CI (`openapi-codegen-check` → ./node_modules/.bin/prettier, also 3.9.0
|
||||||
|
# via frontend/package.json) format byte-for-byte identically. Без явного pin
|
||||||
|
# mirrors-prettier@v4.0.0-alpha.8 тянет CLI-обёртку, а CI `npx prettier`
|
||||||
|
# плавает в latest → расхождение формата → codegen-gate RED. Меняешь версию
|
||||||
|
# здесь — синхронно меняй prettier в frontend/package.json + lockfile.
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v4.0.0-alpha.8
|
rev: v4.0.0-alpha.8
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
|
additional_dependencies: ["prettier@3.9.0"]
|
||||||
files: ^frontend/.*\.(ts|tsx|js|jsx|json|css|md)$
|
files: ^frontend/.*\.(ts|tsx|js|jsx|json|css|md)$
|
||||||
exclude: ^frontend/(node_modules|\.next|package-lock\.json)/
|
exclude: ^frontend/(node_modules|\.next|package-lock\.json)/
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
|
||||||
| `database-expert` | `data/sql/**.sql`, Alembic, EXPLAIN ANALYZE |
|
| `database-expert` | `data/sql/**.sql`, Alembic, EXPLAIN ANALYZE |
|
||||||
| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**`, `.forgejo/workflows/**` |
|
| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**`, `.forgejo/workflows/**` |
|
||||||
| `code-reviewer` | Pre-push lint (security, correctness, conventions) |
|
| `code-reviewer` | Pre-push lint (security, correctness, conventions) |
|
||||||
|
| `deep-code-reviewer` | Тщательный review критичных PR (миграции / auth / scrapers) + merge authority при ✅ APPROVE |
|
||||||
|
| `qa-tester` | Post-deploy smoke (playwright / curl / SQL) сразу после merge+deploy — rule #7 |
|
||||||
|
|
||||||
**Routing:** тривиально (typo, 1-line) → main session. Single-domain clear → worker. Cross-domain / нечётко → `tech-analyst` first. Worker → `code-reviewer` → main commits → push → PR.
|
**Routing:** тривиально (typo, 1-line) → main session. Single-domain clear → worker. Cross-domain / нечётко → `tech-analyst` first. Worker → `code-reviewer` → main commits → push → PR.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,13 @@ roles:
|
||||||
- "/admin/**"
|
- "/admin/**"
|
||||||
- "/api/v1/admin/**"
|
- "/api/v1/admin/**"
|
||||||
- "/trade-in/api/v1/admin/**"
|
- "/trade-in/api/v1/admin/**"
|
||||||
|
expired:
|
||||||
|
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
|
||||||
|
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть
|
||||||
|
# сообщение; /me отдаёт role=expired → RouteGuard рендерит trial-экран.
|
||||||
|
paths: []
|
||||||
|
deny:
|
||||||
|
- "/**"
|
||||||
|
|
||||||
# NB: реальные analyst-логины ДОЛЖНЫ быть добавлены и в
|
# NB: реальные analyst-логины ДОЛЖНЫ быть добавлены и в
|
||||||
# caddy/users.caddy.snippet (Caddy basic_auth) силами devops — иначе Caddy не
|
# caddy/users.caddy.snippet (Caddy basic_auth) силами devops — иначе Caddy не
|
||||||
|
|
@ -72,7 +79,7 @@ users:
|
||||||
user8: pilot
|
user8: pilot
|
||||||
user9: pilot
|
user9: pilot
|
||||||
user10: pilot
|
user10: pilot
|
||||||
praktika: pilot # пилот-аккаунт агентства «Практика» 2026-05-29
|
praktika: expired # пробный доступ закончился 2026-06-27 — см. NoAccessScreen variant="trial"
|
||||||
admintest: admin # temp QA 2026-05-26
|
admintest: admin # temp QA 2026-05-26
|
||||||
pilottest: pilot # temp QA 2026-05-26
|
pilottest: pilot # temp QA 2026-05-26
|
||||||
analysttest: analyst # temp QA 2026-06-07 (#962)
|
analysttest: analyst # temp QA 2026-06-07 (#962)
|
||||||
|
|
|
||||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
|
|
@ -40,6 +40,7 @@
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"openapi-typescript": "^7.0.0",
|
"openapi-typescript": "^7.0.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
|
"prettier": "3.9.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
|
|
@ -14055,6 +14056,22 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.0.tgz",
|
||||||
|
"integrity": "sha512-LjIqSIC5VYLzs9WedVmJ2ljNAGnU+DteIClbahu4L/DBeWjZ6iT/k1lAYyu9JUh+1xINxWadaPw/Pl63y/agAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-format": {
|
"node_modules/pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"openapi-typescript": "^7.0.0",
|
"openapi-typescript": "^7.0.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
|
"prettier": "3.9.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
|
|
|
||||||
|
|
@ -2833,8 +2833,7 @@ export interface components {
|
||||||
nspd_risk_zones?: components["schemas"]["RiskZone"][] | null;
|
nspd_risk_zones?: components["schemas"]["RiskZone"][] | null;
|
||||||
/** Nspd Opportunity Parcels */
|
/** Nspd Opportunity Parcels */
|
||||||
nspd_opportunity_parcels?:
|
nspd_opportunity_parcels?:
|
||||||
| components["schemas"]["OpportunityParcel"][]
|
components["schemas"]["OpportunityParcel"][] | null;
|
||||||
| null;
|
|
||||||
/** Nspd Red Lines */
|
/** Nspd Red Lines */
|
||||||
nspd_red_lines?: components["schemas"]["RedLine"][] | null;
|
nspd_red_lines?: components["schemas"]["RedLine"][] | null;
|
||||||
/** Nspd Dump */
|
/** Nspd Dump */
|
||||||
|
|
@ -3752,8 +3751,7 @@ export interface components {
|
||||||
* @description Категория: competition|permitting|demand|risk|other
|
* @description Категория: competition|permitting|demand|risk|other
|
||||||
*/
|
*/
|
||||||
category?:
|
category?:
|
||||||
| ("competition" | "permitting" | "demand" | "risk" | "other")
|
("competition" | "permitting" | "demand" | "risk" | "other") | null;
|
||||||
| null;
|
|
||||||
/**
|
/**
|
||||||
* Is Confidential
|
* Is Confidential
|
||||||
* @description «Непублично» (§7.13): маркировка чувствительной заметки
|
* @description «Непублично» (§7.13): маркировка чувствительной заметки
|
||||||
|
|
@ -3846,8 +3844,7 @@ export interface components {
|
||||||
body?: string | null;
|
body?: string | null;
|
||||||
/** Category */
|
/** Category */
|
||||||
category?:
|
category?:
|
||||||
| ("competition" | "permitting" | "demand" | "risk" | "other")
|
("competition" | "permitting" | "demand" | "risk" | "other") | null;
|
||||||
| null;
|
|
||||||
/** Is Confidential */
|
/** Is Confidential */
|
||||||
is_confidential?: boolean | null;
|
is_confidential?: boolean | null;
|
||||||
/** District */
|
/** District */
|
||||||
|
|
@ -4630,12 +4627,10 @@ export interface components {
|
||||||
year_built?: number | null;
|
year_built?: number | null;
|
||||||
/** House Type */
|
/** House Type */
|
||||||
house_type?:
|
house_type?:
|
||||||
| ("panel" | "brick" | "monolith" | "monolith_brick" | "other")
|
("panel" | "brick" | "monolith" | "monolith_brick" | "other") | null;
|
||||||
| null;
|
|
||||||
/** Repair State */
|
/** Repair State */
|
||||||
repair_state?:
|
repair_state?:
|
||||||
| ("needs_repair" | "standard" | "good" | "excellent")
|
("needs_repair" | "standard" | "good" | "excellent") | null;
|
||||||
| null;
|
|
||||||
/** Has Balcony */
|
/** Has Balcony */
|
||||||
has_balcony?: boolean | null;
|
has_balcony?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
@ -7877,8 +7872,7 @@ export interface operations {
|
||||||
cad_num?: string | null;
|
cad_num?: string | null;
|
||||||
/** @description Фильтр по категории */
|
/** @description Фильтр по категории */
|
||||||
category?:
|
category?:
|
||||||
| ("competition" | "permitting" | "demand" | "risk" | "other")
|
("competition" | "permitting" | "demand" | "risk" | "other") | null;
|
||||||
| null;
|
|
||||||
/** @description Фильтр по пометке «непублично» */
|
/** @description Фильтр по пометке «непублично» */
|
||||||
is_confidential?: boolean | null;
|
is_confidential?: boolean | null;
|
||||||
/** @description Фильтр по автору */
|
/** @description Фильтр по автору */
|
||||||
|
|
|
||||||
95
scripts/claude-hooks/check-secret-read.py
Normal file
95
scripts/claude-hooks/check-secret-read.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Claude Code PreToolUse hook — blocks Bash commands that READ or TRANSMIT secret
|
||||||
|
files (.env*, .pgpass, ssh private keys).
|
||||||
|
|
||||||
|
Why: `.claude/settings.json` permissions end with a blanket `Bash(*)` allow, which
|
||||||
|
sidesteps the `Read(./.env*)` deny rule — `cat backend/.env`, `Get-Content .env`,
|
||||||
|
`curl -F @backend/.env …` all exfiltrate prod DB passwords + FORGEJO/GLITCHTIP tokens
|
||||||
|
without a single prompt. This hook closes that bypass at the command layer.
|
||||||
|
|
||||||
|
Wired in `.claude/settings.json` hooks.PreToolUse with matcher "Bash".
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Reads hook input JSON from stdin; extracts tool_input.command.
|
||||||
|
- Blocks (exit 2, stderr -> Claude) iff the command references a REAL secret file
|
||||||
|
AND uses a read/transmit verb, OR `< file` redirection, OR `@file` upload, OR `open(`.
|
||||||
|
- Template files (.env.example / .sample / .template / .dist / .md) are allowed.
|
||||||
|
- `ls`/`stat`/`test -f` on a secret file are allowed (they don't read content).
|
||||||
|
- Fail-OPEN on parse errors (exit 0): this is a tripwire layered on top of the deny
|
||||||
|
list + (future) sandbox, not the sole guard — better to under-block than to wedge
|
||||||
|
every Bash call on a malformed payload.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# A secret-file token: `.env` with any number of dotted segments, `.pgpass`, or an
|
||||||
|
# ssh private key. The leading delimiter ([start | space | quote | = : < @ ( / ])
|
||||||
|
# anchors it as a filename/path component, not a substring of another word.
|
||||||
|
SECRET_RE = re.compile(
|
||||||
|
r"(?:^|[\s'\"=:<@(/])"
|
||||||
|
r"(\.env(?:\.[\w-]+)*|\.pgpass|id_rsa[\w.]*|id_ed25519[\w.]*|id_ecdsa[\w.]*)"
|
||||||
|
r"(?![\w-])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
TEMPLATE_SUFFIXES = (".example", ".sample", ".template", ".dist", ".md")
|
||||||
|
|
||||||
|
# Verbs that read a file's content or push it off-box.
|
||||||
|
READ_VERB_RE = re.compile(
|
||||||
|
r"(?i)(?<![\w-])("
|
||||||
|
r"cat|tac|nl|head|tail|less|more|od|xxd|hexdump|strings|base64|"
|
||||||
|
r"type|gc|get-content|gp|select-string|sls|"
|
||||||
|
r"cp|copy|scp|rsync|sftp|"
|
||||||
|
r"curl|wget|invoke-webrequest|iwr|"
|
||||||
|
r"sed|awk|grep|findstr|sort|paste|cut|tr|dd|source|\."
|
||||||
|
r")(?![\w-])"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_template(token: str) -> bool:
|
||||||
|
low = token.lower()
|
||||||
|
return any(low.endswith(s) for s in TEMPLATE_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
payload = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
tool = payload.get("tool_name") or payload.get("tool") or ""
|
||||||
|
if tool != "Bash":
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cmd = (payload.get("tool_input") or {}).get("command") or ""
|
||||||
|
if not cmd:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
hits = [m.group(1) for m in SECRET_RE.finditer(cmd) if not _is_template(m.group(1))]
|
||||||
|
if not hits:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
danger = (
|
||||||
|
READ_VERB_RE.search(cmd)
|
||||||
|
or re.search(r"<\s*\S*\.env\b", cmd, re.IGNORECASE)
|
||||||
|
or re.search(r"@\S*\.env\b", cmd, re.IGNORECASE)
|
||||||
|
or re.search(r"open\(\s*['\"][^'\"]*\.env", cmd, re.IGNORECASE)
|
||||||
|
)
|
||||||
|
if not danger:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
msg = [
|
||||||
|
f"Blocked: команда читает/передаёт секрет-файл ({hits[0]}).",
|
||||||
|
"Blanket `Bash(*)` обходит `Read(./.env*)` deny — этот PreToolUse-hook закрывает дыру.",
|
||||||
|
"Секрет-файлы содержат prod DB-пароли + FORGEJO/GLITCHTIP токены.",
|
||||||
|
"Если правда нужно прочитать — сделай это вручную вне Claude, либо используй `.env.example`.",
|
||||||
|
]
|
||||||
|
print("\n".join(msg), file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
87
scripts/claude-hooks/check-sql-pitfalls.py
Normal file
87
scripts/claude-hooks/check-sql-pitfalls.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Claude Code PostToolUse hook — blocks `import psycopg2` in backend Python.
|
||||||
|
|
||||||
|
GenDesign uses psycopg v3 (`psycopg[binary]>=3.2.0`); psycopg2 is NOT installed,
|
||||||
|
so `import psycopg2` is a guaranteed `ModuleNotFoundError` at runtime — a recurring
|
||||||
|
bug class (see `.claude/rules/backend.md`). This catches it at edit-time, not on CI.
|
||||||
|
|
||||||
|
NOTE: the related `:param::type` cast trap is deliberately NOT checked here — the
|
||||||
|
codebase documents that anti-pattern in dozens of comments/test-docstrings
|
||||||
|
("never :param::type"), so a regex would false-positive constantly. That trap is
|
||||||
|
already covered by dedicated unit tests.
|
||||||
|
|
||||||
|
Wired in `.claude/settings.json` hooks.PostToolUse with matcher "Edit|Write|MultiEdit".
|
||||||
|
Watches `backend/**/*.py` (also `tradein-mvp/backend/**/*.py`, normalized to `backend/`).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WATCH_PATH_PREFIX = "backend/"
|
||||||
|
# Real import statement at line start (after optional indent); not a comment or a
|
||||||
|
# docstring sentence like "never import psycopg2" (which starts with a word, not `import`).
|
||||||
|
PSYCOPG2_RE = re.compile(
|
||||||
|
r"(?m)^\s*(?:import\s+psycopg2|from\s+psycopg2(?:\.[\w.]+)?\s+import)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path(raw: str) -> str:
|
||||||
|
"""Repo-relative POSIX path anchored on the first `backend` segment."""
|
||||||
|
parts = Path(raw).parts
|
||||||
|
try:
|
||||||
|
idx = parts.index("backend")
|
||||||
|
except ValueError:
|
||||||
|
return Path(raw).as_posix()
|
||||||
|
return Path(*parts[idx:]).as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
payload = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
tool_name = payload.get("tool_name") or payload.get("tool") or ""
|
||||||
|
if tool_name not in {"Edit", "Write", "MultiEdit"}:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
tool_input = payload.get("tool_input") or {}
|
||||||
|
file_path = tool_input.get("file_path") or tool_input.get("path") or ""
|
||||||
|
if not file_path:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
norm = normalize_path(file_path)
|
||||||
|
if not norm.startswith(WATCH_PATH_PREFIX) or not norm.endswith(".py"):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
edits = tool_input.get("edits")
|
||||||
|
if isinstance(edits, list):
|
||||||
|
new_text = "\n".join(
|
||||||
|
str(e.get("new_string") or "") for e in edits if isinstance(e, dict)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_text = tool_input.get("new_string") or tool_input.get("content") or ""
|
||||||
|
if not new_text:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for lineno, line in enumerate(new_text.splitlines(), start=1):
|
||||||
|
if line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
if PSYCOPG2_RE.match(line):
|
||||||
|
msg = [
|
||||||
|
f"Blocked: `import psycopg2` в {norm} (line {lineno}).",
|
||||||
|
"psycopg2 НЕ установлен → ModuleNotFoundError. Используй `import psycopg` (v3).",
|
||||||
|
"Bulk INSERT: `cur.executemany()` / COPY (НЕ execute_values). См. .claude/rules/backend.md.",
|
||||||
|
]
|
||||||
|
print("\n".join(msg), file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
43
scripts/claude-hooks/session-preflight.py
Normal file
43
scripts/claude-hooks/session-preflight.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Claude Code SessionStart hook — one-line preflight status printed into session context.
|
||||||
|
|
||||||
|
Surfaces the session-setup facts that have caused real lost time:
|
||||||
|
- FORGEJO_ACCESS_TOKEN present? (goern forgejo-MCP needs it BEFORE launch; missing -> idle bot)
|
||||||
|
- OBSIDIAN_API_KEY present? (vault MCP)
|
||||||
|
- DB tunnel localhost:15432 reachable? ("tunnel :8000 is gendesign not tradein" / tunnel-down traps)
|
||||||
|
|
||||||
|
Wired in `.claude/settings.json` hooks.SessionStart. ALWAYS exits 0 — informational only,
|
||||||
|
never blocks. Fast (<~0.6s): single non-blocking TCP probe with a short timeout.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _tcp_ok(host: str, port: int, timeout: float = 0.5) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=timeout):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
forgejo = "set" if os.environ.get("FORGEJO_ACCESS_TOKEN") else "MISSING"
|
||||||
|
obsidian = "set" if os.environ.get("OBSIDIAN_API_KEY") else "MISSING"
|
||||||
|
tunnel = "up" if _tcp_ok("localhost", 15432) else "DOWN"
|
||||||
|
print(
|
||||||
|
f"[preflight] FORGEJO_ACCESS_TOKEN={forgejo} · OBSIDIAN_API_KEY={obsidian} "
|
||||||
|
f"· db-tunnel(:15432)={tunnel}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -31,7 +31,7 @@ import yaml
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Role = Literal["admin", "pilot", "analyst"]
|
Role = Literal["admin", "pilot", "analyst", "expired"]
|
||||||
|
|
||||||
|
|
||||||
class UserScope(TypedDict):
|
class UserScope(TypedDict):
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3087,10 +3087,10 @@ async def run_avito_full_load(
|
||||||
class DomClickCitySweepCounters:
|
class DomClickCitySweepCounters:
|
||||||
"""Aggregate counters для DomClick citywide sweep run.
|
"""Aggregate counters для DomClick citywide sweep run.
|
||||||
|
|
||||||
DomClick SERP — citywide (city_id), не geo-bbox: anchor-loop отсутствует.
|
DomClick BFF — citywide (city_id), не geo-bbox: anchor-loop отсутствует.
|
||||||
Один проход fetch_city перебирает rooms × pages внутри scraper'а.
|
Один проход fetch_city перебирает ROOM_BUCKETS × pages внутри scraper'а.
|
||||||
Поля симметричны sibling-counters, но без detail_*/houses_* (DomClick SERP-only)
|
Поля симметричны sibling-counters, но без detail_*/houses_* (SERP-only)
|
||||||
и без anchors_* (нет anchor-фазы). pages_fetched = rooms × pages worst-case.
|
и без anchors_* (нет anchor-фазы). pages_fetched = 6 buckets × pages worst-case.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
lots_fetched: int = 0
|
lots_fetched: int = 0
|
||||||
|
|
@ -3098,17 +3098,21 @@ class DomClickCitySweepCounters:
|
||||||
lots_updated: int = 0
|
lots_updated: int = 0
|
||||||
pages_fetched: int = 0
|
pages_fetched: int = 0
|
||||||
errors_count: int = 0
|
errors_count: int = 0
|
||||||
|
blocked: int = 0 # 1 если QRATOR-блок был во время sweep
|
||||||
|
geo_filtered: int = 0 # число офферов отфильтрованных geo-guard
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, int]:
|
def to_dict(self) -> dict[str, int]:
|
||||||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
return {f.name: getattr(self, f.name) for f in fields(self)}
|
||||||
|
|
||||||
|
|
||||||
# Дефолтные параметры sweep'а (EKB city_id=4, все комнатности).
|
# Дефолтные параметры sweep'а (EKB city_id=4).
|
||||||
DOMCLICK_DEFAULT_CITY_ID: int = 4
|
DOMCLICK_DEFAULT_CITY_ID: int = 4
|
||||||
DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4]
|
DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] # vestigial; scraper sweeps all buckets
|
||||||
# Оценка времени одного fetch'а (network + camoufox render + parse) для watchdog.
|
# Число BFF-бакетов (st/1/2/3/4/5+) — фиксировано; используется для watchdog.
|
||||||
|
_DOMCLICK_NUM_BUCKETS: int = 6
|
||||||
|
# Оценка времени одного fetch'а (network + parse) для watchdog.
|
||||||
_DOMCLICK_PER_FETCH_S: float = 12.0
|
_DOMCLICK_PER_FETCH_S: float = 12.0
|
||||||
# Буфер сверху расчётного бюджета (cold browser start, save-фаза).
|
# Буфер сверху расчётного бюджета (cold browser start, save-фаза, price-splits).
|
||||||
_DOMCLICK_SWEEP_BUDGET_S: float = 300.0
|
_DOMCLICK_SWEEP_BUDGET_S: float = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3118,46 +3122,51 @@ async def run_domclick_city_sweep(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
city_id: int = DOMCLICK_DEFAULT_CITY_ID,
|
city_id: int = DOMCLICK_DEFAULT_CITY_ID,
|
||||||
rooms: list[int] | None = None,
|
rooms: list[int] | None = None,
|
||||||
pages: int = 5,
|
pages: int = 100,
|
||||||
request_delay_sec: float | None = None,
|
request_delay_sec: float | None = None,
|
||||||
) -> DomClickCitySweepCounters:
|
) -> DomClickCitySweepCounters:
|
||||||
"""DomClick citywide sweep: SERP по city_id × rooms × pages → save → house-match.
|
"""DomClick citywide sweep через BFF JSON API.
|
||||||
|
|
||||||
Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE:
|
Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE:
|
||||||
DomClick SERP не поддерживает geo-radius (fetch_around → NotImplementedError),
|
DomClick не поддерживает geo-radius (fetch_around → NotImplementedError),
|
||||||
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city уже перебирает
|
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все
|
||||||
rooms × pages через внутренний BrowserFetcher (camoufox headless) и
|
ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → shared mobile proxy →
|
||||||
дедуплицирует по source_id.
|
обходит QRATOR.
|
||||||
|
|
||||||
Фазы (единственный citywide проход):
|
Фазы (единственный citywide проход):
|
||||||
1. SERP: DomClickScraper().fetch_city(city_id, rooms, pages).
|
1. SERP: DomClickScraper().fetch_city(city_id, rooms, pages).
|
||||||
2. SAVE: save_listings(db, lots, run_id=run_id) — BaseScraper save path,
|
2. SAVE: save_listings(db, lots, run_id=run_id) — house-match по lat/lon и
|
||||||
триггерит существующий house-match hook (адрес-fingerprint tier, т.к.
|
address-fingerprint (BFF отдаёт координаты).
|
||||||
DomClick SERP не отдаёт lat/lon).
|
|
||||||
|
|
||||||
Watchdog: весь fetch_city оборачивается в asyncio.wait_for. Таймаут считается
|
Watchdog: весь fetch_city оборачивается в asyncio.wait_for. Таймаут считается
|
||||||
из rooms × pages × per-fetch + buffer (минимум ANCHOR_TIMEOUT_SEC).
|
из 6 buckets × pages × per-fetch + buffer (минимум ANCHOR_TIMEOUT_SEC).
|
||||||
|
Реальный sweep ≈ 330 страниц (rooms=2 требует price-split).
|
||||||
|
|
||||||
Cooperative cancel: is_cancelled(db, run_id) проверяется перед SERP-фазой.
|
Cooperative cancel: is_cancelled(db, run_id) проверяется перед SERP-фазой.
|
||||||
mark_done вызывается ВСЕГДА (кроме cancel / fatal). lat=lon=None у всех lots →
|
|
||||||
house-match использует только address_fingerprint tier.
|
ЧЕСТНЫЙ СТАТУС (#1968): если scraper сообщил QRATOR-блок И lots == 0 →
|
||||||
|
mark_failed с объяснением. Иначе mark_done (в т.ч. при lots==0 без блока:
|
||||||
|
genuinely empty run).
|
||||||
|
|
||||||
Возвращает DomClickCitySweepCounters.
|
Возвращает DomClickCitySweepCounters.
|
||||||
"""
|
"""
|
||||||
from app.services.scrapers.domclick import DomClickScraper
|
from app.services.scrapers.domclick import ROOM_BUCKETS, DomClickScraper
|
||||||
|
|
||||||
_rooms = rooms if rooms is not None else list(DOMCLICK_DEFAULT_ROOMS)
|
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
|
||||||
_resolved_delay = request_delay_sec if request_delay_sec is not None else 8.0
|
|
||||||
counters = DomClickCitySweepCounters()
|
counters = DomClickCitySweepCounters()
|
||||||
|
|
||||||
# Watchdog-таймаут: число fetch'ей = len(rooms) × pages (worst-case, без
|
# Watchdog: 6 buckets × pages × per_fetch + budget.
|
||||||
# early-break на пустой странице). Каждый fetch ≈ delay + render/parse overhead.
|
# Реальный worst-case (rooms=2 price-split) ≈ 330 страниц × (delay+12s).
|
||||||
_num_fetches = max(1, len(_rooms)) * max(1, pages)
|
# pages=100 → 600 fetch'ей × ~18s + 300 ≈ 11100s (~3ч) — намеренно generous.
|
||||||
|
_num_fetches = _DOMCLICK_NUM_BUCKETS * max(1, pages)
|
||||||
_sweep_timeout = max(
|
_sweep_timeout = max(
|
||||||
ANCHOR_TIMEOUT_SEC,
|
ANCHOR_TIMEOUT_SEC,
|
||||||
int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S),
|
int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Мутируемый контейнер для захвата scraper-ссылки из замыкания.
|
||||||
|
_scraper_ref: list[DomClickScraper] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── Cooperative cancel перед SERP-фазой ──────────────────────────────
|
# ── Cooperative cancel перед SERP-фазой ──────────────────────────────
|
||||||
if scrape_runs.is_cancelled(db, run_id):
|
if scrape_runs.is_cancelled(db, run_id):
|
||||||
|
|
@ -3166,10 +3175,11 @@ async def run_domclick_city_sweep(
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"domclick-sweep run_id=%d: citywide SERP city_id=%d rooms=%s pages=%d (watchdog %ds)",
|
"domclick-sweep run_id=%d: BFF citywide sweep city_id=%d "
|
||||||
|
"buckets=%d pages_cap=%d (watchdog %ds)",
|
||||||
run_id,
|
run_id,
|
||||||
city_id,
|
city_id,
|
||||||
_rooms,
|
len(ROOM_BUCKETS),
|
||||||
pages,
|
pages,
|
||||||
_sweep_timeout,
|
_sweep_timeout,
|
||||||
)
|
)
|
||||||
|
|
@ -3179,10 +3189,11 @@ async def run_domclick_city_sweep(
|
||||||
async def _domclick_phase() -> None:
|
async def _domclick_phase() -> None:
|
||||||
"""Единственная citywide-фаза: fetch_city + save."""
|
"""Единственная citywide-фаза: fetch_city + save."""
|
||||||
nonlocal lots
|
nonlocal lots
|
||||||
async with DomClickScraper() as scraper:
|
async with DomClickScraper() as _scraper:
|
||||||
|
_scraper_ref.append(_scraper)
|
||||||
if request_delay_sec is not None:
|
if request_delay_sec is not None:
|
||||||
scraper.request_delay_sec = _resolved_delay
|
_scraper.request_delay_sec = _resolved_delay
|
||||||
lots = await scraper.fetch_city(city_id=city_id, rooms=_rooms, pages=pages)
|
lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||||
counters.lots_fetched += len(lots)
|
counters.lots_fetched += len(lots)
|
||||||
if lots:
|
if lots:
|
||||||
inserted, updated = save_listings(db, lots, run_id=run_id)
|
inserted, updated = save_listings(db, lots, run_id=run_id)
|
||||||
|
|
@ -3202,18 +3213,53 @@ async def run_domclick_city_sweep(
|
||||||
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
|
logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
|
||||||
counters.errors_count += 1
|
counters.errors_count += 1
|
||||||
|
|
||||||
# pages_fetched: worst-case число запрошенных страниц (rooms × pages).
|
# Перенести счётчики scraper'а в counters (если scraper был создан).
|
||||||
|
if _scraper_ref:
|
||||||
|
_s = _scraper_ref[0]
|
||||||
|
counters.blocked = 1 if _s.blocked else 0
|
||||||
|
counters.geo_filtered = _s.geo_filtered
|
||||||
|
# Не-block fetch-ошибки скрейпера учитываем в errors_count: они должны
|
||||||
|
# влиять на honest-status (0 lots + fetch_errors → НЕ ложный mark_done).
|
||||||
|
counters.errors_count += _s.fetch_errors
|
||||||
|
|
||||||
|
# pages_fetched: worst-case число страниц (buckets × pages cap).
|
||||||
counters.pages_fetched = _num_fetches
|
counters.pages_fetched = _num_fetches
|
||||||
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
|
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||||
scrape_runs.mark_done(db, run_id, counters.to_dict())
|
|
||||||
|
# ── ЧЕСТНЫЙ СТАТУС (#1968) ────────────────────────────────────────────
|
||||||
|
# 0 lots + (block ИЛИ любая ошибка) → failed: run без результата по
|
||||||
|
# внешней причине не должен маскироваться как 'done'. Нераспознанный
|
||||||
|
# block-вариант (нет литерал-маркера) приходит как fetch_error, не blocked —
|
||||||
|
# поэтому errors_count тоже триггерит failed. Genuinely empty 0-error run
|
||||||
|
# → done. Partial run с lots>0 остаётся done даже если часть бакетов упала.
|
||||||
|
if counters.lots_fetched == 0 and (counters.blocked or counters.errors_count > 0):
|
||||||
|
logger.error(
|
||||||
|
"domclick-sweep run_id=%d: 0 listings with blocked=%d errors=%d "
|
||||||
|
"— marking failed",
|
||||||
|
run_id,
|
||||||
|
counters.blocked,
|
||||||
|
counters.errors_count,
|
||||||
|
)
|
||||||
|
scrape_runs.mark_failed(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
"QRATOR block or fetch errors — 0 listings",
|
||||||
|
counters.to_dict(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
scrape_runs.mark_done(db, run_id, counters.to_dict())
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"domclick-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) pages=%d errors=%d",
|
"domclick-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) "
|
||||||
|
"pages=%d errors=%d blocked=%d geo_filtered=%d",
|
||||||
run_id,
|
run_id,
|
||||||
counters.lots_fetched,
|
counters.lots_fetched,
|
||||||
counters.lots_inserted,
|
counters.lots_inserted,
|
||||||
counters.lots_updated,
|
counters.lots_updated,
|
||||||
counters.pages_fetched,
|
counters.pages_fetched,
|
||||||
counters.errors_count,
|
counters.errors_count,
|
||||||
|
counters.blocked,
|
||||||
|
counters.geo_filtered,
|
||||||
)
|
)
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,32 @@
|
||||||
"""DomClick.ru scraper — вторичка через headless BrowserFetcher (#796).
|
"""DomClick.ru scraper — вторичка через JSON BFF API (#1968).
|
||||||
|
|
||||||
Стратегия: HTML scrape через camoufox (AsyncCamoufox) — единственный
|
Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
|
||||||
рабочий путь: DataDome пропускает headless Playwright, curl_cffi -> 401.
|
BrowserFetcher(source="domclick") (generic provider → shared mobile proxy).
|
||||||
|
QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy.
|
||||||
|
|
||||||
URL шаблон:
|
Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
|
||||||
https://domclick.ru/search?deal_type=sale&category=living&offer_type=flat
|
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
|
||||||
&city_id={city_id}&rooms={r}&p={page}
|
|
||||||
|
|
||||||
Оффер-карточки: `a[href*="/card/"]` с href-паттерном `/card/sale__flat__<id>`.
|
Стратегия нумерации комнат (ROOM_BUCKETS):
|
||||||
Координаты SERP не отдаёт (lat = lon = None).
|
"st" — студии (force rooms=0 в ScrapedLot)
|
||||||
|
"1"–"4" — соответственно
|
||||||
|
"5+" — 5 и более комнат
|
||||||
|
|
||||||
Важно: selectolax `.text()` объединяет все дочерние текстовые узлы без
|
Пагинация: offset 0, 20, 40, …, cap=2000. Если snippetsCount > 2000
|
||||||
разделителей. Числа из адреса (номер дома) могут слипнуться с ценой.
|
для бакета, рекурсивно делим по цене (binary split) до тех пор пока
|
||||||
Поэтому цену извлекаем из каждого дочернего элемента отдельно (а не из
|
каждая ветка укладывается в cap.
|
||||||
суммарного card_text), где элемент содержит символ рубля.
|
|
||||||
|
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
|
||||||
|
через detail-эндпоинт после первоначального сбора.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
from datetime import date
|
||||||
from urllib.parse import urljoin
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
from selectolax.parser import HTMLParser, Node
|
|
||||||
|
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
||||||
|
|
@ -30,112 +34,179 @@ from app.services.scrapers.domclick_exceptions import DomClickBlockedError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── DataDome block detection ─────────────────────────────────────────────────
|
# ── API constants ─────────────────────────────────────────────────────────────
|
||||||
_DATADOME_MARKERS = ("datadome", "blocked", "access denied", "bot detected")
|
|
||||||
|
|
||||||
|
_BFF_BASE = "https://bff-search-web.domclick.ru"
|
||||||
|
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
|
||||||
|
_EKB_AREA_ID = "20561"
|
||||||
|
|
||||||
def _is_blocked_page(html: str) -> bool:
|
# Buckets to sweep — порядок влияет на логи.
|
||||||
head = html[:2048].lower()
|
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
|
||||||
return any(m in head for m in _DATADOME_MARKERS)
|
|
||||||
|
|
||||||
|
OFFSET_CAP: int = 2000 # max offset принятый BFF API
|
||||||
|
PAGE_SIZE: int = 20 # items per page (жёстко задан API)
|
||||||
|
# Потолок цены для binary split. 1 млрд ₽ — безопасно выше любой ЕКБ-квартиры;
|
||||||
|
# выбран так чтобы НЕ отбрасывать листинги дороже потолка при сплите unbounded-бакета
|
||||||
|
# (родительский count не ограничен сверху → clamped _lte должен покрывать весь хвост).
|
||||||
|
LTE_MAX: int = 1_000_000_000
|
||||||
|
MIN_PRICE_SPAN: int = 100_000 # ниже этого span прекращаем делить
|
||||||
|
|
||||||
# ── Regex helpers ────────────────────────────────────────────────────────────
|
# ── QRATOR block detection ────────────────────────────────────────────────────
|
||||||
# _RE_ROOMS расширен относительно avito.py: DomClick пишет «2-комн. квартира»
|
|
||||||
# (с «комн.») — avito.py-вариант ловит только «N-к. кв.» / «N-к квартира».
|
|
||||||
_RE_ROOMS = re.compile(
|
|
||||||
r"(\d)-(?:комн\.?\s*(?:квартира|кв\.?)?|к\.?\s*(?:квартира|кв\.?))",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_RE_STUDIO = re.compile(r"\bстуди[яиюей]\b", 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)
|
|
||||||
|
|
||||||
# Цена: парсим из одного элемента DOM (не из суммарного card_text) —
|
_QRATOR_MARKERS: tuple[str, ...] = (
|
||||||
# иначе число дома может слипнуться с ценой. Цена всегда в элементе с «₽».
|
"qrator",
|
||||||
_RE_PRICE_EL = re.compile(r"([\d\s ]+)\s*[₽р](?:уб\.?)?", re.IGNORECASE)
|
"bot_mitigation",
|
||||||
|
"система защиты",
|
||||||
# source_id из href вида /card/sale__flat__2075671636
|
"403 | домклик",
|
||||||
_RE_SOURCE_ID = re.compile(r"sale__flat__(\d+)")
|
"captcha",
|
||||||
|
"access denied",
|
||||||
# Адресные ключевые слова (эвристика)
|
|
||||||
_RE_ADDR_KW = re.compile(
|
|
||||||
r"ул\.|улица|пер\.|переулок|пр-т|проспект"
|
|
||||||
r"|бул\.|бульвар|шоссе|наб\.|набережная"
|
|
||||||
r"|пл\.|площадь|тракт|д\.\s*\d",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── EKB geo guard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── Вспомогательные функции ──────────────────────────────────────────────────
|
_EKB_LAT_MIN: float = 56.6
|
||||||
|
_EKB_LAT_MAX: float = 57.0
|
||||||
|
_EKB_LON_MIN: float = 60.2
|
||||||
|
_EKB_LON_MAX: float = 60.9
|
||||||
|
_EKB_REGION_NAME: str = "Екатеринбург"
|
||||||
|
|
||||||
|
|
||||||
def _extract_rooms(text: str) -> int | None:
|
# ── JSON extraction ───────────────────────────────────────────────────────────
|
||||||
if _RE_STUDIO.search(text):
|
|
||||||
return 0
|
|
||||||
m = _RE_ROOMS.search(text)
|
|
||||||
return int(m.group(1)) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_area(text: str) -> float | None:
|
def _extract_json(html: str) -> dict[str, Any]:
|
||||||
m = _RE_AREA.search(text)
|
"""Извлекает JSON-объект из ответа BrowserFetcher.
|
||||||
return float(m.group(1).replace(",", ".")) if m else None
|
|
||||||
|
|
||||||
|
BrowserFetcher оборачивает JSON в HTML-страницу двумя способами:
|
||||||
|
1. Bare body: ``{"result": ...}`` напрямую в <body>.
|
||||||
|
2. <pre>-wrapped: ``<html><body><pre>{"result": ...}</pre></body></html>``.
|
||||||
|
|
||||||
def _extract_floor(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
|
|
||||||
|
|
||||||
|
Raises:
|
||||||
def _extract_price_from_element(text: str) -> int | None:
|
DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры.
|
||||||
"""Извлекаем цену из текста ОДНОГО элемента DOM.
|
ValueError: если JSON не найден или не является dict.
|
||||||
|
|
||||||
Парсим из одного span/div — не из суммарного card_text. Это исключает
|
|
||||||
случай, когда адресный номер дома стоит вплотную перед ценой в
|
|
||||||
объединённом тексте карточки («Ленина, 503 100 000 руб.»).
|
|
||||||
"""
|
"""
|
||||||
m = _RE_PRICE_EL.search(text)
|
# Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
|
||||||
if m:
|
# стоять за пределами head в крупных challenge-страницах.
|
||||||
# Нормализуем пробелы любого вида (обычный, неразрывный, узкий)
|
html_lower = html.lower()
|
||||||
raw = re.sub(r"[\s ]", "", m.group(1))
|
if any(m in html_lower for m in _QRATOR_MARKERS):
|
||||||
if raw.isdigit():
|
raise DomClickBlockedError(
|
||||||
val = int(raw)
|
f"DomClick BFF: QRATOR block page detected (markers checked: {_QRATOR_MARKERS[:2]})"
|
||||||
if val > 0:
|
)
|
||||||
return val
|
|
||||||
return None
|
start = html.find("{")
|
||||||
|
end = html.rfind("}")
|
||||||
|
if start == -1 or end == -1 or end <= start:
|
||||||
|
raise ValueError(f"No JSON object found in BFF response (len={len(html)})")
|
||||||
|
|
||||||
|
data = json.loads(html[start : end + 1])
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f"BFF response JSON is not a dict: {type(data)}")
|
||||||
|
return data # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
def _extract_source_id(href: str) -> str | None:
|
# ── URL builders ──────────────────────────────────────────────────────────────
|
||||||
"""Числовой ID из href типа /card/sale__flat__2075671636."""
|
|
||||||
m = _RE_SOURCE_ID.search(href)
|
|
||||||
return m.group(1) if m else None
|
def _build_offers_url(
|
||||||
|
rooms: str,
|
||||||
|
price_gte: int | None,
|
||||||
|
price_lte: int | None,
|
||||||
|
offset: int,
|
||||||
|
) -> str:
|
||||||
|
"""Строит URL для GET /api/offers/v1 с пагинацией.
|
||||||
|
|
||||||
|
urlencode кодирует "5+" → "5%2B" (literal '+' в query string = space → reject).
|
||||||
|
"""
|
||||||
|
params: list[tuple[str, str]] = [
|
||||||
|
("address", _EKB_ADDRESS_GUID),
|
||||||
|
("aids", _EKB_AREA_ID),
|
||||||
|
("deal_type", "sale"),
|
||||||
|
("category", "living"),
|
||||||
|
("offer_type", "flat"),
|
||||||
|
("rooms", rooms),
|
||||||
|
("sort", "qi"),
|
||||||
|
("sort_dir", "desc"),
|
||||||
|
("offset", str(offset)),
|
||||||
|
("limit", str(PAGE_SIZE)),
|
||||||
|
]
|
||||||
|
if price_gte is not None:
|
||||||
|
params.append(("sale_price__gte", str(price_gte)))
|
||||||
|
if price_lte is not None:
|
||||||
|
params.append(("sale_price__lte", str(price_lte)))
|
||||||
|
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_count_url(
|
||||||
|
rooms: str,
|
||||||
|
price_gte: int | None,
|
||||||
|
price_lte: int | None,
|
||||||
|
) -> str:
|
||||||
|
"""Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
|
||||||
|
params: list[tuple[str, str]] = [
|
||||||
|
("address", _EKB_ADDRESS_GUID),
|
||||||
|
("aids", _EKB_AREA_ID),
|
||||||
|
("deal_type", "sale"),
|
||||||
|
("category", "living"),
|
||||||
|
("offer_type", "flat"),
|
||||||
|
("rooms", rooms),
|
||||||
|
("sort", "qi"),
|
||||||
|
("sort_dir", "desc"),
|
||||||
|
]
|
||||||
|
if price_gte is not None:
|
||||||
|
params.append(("sale_price__gte", str(price_gte)))
|
||||||
|
if price_lte is not None:
|
||||||
|
params.append(("sale_price__lte", str(price_lte)))
|
||||||
|
return f"{_BFF_BASE}/api/offers/count/v1?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_publish_date(iso: str | None) -> date | None:
|
||||||
|
"""Парсит ISO8601 дату публикации → date. None при ошибке."""
|
||||||
|
if not iso:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(iso[:10])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── DomClickScraper ──────────────────────────────────────────────────────────
|
# ── DomClickScraper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class DomClickScraper(BaseScraper):
|
class DomClickScraper(BaseScraper):
|
||||||
"""DomClick вторичка parser. Источник = 'domklik'.
|
"""DomClick вторичка через JSON BFF API. Источник = 'domklik'.
|
||||||
|
|
||||||
Использует BrowserFetcher (camoufox headless Firefox) — DataDome пропускает
|
Использует BrowserFetcher(source="domclick") (generic provider → shared mobile
|
||||||
его без дополнительной stealth-настройки. curl_cffi -> 401 (DataDome блок).
|
proxy), который обходит QRATOR. Прямые curl/httpx-запросы с datacenter-IP
|
||||||
|
блокируются QRATOR.
|
||||||
|
|
||||||
Основной метод: fetch_city(city_id, rooms, pages).
|
Основной метод: fetch_city(city_id, rooms, pages).
|
||||||
fetch_around() не реализован: DomClick не поддерживает geo-radius в URL.
|
fetch_around() не реализован: DomClick не поддерживает geo-radius в URL.
|
||||||
|
|
||||||
|
Counters (публичные после fetch_city):
|
||||||
|
parse_failures — офферы с ошибкой маппинга
|
||||||
|
geo_filtered — офферы вне ЕКБ bbox или с неверным offerRegionName
|
||||||
|
blocked — True если sweep был прерван QRATOR-блоком
|
||||||
|
fetch_errors — не-block ошибки извлечения JSON (truncated/garbled/bad shape)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "domklik"
|
name = "domklik"
|
||||||
source = "domklik"
|
source = "domklik"
|
||||||
base_url = "https://domclick.ru"
|
base_url = "https://domclick.ru"
|
||||||
# DataDome — консервативная задержка между страницами (fallback до init)
|
# Консервативная задержка между страницами (fallback до init)
|
||||||
request_delay_sec = 8.0
|
request_delay_sec = 8.0
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.request_delay_sec = get_scraper_delay(self.name)
|
self.request_delay_sec = get_scraper_delay(self.name)
|
||||||
# Счётчик карточек с неожиданной DOM-структурой (для observability)
|
|
||||||
self.parse_failures: int = 0
|
self.parse_failures: int = 0
|
||||||
|
self.geo_filtered: int = 0
|
||||||
|
self.blocked: bool = False
|
||||||
|
# Не-block ошибки извлечения JSON (truncated/garbled response, неверная
|
||||||
|
# структура). В отличие от parse_failures (per-item), это per-fetch ошибки,
|
||||||
|
# которые ограничивают сбор бакета. Учитываются в honest-status pipeline.
|
||||||
|
self.fetch_errors: int = 0
|
||||||
|
|
||||||
async def __aenter__(self) -> DomClickScraper:
|
async def __aenter__(self) -> DomClickScraper:
|
||||||
await super().__aenter__()
|
await super().__aenter__()
|
||||||
|
|
@ -155,237 +226,361 @@ class DomClickScraper(BaseScraper):
|
||||||
self,
|
self,
|
||||||
city_id: int,
|
city_id: int,
|
||||||
rooms: list[int] | None = None,
|
rooms: list[int] | None = None,
|
||||||
pages: int = 20,
|
pages: int = 100,
|
||||||
) -> list[ScrapedLot]:
|
) -> list[ScrapedLot]:
|
||||||
"""Citywide sweep: все страницы SERP для city_id.
|
"""Citywide sweep через BFF JSON API.
|
||||||
|
|
||||||
|
Аргументы city_id и rooms принимаются для совместимости сигнатуры с
|
||||||
|
вызывающим кодом (run_domclick_city_sweep), но:
|
||||||
|
- city_id — vestigial (EКБ захардкожен через GUID).
|
||||||
|
- rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
city_id: числовой ID города в DomClick (например, 4 = Екатеринбург).
|
city_id: игнорируется (EKB захардкожен).
|
||||||
rooms: список значений комнатности (0=студия, 1, 2, 3, 4, ...).
|
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
|
||||||
None -> без фильтра комнатности (все квартиры сразу).
|
pages: максимальное число страниц на бакет (safety cap).
|
||||||
pages: максимальное число страниц на один sweep. Break on empty.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Дедуплицированный по source_id список ScrapedLot.
|
Дедуплицированный по source_id список ScrapedLot.
|
||||||
"""
|
"""
|
||||||
|
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
|
||||||
|
|
||||||
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
|
|
||||||
all_lots: list[ScrapedLot] = []
|
out_lots: list[ScrapedLot] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
# Формируем список room-значений для sweep'ов
|
|
||||||
room_values: list[int | None] = [None] if not rooms else list(rooms)
|
|
||||||
|
|
||||||
async with BrowserFetcher(source="domclick") as fetcher:
|
async with BrowserFetcher(source="domclick") as fetcher:
|
||||||
for room_val in room_values:
|
for bucket in ROOM_BUCKETS:
|
||||||
room_label = f"rooms={room_val}" if room_val is not None else "all_rooms"
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"domklik: city_id=%d %s sweep (max %d pages)", city_id, room_label, pages
|
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
|
||||||
|
bucket,
|
||||||
|
city_id,
|
||||||
|
pages,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
for page_num in range(1, pages + 1):
|
await self._sweep_bucket(
|
||||||
url = self._build_url(city_id, room_val, page_num)
|
fetcher=fetcher,
|
||||||
logger.debug("domklik: fetch %s", url)
|
rooms=bucket,
|
||||||
|
price_gte=None,
|
||||||
try:
|
price_lte=None,
|
||||||
html = await fetcher.fetch(url)
|
seen_ids=seen_ids,
|
||||||
except Exception as exc:
|
out_lots=out_lots,
|
||||||
logger.error(
|
pages=pages,
|
||||||
"domklik: fetch failed city_id=%d page=%d: %s",
|
|
||||||
city_id,
|
|
||||||
page_num,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
lots = self._parse_html(html)
|
|
||||||
except DomClickBlockedError:
|
|
||||||
logger.warning(
|
|
||||||
"domklik: DataDome block at city_id=%d page=%d — stopping sweep",
|
|
||||||
city_id,
|
|
||||||
page_num,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
if not lots:
|
|
||||||
logger.info(
|
|
||||||
"domklik: empty page city_id=%d %s page=%d — stopping sweep",
|
|
||||||
city_id,
|
|
||||||
room_label,
|
|
||||||
page_num,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Дедупликация по source_id (кросс-sweep и кросс-страница)
|
|
||||||
new_lots: list[ScrapedLot] = []
|
|
||||||
for lot in lots:
|
|
||||||
key = lot.source_id or lot.source_url
|
|
||||||
if key not in seen_ids:
|
|
||||||
seen_ids.add(key)
|
|
||||||
new_lots.append(lot)
|
|
||||||
|
|
||||||
all_lots.extend(new_lots)
|
|
||||||
logger.info(
|
|
||||||
"domklik: city_id=%d %s page=%d -> %d new (total %d)",
|
|
||||||
city_id,
|
|
||||||
room_label,
|
|
||||||
page_num,
|
|
||||||
len(new_lots),
|
|
||||||
len(all_lots),
|
|
||||||
)
|
)
|
||||||
|
except DomClickBlockedError:
|
||||||
# Пауза между страницами (anti-DataDome)
|
self.blocked = True
|
||||||
if page_num < pages:
|
logger.error(
|
||||||
await self.sleep_between_requests()
|
"domklik: QRATOR block during rooms=%r — aborting all buckets",
|
||||||
|
bucket,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
# Defensive: bucket-level ошибка не должна убивать весь sweep.
|
||||||
|
# _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++),
|
||||||
|
# но если что-то всё же всплыло — переходим к следующему бакету.
|
||||||
|
self.fetch_errors += 1
|
||||||
|
logger.warning(
|
||||||
|
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
||||||
|
bucket,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"domklik: fetch_city done city_id=%d total=%d parse_failures=%d",
|
"domklik: fetch_city done city_id=%d total=%d "
|
||||||
|
"parse_failures=%d geo_filtered=%d fetch_errors=%d blocked=%s",
|
||||||
city_id,
|
city_id,
|
||||||
len(all_lots),
|
len(out_lots),
|
||||||
self.parse_failures,
|
self.parse_failures,
|
||||||
|
self.geo_filtered,
|
||||||
|
self.fetch_errors,
|
||||||
|
self.blocked,
|
||||||
)
|
)
|
||||||
return all_lots
|
return out_lots
|
||||||
|
|
||||||
# ── URL builder ───────────────────────────────────────────────────────────
|
# ── Internal sweep helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_url(self, city_id: int, rooms: int | None, page: int) -> str:
|
async def _count(
|
||||||
"""Строит URL SERP DomClick."""
|
self,
|
||||||
url = (
|
fetcher: Any,
|
||||||
f"{self.base_url}/search"
|
rooms: str,
|
||||||
f"?deal_type=sale&category=living&offer_type=flat"
|
price_gte: int | None,
|
||||||
f"&city_id={city_id}"
|
price_lte: int | None,
|
||||||
)
|
) -> int:
|
||||||
if rooms is not None:
|
"""Запрашивает snippetsCount для данного бакета (rooms + price range).
|
||||||
url += f"&rooms={rooms}"
|
|
||||||
url += f"&p={page}"
|
|
||||||
return url
|
|
||||||
|
|
||||||
# ── HTML parsing ──────────────────────────────────────────────────────────
|
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
|
||||||
|
извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0
|
||||||
def _parse_html(self, html: str) -> list[ScrapedLot]:
|
(бакет пропускается, sweep продолжается).
|
||||||
"""Парсим карточки из HTML через selectolax.
|
|
||||||
|
|
||||||
Селектор: `a[href*="/card/"]` с паттерном `/card/sale__flat__<id>`.
|
|
||||||
"""
|
"""
|
||||||
if _is_blocked_page(html):
|
url = _build_count_url(rooms, price_gte, price_lte)
|
||||||
logger.warning("domklik: DataDome block page detected, 0 cards returned")
|
logger.debug("domklik: count url=%s", url)
|
||||||
raise DomClickBlockedError("DomClick returned DataDome block page")
|
html = await fetcher.fetch(url)
|
||||||
tree = HTMLParser(html)
|
try:
|
||||||
card_links = tree.css('a[href*="/card/"]')
|
data = _extract_json(html)
|
||||||
|
except DomClickBlockedError:
|
||||||
|
raise
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
self.fetch_errors += 1
|
||||||
|
logger.warning(
|
||||||
|
"domklik: _count JSON extract failed rooms=%r price=[%s,%s] — skip bucket",
|
||||||
|
rooms,
|
||||||
|
price_gte,
|
||||||
|
price_lte,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
# {"result": null} → None, {"snippetsCount": null} → None: harden обе.
|
||||||
|
res = data.get("result") or {}
|
||||||
|
raw = res.get("snippetsCount")
|
||||||
|
return int(raw) if raw else 0
|
||||||
|
|
||||||
seen_hrefs: set[str] = set()
|
async def _sweep_bucket(
|
||||||
lots: list[ScrapedLot] = []
|
self,
|
||||||
|
fetcher: Any,
|
||||||
|
rooms: str,
|
||||||
|
price_gte: int | None,
|
||||||
|
price_lte: int | None,
|
||||||
|
seen_ids: set[str],
|
||||||
|
out_lots: list[ScrapedLot],
|
||||||
|
pages: int,
|
||||||
|
) -> None:
|
||||||
|
"""Рекурсивный sweep бакета (rooms, price_gte, price_lte).
|
||||||
|
|
||||||
for link in card_links:
|
Если snippetsCount > OFFSET_CAP — делим диапазон цен пополам и
|
||||||
href = link.attributes.get("href", "")
|
рекурсируем в каждую половину. Остановка рекурсии:
|
||||||
# Только карточки вторичного жилья
|
- span <= MIN_PRICE_SPAN → пагинируем как есть (с предупреждением о truncation)
|
||||||
if "sale__flat__" not in href:
|
- snippetsCount == 0 → пропускаем
|
||||||
continue
|
- snippetsCount <= OFFSET_CAP → пагинируем
|
||||||
if href in seen_hrefs:
|
|
||||||
continue
|
|
||||||
seen_hrefs.add(href)
|
|
||||||
|
|
||||||
lot = self._card_link_to_lot(link, href)
|
Raises:
|
||||||
if lot is not None:
|
DomClickBlockedError: если QRATOR-блок — propagate наверх.
|
||||||
lots.append(lot)
|
"""
|
||||||
|
count = await self._count(fetcher, rooms, price_gte, price_lte)
|
||||||
|
if count == 0:
|
||||||
|
return
|
||||||
|
|
||||||
return lots
|
if count > OFFSET_CAP:
|
||||||
|
_gte = price_gte if price_gte is not None else 0
|
||||||
|
_lte = price_lte if price_lte is not None else LTE_MAX
|
||||||
|
span = _lte - _gte
|
||||||
|
if span <= MIN_PRICE_SPAN:
|
||||||
|
logger.warning(
|
||||||
|
"domklik: rooms=%r price=[%s,%s] count=%d > cap=%d "
|
||||||
|
"but span=%d <= min=%d — paginating as-is (bucket truncated at %d)",
|
||||||
|
rooms,
|
||||||
|
price_gte,
|
||||||
|
price_lte,
|
||||||
|
count,
|
||||||
|
OFFSET_CAP,
|
||||||
|
span,
|
||||||
|
MIN_PRICE_SPAN,
|
||||||
|
OFFSET_CAP,
|
||||||
|
)
|
||||||
|
await self._paginate(
|
||||||
|
fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages
|
||||||
|
)
|
||||||
|
return
|
||||||
|
mid = (_gte + _lte) // 2
|
||||||
|
logger.debug(
|
||||||
|
"domklik: rooms=%r count=%d > cap=%d — price-split [%d,%d] → [%d,%d]+[%d,%d]",
|
||||||
|
rooms,
|
||||||
|
count,
|
||||||
|
OFFSET_CAP,
|
||||||
|
_gte,
|
||||||
|
_lte,
|
||||||
|
_gte,
|
||||||
|
mid,
|
||||||
|
mid + 1,
|
||||||
|
_lte,
|
||||||
|
)
|
||||||
|
await self._sweep_bucket(fetcher, rooms, _gte, mid, seen_ids, out_lots, pages)
|
||||||
|
await self._sweep_bucket(fetcher, rooms, mid + 1, _lte, seen_ids, out_lots, pages)
|
||||||
|
else:
|
||||||
|
await self._paginate(fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages)
|
||||||
|
|
||||||
def _card_link_to_lot(self, link: Node, href: str) -> ScrapedLot | None:
|
async def _paginate(
|
||||||
"""Парсинг одной карточки-ссылки -> ScrapedLot.
|
self,
|
||||||
|
fetcher: Any,
|
||||||
|
rooms: str,
|
||||||
|
price_gte: int | None,
|
||||||
|
price_lte: int | None,
|
||||||
|
seen_ids: set[str],
|
||||||
|
out_lots: list[ScrapedLot],
|
||||||
|
pages: int,
|
||||||
|
) -> None:
|
||||||
|
"""Пагинирует один бакет (rooms, price range) до исчерпания или cap.
|
||||||
|
|
||||||
DomClick SERP оборачивает карточку в `<a href="/card/...">` — вся
|
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
|
||||||
информация (title, цена, адрес) внутри этого элемента.
|
извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и break
|
||||||
|
(трактуем как конец бакета — уже собранные lots сохраняются).
|
||||||
|
|
||||||
Цену извлекаем из отдельных дочерних элементов (не из объединённого
|
Raises:
|
||||||
card_text) чтобы исключить слипание числа дома с ценой.
|
DomClickBlockedError: propagate из _extract_json при QRATOR-блоке.
|
||||||
|
"""
|
||||||
|
force_rooms = 0 if rooms == "st" else None
|
||||||
|
page_idx = 0
|
||||||
|
while page_idx < pages:
|
||||||
|
offset = page_idx * PAGE_SIZE
|
||||||
|
if offset >= OFFSET_CAP:
|
||||||
|
break
|
||||||
|
url = _build_offers_url(rooms, price_gte, price_lte, offset)
|
||||||
|
logger.debug("domklik: offers url=%s", url)
|
||||||
|
html = await fetcher.fetch(url)
|
||||||
|
try:
|
||||||
|
data = _extract_json(html)
|
||||||
|
except DomClickBlockedError:
|
||||||
|
raise
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
self.fetch_errors += 1
|
||||||
|
logger.warning(
|
||||||
|
"domklik: _paginate JSON extract failed rooms=%r offset=%d "
|
||||||
|
"— ending bucket (lots so far preserved)",
|
||||||
|
rooms,
|
||||||
|
offset,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# {"result": null} → None; harden до .get("items").
|
||||||
|
items: list[dict[str, Any]] = (data.get("result") or {}).get("items") or []
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
lot = self._map_item(item, force_rooms=force_rooms)
|
||||||
|
if lot is None:
|
||||||
|
continue
|
||||||
|
if not self._is_geo_ok(item):
|
||||||
|
self.geo_filtered += 1
|
||||||
|
continue
|
||||||
|
key = lot.source_id or lot.source_url
|
||||||
|
if key in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(key)
|
||||||
|
out_lots.append(lot)
|
||||||
|
|
||||||
|
page_idx += 1
|
||||||
|
if page_idx < pages and len(items) == PAGE_SIZE:
|
||||||
|
await self.sleep_between_requests()
|
||||||
|
elif not items or len(items) < PAGE_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _is_geo_ok(self, item: dict[str, Any]) -> bool:
|
||||||
|
"""Гео-гард: пропускает только листинги ЕКБ в bbox.
|
||||||
|
|
||||||
|
aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку.
|
||||||
|
"""
|
||||||
|
region = item.get("offerRegionName", "")
|
||||||
|
if region != _EKB_REGION_NAME:
|
||||||
|
return False
|
||||||
|
loc = item.get("location") or {}
|
||||||
|
lat = loc.get("lat")
|
||||||
|
lon = loc.get("lon")
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
_EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX
|
||||||
|
and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX
|
||||||
|
)
|
||||||
|
|
||||||
|
def _map_item(
|
||||||
|
self, item: dict[str, Any], *, force_rooms: int | None = None
|
||||||
|
) -> ScrapedLot | None:
|
||||||
|
"""Маппинг offer-item из BFF API → ScrapedLot.
|
||||||
|
|
||||||
|
Возвращает None если price <= 0 или при ошибке парсинга.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
source_id = _extract_source_id(href)
|
price = item.get("price", 0) or 0
|
||||||
source_url = urljoin(self.base_url, href)
|
if price <= 0:
|
||||||
|
|
||||||
# Суммарный текст — для rooms/area/floor (эти поля не подвержены
|
|
||||||
# проблеме слипания, т.к. используют специфичные маркеры: «м²», «эт.»)
|
|
||||||
card_text = link.text(strip=True)
|
|
||||||
if not card_text:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
rooms = _extract_rooms(card_text)
|
item_id = item.get("id")
|
||||||
area = _extract_area(card_text)
|
source_id = str(item_id) if item_id is not None else None
|
||||||
floor, total_floors = _extract_floor(card_text)
|
source_url: str = item.get("path", "") or ""
|
||||||
|
|
||||||
# Цена: ищем в каждом дочернем элементе отдельно чтобы не слипались
|
loc = item.get("location") or {}
|
||||||
# числа дома с ценой при конкатенации card_text
|
lat: float | None = loc.get("lat")
|
||||||
price = self._extract_price_from_children(link)
|
lon: float | None = loc.get("lon")
|
||||||
if not price or price <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
address = self._extract_address(link)
|
address_obj = item.get("address") or {}
|
||||||
|
address: str | None = address_obj.get("displayName")
|
||||||
|
|
||||||
|
# 0 — sentinel «неизвестно» для area/floor/total_floors/buildYear
|
||||||
|
# (DomClick эмитит 0 для under-construction / unknown) → None.
|
||||||
|
obj_info = item.get("objectInfo") or {}
|
||||||
|
area_raw = obj_info.get("area")
|
||||||
|
area_m2: float | None = float(area_raw) if area_raw else None
|
||||||
|
floor_raw = obj_info.get("floor")
|
||||||
|
floor: int | None = int(floor_raw) if floor_raw else None
|
||||||
|
|
||||||
|
house = item.get("house") or {}
|
||||||
|
floors_raw = house.get("floors")
|
||||||
|
total_floors: int | None = int(floors_raw) if floors_raw else None
|
||||||
|
year_raw = house.get("buildYear")
|
||||||
|
year_built: int | None = int(year_raw) if year_raw else None
|
||||||
|
|
||||||
|
# rooms: 0 — ВАЛИДНОЕ значение (студия), поэтому проверка is not None,
|
||||||
|
# а не truthy. force_rooms=0 (st-бакет) перебивает objectInfo.
|
||||||
|
if force_rooms is not None:
|
||||||
|
rooms: int | None = force_rooms
|
||||||
|
else:
|
||||||
|
rooms_raw = obj_info.get("rooms")
|
||||||
|
rooms = int(rooms_raw) if rooms_raw is not None else None
|
||||||
|
|
||||||
|
square_price_raw = item.get("squarePrice")
|
||||||
|
price_per_m2: int | None = int(square_price_raw) if square_price_raw else None
|
||||||
|
if price_per_m2 is None and area_m2 and area_m2 > 0:
|
||||||
|
price_per_m2 = int(price / area_m2)
|
||||||
|
|
||||||
|
flat_complex = item.get("flatComplex") or {}
|
||||||
|
raw_payload: dict[str, Any] = {
|
||||||
|
"isRosreestrApproved": item.get("isRosreestrApproved"),
|
||||||
|
"squarePrice": square_price_raw,
|
||||||
|
"lastPriceHistoryState": item.get("lastPriceHistoryState"),
|
||||||
|
"flatComplex": {
|
||||||
|
"name": flat_complex.get("name"),
|
||||||
|
"id": flat_complex.get("id"),
|
||||||
|
"slug": flat_complex.get("slug"),
|
||||||
|
}
|
||||||
|
if flat_complex
|
||||||
|
else None,
|
||||||
|
"updatedDate": item.get("updatedDate"),
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_date = _parse_publish_date(item.get("publishedDate"))
|
||||||
|
|
||||||
return ScrapedLot(
|
return ScrapedLot(
|
||||||
source="domklik",
|
source="domklik",
|
||||||
source_url=source_url,
|
source_url=source_url,
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
address=address,
|
address=address,
|
||||||
lat=None, # DomClick SERP координаты не отдаёт
|
lat=lat,
|
||||||
lon=None,
|
lon=lon,
|
||||||
rooms=rooms,
|
rooms=rooms,
|
||||||
area_m2=area,
|
area_m2=area_m2,
|
||||||
floor=floor,
|
floor=floor,
|
||||||
total_floors=total_floors,
|
total_floors=total_floors,
|
||||||
price_rub=price,
|
year_built=year_built,
|
||||||
|
price_rub=int(price),
|
||||||
|
price_per_m2=price_per_m2,
|
||||||
listing_segment="vtorichka",
|
listing_segment="vtorichka",
|
||||||
raw_payload={"card_text": card_text[:500]},
|
publish_date=publish_date,
|
||||||
|
raw_payload=raw_payload,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.parse_failures += 1
|
self.parse_failures += 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"domclick _card_link_to_lot: parse failed href=%r (parse_failures=%d)",
|
"domklik: _map_item failed for item id=%r (parse_failures=%d)",
|
||||||
href,
|
item.get("id"),
|
||||||
self.parse_failures,
|
self.parse_failures,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_price_from_children(self, link: Node) -> int | None:
|
|
||||||
"""Ищем цену в дочерних элементах карточки по символу рубля.
|
|
||||||
|
|
||||||
Итерируем по всем потомкам: первый элемент с рублёвым символом
|
|
||||||
в тексте — источник цены.
|
|
||||||
"""
|
|
||||||
for selector in ("span", "div", "p", "strong", "b"):
|
|
||||||
for el in link.css(selector):
|
|
||||||
text = el.text(strip=True)
|
|
||||||
price = _extract_price_from_element(text)
|
|
||||||
if price:
|
|
||||||
return price
|
|
||||||
# Fallback: суммарный текст карточки (если структура нестандартная)
|
|
||||||
return _extract_price_from_element(link.text(strip=True))
|
|
||||||
|
|
||||||
def _extract_address(self, link: Node) -> str | None:
|
|
||||||
"""Эвристика извлечения адреса из DOM карточки.
|
|
||||||
|
|
||||||
DomClick помещает адрес в отдельный текстовый блок внутри карточки.
|
|
||||||
Обходим вложенные элементы и ищем первый текст с адресными ключевыми
|
|
||||||
словами, пропуская строки с площадью/этажом/ценой.
|
|
||||||
|
|
||||||
Возвращает None если адрес не найден.
|
|
||||||
"""
|
|
||||||
for selector in ("span", "p", "div"):
|
|
||||||
for el in link.css(selector):
|
|
||||||
text = el.text(strip=True)
|
|
||||||
if not text or len(text) < 5 or len(text) > 200:
|
|
||||||
continue
|
|
||||||
# Пропускаем блоки с площадью / этажом / ценой
|
|
||||||
if _RE_AREA.search(text) or _RE_FLOOR.search(text):
|
|
||||||
continue
|
|
||||||
if _RE_PRICE_EL.search(text):
|
|
||||||
continue
|
|
||||||
if _RE_ADDR_KW.search(text):
|
|
||||||
return text
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Convenience runner (для Celery tasks) ────────────────────────────────────
|
# ── Convenience runner (для Celery tasks) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -393,14 +588,14 @@ class DomClickScraper(BaseScraper):
|
||||||
async def scrape_domclick_city(
|
async def scrape_domclick_city(
|
||||||
city_id: int,
|
city_id: int,
|
||||||
rooms: list[int] | None = None,
|
rooms: list[int] | None = None,
|
||||||
pages: int = 20,
|
pages: int = 100,
|
||||||
) -> list[ScrapedLot]:
|
) -> list[ScrapedLot]:
|
||||||
"""Удобная точка входа для вызова из Celery tasks.
|
"""Удобная точка входа для вызова из Celery tasks.
|
||||||
|
|
||||||
Пример::
|
Пример::
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
lots = asyncio.run(scrape_domclick_city(city_id=4, rooms=[1, 2, 3]))
|
lots = asyncio.run(scrape_domclick_city(city_id=4, rooms=None))
|
||||||
"""
|
"""
|
||||||
async with DomClickScraper() as scraper:
|
async with DomClickScraper() as scraper:
|
||||||
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,12 @@
|
||||||
|
|
||||||
|
|
||||||
class DomClickBlockedError(Exception):
|
class DomClickBlockedError(Exception):
|
||||||
"""DomClick вернул DataDome block-страницу (HTTP 200 + block HTML)."""
|
"""DomClick BFF вернул QRATOR block-страницу (HTTP 200 + block HTML).
|
||||||
|
|
||||||
|
QRATOR (qrator.net) — WAF/DDoS-защита domclick.ru. Блокирует datacenter-IP
|
||||||
|
и возвращает HTML с маркерами: "qrator", "bot_mitigation", "система защиты",
|
||||||
|
"403 | домклик", "captcha", "access denied".
|
||||||
|
|
||||||
|
Обходится через shared mobile proxy (BrowserFetcher(source="domclick") →
|
||||||
|
generic provider → мобильный egress).
|
||||||
|
"""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- 138_domclick_bff_rewrite_schedule.sql
|
||||||
|
--
|
||||||
|
-- Обновляет параметры расписания domclick_city_sweep после переписки скрейпера
|
||||||
|
-- на BFF JSON API (https://bff-search-web.domclick.ru/api/offers/v1).
|
||||||
|
--
|
||||||
|
-- DORMANT: enabled остаётся false до тех пор пока оператор не верифицирует
|
||||||
|
-- прод-smoke нового BFF-скрейпера вручную:
|
||||||
|
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'domclick_city_sweep';
|
||||||
|
--
|
||||||
|
-- Ключевые изменения относительно 122_enable_domclick_city_sweep.sql:
|
||||||
|
-- - pages_per_anchor поднят до 100 (каждый бакет до 2000 листингов,
|
||||||
|
-- rooms=2 нуждается в price-split → реально ≈330 страниц)
|
||||||
|
-- - rooms убран из default_params — скрейпер всегда обходит все ROOM_BUCKETS
|
||||||
|
-- (st/1/2/3/4/5+) внутри независимо от этого параметра
|
||||||
|
-- - request_delay_sec оставлен 6 (как в 122; BFF JSON легче HTML-рендера)
|
||||||
|
--
|
||||||
|
-- Зависимости: 052_scrape_schedules.sql (таблица), 122_enable_domclick_city_sweep.sql
|
||||||
|
-- (строка должна существовать — INSERT ON CONFLICT DO NOTHING в 122).
|
||||||
|
-- Idempotent: UPDATE не упадёт если строка уже обновлена (runs повторно безопасно).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE scrape_schedules
|
||||||
|
SET enabled = false,
|
||||||
|
default_params = jsonb_build_object(
|
||||||
|
'city_id', 4,
|
||||||
|
'pages_per_anchor', 100,
|
||||||
|
'request_delay_sec', 6
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE source = 'domclick_city_sweep';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,252 +1,526 @@
|
||||||
"""Unit-тесты для DomClick scraper (#796).
|
"""Unit-тесты для DomClick BFF scraper (#1968).
|
||||||
|
|
||||||
Тестируем:
|
Тестируем:
|
||||||
- Парсинг title-паттерна (rooms/area/floor) из текста карточки
|
- _extract_json: bare + <pre>-wrapped → dict; QRATOR-маркер → DomClickBlockedError
|
||||||
- Парсинг цены из отдельного DOM-элемента
|
- _build_offers_url / _build_count_url: корректные параметры, "5+" → "5%2B",
|
||||||
- Извлечение source_id из href
|
offers имеет offset/limit, count — нет
|
||||||
- Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML
|
- _map_item: маппинг realistic offer dict → ScrapedLot; price=0 → None; studio
|
||||||
|
- _is_geo_ok: гео-гард по bbox + offerRegionName
|
||||||
|
- _sweep_bucket: price-split при count > OFFSET_CAP
|
||||||
|
- DomClickScraper: source/name, fetch_around raises NotImplementedError
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.scrapers.domclick import (
|
from app.services.scrapers.domclick import (
|
||||||
|
LTE_MAX,
|
||||||
|
OFFSET_CAP,
|
||||||
DomClickScraper,
|
DomClickScraper,
|
||||||
_extract_area,
|
_build_count_url,
|
||||||
_extract_floor,
|
_build_offers_url,
|
||||||
_extract_price_from_element,
|
_extract_json,
|
||||||
_extract_rooms,
|
|
||||||
_extract_source_id,
|
|
||||||
)
|
)
|
||||||
from app.services.scrapers.domclick_exceptions import DomClickBlockedError
|
from app.services.scrapers.domclick_exceptions import DomClickBlockedError
|
||||||
|
|
||||||
# ── Rooms / studio ───────────────────────────────────────────────────────────
|
# ── _extract_json ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_extract_json_bare_body() -> None:
|
||||||
"text, expected",
|
"""Bare JSON body (без HTML-обёртки) корректно парсится."""
|
||||||
[
|
html = '{"result": {"items": [], "snippetsCount": 42}}'
|
||||||
("2-комн. квартира 53,5 м² 5/15 эт.", 2),
|
data = _extract_json(html)
|
||||||
("1-к. квартира 32,1 м² 3/9 эт.", 1),
|
assert data["result"]["snippetsCount"] == 42
|
||||||
("3-комн. кв. 78 м² 10/12 эт.", 3),
|
|
||||||
("4-комн. квартира 95 м² 2/5 эт.", 4),
|
|
||||||
("студия 24 м² 1/10 эт.", 0),
|
|
||||||
("Студия 30,5 м² 8/22 эт.", 0),
|
|
||||||
("Нет комнат — просто текст", None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extract_rooms(text: str, expected: int | None) -> None:
|
|
||||||
assert _extract_rooms(text) == expected
|
|
||||||
|
|
||||||
|
|
||||||
# ── Area ─────────────────────────────────────────────────────────────────────
|
def test_extract_json_pre_wrapped() -> None:
|
||||||
|
"""JSON в <pre>-теге корректно парсится."""
|
||||||
|
html = (
|
||||||
|
"<html><head><title>DomClick</title></head>"
|
||||||
|
'<body><pre>{"result": {"items": [{"id": 1}]}}</pre></body></html>'
|
||||||
|
)
|
||||||
|
data = _extract_json(html)
|
||||||
|
assert data["result"]["items"][0]["id"] == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_extract_json_qrator_marker_raises() -> None:
|
||||||
"text, expected",
|
"""HTML с QRATOR-маркером вызывает DomClickBlockedError."""
|
||||||
[
|
blocked_html = (
|
||||||
("2-комн. квартира 53,5 м² 5/15 эт.", 53.5),
|
"<html><head><title>QRATOR protection</title></head>"
|
||||||
("студия 24 м² 1/10 эт.", 24.0),
|
"<body>Access denied by bot_mitigation system</body></html>"
|
||||||
("78м2 на этаже", 78.0),
|
)
|
||||||
("площадь 101.7 м²", 101.7),
|
with pytest.raises(DomClickBlockedError):
|
||||||
("Нет площади", None),
|
_extract_json(blocked_html)
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extract_area(text: str, expected: float | None) -> None:
|
|
||||||
assert _extract_area(text) == expected
|
|
||||||
|
|
||||||
|
|
||||||
# ── Floor ─────────────────────────────────────────────────────────────────────
|
def test_extract_json_captcha_raises() -> None:
|
||||||
|
"""HTML с captcha-маркером вызывает DomClickBlockedError."""
|
||||||
|
html = "<html><body>Please solve the captcha to continue</body></html>"
|
||||||
|
with pytest.raises(DomClickBlockedError):
|
||||||
|
_extract_json(html)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_extract_json_no_json_raises() -> None:
|
||||||
"text, floor, total",
|
"""HTML без JSON-объекта вызывает ValueError."""
|
||||||
[
|
with pytest.raises(ValueError, match="No JSON"):
|
||||||
("2-комн. квартира 53,5 м² 5/15 эт.", 5, 15),
|
_extract_json("<html><body>plain text</body></html>")
|
||||||
("студия 24 м² 1/10 эт.", 1, 10),
|
|
||||||
("12/24 эт.", 12, 24),
|
|
||||||
("нет этажа", None, None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extract_floor(text: str, floor: int | None, total: int | None) -> None:
|
|
||||||
assert _extract_floor(text) == (floor, total)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Price (per-element) ───────────────────────────────────────────────────────
|
# ── _build_offers_url ────────────────────────────────────────────────────────
|
||||||
# Цена парсится из отдельного DOM-элемента (не из суммарного card_text),
|
|
||||||
# поэтому числа адреса не загрязняют парсинг цены.
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_build_offers_url_basic_params() -> None:
|
||||||
"text, expected",
|
url = _build_offers_url("1", None, None, 0)
|
||||||
[
|
assert "deal_type=sale" in url
|
||||||
("5 200 000 ₽", 5_200_000),
|
assert "category=living" in url
|
||||||
("12 345 678 ₽", 12_345_678),
|
assert "offer_type=flat" in url
|
||||||
("3 990 000 руб.", 3_990_000),
|
assert "rooms=1" in url
|
||||||
("от 4 500 000 ₽", 4_500_000),
|
assert "offset=0" in url
|
||||||
("без цены", None),
|
assert "limit=20" in url
|
||||||
("0 ₽", None),
|
assert "sale_price__gte" not in url
|
||||||
],
|
assert "sale_price__lte" not in url
|
||||||
)
|
|
||||||
def test_extract_price_from_element(text: str, expected: int | None) -> None:
|
|
||||||
assert _extract_price_from_element(text) == expected
|
|
||||||
|
|
||||||
|
|
||||||
# ── source_id from href ───────────────────────────────────────────────────────
|
def test_build_offers_url_5plus_encoded() -> None:
|
||||||
|
"""rooms='5+' должен кодироваться как '5%2B' в URL."""
|
||||||
|
url = _build_offers_url("5+", None, None, 0)
|
||||||
|
assert "5%2B" in url
|
||||||
|
# literal '+' в URL не должно быть для значения rooms
|
||||||
|
assert "rooms=5+" not in url
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_build_offers_url_with_price_range() -> None:
|
||||||
"href, expected",
|
url = _build_offers_url("2", 3_000_000, 6_000_000, 40)
|
||||||
[
|
assert "sale_price__gte=3000000" in url
|
||||||
("/card/sale__flat__2075671636", "2075671636"),
|
assert "sale_price__lte=6000000" in url
|
||||||
("https://domclick.ru/card/sale__flat__123456789", "123456789"),
|
assert "offset=40" in url
|
||||||
("/card/sale__flat__1", "1"),
|
|
||||||
("/card/something_else", None),
|
|
||||||
("", None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extract_source_id(href: str, expected: str | None) -> None:
|
|
||||||
assert _extract_source_id(href) == expected
|
|
||||||
|
|
||||||
|
|
||||||
# ── _parse_html -> list[ScrapedLot] ──────────────────────────────────────────
|
def test_build_offers_url_ekb_guid_present() -> None:
|
||||||
# Цена в каждой карточке вынесена в отдельный <span> с символом рубля,
|
url = _build_offers_url("3", None, None, 0)
|
||||||
# чтобы не сливаться с адресным номером дома при конкатенации card_text.
|
assert "0d475b79-88de-4054-818c-37d8f9d0d440" in url
|
||||||
|
assert "aids=20561" in url
|
||||||
_MOCK_HTML = """\
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<!-- Карточка 1: комнатность, площадь, этаж, адрес, цена в отдельных span -->
|
|
||||||
<a href="/card/sale__flat__2075671636">
|
|
||||||
<span>2-комн. квартира 53,5 м² 5/15 эт.</span>
|
|
||||||
<span>ул. Малышева, 1</span>
|
|
||||||
<span>5 200 000 ₽</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Карточка 2: студия -->
|
|
||||||
<a href="/card/sale__flat__1111111111">
|
|
||||||
<span>Студия 28 м² 3/10 эт.</span>
|
|
||||||
<span>пр-т Ленина, 50</span>
|
|
||||||
<span>3 100 000 ₽</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Карточка 3: без цены -> должна быть отфильтрована -->
|
|
||||||
<a href="/card/sale__flat__9999999999">
|
|
||||||
<span>1-комн. квартира 32 м² 2/9 эт.</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Не карточка (без sale__flat__) -> должна быть отфильтрована -->
|
|
||||||
<a href="/card/sale__apartment__123">
|
|
||||||
<span>что-то другое</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Дубликат первой карточки -> один раз на страницу -->
|
|
||||||
<a href="/card/sale__flat__2075671636">
|
|
||||||
<span>2-комн. квартира 53,5 м² 5/15 эт.</span>
|
|
||||||
<span>5 200 000 ₽</span>
|
|
||||||
</a>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_html_returns_lots() -> None:
|
def test_build_offers_url_bff_host() -> None:
|
||||||
scraper = DomClickScraper()
|
url = _build_offers_url("1", None, None, 0)
|
||||||
lots = scraper._parse_html(_MOCK_HTML)
|
assert url.startswith("https://bff-search-web.domclick.ru/api/offers/v1?")
|
||||||
|
|
||||||
# Карточки 1 и 2 парсятся; карточка 3 (без цены) отфильтрована; дубль — дедуплицирован
|
|
||||||
assert len(lots) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_html_card1_fields() -> None:
|
# ── _build_count_url ─────────────────────────────────────────────────────────
|
||||||
scraper = DomClickScraper()
|
|
||||||
lots = scraper._parse_html(_MOCK_HTML)
|
|
||||||
|
|
||||||
card1 = next(lot for lot in lots if lot.source_id == "2075671636")
|
|
||||||
assert card1.source == "domklik"
|
|
||||||
assert card1.source_url == "https://domclick.ru/card/sale__flat__2075671636"
|
|
||||||
assert card1.rooms == 2
|
|
||||||
assert card1.area_m2 == 53.5
|
|
||||||
assert card1.floor == 5
|
|
||||||
assert card1.total_floors == 15
|
|
||||||
assert card1.price_rub == 5_200_000
|
|
||||||
assert card1.lat is None
|
|
||||||
assert card1.lon is None
|
|
||||||
assert card1.listing_segment == "vtorichka"
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_html_studio() -> None:
|
def test_build_count_url_no_offset_limit() -> None:
|
||||||
scraper = DomClickScraper()
|
"""count url не должен содержать offset и limit."""
|
||||||
lots = scraper._parse_html(_MOCK_HTML)
|
url = _build_count_url("2", None, None)
|
||||||
|
assert "offset" not in url
|
||||||
studio = next(lot for lot in lots if lot.source_id == "1111111111")
|
assert "limit" not in url
|
||||||
assert studio.rooms == 0 # студия
|
|
||||||
assert studio.area_m2 == 28.0
|
|
||||||
assert studio.price_rub == 3_100_000
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_html_no_price_filtered() -> None:
|
def test_build_count_url_5plus_encoded() -> None:
|
||||||
scraper = DomClickScraper()
|
url = _build_count_url("5+", None, None)
|
||||||
lots = scraper._parse_html(_MOCK_HTML)
|
assert "5%2B" in url
|
||||||
|
assert "rooms=5+" not in url
|
||||||
source_ids = {lot.source_id for lot in lots}
|
|
||||||
assert "9999999999" not in source_ids
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_html_dedup_same_href() -> None:
|
def test_build_count_url_with_price() -> None:
|
||||||
"""Дубликат href на одной странице должен давать одну карточку."""
|
url = _build_count_url("1", 1_000_000, 4_000_000)
|
||||||
scraper = DomClickScraper()
|
assert "sale_price__gte=1000000" in url
|
||||||
lots = scraper._parse_html(_MOCK_HTML)
|
assert "sale_price__lte=4000000" in url
|
||||||
|
|
||||||
ids_2075 = [lot for lot in lots if lot.source_id == "2075671636"]
|
|
||||||
assert len(ids_2075) == 1
|
def test_build_count_url_bff_host() -> None:
|
||||||
|
url = _build_count_url("1", None, None)
|
||||||
|
assert url.startswith("https://bff-search-web.domclick.ru/api/offers/count/v1?")
|
||||||
|
|
||||||
|
|
||||||
|
# ── _map_item → ScrapedLot ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_REALISTIC_ITEM: dict[str, Any] = {
|
||||||
|
"id": 2075671636,
|
||||||
|
"path": "https://domclick.ru/card/sale__flat__2075671636",
|
||||||
|
"location": {"lat": 56.838, "lon": 60.612},
|
||||||
|
"address": {"displayName": "Екатеринбург, улица Малышева, 1"},
|
||||||
|
"objectInfo": {"area": 53.5, "rooms": 2, "floor": 5, "isApartment": False},
|
||||||
|
"house": {"floors": 15, "buildYear": 2003},
|
||||||
|
"price": 5_200_000,
|
||||||
|
"squarePrice": 97196,
|
||||||
|
"flatComplex": {"id": 12345, "name": "ЖК Тест", "slug": "zhk-test"},
|
||||||
|
"isRosreestrApproved": True,
|
||||||
|
"publishedDate": "2026-06-01T10:00:00+05:00",
|
||||||
|
"updatedDate": "2026-06-15T12:00:00+05:00",
|
||||||
|
"lastPriceHistoryState": {"direction": "DECREASED"},
|
||||||
|
"offerRegionName": "Екатеринбург",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_item_basic_fields() -> None:
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
lot = scraper._map_item(_REALISTIC_ITEM)
|
||||||
|
assert lot is not None
|
||||||
|
assert lot.source == "domklik"
|
||||||
|
assert lot.source_id == "2075671636"
|
||||||
|
assert lot.source_url == "https://domclick.ru/card/sale__flat__2075671636"
|
||||||
|
assert lot.lat == pytest.approx(56.838)
|
||||||
|
assert lot.lon == pytest.approx(60.612)
|
||||||
|
assert lot.address == "Екатеринбург, улица Малышева, 1"
|
||||||
|
assert lot.rooms == 2
|
||||||
|
assert lot.area_m2 == pytest.approx(53.5)
|
||||||
|
assert lot.floor == 5
|
||||||
|
assert lot.total_floors == 15
|
||||||
|
assert lot.year_built == 2003
|
||||||
|
assert lot.price_rub == 5_200_000
|
||||||
|
assert lot.price_per_m2 == 97196
|
||||||
|
assert lot.listing_segment == "vtorichka"
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_item_publish_date() -> None:
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
lot = scraper._map_item(_REALISTIC_ITEM)
|
||||||
|
assert lot is not None
|
||||||
|
assert lot.publish_date == date(2026, 6, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_item_raw_payload() -> None:
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
lot = scraper._map_item(_REALISTIC_ITEM)
|
||||||
|
assert lot is not None
|
||||||
|
assert lot.raw_payload is not None
|
||||||
|
assert lot.raw_payload["isRosreestrApproved"] is True
|
||||||
|
assert lot.raw_payload["flatComplex"]["name"] == "ЖК Тест"
|
||||||
|
assert lot.raw_payload["lastPriceHistoryState"]["direction"] == "DECREASED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_item_studio_force_rooms_zero() -> None:
|
||||||
|
"""force_rooms=0 (студия-бакет) перебивает objectInfo.rooms."""
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
item = {**_REALISTIC_ITEM, "objectInfo": {"area": 28.0, "rooms": 1, "floor": 3}}
|
||||||
|
lot = scraper._map_item(item, force_rooms=0)
|
||||||
|
assert lot is not None
|
||||||
|
assert lot.rooms == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_item_zero_price_returns_none() -> None:
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
item = {**_REALISTIC_ITEM, "price": 0}
|
||||||
|
assert scraper._map_item(item) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_item_zero_sentinels_become_none() -> None:
|
||||||
|
"""buildYear/area/floor/total_floors == 0 (sentinel «неизвестно») → None.
|
||||||
|
|
||||||
|
rooms=0 НЕ затрагивается (валидная студия), но здесь force_rooms не задан и
|
||||||
|
objectInfo.rooms=2, так что rooms остаётся 2.
|
||||||
|
"""
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
item = {
|
||||||
|
**_REALISTIC_ITEM,
|
||||||
|
"objectInfo": {"area": 0, "rooms": 2, "floor": 0},
|
||||||
|
"house": {"floors": 0, "buildYear": 0},
|
||||||
|
}
|
||||||
|
lot = scraper._map_item(item)
|
||||||
|
assert lot is not None
|
||||||
|
assert lot.area_m2 is None
|
||||||
|
assert lot.floor is None
|
||||||
|
assert lot.total_floors is None
|
||||||
|
assert lot.year_built is None
|
||||||
|
assert lot.rooms == 2 # rooms 0-sentinel НЕ применяется
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_item_rooms_zero_studio_preserved() -> None:
|
||||||
|
"""force_rooms=0 даёт rooms=0 (студия) — 0 здесь валиден, не sentinel."""
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
item = {**_REALISTIC_ITEM, "objectInfo": {"area": 24.0, "rooms": 0, "floor": 2}}
|
||||||
|
lot = scraper._map_item(item, force_rooms=0)
|
||||||
|
assert lot is not None
|
||||||
|
assert lot.rooms == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_item_price_per_m2_fallback() -> None:
|
||||||
|
"""Если squarePrice отсутствует, price_per_m2 вычисляется из price / area_m2."""
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
item = {**_REALISTIC_ITEM, "squarePrice": None}
|
||||||
|
lot = scraper._map_item(item)
|
||||||
|
assert lot is not None
|
||||||
|
assert lot.price_per_m2 == int(5_200_000 / 53.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _is_geo_ok — гео-гард ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_geo_ok_valid_ekb_item() -> None:
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
assert scraper._is_geo_ok(_REALISTIC_ITEM) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_geo_ok_wrong_region_name() -> None:
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
item = {**_REALISTIC_ITEM, "offerRegionName": "Москва"}
|
||||||
|
assert scraper._is_geo_ok(item) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_geo_ok_out_of_bbox_lat() -> None:
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
item = {**_REALISTIC_ITEM, "location": {"lat": 55.5, "lon": 60.6}}
|
||||||
|
assert scraper._is_geo_ok(item) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_geo_ok_out_of_bbox_lon() -> None:
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
item = {**_REALISTIC_ITEM, "location": {"lat": 56.8, "lon": 61.5}}
|
||||||
|
assert scraper._is_geo_ok(item) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_geo_ok_missing_coords() -> None:
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
item = {**_REALISTIC_ITEM, "location": {"lat": None, "lon": None}}
|
||||||
|
assert scraper._is_geo_ok(item) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── _sweep_bucket price-split ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sweep_bucket_price_split_boundaries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""_sweep_bucket: top-level unbounded split даёт contiguous non-overlapping ranges.
|
||||||
|
|
||||||
|
rooms="2" count>cap один раз, потом ≤cap → ровно 2 leaf-бакета, покрывающих
|
||||||
|
[0, LTE_MAX] без перекрытия и без зазора: [(0, mid), (mid+1, LTE_MAX)].
|
||||||
|
"""
|
||||||
|
call_count: dict[str, int] = {"n": 0}
|
||||||
|
paginate_calls: list[tuple[str, int | None, int | None]] = []
|
||||||
|
|
||||||
|
async def fake_count(
|
||||||
|
self: DomClickScraper,
|
||||||
|
fetcher: object,
|
||||||
|
rooms: str,
|
||||||
|
price_gte: int | None,
|
||||||
|
price_lte: int | None,
|
||||||
|
) -> int:
|
||||||
|
call_count["n"] += 1
|
||||||
|
# Первый вызов (unbounded) → over cap → price-split; остальные → под cap
|
||||||
|
return OFFSET_CAP + 1 if call_count["n"] == 1 else 1
|
||||||
|
|
||||||
|
async def fake_paginate(
|
||||||
|
self: DomClickScraper,
|
||||||
|
fetcher: object,
|
||||||
|
rooms: str,
|
||||||
|
price_gte: int | None,
|
||||||
|
price_lte: int | None,
|
||||||
|
seen_ids: set[str],
|
||||||
|
out_lots: list,
|
||||||
|
pages: int,
|
||||||
|
) -> None:
|
||||||
|
paginate_calls.append((rooms, price_gte, price_lte))
|
||||||
|
|
||||||
|
monkeypatch.setattr(DomClickScraper, "_count", fake_count)
|
||||||
|
monkeypatch.setattr(DomClickScraper, "_paginate", fake_paginate)
|
||||||
|
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
scraper.geo_filtered = 0
|
||||||
|
scraper.blocked = False
|
||||||
|
scraper.fetch_errors = 0
|
||||||
|
scraper.request_delay_sec = 0.0
|
||||||
|
|
||||||
|
await scraper._sweep_bucket(
|
||||||
|
fetcher=object(),
|
||||||
|
rooms="2",
|
||||||
|
price_gte=None,
|
||||||
|
price_lte=None,
|
||||||
|
seen_ids=set(),
|
||||||
|
out_lots=[],
|
||||||
|
pages=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1 count call (over cap) + 2 recursive count calls (≤ cap) = 3 total
|
||||||
|
assert call_count["n"] == 3
|
||||||
|
# Both sub-buckets paginated, с точными границами split'а unbounded-диапазона.
|
||||||
|
mid = (0 + LTE_MAX) // 2
|
||||||
|
assert paginate_calls == [("2", 0, mid), ("2", mid + 1, LTE_MAX)]
|
||||||
|
# Sanity: contiguous (нет зазора) + non-overlapping + покрывают весь хвост.
|
||||||
|
assert paginate_calls[0][2] + 1 == paginate_calls[1][1] # mid + 1
|
||||||
|
assert paginate_calls[0][1] == 0
|
||||||
|
assert paginate_calls[1][2] == LTE_MAX
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sweep_bucket_zero_count_no_paginate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""_sweep_bucket при count==0 не вызывает _paginate."""
|
||||||
|
paginate_called = False
|
||||||
|
|
||||||
|
async def fake_count(self: DomClickScraper, *_args: object, **_kwargs: object) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def fake_paginate(self: DomClickScraper, *_args: object, **_kwargs: object) -> None:
|
||||||
|
nonlocal paginate_called
|
||||||
|
paginate_called = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(DomClickScraper, "_count", fake_count)
|
||||||
|
monkeypatch.setattr(DomClickScraper, "_paginate", fake_paginate)
|
||||||
|
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
scraper.geo_filtered = 0
|
||||||
|
scraper.blocked = False
|
||||||
|
scraper.fetch_errors = 0
|
||||||
|
scraper.request_delay_sec = 0.0
|
||||||
|
|
||||||
|
await scraper._sweep_bucket(
|
||||||
|
fetcher=object(),
|
||||||
|
rooms="3",
|
||||||
|
price_gte=None,
|
||||||
|
price_lte=None,
|
||||||
|
seen_ids=set(),
|
||||||
|
out_lots=[],
|
||||||
|
pages=5,
|
||||||
|
)
|
||||||
|
assert paginate_called is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── _paginate resilience — mid-bucket parse error ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFetcher:
|
||||||
|
"""Стаб BrowserFetcher: возвращает заранее заданные ответы по порядку вызовов."""
|
||||||
|
|
||||||
|
def __init__(self, responses: list[str]) -> None:
|
||||||
|
self._responses = responses
|
||||||
|
self._idx = 0
|
||||||
|
|
||||||
|
async def fetch(self, _url: str) -> str:
|
||||||
|
resp = self._responses[min(self._idx, len(self._responses) - 1)]
|
||||||
|
self._idx += 1
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paginate_mid_bucket_parse_error_preserves_lots() -> None:
|
||||||
|
"""Garbled JSON на page 2 → bucket breaks, page-1 lots survive, fetch_errors++.
|
||||||
|
|
||||||
|
Page 1 — валидный JSON с 20 items (PAGE_SIZE) → продолжаем; page 2 — мусор
|
||||||
|
(ValueError из _extract_json) → fetch_errors++ и break, уже собранные lots целы.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
page1_items = [
|
||||||
|
{
|
||||||
|
"id": 1000 + i,
|
||||||
|
"path": f"https://domclick.ru/card/sale__flat__{1000 + i}",
|
||||||
|
"location": {"lat": 56.84, "lon": 60.6},
|
||||||
|
"address": {"displayName": "Екатеринбург, тест"},
|
||||||
|
"objectInfo": {"area": 40.0, "rooms": 1, "floor": 3},
|
||||||
|
"house": {"floors": 9, "buildYear": 2005},
|
||||||
|
"price": 4_000_000 + i,
|
||||||
|
"squarePrice": 100000,
|
||||||
|
"offerRegionName": "Екатеринбург",
|
||||||
|
}
|
||||||
|
for i in range(20)
|
||||||
|
]
|
||||||
|
page1 = json.dumps({"result": {"items": page1_items}})
|
||||||
|
garbled = "<html><body><pre>{not valid json at all</pre></body></html>"
|
||||||
|
fetcher = _FakeFetcher([page1, garbled])
|
||||||
|
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
scraper.geo_filtered = 0
|
||||||
|
scraper.blocked = False
|
||||||
|
scraper.fetch_errors = 0
|
||||||
|
scraper.request_delay_sec = 0.0
|
||||||
|
|
||||||
|
out_lots: list = []
|
||||||
|
await scraper._paginate(
|
||||||
|
fetcher=fetcher,
|
||||||
|
rooms="1",
|
||||||
|
price_gte=None,
|
||||||
|
price_lte=None,
|
||||||
|
seen_ids=set(),
|
||||||
|
out_lots=out_lots,
|
||||||
|
pages=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(out_lots) == 20 # page-1 lots сохранены
|
||||||
|
assert scraper.fetch_errors == 1 # page-2 garbled посчитан
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_city_bucket_error_continues_to_next_bucket(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Ошибка в одном бакете НЕ убивает весь sweep — остальные бакеты отрабатывают."""
|
||||||
|
swept: list[str] = []
|
||||||
|
|
||||||
|
async def fake_sweep_bucket(
|
||||||
|
self: DomClickScraper,
|
||||||
|
fetcher: object,
|
||||||
|
rooms: str,
|
||||||
|
price_gte: int | None,
|
||||||
|
price_lte: int | None,
|
||||||
|
seen_ids: set[str],
|
||||||
|
out_lots: list,
|
||||||
|
pages: int,
|
||||||
|
) -> None:
|
||||||
|
swept.append(rooms)
|
||||||
|
if rooms == "2":
|
||||||
|
raise ValueError("garbled bucket")
|
||||||
|
|
||||||
|
# Заглушка BrowserFetcher через async context manager
|
||||||
|
class _BFStub:
|
||||||
|
async def __aenter__(self) -> _BFStub:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_a: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch(self, _url: str) -> str:
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
import app.services.scrapers.browser_fetcher as bf_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(DomClickScraper, "_sweep_bucket", fake_sweep_bucket)
|
||||||
|
monkeypatch.setattr(bf_mod, "BrowserFetcher", lambda **_k: _BFStub())
|
||||||
|
|
||||||
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
scraper.geo_filtered = 0
|
||||||
|
scraper.blocked = False
|
||||||
|
scraper.fetch_errors = 0
|
||||||
|
scraper.request_delay_sec = 0.0
|
||||||
|
|
||||||
|
lots = await scraper.fetch_city(city_id=4, rooms=None, pages=5)
|
||||||
|
|
||||||
|
# Все 6 бакетов посещены несмотря на ошибку в "2"
|
||||||
|
assert swept == ["st", "1", "2", "3", "4", "5+"]
|
||||||
|
assert scraper.fetch_errors == 1
|
||||||
|
assert scraper.blocked is False
|
||||||
|
assert lots == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── DomClickScraper — class attrs ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_scraper_source_name() -> None:
|
def test_scraper_source_name() -> None:
|
||||||
scraper = DomClickScraper()
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
|
scraper.parse_failures = 0
|
||||||
|
scraper.geo_filtered = 0
|
||||||
|
scraper.blocked = False
|
||||||
|
scraper.request_delay_sec = 0.0
|
||||||
assert scraper.name == "domklik"
|
assert scraper.name == "domklik"
|
||||||
assert scraper.source == "domklik"
|
assert scraper.source == "domklik"
|
||||||
assert scraper.base_url == "https://domclick.ru"
|
assert scraper.base_url == "https://domclick.ru"
|
||||||
# request_delay_sec установлен из get_scraper_delay (DB или fallback 8.0)
|
|
||||||
assert scraper.request_delay_sec >= 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_around_raises() -> None:
|
def test_fetch_around_raises() -> None:
|
||||||
scraper = DomClickScraper()
|
scraper = DomClickScraper.__new__(DomClickScraper)
|
||||||
with pytest.raises(NotImplementedError, match="geo-radius"):
|
with pytest.raises(NotImplementedError, match="geo-radius"):
|
||||||
asyncio.run(scraper.fetch_around(56.8, 60.6))
|
asyncio.run(scraper.fetch_around(56.8, 60.6))
|
||||||
|
|
||||||
|
|
||||||
def test_build_url_no_rooms() -> None:
|
|
||||||
scraper = DomClickScraper()
|
|
||||||
url = scraper._build_url(city_id=4, rooms=None, page=1)
|
|
||||||
assert "city_id=4" in url
|
|
||||||
assert "rooms" not in url
|
|
||||||
assert "p=1" in url
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_url_with_rooms() -> None:
|
|
||||||
scraper = DomClickScraper()
|
|
||||||
url = scraper._build_url(city_id=4, rooms=2, page=3)
|
|
||||||
assert "rooms=2" in url
|
|
||||||
assert "p=3" in url
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_failures_counter() -> None:
|
|
||||||
"""parse_failures счётчик начинается с 0."""
|
|
||||||
scraper = DomClickScraper()
|
|
||||||
assert scraper.parse_failures == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_blocked_page_raises_domclick_blocked_error() -> None:
|
|
||||||
"""HTML с datadome в начале вызывает DomClickBlockedError."""
|
|
||||||
scraper = DomClickScraper()
|
|
||||||
blocked_html = (
|
|
||||||
"<html><head><title>datadome protection</title></head><body>blocked</body></html>"
|
|
||||||
)
|
|
||||||
with pytest.raises(DomClickBlockedError):
|
|
||||||
scraper._parse_html(blocked_html)
|
|
||||||
|
|
|
||||||
|
|
@ -217,9 +217,8 @@ def _rows_for_bucket(
|
||||||
def test_derive_ratios_per_bucket_exact() -> None:
|
def test_derive_ratios_per_bucket_exact() -> None:
|
||||||
# Each bucket ≥ MIN_BUCKET deals so every bucket gets its OWN ratio.
|
# Each bucket ≥ MIN_BUCKET deals so every bucket gets its OWN ratio.
|
||||||
# bucket 1: sold/ask = 80k/100k = 0.80 ; bucket 2: 150k/200k = 0.75.
|
# bucket 1: sold/ask = 80k/100k = 0.80 ; bucket 2: 150k/200k = 0.75.
|
||||||
rows = (
|
rows = _rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=80_000.0) + _rows_for_bucket(
|
||||||
_rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=80_000.0)
|
2, n=bt.MIN_BUCKET, ask=200_000.0, sold=150_000.0
|
||||||
+ _rows_for_bucket(2, n=bt.MIN_BUCKET, ask=200_000.0, sold=150_000.0)
|
|
||||||
)
|
)
|
||||||
ratios, meta = bt._derive_room_ratios(rows)
|
ratios, meta = bt._derive_room_ratios(rows)
|
||||||
assert ratios[1] == pytest.approx(0.80)
|
assert ratios[1] == pytest.approx(0.80)
|
||||||
|
|
@ -328,9 +327,7 @@ def test_corrected_metrics_cancel_plus_30_pct_bias_to_zero() -> None:
|
||||||
# in-sample and re-applying it MUST collapse the corrected bias to ~0.
|
# in-sample and re-applying it MUST collapse the corrected bias to ~0.
|
||||||
rows: list[tuple[float, float, int]] = []
|
rows: list[tuple[float, float, int]] = []
|
||||||
for bucket, sold in ((0, 80_000.0), (1, 100_000.0), (2, 150_000.0)):
|
for bucket, sold in ((0, 80_000.0), (1, 100_000.0), (2, 150_000.0)):
|
||||||
rows += _rows_for_bucket(
|
rows += _rows_for_bucket(bucket, n=bt.MIN_BUCKET, ask=1.30 * sold, sold=sold)
|
||||||
bucket, n=bt.MIN_BUCKET, ask=1.30 * sold, sold=sold
|
|
||||||
)
|
|
||||||
|
|
||||||
# sanity: the ASKING block really is +30%.
|
# sanity: the ASKING block really is +30%.
|
||||||
asking = bt._compute_metrics(rows)
|
asking = bt._compute_metrics(rows)
|
||||||
|
|
@ -346,9 +343,7 @@ def test_corrected_metrics_cancel_plus_30_pct_bias_to_zero() -> None:
|
||||||
assert corrected["overall"]["median_bias_pct"] == pytest.approx(0.0, abs=1e-6)
|
assert corrected["overall"]["median_bias_pct"] == pytest.approx(0.0, abs=1e-6)
|
||||||
assert corrected["overall"]["mape_pct"] == pytest.approx(0.0, abs=1e-6)
|
assert corrected["overall"]["mape_pct"] == pytest.approx(0.0, abs=1e-6)
|
||||||
for bucket in (0, 1, 2):
|
for bucket in (0, 1, 2):
|
||||||
assert corrected["per_rooms"][bucket]["median_bias_pct"] == pytest.approx(
|
assert corrected["per_rooms"][bucket]["median_bias_pct"] == pytest.approx(0.0, abs=1e-6)
|
||||||
0.0, abs=1e-6
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_corrected_metrics_global_fallback_cancels_uniform_bias() -> None:
|
def test_corrected_metrics_global_fallback_cancels_uniform_bias() -> None:
|
||||||
|
|
@ -469,8 +464,18 @@ def test_argparse_defaults() -> None:
|
||||||
|
|
||||||
def test_argparse_overrides() -> None:
|
def test_argparse_overrides() -> None:
|
||||||
ns = bt._parse_args(
|
ns = bt._parse_args(
|
||||||
["--sample", "50", "--since", "2024-01-01", "--radius", "2000",
|
[
|
||||||
"--rooms-tolerance", "1", "--holdout-split", "--json"]
|
"--sample",
|
||||||
|
"50",
|
||||||
|
"--since",
|
||||||
|
"2024-01-01",
|
||||||
|
"--radius",
|
||||||
|
"2000",
|
||||||
|
"--rooms-tolerance",
|
||||||
|
"1",
|
||||||
|
"--holdout-split",
|
||||||
|
"--json",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
assert ns.sample == 50
|
assert ns.sample == 50
|
||||||
assert ns.since == "2024-01-01"
|
assert ns.since == "2024-01-01"
|
||||||
|
|
@ -478,3 +483,328 @@ def test_argparse_overrides() -> None:
|
||||||
assert ns.rooms_tolerance == 1
|
assert ns.rooms_tolerance == 1
|
||||||
assert ns.holdout_split is True
|
assert ns.holdout_split is True
|
||||||
assert ns.json is True
|
assert ns.json is True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #1966 full spine — --engine flag.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_argparse_engine_defaults_to_full() -> None:
|
||||||
|
assert bt._parse_args([]).engine == "full"
|
||||||
|
|
||||||
|
|
||||||
|
def test_argparse_engine_asking_core_override() -> None:
|
||||||
|
assert bt._parse_args(["--engine", "asking-core"]).engine == "asking-core"
|
||||||
|
assert bt._parse_args(["--engine", "full"]).engine == "full"
|
||||||
|
|
||||||
|
|
||||||
|
def test_argparse_engine_rejects_unknown() -> None:
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
bt._parse_args(["--engine", "bogus"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #1966 _bucketize_confidence / _segment_label — pure bucketing.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_bucketize_confidence_canonical_passthrough() -> None:
|
||||||
|
assert bt._bucketize_confidence("high") == "high"
|
||||||
|
assert bt._bucketize_confidence("medium") == "medium"
|
||||||
|
assert bt._bucketize_confidence("low") == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bucketize_confidence_unknown_maps_to_other() -> None:
|
||||||
|
assert bt._bucketize_confidence("weird") == "other"
|
||||||
|
assert bt._bucketize_confidence("") == "other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_label_bands_and_boundaries() -> None:
|
||||||
|
# Boundaries are upper-exclusive: 120k falls into комфорт, not эконом.
|
||||||
|
assert bt._segment_label(100_000) == "эконом"
|
||||||
|
assert bt._segment_label(119_999) == "эконом"
|
||||||
|
assert bt._segment_label(120_000) == "комфорт"
|
||||||
|
assert bt._segment_label(150_000) == "комфорт"
|
||||||
|
assert bt._segment_label(160_000) == "бизнес"
|
||||||
|
assert bt._segment_label(219_999) == "бизнес"
|
||||||
|
assert bt._segment_label(220_000) == "элит"
|
||||||
|
assert bt._segment_label(300_000) == "премиум"
|
||||||
|
assert bt._segment_label(2_000_000) == "премиум" # +inf tail catches the top
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #1966 _segment_metrics — per-price-segment signed error (band by SOLD ppm²).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_metrics_buckets_by_sold_price() -> None:
|
||||||
|
rows = [
|
||||||
|
(110_000.0, 100_000.0), # sold эконом, +10
|
||||||
|
(132_000.0, 110_000.0), # sold эконом, +20 → median эконом bias +15
|
||||||
|
(165_000.0, 150_000.0), # sold комфорт, +10
|
||||||
|
(330_000.0, 300_000.0), # sold премиум, +10
|
||||||
|
]
|
||||||
|
seg = bt._segment_metrics(rows)
|
||||||
|
assert set(seg.keys()) == {label for label, _ in bt.PRICE_SEGMENTS_PPM2}
|
||||||
|
assert seg["эконом"]["n"] == 2
|
||||||
|
assert seg["эконом"]["median_bias_pct"] == pytest.approx(15.0)
|
||||||
|
assert seg["комфорт"]["n"] == 1
|
||||||
|
assert seg["комфорт"]["median_bias_pct"] == pytest.approx(10.0)
|
||||||
|
assert seg["премиум"]["n"] == 1
|
||||||
|
# bands with no rows are still present with n=0.
|
||||||
|
assert seg["бизнес"]["n"] == 0
|
||||||
|
assert seg["элит"]["n"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_metrics_drops_nonpositive_sold() -> None:
|
||||||
|
seg = bt._segment_metrics([(100_000.0, 0.0), (100_000.0, -1.0)])
|
||||||
|
assert all(seg[label]["n"] == 0 for label, _ in bt.PRICE_SEGMENTS_PPM2)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #1966 _range_coverage — inside / outside / boundary inclusive.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_coverage_inside_outside_and_boundary_inclusive() -> None:
|
||||||
|
rows = [
|
||||||
|
(100.0, 90.0, 110.0), # inside
|
||||||
|
(80.0, 90.0, 110.0), # below low → outside
|
||||||
|
(120.0, 90.0, 110.0), # above high → outside
|
||||||
|
(90.0, 90.0, 110.0), # exactly on low → covered (inclusive)
|
||||||
|
(110.0, 90.0, 110.0), # exactly on high → covered (inclusive)
|
||||||
|
]
|
||||||
|
cov = bt._range_coverage(rows)
|
||||||
|
assert cov["n"] == 5
|
||||||
|
assert cov["n_covered"] == 3
|
||||||
|
assert cov["coverage_pct"] == pytest.approx(60.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_coverage_empty_returns_none_pct() -> None:
|
||||||
|
cov = bt._range_coverage([])
|
||||||
|
assert cov["n"] == 0
|
||||||
|
assert cov["n_covered"] == 0
|
||||||
|
assert cov["coverage_pct"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_coverage_full_and_zero() -> None:
|
||||||
|
assert bt._range_coverage([(100.0, 50.0, 150.0)])["coverage_pct"] == pytest.approx(100.0)
|
||||||
|
assert bt._range_coverage([(10.0, 50.0, 150.0)])["coverage_pct"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #1966 _sharpness — median relative range width (guards coverage gaming).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_sharpness_median_relative_width() -> None:
|
||||||
|
rows = [
|
||||||
|
(100.0, 90.0, 110.0), # width 20 / point 100 = 0.20
|
||||||
|
(200.0, 150.0, 250.0), # width 100 / point 200 = 0.50
|
||||||
|
]
|
||||||
|
sh = bt._sharpness(rows)
|
||||||
|
assert sh["n"] == 2
|
||||||
|
assert sh["median_rel_width"] == pytest.approx(0.35) # median(0.20, 0.50)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sharpness_drops_nonpositive_point_and_empty() -> None:
|
||||||
|
assert bt._sharpness([(0.0, 1.0, 2.0)])["median_rel_width"] is None
|
||||||
|
assert bt._sharpness([(-5.0, 1.0, 2.0)])["n"] == 0
|
||||||
|
assert bt._sharpness([])["median_rel_width"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #1966 _calibration_metrics — per-confidence n / coverage% / MAPE%.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_metrics_per_confidence_n_coverage_mape() -> None:
|
||||||
|
rows = [
|
||||||
|
("high", 5.0, True),
|
||||||
|
("high", 15.0, True), # high: n=2, covered 2/2=100%, mape median(5,15)=10
|
||||||
|
("low", 40.0, False),
|
||||||
|
("low", 60.0, True), # low: n=2, covered 1/2=50%, mape median(40,60)=50
|
||||||
|
]
|
||||||
|
cal = bt._calibration_metrics(rows)
|
||||||
|
# canonical buckets always present.
|
||||||
|
assert set(("high", "medium", "low")).issubset(cal.keys())
|
||||||
|
assert cal["high"]["n"] == 2
|
||||||
|
assert cal["high"]["coverage_pct"] == pytest.approx(100.0)
|
||||||
|
assert cal["high"]["mape_pct"] == pytest.approx(10.0)
|
||||||
|
assert cal["low"]["n"] == 2
|
||||||
|
assert cal["low"]["coverage_pct"] == pytest.approx(50.0)
|
||||||
|
assert cal["low"]["mape_pct"] == pytest.approx(50.0)
|
||||||
|
# empty canonical bucket renders with n=0 / None metrics, not missing.
|
||||||
|
assert cal["medium"]["n"] == 0
|
||||||
|
assert cal["medium"]["coverage_pct"] is None
|
||||||
|
assert cal["medium"]["mape_pct"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_metrics_handles_none_signed_and_covered() -> None:
|
||||||
|
# A prediction with no expected_sold (signed None) and no range (covered None)
|
||||||
|
# still counts toward n but not toward coverage/MAPE.
|
||||||
|
rows: list[tuple[str, float | None, bool | None]] = [
|
||||||
|
("high", None, None),
|
||||||
|
("high", 10.0, True),
|
||||||
|
]
|
||||||
|
cal = bt._calibration_metrics(rows)
|
||||||
|
assert cal["high"]["n"] == 2
|
||||||
|
assert cal["high"]["coverage_pct"] == pytest.approx(100.0) # only the 1 with covered
|
||||||
|
assert cal["high"]["mape_pct"] == pytest.approx(10.0) # only the 1 with signed
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_metrics_appends_other_bucket() -> None:
|
||||||
|
cal = bt._calibration_metrics([("exotic", 5.0, True)])
|
||||||
|
assert "other" in cal
|
||||||
|
assert cal["other"]["n"] == 1
|
||||||
|
# canonical three still present even though empty.
|
||||||
|
assert cal["high"]["n"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #1966 Prediction + _compute_full_metrics — integration of the new blocks.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _pred(
|
||||||
|
*,
|
||||||
|
rooms: int = 2,
|
||||||
|
area: float = 50.0,
|
||||||
|
sold_ppm2: float = 100_000.0,
|
||||||
|
median_ppm2: float = 120_000.0,
|
||||||
|
confidence: str = "high",
|
||||||
|
es_ppm2: float | None = 100_000.0,
|
||||||
|
es_price: float | None = 5_000_000.0,
|
||||||
|
range_low: float | None = 4_500_000.0,
|
||||||
|
range_high: float | None = 5_500_000.0,
|
||||||
|
anchor_tier: str | None = None,
|
||||||
|
deal_id: int = 1,
|
||||||
|
) -> bt.Prediction:
|
||||||
|
return bt.Prediction(
|
||||||
|
deal_id=deal_id,
|
||||||
|
rooms=rooms,
|
||||||
|
area_m2=area,
|
||||||
|
sold_ppm2=sold_ppm2,
|
||||||
|
median_ppm2=median_ppm2,
|
||||||
|
confidence=confidence,
|
||||||
|
anchor_tier=anchor_tier,
|
||||||
|
expected_sold_ppm2=es_ppm2,
|
||||||
|
expected_sold_price=es_price,
|
||||||
|
range_low=range_low,
|
||||||
|
range_high=range_high,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prediction_sold_total_property() -> None:
|
||||||
|
p = _pred(sold_ppm2=100_000.0, area=50.0)
|
||||||
|
assert p.sold_total == pytest.approx(5_000_000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_full_metrics_structure_and_blocks() -> None:
|
||||||
|
preds = [
|
||||||
|
# sold_total = 100k*50 = 5.0M, range [4.5M, 5.5M] → covered; es +0%
|
||||||
|
_pred(deal_id=1, confidence="high", es_ppm2=100_000.0, sold_ppm2=100_000.0),
|
||||||
|
# sold_total = 200k*50 = 10.0M, range [4.5M,5.5M] → NOT covered; es -50%
|
||||||
|
_pred(
|
||||||
|
deal_id=3,
|
||||||
|
confidence="low",
|
||||||
|
es_ppm2=100_000.0,
|
||||||
|
sold_ppm2=200_000.0,
|
||||||
|
median_ppm2=120_000.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
m = bt._compute_full_metrics(preds, n_no_prediction=4, per_rooms_no_prediction={2: 4})
|
||||||
|
|
||||||
|
# expected_sold block: overall + per_rooms + per_segment, carries no-pred count.
|
||||||
|
assert m["expected_sold"]["overall"]["n"] == 2
|
||||||
|
assert m["expected_sold"]["overall"]["n_no_analogs"] == 4 # repurposed = no_pred
|
||||||
|
assert "per_segment" in m["expected_sold"]
|
||||||
|
assert set(m["expected_sold"]["per_segment"].keys()) == {
|
||||||
|
label for label, _ in bt.PRICE_SEGMENTS_PPM2
|
||||||
|
}
|
||||||
|
|
||||||
|
# range coverage: 1 of 2 inside → 50% overall.
|
||||||
|
assert m["range_coverage"]["overall"]["n"] == 2
|
||||||
|
assert m["range_coverage"]["overall"]["n_covered"] == 1
|
||||||
|
assert m["range_coverage"]["overall"]["coverage_pct"] == pytest.approx(50.0)
|
||||||
|
# per-confidence: high covered 100%, low covered 0%.
|
||||||
|
assert m["range_coverage"]["per_confidence"]["high"]["coverage_pct"] == pytest.approx(100.0)
|
||||||
|
assert m["range_coverage"]["per_confidence"]["low"]["coverage_pct"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
# calibration: high tighter/accurate, low not.
|
||||||
|
assert m["calibration"]["high"]["n"] == 1
|
||||||
|
assert m["calibration"]["high"]["coverage_pct"] == pytest.approx(100.0)
|
||||||
|
assert m["calibration"]["high"]["mape_pct"] == pytest.approx(0.0)
|
||||||
|
assert m["calibration"]["low"]["coverage_pct"] == pytest.approx(0.0)
|
||||||
|
assert m["calibration"]["low"]["mape_pct"] == pytest.approx(50.0)
|
||||||
|
|
||||||
|
# sharpness present.
|
||||||
|
assert m["sharpness"]["n"] == 2
|
||||||
|
assert m["sharpness"]["median_rel_width"] is not None
|
||||||
|
|
||||||
|
# confidence order: canonical three first.
|
||||||
|
assert m["confidence_order"][:3] == ["high", "medium", "low"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_full_metrics_empty_is_safe() -> None:
|
||||||
|
m = bt._compute_full_metrics([])
|
||||||
|
assert m["expected_sold"]["overall"]["n"] == 0
|
||||||
|
assert m["range_coverage"]["overall"]["coverage_pct"] is None
|
||||||
|
assert m["calibration"]["high"]["n"] == 0
|
||||||
|
assert m["sharpness"]["median_rel_width"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_full_metrics_no_expected_sold_counts_in_calibration_only() -> None:
|
||||||
|
# A priced deal with no expected_sold (ratio unresolved) and no range:
|
||||||
|
# counts in calibration n but contributes nothing to expected_sold / coverage.
|
||||||
|
preds = [
|
||||||
|
_pred(
|
||||||
|
deal_id=1,
|
||||||
|
confidence="medium",
|
||||||
|
es_ppm2=None,
|
||||||
|
es_price=None,
|
||||||
|
range_low=None,
|
||||||
|
range_high=None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
m = bt._compute_full_metrics(preds)
|
||||||
|
assert m["expected_sold"]["overall"]["n"] == 0 # no es row
|
||||||
|
assert m["range_coverage"]["overall"]["n"] == 0 # no range row
|
||||||
|
assert m["calibration"]["medium"]["n"] == 1 # still counted
|
||||||
|
assert m["calibration"]["medium"]["coverage_pct"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# #1966 _render_full_table — smoke (must not crash, renders all blocks).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_full_table_runs_on_real_metrics() -> None:
|
||||||
|
preds = [
|
||||||
|
_pred(deal_id=1, confidence="high", es_ppm2=100_000.0, sold_ppm2=100_000.0),
|
||||||
|
_pred(deal_id=3, confidence="low", es_ppm2=100_000.0, sold_ppm2=200_000.0),
|
||||||
|
]
|
||||||
|
m = bt._compute_full_metrics(preds, n_no_prediction=1, per_rooms_no_prediction={2: 1})
|
||||||
|
m["headline"] = {
|
||||||
|
"deal_median_ppm2": 100_000.0,
|
||||||
|
"ask_median_ppm2": 120_000.0,
|
||||||
|
"spread_pct": 20.0,
|
||||||
|
}
|
||||||
|
out = bt._render_full_table(m)
|
||||||
|
assert "full spine" in out
|
||||||
|
assert "EXPECTED_SOLD" in out
|
||||||
|
assert "RANGE COVERAGE" in out
|
||||||
|
assert "CONFIDENCE CALIBRATION" in out
|
||||||
|
assert "SHARPNESS" in out
|
||||||
|
assert "per price-segment" in out
|
||||||
|
assert "эконом" in out # segment band rendered
|
||||||
|
assert "regression baseline" in out # caveat present
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_full_table_handles_empty_sample() -> None:
|
||||||
|
m = bt._compute_full_metrics([])
|
||||||
|
m["headline"] = {"deal_median_ppm2": None, "ask_median_ppm2": None, "spread_pct": None}
|
||||||
|
out = bt._render_full_table(m)
|
||||||
|
assert "n/a" in out # None metrics render as n/a, no crash
|
||||||
|
assert "BACKTEST" in out
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ def _stub_domclick_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
def _init(self: DomClickScraper) -> None:
|
def _init(self: DomClickScraper) -> None:
|
||||||
self.request_delay_sec = 0.0
|
self.request_delay_sec = 0.0
|
||||||
self.parse_failures = 0
|
self.parse_failures = 0
|
||||||
|
self.geo_filtered = 0
|
||||||
|
self.blocked = False
|
||||||
|
self.fetch_errors = 0
|
||||||
|
|
||||||
async def _aenter(self: DomClickScraper) -> DomClickScraper:
|
async def _aenter(self: DomClickScraper) -> DomClickScraper:
|
||||||
return self
|
return self
|
||||||
|
|
@ -109,6 +112,8 @@ def test_domclick_sweep_counters_defaults() -> None:
|
||||||
assert c.lots_updated == 0
|
assert c.lots_updated == 0
|
||||||
assert c.pages_fetched == 0
|
assert c.pages_fetched == 0
|
||||||
assert c.errors_count == 0
|
assert c.errors_count == 0
|
||||||
|
assert c.blocked == 0
|
||||||
|
assert c.geo_filtered == 0
|
||||||
|
|
||||||
|
|
||||||
def test_domclick_sweep_counters_to_dict_all_keys() -> None:
|
def test_domclick_sweep_counters_to_dict_all_keys() -> None:
|
||||||
|
|
@ -233,8 +238,13 @@ async def test_cancel_before_serp_skips_fetch(monkeypatch: pytest.MonkeyPatch) -
|
||||||
assert fake.done is None
|
assert fake.done is None
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_fetch_city_exception_no_reraise(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Исключение в fetch_city → errors_count++, sweep завершается mark_done (не fatal)."""
|
"""Исключение в fetch_city → errors_count++, sweep НЕ ре-райзит (graceful).
|
||||||
|
|
||||||
|
Honest-status (#1968): полный крах SERP-фазы (errors_count=1, 0 lots) → mark_failed,
|
||||||
|
НЕ mark_done. Run без единого листинга по внешней причине — это реальный failure,
|
||||||
|
а не успешный пустой прогон. Re-raise при этом НЕ происходит (task завершается чисто).
|
||||||
|
"""
|
||||||
|
|
||||||
async def fake_fetch_fail(
|
async def fake_fetch_fail(
|
||||||
self: DomClickScraper,
|
self: DomClickScraper,
|
||||||
|
|
@ -242,7 +252,7 @@ async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) ->
|
||||||
rooms: list[int] | None = None,
|
rooms: list[int] | None = None,
|
||||||
pages: int = 20,
|
pages: int = 20,
|
||||||
) -> list[ScrapedLot]:
|
) -> list[ScrapedLot]:
|
||||||
raise RuntimeError("camoufox crashed")
|
raise RuntimeError("browser crashed")
|
||||||
|
|
||||||
monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_fail)
|
monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_fail)
|
||||||
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0))
|
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0))
|
||||||
|
|
@ -256,36 +266,107 @@ async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) ->
|
||||||
|
|
||||||
assert counters.errors_count == 1
|
assert counters.errors_count == 1
|
||||||
assert counters.lots_fetched == 0
|
assert counters.lots_fetched == 0
|
||||||
assert fake.done is not None, "graceful: mark_done вызывается несмотря на ошибку SERP"
|
# 0 lots + error → honest mark_failed (не маскируем как done)
|
||||||
assert fake.failed is None
|
assert fake.failed is not None
|
||||||
|
assert fake.done is None
|
||||||
|
|
||||||
|
|
||||||
# ── _parse_html parser unit test ────────────────────────────────────────────
|
async def test_fetch_errors_and_no_lots_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Scraper fetch_errors>0 + 0 лотов (без block) → mark_failed (FIX 3 / closes #1968).
|
||||||
|
|
||||||
|
Нераспознанный block-вариант приходит как fetch_error (не blocked=True), но
|
||||||
def test_parse_html_minimal_card() -> None:
|
honest-status всё равно обязан пометить run failed при пустом результате.
|
||||||
"""Минимальная DomClick карточка → ScrapedLot с rooms/area/floor/price/source_id."""
|
|
||||||
# __init__ застаблен фикстурой _stub_domclick_lifecycle → нет DB/browser
|
|
||||||
scraper = DomClickScraper()
|
|
||||||
html = """
|
|
||||||
<html><body>
|
|
||||||
<a href="/card/sale__flat__2075671636">
|
|
||||||
<div>2-комн. квартира</div>
|
|
||||||
<span>52,3 м²</span>
|
|
||||||
<span>5 / 16 эт.</span>
|
|
||||||
<span>Екатеринбург, улица Ленина, 10</span>
|
|
||||||
<span>6 500 000 ₽</span>
|
|
||||||
</a>
|
|
||||||
</body></html>
|
|
||||||
"""
|
"""
|
||||||
lots = scraper._parse_html(html)
|
|
||||||
assert len(lots) == 1
|
async def fake_fetch_errs(
|
||||||
lot = lots[0]
|
self: DomClickScraper,
|
||||||
|
city_id: int,
|
||||||
|
rooms: list[int] | None = None,
|
||||||
|
pages: int = 100,
|
||||||
|
) -> list[ScrapedLot]:
|
||||||
|
# Имитируем не-block ошибки извлечения JSON: blocked остаётся False
|
||||||
|
self.fetch_errors = 3
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_errs)
|
||||||
|
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0))
|
||||||
|
|
||||||
|
fake = _FakeRuns()
|
||||||
|
_install_runs(monkeypatch, fake)
|
||||||
|
|
||||||
|
counters = await scrape_pipeline.run_domclick_city_sweep(
|
||||||
|
db=MagicMock(), run_id=6, request_delay_sec=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert counters.lots_fetched == 0
|
||||||
|
assert counters.blocked == 0
|
||||||
|
assert counters.errors_count >= 3 # fetch_errors свёрнуты в errors_count
|
||||||
|
assert fake.failed is not None
|
||||||
|
assert fake.done is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_blocked_and_no_lots_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""QRATOR-блок + 0 лотов → mark_failed (честный статус, closes #1968)."""
|
||||||
|
|
||||||
|
async def fake_fetch_blocked(
|
||||||
|
self: DomClickScraper,
|
||||||
|
city_id: int,
|
||||||
|
rooms: list[int] | None = None,
|
||||||
|
pages: int = 100,
|
||||||
|
) -> list[ScrapedLot]:
|
||||||
|
# Имитируем QRATOR-блок: установим флаг на scraper и вернём пустой список
|
||||||
|
self.blocked = True
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_blocked)
|
||||||
|
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0))
|
||||||
|
|
||||||
|
fake = _FakeRuns()
|
||||||
|
_install_runs(monkeypatch, fake)
|
||||||
|
|
||||||
|
counters = await scrape_pipeline.run_domclick_city_sweep(
|
||||||
|
db=MagicMock(), run_id=5, request_delay_sec=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert counters.lots_fetched == 0
|
||||||
|
assert counters.blocked == 1
|
||||||
|
# Честный статус: mark_failed должен быть вызван, mark_done — нет
|
||||||
|
assert fake.failed is not None
|
||||||
|
assert "QRATOR" in fake.failed[0]
|
||||||
|
assert fake.done is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── _map_item via sweep — quick integration smoke ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_item_basic_mapping() -> None:
|
||||||
|
"""DomClickScraper._map_item маппит BFF offer-item → ScrapedLot корректно."""
|
||||||
|
scraper = DomClickScraper() # __init__ stubbed
|
||||||
|
item = {
|
||||||
|
"id": 111222333,
|
||||||
|
"path": "https://domclick.ru/card/sale__flat__111222333",
|
||||||
|
"location": {"lat": 56.838, "lon": 60.612},
|
||||||
|
"address": {"displayName": "Екатеринбург, улица Ленина, 10"},
|
||||||
|
"objectInfo": {"area": 52.3, "rooms": 2, "floor": 5},
|
||||||
|
"house": {"floors": 16, "buildYear": 2010},
|
||||||
|
"price": 6_500_000,
|
||||||
|
"squarePrice": 124282,
|
||||||
|
"flatComplex": None,
|
||||||
|
"isRosreestrApproved": False,
|
||||||
|
"publishedDate": "2026-06-01T10:00:00+05:00",
|
||||||
|
"updatedDate": None,
|
||||||
|
"lastPriceHistoryState": None,
|
||||||
|
"offerRegionName": "Екатеринбург",
|
||||||
|
}
|
||||||
|
lot = scraper._map_item(item)
|
||||||
|
assert lot is not None
|
||||||
assert lot.source == "domklik"
|
assert lot.source == "domklik"
|
||||||
assert lot.source_id == "2075671636"
|
assert lot.source_id == "111222333"
|
||||||
assert lot.rooms == 2
|
assert lot.rooms == 2
|
||||||
assert lot.area_m2 == pytest.approx(52.3)
|
assert lot.area_m2 == pytest.approx(52.3)
|
||||||
assert lot.floor == 5
|
assert lot.floor == 5
|
||||||
assert lot.total_floors == 16
|
assert lot.total_floors == 16
|
||||||
assert lot.price_rub == 6_500_000
|
assert lot.price_rub == 6_500_000
|
||||||
assert lot.lat is None and lot.lon is None
|
assert lot.listing_segment == "vtorichka"
|
||||||
|
assert lot.lat == pytest.approx(56.838)
|
||||||
|
assert lot.lon == pytest.approx(60.612)
|
||||||
|
|
|
||||||
401
tradein-mvp/backend/tests/test_estimator_price_spine.py
Normal file
401
tradein-mvp/backend/tests/test_estimator_price_spine.py
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
"""Hermetic unit tests for _price_from_inputs (#1966).
|
||||||
|
|
||||||
|
Calls the pure synchronous pricing function directly with stub callables and
|
||||||
|
hand-built inputs — no DB, no network, no mocks. Verifies that the extraction
|
||||||
|
preserved the pricing logic identically to the original block in estimate_quality.
|
||||||
|
|
||||||
|
NOTE: importing app.services.estimator pulls app.core.config.Settings which
|
||||||
|
requires DATABASE_URL. Set it BEFORE importing app modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.services import estimator
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _geo(coarse: bool = False) -> GeocodeResult:
|
||||||
|
"""Minimal GeocodeResult for test injection."""
|
||||||
|
full_address = "Екатеринбург" if coarse else "ул. Тестовая, 1"
|
||||||
|
return GeocodeResult(
|
||||||
|
lat=56.838,
|
||||||
|
lon=60.597,
|
||||||
|
full_address=full_address,
|
||||||
|
provider="nominatim",
|
||||||
|
confidence="approximate",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lot(ppm2: float, address: str = "ул. Тестовая, 1", source: str = "avito") -> dict:
|
||||||
|
return {"price_per_m2": ppm2, "address": address, "source": source}
|
||||||
|
|
||||||
|
|
||||||
|
def _lots(ppm2: float, n: int = 7) -> list[dict]:
|
||||||
|
"""n unique-address lots all at the same ppm2."""
|
||||||
|
return [_lot(ppm2, address=f"ул. Тестовая, {i + 1}") for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def _dkp_raw(
|
||||||
|
low: int = 80_000,
|
||||||
|
median: int = 120_000,
|
||||||
|
high: int = 150_000,
|
||||||
|
count: int = 20,
|
||||||
|
period_months: int = 12,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"low_ppm2": low,
|
||||||
|
"median_ppm2": median,
|
||||||
|
"high_ppm2": high,
|
||||||
|
"count": count,
|
||||||
|
"period_months": period_months,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _anchor_comp(ppm2: float, area: float = 50.0, rooms: int = 2) -> dict:
|
||||||
|
return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms}
|
||||||
|
|
||||||
|
|
||||||
|
# Stub callables — returned in each test via closure.
|
||||||
|
def _ratio_stub(
|
||||||
|
ratio: float | None,
|
||||||
|
basis: str | None = "per_rooms",
|
||||||
|
) -> "tuple[float | None, str | None]":
|
||||||
|
return ratio, basis if ratio is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _qi_stub_none(q: str) -> "tuple[float, int] | None":
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _qis_stub_empty(qs: list[str]) -> dict[str, float]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _call(
|
||||||
|
*,
|
||||||
|
listings: list[dict] | None = None,
|
||||||
|
area_m2: float = 50.0,
|
||||||
|
rooms: int | None = 2,
|
||||||
|
repair_state: str | None = None,
|
||||||
|
floor: int | None = 5,
|
||||||
|
total_floors: int | None = 10,
|
||||||
|
target_year: int | None = None,
|
||||||
|
analog_tier: str = "W",
|
||||||
|
fallback_used: bool = False,
|
||||||
|
area_widened: bool = False,
|
||||||
|
anchor_comps: list[dict] | None = None,
|
||||||
|
anchor_tier_fetched: str | None = None,
|
||||||
|
dkp_raw: dict | None = None,
|
||||||
|
imv_anchor: dict | None = None,
|
||||||
|
imv_eval=None,
|
||||||
|
yandex_val_present: bool = False,
|
||||||
|
cian_val_present: bool = False,
|
||||||
|
ratio: float | None = None,
|
||||||
|
quarter_index_lookup=None,
|
||||||
|
quarter_indexes_lookup=None,
|
||||||
|
target_house_cadnum: str | None = None,
|
||||||
|
dadata_coarse: bool = False,
|
||||||
|
geo: GeocodeResult | None = None,
|
||||||
|
dadata_qc_geo: int | None = None,
|
||||||
|
) -> estimator.PricingResult:
|
||||||
|
if listings is None:
|
||||||
|
listings = _lots(100_000)
|
||||||
|
if anchor_comps is None:
|
||||||
|
anchor_comps = []
|
||||||
|
if geo is None:
|
||||||
|
geo = _geo(coarse=dadata_coarse)
|
||||||
|
if quarter_index_lookup is None:
|
||||||
|
quarter_index_lookup = _qi_stub_none
|
||||||
|
if quarter_indexes_lookup is None:
|
||||||
|
quarter_indexes_lookup = _qis_stub_empty
|
||||||
|
|
||||||
|
_ratio = ratio
|
||||||
|
_basis = "per_rooms" if ratio is not None else None
|
||||||
|
|
||||||
|
def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
|
||||||
|
return _ratio, _basis if _ratio is not None else None
|
||||||
|
|
||||||
|
return estimator._price_from_inputs(
|
||||||
|
listings=listings,
|
||||||
|
area_m2=area_m2,
|
||||||
|
rooms=rooms,
|
||||||
|
repair_state=repair_state,
|
||||||
|
floor=floor,
|
||||||
|
total_floors=total_floors,
|
||||||
|
target_year=target_year,
|
||||||
|
analog_tier=analog_tier,
|
||||||
|
fallback_used=fallback_used,
|
||||||
|
area_widened=area_widened,
|
||||||
|
anchor_comps=anchor_comps,
|
||||||
|
anchor_tier_fetched=anchor_tier_fetched,
|
||||||
|
dkp_raw=dkp_raw,
|
||||||
|
imv_anchor=imv_anchor,
|
||||||
|
imv_eval=imv_eval,
|
||||||
|
yandex_val_present=yandex_val_present,
|
||||||
|
cian_val_present=cian_val_present,
|
||||||
|
ratio_resolver=ratio_resolver,
|
||||||
|
quarter_index_lookup=quarter_index_lookup,
|
||||||
|
quarter_indexes_lookup=quarter_indexes_lookup,
|
||||||
|
target_house_cadnum=target_house_cadnum,
|
||||||
|
dadata_coarse=dadata_coarse,
|
||||||
|
geo=geo,
|
||||||
|
dadata_qc_geo=dadata_qc_geo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_radius_only_median_and_expected_sold() -> None:
|
||||||
|
"""Pure radius path: 7 uniform lots → correct median, n_analogs, expected_sold."""
|
||||||
|
pr = _call(listings=_lots(100_000, n=7), ratio=0.95)
|
||||||
|
|
||||||
|
assert pr.median_price == int(100_000 * 50.0) # 5_000_000
|
||||||
|
assert pr.median_ppm2 == 100_000.0
|
||||||
|
assert pr.n_analogs == 7
|
||||||
|
assert pr.anchor_tier is None
|
||||||
|
assert pr.dkp_corridor is None
|
||||||
|
assert pr.asking_to_sold_ratio == 0.95
|
||||||
|
assert pr.expected_sold_price == round(5_000_000 * 0.95) # 4_750_000
|
||||||
|
assert pr.expected_sold_per_m2 == round(100_000 * 0.95) # 95_000
|
||||||
|
assert "avito" in pr.sources_used_pre
|
||||||
|
assert len(pr.listings_clean) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_building_anchor_tier_a_mutates_headline() -> None:
|
||||||
|
"""Tier A same-building anchor replaces radius median with higher price.
|
||||||
|
|
||||||
|
5 radius lots at 100k ppm2 (5M total). 5 anchor comps at 200k ppm2 (10M total).
|
||||||
|
After anchor fires: median_price >> radius median, n_analogs == anchor count.
|
||||||
|
"""
|
||||||
|
comps = [_anchor_comp(200_000) for _ in range(5)]
|
||||||
|
pr = _call(
|
||||||
|
listings=_lots(100_000, n=5),
|
||||||
|
anchor_comps=comps,
|
||||||
|
anchor_tier_fetched="A",
|
||||||
|
ratio=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Anchor must have fired (not suppressed).
|
||||||
|
assert pr.anchor_tier == "A"
|
||||||
|
# Headline is anchor-derived — must be above radius median (5_000_000).
|
||||||
|
assert pr.median_price > 5_000_000
|
||||||
|
# n_analogs resets to anchor population.
|
||||||
|
assert pr.n_analogs == 5
|
||||||
|
# anchor_comps_used is the injected comps list.
|
||||||
|
assert len(pr.anchor_comps_used) == 5
|
||||||
|
# No ratio → expected_sold is None.
|
||||||
|
assert pr.expected_sold_price is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_c_corridor_gate_suppresses_anchor() -> None:
|
||||||
|
"""Tier C anchor ppm2 >> corridor_high × mult → anchor suppressed.
|
||||||
|
|
||||||
|
anchor_tier remains "C" in the result (gate sets anchor=None but doesn't
|
||||||
|
reset anchor_tier); headline stays at the radius median.
|
||||||
|
"""
|
||||||
|
# 5 comps at 300k ppm2; corridor_high=150k; gate threshold=150k×1.5=225k.
|
||||||
|
# 300k > 225k → suppressed.
|
||||||
|
comps = [_anchor_comp(300_000) for _ in range(5)]
|
||||||
|
radius_median_price = int(100_000 * 50.0)
|
||||||
|
pr = _call(
|
||||||
|
listings=_lots(100_000, n=5),
|
||||||
|
anchor_comps=comps,
|
||||||
|
anchor_tier_fetched="C",
|
||||||
|
dkp_raw=_dkp_raw(high=150_000, count=15),
|
||||||
|
ratio=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tier C gate sets anchor=None but leaves anchor_tier="C".
|
||||||
|
assert pr.anchor_tier == "C"
|
||||||
|
# Headline was NOT mutated by the suppressed anchor — stays at radius median.
|
||||||
|
assert pr.median_price == radius_median_price
|
||||||
|
# anchor_comps_used stays empty (anchor didn't fire).
|
||||||
|
assert pr.anchor_comps_used == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_conf_gate_suppresses_anchor() -> None:
|
||||||
|
"""Low-confidence anchor is suppressed; anchor_tier reset to None.
|
||||||
|
|
||||||
|
4 comps with wide spread → high cv → fsd > 0.20 → confidence='low' → suppressed.
|
||||||
|
"""
|
||||||
|
# 4 comps at [100k, 200k, 300k, 400k] → cv≈0.45, fsd≈0.20 → "low".
|
||||||
|
comps = [_anchor_comp(p) for p in [100_000, 200_000, 300_000, 400_000]]
|
||||||
|
radius_median_price = int(100_000 * 50.0)
|
||||||
|
pr = _call(
|
||||||
|
listings=_lots(100_000, n=5),
|
||||||
|
anchor_comps=comps,
|
||||||
|
anchor_tier_fetched="A", # starts as A, gate resets to None
|
||||||
|
ratio=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gate resets anchor_tier to None on suppression.
|
||||||
|
assert pr.anchor_tier is None
|
||||||
|
# Headline stays at radius median.
|
||||||
|
assert pr.median_price == radius_median_price
|
||||||
|
assert pr.anchor_comps_used == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_imv_blend_raises_median_when_anchor_tier_none() -> None:
|
||||||
|
"""IMV blend pushes radius median up when IMV >> median × threshold.
|
||||||
|
|
||||||
|
radius median=5M, IMV recommended=7M, area=50, weight=0.5, threshold=1.15.
|
||||||
|
IMV/radius = 7M/5M = 1.4 > 1.15 → blend: new_median = round(5M×0.5 + 7M×0.5).
|
||||||
|
"""
|
||||||
|
imv_anchor = {
|
||||||
|
"recommended_price": 7_000_000,
|
||||||
|
"lower_price": 6_000_000,
|
||||||
|
"higher_price": 8_000_000,
|
||||||
|
"market_count": 50,
|
||||||
|
}
|
||||||
|
pr = _call(
|
||||||
|
listings=_lots(100_000, n=5),
|
||||||
|
imv_anchor=imv_anchor,
|
||||||
|
ratio=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_median = round(5_000_000 * 0.5 + 7_000_000 * 0.5) # 6_000_000
|
||||||
|
assert pr.median_price == expected_median
|
||||||
|
assert pr.range_high == 8_000_000 # from anchor_higher
|
||||||
|
assert pr.avito_imv_summary is not None
|
||||||
|
assert pr.avito_imv_summary.recommended_price == 7_000_000
|
||||||
|
assert "avito_imv" in pr.sources_used_pre
|
||||||
|
|
||||||
|
|
||||||
|
def test_quarter_index_guard2_skip_when_all_analogs_in_target_quarter() -> None:
|
||||||
|
"""Guard-2: when all analogs are in the target quarter, index is NOT applied.
|
||||||
|
|
||||||
|
same_quarter_ratio=1.0 > skip_ratio=0.6 → Guard-2 fires → median unchanged.
|
||||||
|
"""
|
||||||
|
target_cadnum = "66:41:0204016:350"
|
||||||
|
target_quarter = "66:41:0204016"
|
||||||
|
# Analogs are all in the SAME quarter as the target.
|
||||||
|
lots = [
|
||||||
|
{
|
||||||
|
"price_per_m2": 100_000,
|
||||||
|
"address": f"ул. Тестовая, {i + 1}",
|
||||||
|
"source": "avito",
|
||||||
|
"building_cadastral_number": f"{target_quarter}:{100 + i}",
|
||||||
|
}
|
||||||
|
for i in range(5)
|
||||||
|
]
|
||||||
|
|
||||||
|
qi_called: list[str] = []
|
||||||
|
|
||||||
|
def qi_lookup(q: str) -> tuple[float, int] | None:
|
||||||
|
qi_called.append(q)
|
||||||
|
return (1.5, 100) if q == target_quarter else None # high index — would change price
|
||||||
|
|
||||||
|
pr = _call(
|
||||||
|
listings=lots,
|
||||||
|
target_house_cadnum=target_cadnum,
|
||||||
|
quarter_index_lookup=qi_lookup,
|
||||||
|
quarter_indexes_lookup=_qis_stub_empty,
|
||||||
|
ratio=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guard-2 fired: median must remain unchanged (5M, not ×1.5).
|
||||||
|
assert pr.median_price == int(100_000 * 50.0)
|
||||||
|
# quarter_index was looked up but did NOT add "quarter_index" to sources.
|
||||||
|
assert "quarter_index" not in pr.sources_used_pre
|
||||||
|
|
||||||
|
|
||||||
|
def test_quarter_index_applied_when_analogs_in_different_quarter() -> None:
|
||||||
|
"""Quarter-index IS applied when analogs are in a different quarter from target.
|
||||||
|
|
||||||
|
target_qi=1.2, avg_analog_qi=1.0 → factor=1.2 → median_price×1.2.
|
||||||
|
Guard-2 skips (same_quarter_ratio=0.0 < 0.6).
|
||||||
|
"""
|
||||||
|
target_cadnum = "66:41:0204016:350"
|
||||||
|
target_quarter = "66:41:0204016"
|
||||||
|
analog_quarter = "66:41:0999999"
|
||||||
|
lots = [
|
||||||
|
{
|
||||||
|
"price_per_m2": 100_000,
|
||||||
|
"address": f"ул. Иная, {i + 1}",
|
||||||
|
"source": "avito",
|
||||||
|
"building_cadastral_number": f"{analog_quarter}:{i + 1}",
|
||||||
|
}
|
||||||
|
for i in range(5)
|
||||||
|
]
|
||||||
|
|
||||||
|
def qi_lookup(q: str) -> tuple[float, int] | None:
|
||||||
|
return (1.2, 100) if q == target_quarter else None
|
||||||
|
|
||||||
|
def qis_lookup(qs: list[str]) -> dict[str, float]:
|
||||||
|
return {q: 1.0 for q in qs if q == analog_quarter}
|
||||||
|
|
||||||
|
pr = _call(
|
||||||
|
listings=lots,
|
||||||
|
target_house_cadnum=target_cadnum,
|
||||||
|
quarter_index_lookup=qi_lookup,
|
||||||
|
quarter_indexes_lookup=qis_lookup,
|
||||||
|
ratio=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# factor=1.2/1.0=1.2; original radius=5M → adjusted=6M (±rounding via _apply_quarter_index)
|
||||||
|
assert pr.median_price > int(100_000 * 50.0) # index pushed price up
|
||||||
|
assert "quarter_index" in pr.sources_used_pre
|
||||||
|
|
||||||
|
|
||||||
|
def test_corridor_soft_clamp_headline_above_cap() -> None:
|
||||||
|
"""Headline above corridor_high × (1+slack) is clamped down.
|
||||||
|
|
||||||
|
radius lots at 250k ppm2. corridor_high=150k, slack=0.40 →
|
||||||
|
cap=150k×1.40=210k. 250k > 210k → clamped to 210k.
|
||||||
|
"""
|
||||||
|
pr = _call(
|
||||||
|
listings=_lots(250_000, n=7),
|
||||||
|
dkp_raw=_dkp_raw(low=80_000, median=120_000, high=150_000, count=15),
|
||||||
|
ratio=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# cap = 150_000 × 1.40 = 210_000
|
||||||
|
# Clamped: new ppm2 == 210_000, new_price = round(210_000 × 50)
|
||||||
|
assert pr.median_ppm2 == pytest.approx(210_000.0, rel=1e-4)
|
||||||
|
assert pr.median_price == round(210_000 * 50.0)
|
||||||
|
# DKP corridor present in result.
|
||||||
|
assert pr.dkp_corridor is not None
|
||||||
|
assert pr.dkp_corridor.high_ppm2 == 150_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_sold_from_ratio_and_none_when_ratio_none() -> None:
|
||||||
|
"""expected_sold = headline × ratio; when ratio is None, all expected_sold fields None."""
|
||||||
|
# Case A: ratio=0.90 → expected_sold fields filled.
|
||||||
|
pr_ratio = _call(listings=_lots(100_000, n=5), ratio=0.90)
|
||||||
|
assert pr_ratio.asking_to_sold_ratio == 0.90
|
||||||
|
assert pr_ratio.expected_sold_price is not None
|
||||||
|
assert pr_ratio.expected_sold_price == round(pr_ratio.median_price * 0.90)
|
||||||
|
assert pr_ratio.expected_sold_per_m2 is not None
|
||||||
|
assert pr_ratio.expected_sold_range_low is not None
|
||||||
|
assert pr_ratio.expected_sold_range_high is not None
|
||||||
|
|
||||||
|
# Case B: ratio=None → all expected_sold fields None.
|
||||||
|
pr_none = _call(listings=_lots(100_000, n=5), ratio=None)
|
||||||
|
assert pr_none.asking_to_sold_ratio is None
|
||||||
|
assert pr_none.ratio_basis is None
|
||||||
|
assert pr_none.expected_sold_price is None
|
||||||
|
assert pr_none.expected_sold_per_m2 is None
|
||||||
|
assert pr_none.expected_sold_range_low is None
|
||||||
|
assert pr_none.expected_sold_range_high is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_coarse_geo_downgrades_confidence_to_low() -> None:
|
||||||
|
"""dadata_coarse=True with qc_geo=2 → confidence='low' with settlement label."""
|
||||||
|
pr = _call(
|
||||||
|
listings=_lots(100_000, n=7),
|
||||||
|
dadata_coarse=True,
|
||||||
|
dadata_qc_geo=2,
|
||||||
|
ratio=None,
|
||||||
|
# No anchor, no IMV → radius path → anchor_tier is None (not "A" → downgrade applies)
|
||||||
|
geo=_geo(coarse=False), # geo itself not coarse; using dadata_coarse signal
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pr.confidence == "low"
|
||||||
|
assert "населённого пункта" in pr.explanation
|
||||||
|
|
@ -182,7 +182,7 @@ export default function TradeInPage() {
|
||||||
<div>
|
<div>
|
||||||
<h1>Оценка квартиры на вторичке</h1>
|
<h1>Оценка квартиры на вторичке</h1>
|
||||||
<p className="page-subtitle" style={{ marginTop: 8 }}>
|
<p className="page-subtitle" style={{ marginTop: 8 }}>
|
||||||
Агрегируем данные из 7 источников + аналоги в продаже + фактические сделки.
|
Агрегируем 4 источника объявлений (Авито, Циан, Яндекс, N1) и реальные сделки Росреестра.
|
||||||
Время сбора — <span className="num">10–30 сек</span>.
|
Время сбора — <span className="num">10–30 сек</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -336,7 +336,7 @@ export default function TradeInPage() {
|
||||||
<footer className="page-foot">
|
<footer className="page-foot">
|
||||||
<div>
|
<div>
|
||||||
Мера · MVP ·{" "}
|
Мера · MVP ·{" "}
|
||||||
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
|
<span className="mono">data: Avito + Cian + Yandex + N1 + Росреестр</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 18 }}>
|
<div style={{ display: "flex", gap: 18 }}>
|
||||||
<a href="#">Документация</a>
|
<a href="#">Документация</a>
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,26 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface NoAccessScreenProps {
|
interface NoAccessScreenProps {
|
||||||
variant: "user" | "path" | "session";
|
variant: "user" | "path" | "session" | "trial";
|
||||||
path?: string;
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
||||||
|
const title =
|
||||||
|
variant === "session"
|
||||||
|
? "Сессия истекла"
|
||||||
|
: variant === "trial"
|
||||||
|
? "Пробный доступ закончился"
|
||||||
|
: "Доступа нет";
|
||||||
|
|
||||||
const subtitle =
|
const subtitle =
|
||||||
variant === "session"
|
variant === "session"
|
||||||
? "Сессия истекла или вы не авторизованы. Обновите страницу, чтобы войти снова."
|
? "Сессия истекла или вы не авторизованы. Обновите страницу, чтобы войти снова."
|
||||||
: variant === "user"
|
: variant === "user"
|
||||||
? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov."
|
? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov."
|
||||||
: "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору.";
|
: variant === "path"
|
||||||
|
? "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору."
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
|
|
@ -56,18 +65,44 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
||||||
lineHeight: 1.25,
|
lineHeight: 1.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{variant === "session" ? "Сессия истекла" : "Доступа нет"}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
{variant === "trial" ? (
|
||||||
style={{
|
<p
|
||||||
margin: 0,
|
style={{
|
||||||
fontSize: 14,
|
margin: 0,
|
||||||
color: "var(--fg-secondary)",
|
fontSize: 14,
|
||||||
lineHeight: 1.6,
|
color: "var(--fg-secondary)",
|
||||||
}}
|
lineHeight: 1.6,
|
||||||
>
|
}}
|
||||||
{subtitle}
|
>
|
||||||
</p>
|
Пробный доступ к сервису «Мера» закончился. Чтобы продлить доступ
|
||||||
|
или получить полную версию — напишите Артёму Копылову:{" "}
|
||||||
|
<a
|
||||||
|
href="https://t.me/ArtemKopylov87"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
color: "var(--accent)",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@ArtemKopylov87
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{variant === "path" && path ? (
|
{variant === "path" && path ? (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
// Пробный доступ закончился (#praktika): role=expired → спец-экран, а не generic path-deny.
|
||||||
|
if (data.role === "expired") {
|
||||||
|
return <NoAccessScreen variant="trial" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isPathAllowed(data.allowed_paths, data.deny_paths, absolutePath)) {
|
if (!isPathAllowed(data.allowed_paths, data.deny_paths, absolutePath)) {
|
||||||
return <NoAccessScreen variant="path" path={absolutePath} />;
|
return <NoAccessScreen variant="path" path={absolutePath} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SourcesProgress — карточка «Шаг B · Агрегация» под mockup tradein.html.
|
* SourcesProgress — карточка «Шаг B · Агрегация».
|
||||||
*
|
*
|
||||||
* Показывает статус 7 строк-источников. Реально работают 4: Cian + Avito +
|
* Показывает 5 реальных источников: 4 источника объявлений (Циан, Авито,
|
||||||
* Yandex + N1. ДомКлик и Restate пока не реализованы — показываем idle для
|
* Яндекс, N1) + Росреестр (внутренние сделки). Статусы выводятся из реального
|
||||||
* соответствия макету.
|
* `estimate.sources_used` / `countBySource` / `actual_deals`; источник без
|
||||||
|
* вклада честно показывает «нет данных».
|
||||||
*/
|
*/
|
||||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||||
|
|
||||||
|
|
@ -20,15 +21,10 @@ interface SourceRow {
|
||||||
dotClass: string;
|
dotClass: string;
|
||||||
status: "done" | "loading" | "error" | "idle";
|
status: "done" | "loading" | "error" | "idle";
|
||||||
count?: number;
|
count?: number;
|
||||||
median?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMln(rub: number): string {
|
|
||||||
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SourcesProgress({ estimate, isPending }: Props) {
|
export function SourcesProgress({ estimate, isPending }: Props) {
|
||||||
// Маппинг реальных данных из estimate на 7 строк-источников
|
// Маппинг реальных данных из estimate на строки-источники
|
||||||
const used = new Set(estimate?.sources_used ?? []);
|
const used = new Set(estimate?.sources_used ?? []);
|
||||||
const isDone = estimate !== null && !isPending;
|
const isDone = estimate !== null && !isPending;
|
||||||
|
|
||||||
|
|
@ -49,7 +45,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
|
||||||
dotClass: "cian",
|
dotClass: "cian",
|
||||||
status: used.has("cian") ? "done" : (isPending ? "loading" : "idle"),
|
status: used.has("cian") ? "done" : (isPending ? "loading" : "idle"),
|
||||||
count: countBySource.cian,
|
count: countBySource.cian,
|
||||||
median: used.has("cian") ? estimate?.median_price_rub : undefined,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "avito",
|
key: "avito",
|
||||||
|
|
@ -57,13 +52,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
|
||||||
dotClass: "avito",
|
dotClass: "avito",
|
||||||
status: used.has("avito") ? "done" : (isPending ? "loading" : "idle"),
|
status: used.has("avito") ? "done" : (isPending ? "loading" : "idle"),
|
||||||
count: countBySource.avito,
|
count: countBySource.avito,
|
||||||
median: used.has("avito") ? estimate?.median_price_rub : undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "domklik",
|
|
||||||
label: "ДомКлик Прайс",
|
|
||||||
dotClass: "dom",
|
|
||||||
status: isPending ? "loading" : "idle",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "yandex",
|
key: "yandex",
|
||||||
|
|
@ -71,13 +59,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
|
||||||
dotClass: "yandex",
|
dotClass: "yandex",
|
||||||
status: used.has("yandex") ? "done" : (isPending ? "loading" : "idle"),
|
status: used.has("yandex") ? "done" : (isPending ? "loading" : "idle"),
|
||||||
count: countBySource.yandex,
|
count: countBySource.yandex,
|
||||||
median: used.has("yandex") ? estimate?.median_price_rub : undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "restate",
|
|
||||||
label: "Restate",
|
|
||||||
dotClass: "etagi",
|
|
||||||
status: isPending ? "loading" : "idle",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "n1",
|
key: "n1",
|
||||||
|
|
@ -85,7 +66,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
|
||||||
dotClass: "n1",
|
dotClass: "n1",
|
||||||
status: used.has("n1") ? "done" : (isPending ? "loading" : "idle"),
|
status: used.has("n1") ? "done" : (isPending ? "loading" : "idle"),
|
||||||
count: countBySource.n1,
|
count: countBySource.n1,
|
||||||
median: used.has("n1") ? estimate?.median_price_rub : undefined,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "rosreestr",
|
key: "rosreestr",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
|
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
|
||||||
|
|
||||||
export type Role = "admin" | "pilot";
|
export type Role = "admin" | "pilot" | "expired";
|
||||||
|
|
||||||
export interface UserScope {
|
export interface UserScope {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue