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`.
**Что делаю каждый /loop tick (30m):**
**Что делаю каждый /loop tick (15m):**
1. Kill-switch check (label `pause-bots` на repo)
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`.
**Per-tick workflow (5m):**
**Per-tick workflow (2m):**
1. Kill-switch check
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

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}`.
## GHA path triggers
## Path triggers (Forgejo Actions, `.forgejo/workflows/`)
- `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/*.sql``deploy.yml` (main stack)
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh``deploy-obsidian.yml`
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml``deploy.yml` (main Site Finder stack)
- 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 → НЕ триггерит деплой
## После изменения .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
@ -30,7 +32,7 @@ Reference: vault `Bug_RenderMarkdown_JavascriptUrl_May14` (`renderMarkdown.ts:sa
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)

View file

@ -45,8 +45,8 @@ paths:
- **Imperative**: "fix crash" не "fixed crash"
- **Body — почему**, не что (что видно в diff)
- **NO `Co-Authored-By: Claude ...`** — никогда (~/.claude/CLAUDE.md rule)
- Workers оставляют staged — main session коммитит
- **No auto-commit**: написать commit message в чат, user решает когда коммитить
- Workers (subagents) оставляют staged — main session коммитит
- **GenDesign (Mera/Ptica) = full self-service:** main/solo session коммитит → пушит → PR → **merge сам** (см. § Auto-merge policy). `balance_platform` — наоборот: только stage, commit message в чат, не коммитить/пушить/мержить
## PR body template
@ -85,11 +85,11 @@ Closes #N
## 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):
- 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 права)
**Жёсткие исключения (даже при зелёном — НЕ merge, ping human):**
- Diff содержит литеральный secret/token/password/credential (40-char hex, API keys, JWT, и т.д.) — security tripwire.
- 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
@ -122,7 +122,7 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
## Запреты
- ❌ `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`
- ❌ `--no-verify` / `--amend` / `--no-edit` / `--force` без явного approval
- ❌ `@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

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
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
# 1) openapi-typescript из дампнутого файла (эквивалент `npm run codegen`,
# который читает ту же схему по URL). 2) prettier (defaults, как
# pre-commit hook) → формат совпадает с committed-файлом.
# который читает ту же схему по URL). 2) ./node_modules/.bin/prettier —
# 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: |
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
working-directory: frontend

View file

@ -30,10 +30,17 @@ repos:
- id: ruff-format
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
rev: v4.0.0-alpha.8
hooks:
- id: prettier
additional_dependencies: ["prettier@3.9.0"]
files: ^frontend/.*\.(ts|tsx|js|jsx|json|css|md)$
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 |
| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**`, `.forgejo/workflows/**` |
| `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.

View file

@ -54,6 +54,13 @@ roles:
- "/admin/**"
- "/api/v1/admin/**"
- "/trade-in/api/v1/admin/**"
expired:
# Пробный доступ закончился — нет доступа ни к чему. Аккаунт остаётся в
# caddy/users.caddy.snippet (basic_auth), чтобы дойти до фронта и увидеть
# сообщение; /me отдаёт role=expired → RouteGuard рендерит trial-экран.
paths: []
deny:
- "/**"
# NB: реальные analyst-логины ДОЛЖНЫ быть добавлены и в
# caddy/users.caddy.snippet (Caddy basic_auth) силами devops — иначе Caddy не
@ -72,7 +79,7 @@ users:
user8: pilot
user9: pilot
user10: pilot
praktika: pilot # пилот-аккаунт агентства «Практика» 2026-05-29
praktika: expired # пробный доступ закончился 2026-06-27 — см. NoAccessScreen variant="trial"
admintest: admin # temp QA 2026-05-26
pilottest: pilot # temp QA 2026-05-26
analysttest: analyst # temp QA 2026-06-07 (#962)

View file

@ -40,6 +40,7 @@
"jsdom": "^25.0.1",
"openapi-typescript": "^7.0.0",
"postcss": "^8.4.0",
"prettier": "3.9.0",
"tailwindcss": "^4.0.0",
"typescript": "5.9.3",
"vitest": "^2.1.9"
@ -14055,6 +14056,22 @@
"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": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",

View file

@ -45,6 +45,7 @@
"jsdom": "^25.0.1",
"openapi-typescript": "^7.0.0",
"postcss": "^8.4.0",
"prettier": "3.9.0",
"tailwindcss": "^4.0.0",
"typescript": "5.9.3",
"vitest": "^2.1.9"

View file

@ -2833,8 +2833,7 @@ export interface components {
nspd_risk_zones?: components["schemas"]["RiskZone"][] | null;
/** Nspd Opportunity Parcels */
nspd_opportunity_parcels?:
| components["schemas"]["OpportunityParcel"][]
| null;
components["schemas"]["OpportunityParcel"][] | null;
/** Nspd Red Lines */
nspd_red_lines?: components["schemas"]["RedLine"][] | null;
/** Nspd Dump */
@ -3752,8 +3751,7 @@ export interface components {
* @description Категория: competition|permitting|demand|risk|other
*/
category?:
| ("competition" | "permitting" | "demand" | "risk" | "other")
| null;
("competition" | "permitting" | "demand" | "risk" | "other") | null;
/**
* Is Confidential
* @description «Непублично» (§7.13): маркировка чувствительной заметки
@ -3846,8 +3844,7 @@ export interface components {
body?: string | null;
/** Category */
category?:
| ("competition" | "permitting" | "demand" | "risk" | "other")
| null;
("competition" | "permitting" | "demand" | "risk" | "other") | null;
/** Is Confidential */
is_confidential?: boolean | null;
/** District */
@ -4630,12 +4627,10 @@ export interface components {
year_built?: number | null;
/** House Type */
house_type?:
| ("panel" | "brick" | "monolith" | "monolith_brick" | "other")
| null;
("panel" | "brick" | "monolith" | "monolith_brick" | "other") | null;
/** Repair State */
repair_state?:
| ("needs_repair" | "standard" | "good" | "excellent")
| null;
("needs_repair" | "standard" | "good" | "excellent") | null;
/** Has Balcony */
has_balcony?: boolean | null;
};
@ -7877,8 +7872,7 @@ export interface operations {
cad_num?: string | null;
/** @description Фильтр по категории */
category?:
| ("competition" | "permitting" | "demand" | "risk" | "other")
| null;
("competition" | "permitting" | "demand" | "risk" | "other") | null;
/** @description Фильтр по пометке «непублично» */
is_confidential?: boolean | null;
/** @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__)
Role = Literal["admin", "pilot", "analyst"]
Role = Literal["admin", "pilot", "analyst", "expired"]
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:
"""Aggregate counters для DomClick citywide sweep run.
DomClick SERP citywide (city_id), не geo-bbox: anchor-loop отсутствует.
Один проход fetch_city перебирает rooms × pages внутри scraper'а.
Поля симметричны sibling-counters, но без detail_*/houses_* (DomClick SERP-only)
и без anchors_* (нет anchor-фазы). pages_fetched = rooms × pages worst-case.
DomClick BFF citywide (city_id), не geo-bbox: anchor-loop отсутствует.
Один проход fetch_city перебирает ROOM_BUCKETS × pages внутри scraper'а.
Поля симметричны sibling-counters, но без detail_*/houses_* (SERP-only)
и без anchors_* (нет anchor-фазы). pages_fetched = 6 buckets × pages worst-case.
"""
lots_fetched: int = 0
@ -3098,17 +3098,21 @@ class DomClickCitySweepCounters:
lots_updated: int = 0
pages_fetched: 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]:
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_ROOMS: list[int] = [0, 1, 2, 3, 4]
# Оценка времени одного fetch'а (network + camoufox render + parse) для watchdog.
DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] # vestigial; scraper sweeps all buckets
# Число BFF-бакетов (st/1/2/3/4/5+) — фиксировано; используется для watchdog.
_DOMCLICK_NUM_BUCKETS: int = 6
# Оценка времени одного fetch'а (network + parse) для watchdog.
_DOMCLICK_PER_FETCH_S: float = 12.0
# Буфер сверху расчётного бюджета (cold browser start, save-фаза).
# Буфер сверху расчётного бюджета (cold browser start, save-фаза, price-splits).
_DOMCLICK_SWEEP_BUDGET_S: float = 300.0
@ -3118,46 +3122,51 @@ async def run_domclick_city_sweep(
run_id: int,
city_id: int = DOMCLICK_DEFAULT_CITY_ID,
rooms: list[int] | None = None,
pages: int = 5,
pages: int = 100,
request_delay_sec: float | None = None,
) -> 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:
DomClick SERP не поддерживает geo-radius (fetch_around NotImplementedError),
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city уже перебирает
rooms × pages через внутренний BrowserFetcher (camoufox headless) и
дедуплицирует по source_id.
DomClick не поддерживает geo-radius (fetch_around NotImplementedError),
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все
ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher shared mobile proxy
обходит QRATOR.
Фазы (единственный citywide проход):
1. SERP: DomClickScraper().fetch_city(city_id, rooms, pages).
2. SAVE: save_listings(db, lots, run_id=run_id) BaseScraper save path,
триггерит существующий house-match hook (адрес-fingerprint tier, т.к.
DomClick SERP не отдаёт lat/lon).
2. SAVE: save_listings(db, lots, run_id=run_id) house-match по lat/lon и
address-fingerprint (BFF отдаёт координаты).
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-фазой.
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.
"""
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 8.0
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
counters = DomClickCitySweepCounters()
# Watchdog-таймаут: число fetch'ей = len(rooms) × pages (worst-case, без
# early-break на пустой странице). Каждый fetch ≈ delay + render/parse overhead.
_num_fetches = max(1, len(_rooms)) * max(1, pages)
# Watchdog: 6 buckets × pages × per_fetch + budget.
# Реальный worst-case (rooms=2 price-split) ≈ 330 страниц × (delay+12s).
# pages=100 → 600 fetch'ей × ~18s + 300 ≈ 11100s (~3ч) — намеренно generous.
_num_fetches = _DOMCLICK_NUM_BUCKETS * max(1, pages)
_sweep_timeout = max(
ANCHOR_TIMEOUT_SEC,
int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S),
)
# Мутируемый контейнер для захвата scraper-ссылки из замыкания.
_scraper_ref: list[DomClickScraper] = []
try:
# ── Cooperative cancel перед SERP-фазой ──────────────────────────────
if scrape_runs.is_cancelled(db, run_id):
@ -3166,10 +3175,11 @@ async def run_domclick_city_sweep(
return counters
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,
city_id,
_rooms,
len(ROOM_BUCKETS),
pages,
_sweep_timeout,
)
@ -3179,10 +3189,11 @@ async def run_domclick_city_sweep(
async def _domclick_phase() -> None:
"""Единственная citywide-фаза: fetch_city + save."""
nonlocal lots
async with DomClickScraper() as scraper:
async with DomClickScraper() as _scraper:
_scraper_ref.append(_scraper)
if request_delay_sec is not None:
scraper.request_delay_sec = _resolved_delay
lots = await scraper.fetch_city(city_id=city_id, rooms=_rooms, pages=pages)
_scraper.request_delay_sec = _resolved_delay
lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
counters.lots_fetched += len(lots)
if lots:
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)
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
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(
"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,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
counters.pages_fetched,
counters.errors_count,
counters.blocked,
counters.geo_filtered,
)
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) единственный
рабочий путь: DataDome пропускает headless Playwright, curl_cffi -> 401.
Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
BrowserFetcher(source="domclick") (generic provider shared mobile proxy).
QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy.
URL шаблон:
https://domclick.ru/search?deal_type=sale&category=living&offer_type=flat
&city_id={city_id}&rooms={r}&p={page}
Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
Оффер-карточки: `a[href*="/card/"]` с href-паттерном `/card/sale__flat__<id>`.
Координаты SERP не отдаёт (lat = lon = None).
Стратегия нумерации комнат (ROOM_BUCKETS):
"st" студии (force rooms=0 в ScrapedLot)
"1""4" соответственно
"5+" 5 и более комнат
Важно: selectolax `.text()` объединяет все дочерние текстовые узлы без
разделителей. Числа из адреса (номер дома) могут слипнуться с ценой.
Поэтому цену извлекаем из каждого дочернего элемента отдельно (а не из
суммарного card_text), где элемент содержит символ рубля.
Пагинация: offset 0, 20, 40, , cap=2000. Если snippetsCount > 2000
для бакета, рекурсивно делим по цене (binary split) до тех пор пока
каждая ветка укладывается в cap.
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
через detail-эндпоинт после первоначального сбора.
"""
from __future__ import annotations
import json
import logging
import re
from urllib.parse import urljoin
from selectolax.parser import HTMLParser, Node
from datetime import date
from typing import Any
from urllib.parse import urlencode
from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.base import BaseScraper, ScrapedLot
@ -30,112 +34,179 @@ from app.services.scrapers.domclick_exceptions import DomClickBlockedError
logger = logging.getLogger(__name__)
# ── DataDome block detection ─────────────────────────────────────────────────
_DATADOME_MARKERS = ("datadome", "blocked", "access denied", "bot detected")
# ── API constants ─────────────────────────────────────────────────────────────
_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:
head = html[:2048].lower()
return any(m in head for m in _DATADOME_MARKERS)
# Buckets to sweep — порядок влияет на логи.
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
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 ────────────────────────────────────────────────────────────
# _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)
# ── QRATOR block detection ────────────────────────────────────────────────────
# Цена: парсим из одного элемента DOM (не из суммарного card_text) —
# иначе число дома может слипнуться с ценой. Цена всегда в элементе с «₽».
_RE_PRICE_EL = re.compile(r"([\d\s ]+)\s*[₽р](?:уб\.?)?", re.IGNORECASE)
# source_id из href вида /card/sale__flat__2075671636
_RE_SOURCE_ID = re.compile(r"sale__flat__(\d+)")
# Адресные ключевые слова (эвристика)
_RE_ADDR_KW = re.compile(
r"ул\.|улица|пер\.|переулок|пр-т|проспект"
r"|бул\.|бульвар|шоссе|наб\.|набережная"
r"|пл\.|площадь|тракт|д\.\s*\d",
re.IGNORECASE,
_QRATOR_MARKERS: tuple[str, ...] = (
"qrator",
"bot_mitigation",
"система защиты",
"403 | домклик",
"captcha",
"access denied",
)
# ── 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:
if _RE_STUDIO.search(text):
return 0
m = _RE_ROOMS.search(text)
return int(m.group(1)) if m else None
# ── JSON extraction ───────────────────────────────────────────────────────────
def _extract_area(text: str) -> float | None:
m = _RE_AREA.search(text)
return float(m.group(1).replace(",", ".")) if m else None
def _extract_json(html: str) -> dict[str, Any]:
"""Извлекает JSON-объект из ответа BrowserFetcher.
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
Стратегия: найти первый '{' и последний '}' работает для обоих вариантов.
def _extract_price_from_element(text: str) -> int | None:
"""Извлекаем цену из текста ОДНОГО элемента DOM.
Парсим из одного span/div не из суммарного card_text. Это исключает
случай, когда адресный номер дома стоит вплотную перед ценой в
объединённом тексте карточки («Ленина, 503 100 000 руб.»).
Raises:
DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры.
ValueError: если JSON не найден или не является dict.
"""
m = _RE_PRICE_EL.search(text)
if m:
# Нормализуем пробелы любого вида (обычный, неразрывный, узкий)
raw = re.sub(r"[\s ]", "", m.group(1))
if raw.isdigit():
val = int(raw)
if val > 0:
return val
return None
# Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
# стоять за пределами head в крупных challenge-страницах.
html_lower = html.lower()
if any(m in html_lower for m in _QRATOR_MARKERS):
raise DomClickBlockedError(
f"DomClick BFF: QRATOR block page detected (markers checked: {_QRATOR_MARKERS[:2]})"
)
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:
"""Числовой ID из href типа /card/sale__flat__2075671636."""
m = _RE_SOURCE_ID.search(href)
return m.group(1) if m else None
# ── URL builders ──────────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────────────
class DomClickScraper(BaseScraper):
"""DomClick вторичка parser. Источник = 'domklik'.
"""DomClick вторичка через JSON BFF API. Источник = 'domklik'.
Использует BrowserFetcher (camoufox headless Firefox) DataDome пропускает
его без дополнительной stealth-настройки. curl_cffi -> 401 (DataDome блок).
Использует BrowserFetcher(source="domclick") (generic provider shared mobile
proxy), который обходит QRATOR. Прямые curl/httpx-запросы с datacenter-IP
блокируются QRATOR.
Основной метод: fetch_city(city_id, rooms, pages).
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"
source = "domklik"
base_url = "https://domclick.ru"
# DataDome — консервативная задержка между страницами (fallback до init)
# Консервативная задержка между страницами (fallback до init)
request_delay_sec = 8.0
def __init__(self) -> None:
super().__init__()
self.request_delay_sec = get_scraper_delay(self.name)
# Счётчик карточек с неожиданной DOM-структурой (для observability)
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:
await super().__aenter__()
@ -155,237 +226,361 @@ class DomClickScraper(BaseScraper):
self,
city_id: int,
rooms: list[int] | None = None,
pages: int = 20,
pages: int = 100,
) -> 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:
city_id: числовой ID города в DomClick (например, 4 = Екатеринбург).
rooms: список значений комнатности (0=студия, 1, 2, 3, 4, ...).
None -> без фильтра комнатности (все квартиры сразу).
pages: максимальное число страниц на один sweep. Break on empty.
city_id: игнорируется (EKB захардкожен).
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
pages: максимальное число страниц на бакет (safety cap).
Returns:
Дедуплицированный по source_id список ScrapedLot.
"""
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
from app.services.scrapers.browser_fetcher import BrowserFetcher
all_lots: list[ScrapedLot] = []
out_lots: list[ScrapedLot] = []
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:
for room_val in room_values:
room_label = f"rooms={room_val}" if room_val is not None else "all_rooms"
for bucket in ROOM_BUCKETS:
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,
)
for page_num in range(1, pages + 1):
url = self._build_url(city_id, room_val, page_num)
logger.debug("domklik: fetch %s", url)
try:
html = await fetcher.fetch(url)
except Exception as exc:
logger.error(
"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),
try:
await self._sweep_bucket(
fetcher=fetcher,
rooms=bucket,
price_gte=None,
price_lte=None,
seen_ids=seen_ids,
out_lots=out_lots,
pages=pages,
)
# Пауза между страницами (anti-DataDome)
if page_num < pages:
await self.sleep_between_requests()
except DomClickBlockedError:
self.blocked = True
logger.error(
"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(
"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,
len(all_lots),
len(out_lots),
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:
"""Строит URL SERP DomClick."""
url = (
f"{self.base_url}/search"
f"?deal_type=sale&category=living&offer_type=flat"
f"&city_id={city_id}"
)
if rooms is not None:
url += f"&rooms={rooms}"
url += f"&p={page}"
return url
async def _count(
self,
fetcher: Any,
rooms: str,
price_gte: int | None,
price_lte: int | None,
) -> int:
"""Запрашивает snippetsCount для данного бакета (rooms + price range).
# ── HTML parsing ──────────────────────────────────────────────────────────
def _parse_html(self, html: str) -> list[ScrapedLot]:
"""Парсим карточки из HTML через selectolax.
Селектор: `a[href*="/card/"]` с паттерном `/card/sale__flat__<id>`.
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и return 0
(бакет пропускается, sweep продолжается).
"""
if _is_blocked_page(html):
logger.warning("domklik: DataDome block page detected, 0 cards returned")
raise DomClickBlockedError("DomClick returned DataDome block page")
tree = HTMLParser(html)
card_links = tree.css('a[href*="/card/"]')
url = _build_count_url(rooms, price_gte, price_lte)
logger.debug("domklik: count 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: _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()
lots: list[ScrapedLot] = []
async def _sweep_bucket(
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:
href = link.attributes.get("href", "")
# Только карточки вторичного жилья
if "sale__flat__" not in href:
continue
if href in seen_hrefs:
continue
seen_hrefs.add(href)
Если snippetsCount > OFFSET_CAP делим диапазон цен пополам и
рекурсируем в каждую половину. Остановка рекурсии:
- span <= MIN_PRICE_SPAN пагинируем как есть (с предупреждением о truncation)
- snippetsCount == 0 пропускаем
- snippetsCount <= OFFSET_CAP пагинируем
lot = self._card_link_to_lot(link, href)
if lot is not None:
lots.append(lot)
Raises:
DomClickBlockedError: если QRATOR-блок propagate наверх.
"""
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:
"""Парсинг одной карточки-ссылки -> ScrapedLot.
async def _paginate(
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/...">` вся
информация (title, цена, адрес) внутри этого элемента.
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и break
(трактуем как конец бакета уже собранные lots сохраняются).
Цену извлекаем из отдельных дочерних элементов (не из объединённого
card_text) чтобы исключить слипание числа дома с ценой.
Raises:
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:
source_id = _extract_source_id(href)
source_url = urljoin(self.base_url, href)
# Суммарный текст — для rooms/area/floor (эти поля не подвержены
# проблеме слипания, т.к. используют специфичные маркеры: «м²», «эт.»)
card_text = link.text(strip=True)
if not card_text:
price = item.get("price", 0) or 0
if price <= 0:
return None
rooms = _extract_rooms(card_text)
area = _extract_area(card_text)
floor, total_floors = _extract_floor(card_text)
item_id = item.get("id")
source_id = str(item_id) if item_id is not None else None
source_url: str = item.get("path", "") or ""
# Цена: ищем в каждом дочернем элементе отдельно чтобы не слипались
# числа дома с ценой при конкатенации card_text
price = self._extract_price_from_children(link)
if not price or price <= 0:
return None
loc = item.get("location") or {}
lat: float | None = loc.get("lat")
lon: float | None = loc.get("lon")
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(
source="domklik",
source_url=source_url,
source_id=source_id,
address=address,
lat=None, # DomClick SERP координаты не отдаёт
lon=None,
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area,
area_m2=area_m2,
floor=floor,
total_floors=total_floors,
price_rub=price,
year_built=year_built,
price_rub=int(price),
price_per_m2=price_per_m2,
listing_segment="vtorichka",
raw_payload={"card_text": card_text[:500]},
publish_date=publish_date,
raw_payload=raw_payload,
)
except Exception:
self.parse_failures += 1
logger.warning(
"domclick _card_link_to_lot: parse failed href=%r (parse_failures=%d)",
href,
"domklik: _map_item failed for item id=%r (parse_failures=%d)",
item.get("id"),
self.parse_failures,
exc_info=True,
)
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) ────────────────────────────────────
@ -393,14 +588,14 @@ class DomClickScraper(BaseScraper):
async def scrape_domclick_city(
city_id: int,
rooms: list[int] | None = None,
pages: int = 20,
pages: int = 100,
) -> list[ScrapedLot]:
"""Удобная точка входа для вызова из Celery tasks.
Пример::
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:
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)

View file

@ -2,4 +2,12 @@
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) из текста карточки
- Парсинг цены из отдельного DOM-элемента
- Извлечение source_id из href
- Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML
- _extract_json: bare + <pre>-wrapped dict; QRATOR-маркер DomClickBlockedError
- _build_offers_url / _build_count_url: корректные параметры, "5+" "5%2B",
offers имеет offset/limit, count нет
- _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
import asyncio
from typing import Any
import pytest
from app.services.scrapers.domclick import (
LTE_MAX,
OFFSET_CAP,
DomClickScraper,
_extract_area,
_extract_floor,
_extract_price_from_element,
_extract_rooms,
_extract_source_id,
_build_count_url,
_build_offers_url,
_extract_json,
)
from app.services.scrapers.domclick_exceptions import DomClickBlockedError
# ── Rooms / studio ───────────────────────────────────────────────────────────
# ── _extract_json ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"text, expected",
[
("2-комн. квартира 53,5 м² 5/15 эт.", 2),
("1-к. квартира 32,1 м² 3/9 эт.", 1),
("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
def test_extract_json_bare_body() -> None:
"""Bare JSON body (без HTML-обёртки) корректно парсится."""
html = '{"result": {"items": [], "snippetsCount": 42}}'
data = _extract_json(html)
assert data["result"]["snippetsCount"] == 42
# ── 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(
"text, expected",
[
("2-комн. квартира 53,5 м² 5/15 эт.", 53.5),
("студия 24 м² 1/10 эт.", 24.0),
("78м2 на этаже", 78.0),
("площадь 101.7 м²", 101.7),
("Нет площади", None),
],
)
def test_extract_area(text: str, expected: float | None) -> None:
assert _extract_area(text) == expected
def test_extract_json_qrator_marker_raises() -> None:
"""HTML с QRATOR-маркером вызывает DomClickBlockedError."""
blocked_html = (
"<html><head><title>QRATOR protection</title></head>"
"<body>Access denied by bot_mitigation system</body></html>"
)
with pytest.raises(DomClickBlockedError):
_extract_json(blocked_html)
# ── 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(
"text, floor, total",
[
("2-комн. квартира 53,5 м² 5/15 эт.", 5, 15),
("студия 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)
def test_extract_json_no_json_raises() -> None:
"""HTML без JSON-объекта вызывает ValueError."""
with pytest.raises(ValueError, match="No JSON"):
_extract_json("<html><body>plain text</body></html>")
# ── Price (per-element) ───────────────────────────────────────────────────────
# Цена парсится из отдельного DOM-элемента (не из суммарного card_text),
# поэтому числа адреса не загрязняют парсинг цены.
# ── _build_offers_url ────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"text, expected",
[
("5 200 000 ₽", 5_200_000),
("12 345 678 ₽", 12_345_678),
("3 990 000 руб.", 3_990_000),
("от 4 500 000 ₽", 4_500_000),
("без цены", None),
("0 ₽", None),
],
)
def test_extract_price_from_element(text: str, expected: int | None) -> None:
assert _extract_price_from_element(text) == expected
def test_build_offers_url_basic_params() -> None:
url = _build_offers_url("1", None, None, 0)
assert "deal_type=sale" in url
assert "category=living" in url
assert "offer_type=flat" in url
assert "rooms=1" in url
assert "offset=0" in url
assert "limit=20" in url
assert "sale_price__gte" not in url
assert "sale_price__lte" not in url
# ── 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(
"href, expected",
[
("/card/sale__flat__2075671636", "2075671636"),
("https://domclick.ru/card/sale__flat__123456789", "123456789"),
("/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
def test_build_offers_url_with_price_range() -> None:
url = _build_offers_url("2", 3_000_000, 6_000_000, 40)
assert "sale_price__gte=3000000" in url
assert "sale_price__lte=6000000" in url
assert "offset=40" in url
# ── _parse_html -> list[ScrapedLot] ──────────────────────────────────────────
# Цена в каждой карточке вынесена в отдельный <span> с символом рубля,
# чтобы не сливаться с адресным номером дома при конкатенации card_text.
_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_build_offers_url_ekb_guid_present() -> None:
url = _build_offers_url("3", None, None, 0)
assert "0d475b79-88de-4054-818c-37d8f9d0d440" in url
assert "aids=20561" in url
def test_parse_html_returns_lots() -> None:
scraper = DomClickScraper()
lots = scraper._parse_html(_MOCK_HTML)
# Карточки 1 и 2 парсятся; карточка 3 (без цены) отфильтрована; дубль — дедуплицирован
assert len(lots) == 2
def test_build_offers_url_bff_host() -> None:
url = _build_offers_url("1", None, None, 0)
assert url.startswith("https://bff-search-web.domclick.ru/api/offers/v1?")
def test_parse_html_card1_fields() -> None:
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"
# ── _build_count_url ─────────────────────────────────────────────────────────
def test_parse_html_studio() -> None:
scraper = DomClickScraper()
lots = scraper._parse_html(_MOCK_HTML)
studio = next(lot for lot in lots if lot.source_id == "1111111111")
assert studio.rooms == 0 # студия
assert studio.area_m2 == 28.0
assert studio.price_rub == 3_100_000
def test_build_count_url_no_offset_limit() -> None:
"""count url не должен содержать offset и limit."""
url = _build_count_url("2", None, None)
assert "offset" not in url
assert "limit" not in url
def test_parse_html_no_price_filtered() -> None:
scraper = DomClickScraper()
lots = scraper._parse_html(_MOCK_HTML)
source_ids = {lot.source_id for lot in lots}
assert "9999999999" not in source_ids
def test_build_count_url_5plus_encoded() -> None:
url = _build_count_url("5+", None, None)
assert "5%2B" in url
assert "rooms=5+" not in url
def test_parse_html_dedup_same_href() -> None:
"""Дубликат href на одной странице должен давать одну карточку."""
scraper = DomClickScraper()
lots = scraper._parse_html(_MOCK_HTML)
def test_build_count_url_with_price() -> None:
url = _build_count_url("1", 1_000_000, 4_000_000)
assert "sale_price__gte=1000000" in url
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:
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.source == "domklik"
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:
scraper = DomClickScraper()
scraper = DomClickScraper.__new__(DomClickScraper)
with pytest.raises(NotImplementedError, match="geo-radius"):
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:
# 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.
rows = (
_rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=80_000.0)
+ _rows_for_bucket(2, n=bt.MIN_BUCKET, ask=200_000.0, sold=150_000.0)
rows = _rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=80_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)
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.
rows: list[tuple[float, float, int]] = []
for bucket, sold in ((0, 80_000.0), (1, 100_000.0), (2, 150_000.0)):
rows += _rows_for_bucket(
bucket, n=bt.MIN_BUCKET, ask=1.30 * sold, sold=sold
)
rows += _rows_for_bucket(bucket, n=bt.MIN_BUCKET, ask=1.30 * sold, sold=sold)
# sanity: the ASKING block really is +30%.
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"]["mape_pct"] == pytest.approx(0.0, abs=1e-6)
for bucket in (0, 1, 2):
assert corrected["per_rooms"][bucket]["median_bias_pct"] == pytest.approx(
0.0, abs=1e-6
)
assert corrected["per_rooms"][bucket]["median_bias_pct"] == pytest.approx(0.0, abs=1e-6)
def test_corrected_metrics_global_fallback_cancels_uniform_bias() -> None:
@ -469,8 +464,18 @@ def test_argparse_defaults() -> None:
def test_argparse_overrides() -> None:
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.since == "2024-01-01"
@ -478,3 +483,328 @@ def test_argparse_overrides() -> None:
assert ns.rooms_tolerance == 1
assert ns.holdout_split 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:
self.request_delay_sec = 0.0
self.parse_failures = 0
self.geo_filtered = 0
self.blocked = False
self.fetch_errors = 0
async def _aenter(self: DomClickScraper) -> DomClickScraper:
return self
@ -109,6 +112,8 @@ def test_domclick_sweep_counters_defaults() -> None:
assert c.lots_updated == 0
assert c.pages_fetched == 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:
@ -233,8 +238,13 @@ async def test_cancel_before_serp_skips_fetch(monkeypatch: pytest.MonkeyPatch) -
assert fake.done is None
async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) -> None:
"""Исключение в fetch_city → errors_count++, sweep завершается mark_done (не fatal)."""
async def test_fetch_city_exception_no_reraise(monkeypatch: pytest.MonkeyPatch) -> None:
"""Исключение в 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(
self: DomClickScraper,
@ -242,7 +252,7 @@ async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) ->
rooms: list[int] | None = None,
pages: int = 20,
) -> list[ScrapedLot]:
raise RuntimeError("camoufox crashed")
raise RuntimeError("browser crashed")
monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_fail)
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.lots_fetched == 0
assert fake.done is not None, "graceful: mark_done вызывается несмотря на ошибку SERP"
assert fake.failed is None
# 0 lots + error → honest mark_failed (не маскируем как done)
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).
def test_parse_html_minimal_card() -> None:
"""Минимальная 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>
Нераспознанный block-вариант приходит как fetch_error (не blocked=True), но
honest-status всё равно обязан пометить run failed при пустом результате.
"""
lots = scraper._parse_html(html)
assert len(lots) == 1
lot = lots[0]
async def fake_fetch_errs(
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_id == "2075671636"
assert lot.source_id == "111222333"
assert lot.rooms == 2
assert lot.area_m2 == pytest.approx(52.3)
assert lot.floor == 5
assert lot.total_floors == 16
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>
<h1>Оценка квартиры на вторичке</h1>
<p className="page-subtitle" style={{ marginTop: 8 }}>
Агрегируем данные из 7 источников + аналоги в продаже + фактические сделки.
Агрегируем 4 источника объявлений (Авито, Циан, Яндекс, N1) и реальные сделки Росреестра.
Время сбора <span className="num">1030 сек</span>.
</p>
</div>
@ -336,7 +336,7 @@ export default function TradeInPage() {
<footer className="page-foot">
<div>
Мера · MVP ·{" "}
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
<span className="mono">data: Avito + Cian + Yandex + N1 + Росреестр</span>
</div>
<div style={{ display: "flex", gap: 18 }}>
<a href="#">Документация</a>

View file

@ -10,17 +10,26 @@
*/
interface NoAccessScreenProps {
variant: "user" | "path" | "session";
variant: "user" | "path" | "session" | "trial";
path?: string;
}
export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
const title =
variant === "session"
? "Сессия истекла"
: variant === "trial"
? "Пробный доступ закончился"
: "Доступа нет";
const subtitle =
variant === "session"
? "Сессия истекла или вы не авторизованы. Обновите страницу, чтобы войти снова."
: variant === "user"
? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov."
: "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору.";
: variant === "path"
? "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору."
: null;
return (
<main
@ -56,18 +65,44 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
lineHeight: 1.25,
}}
>
{variant === "session" ? "Сессия истекла" : "Доступа нет"}
{title}
</h1>
<p
style={{
margin: 0,
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.6,
}}
>
{subtitle}
</p>
{variant === "trial" ? (
<p
style={{
margin: 0,
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.6,
}}
>
Пробный доступ к сервису «Мера» закончился. Чтобы продлить доступ
или получить полную версию напишите Артёму Копылову:{" "}
<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 ? (
<p
style={{

View file

@ -63,6 +63,11 @@ export function RouteGuard({ children }: RouteGuardProps) {
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)) {
return <NoAccessScreen variant="path" path={absolutePath} />;
}

View file

@ -1,11 +1,12 @@
"use client";
/**
* SourcesProgress карточка «Шаг B · Агрегация» под mockup tradein.html.
* SourcesProgress карточка «Шаг B · Агрегация».
*
* Показывает статус 7 строк-источников. Реально работают 4: Cian + Avito +
* Yandex + N1. ДомКлик и Restate пока не реализованы показываем idle для
* соответствия макету.
* Показывает 5 реальных источников: 4 источника объявлений (Циан, Авито,
* Яндекс, N1) + Росреестр (внутренние сделки). Статусы выводятся из реального
* `estimate.sources_used` / `countBySource` / `actual_deals`; источник без
* вклада честно показывает «нет данных».
*/
import type { AggregatedEstimate } from "@/types/trade-in";
@ -20,15 +21,10 @@ interface SourceRow {
dotClass: string;
status: "done" | "loading" | "error" | "idle";
count?: number;
median?: number;
}
function formatMln(rub: number): string {
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
}
export function SourcesProgress({ estimate, isPending }: Props) {
// Маппинг реальных данных из estimate на 7 строк-источников
// Маппинг реальных данных из estimate на строки-источники
const used = new Set(estimate?.sources_used ?? []);
const isDone = estimate !== null && !isPending;
@ -49,7 +45,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
dotClass: "cian",
status: used.has("cian") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.cian,
median: used.has("cian") ? estimate?.median_price_rub : undefined,
},
{
key: "avito",
@ -57,13 +52,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
dotClass: "avito",
status: used.has("avito") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.avito,
median: used.has("avito") ? estimate?.median_price_rub : undefined,
},
{
key: "domklik",
label: "ДомКлик Прайс",
dotClass: "dom",
status: isPending ? "loading" : "idle",
},
{
key: "yandex",
@ -71,13 +59,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
dotClass: "yandex",
status: used.has("yandex") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.yandex,
median: used.has("yandex") ? estimate?.median_price_rub : undefined,
},
{
key: "restate",
label: "Restate",
dotClass: "etagi",
status: isPending ? "loading" : "idle",
},
{
key: "n1",
@ -85,7 +66,6 @@ export function SourcesProgress({ estimate, isPending }: Props) {
dotClass: "n1",
status: used.has("n1") ? "done" : (isPending ? "loading" : "idle"),
count: countBySource.n1,
median: used.has("n1") ? estimate?.median_price_rub : undefined,
},
{
key: "rosreestr",

View file

@ -14,7 +14,7 @@ import { useQuery } from "@tanstack/react-query";
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
export type Role = "admin" | "pilot";
export type Role = "admin" | "pilot" | "expired";
export interface UserScope {
username: string;