Merge branch 'main' into fix/report-data-1953
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m36s
CI / openapi-codegen-check (pull_request) Successful in 2m28s
CI / backend-tests (pull_request) Successful in 14m16s

This commit is contained in:
Light1YT 2026-06-27 16:26:39 +05:00
commit d5e51ca809
34 changed files with 3862 additions and 1464 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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`.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 Фильтр по автору */

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

@ -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">1030 сек</span>. Время сбора <span className="num">1030 сек</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>

View file

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

View file

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

View file

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

View file

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