gendesign/backend/app/services/scrapers/obj_checks.py
bot-backend 14f3ef2019
All checks were successful
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m20s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m52s
fix(week-review): backend-аудит v2 — 82 фиксов (#1660)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-17 17:13:38 +00:00

224 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Extractor for 6 «Проверено на наш.дом.рф» checks per object.
Issue #297, sub-task 22f. Table created in PR #303 (data/sql/111_22f_domrf_obj_checks.sql).
Check types (canonical):
no_problems / docs / timing / photos / bankruptcy / declaration
Source endpoint: /сервисы/api/object/{obj_id}/checks
(pattern analogичен /infrastructure и /photos — те же сервисы/api/object/{obj_id}/ prefix)
URL not verified via devtools — структура payload выведена из аудита страницы ЖК.
Если endpoint не существует — scraper получит HTTP-ошибку, которую _classify_and_log
запишет в kn_scrape_failures, данные в domrf_obj_checks не поступят, scrape не упадёт.
Expected payload shape (предположительно):
{
"data": {
"noProblemObjects": true, # no_problems
"hasDocuments": true, # docs
"meetsDeadlines": true, # timing
"hasPhotos": true, # photos
"notBankrupt": true, # bankruptcy
"hasDeclaration": true # declaration
}
}
OR possibly an array:
[{"checkType": "no_problems", "passed": true}, ...]
Поддерживаются оба варианта: dict-payload (поля маппятся через CHECK_FIELD_MAP)
и list-payload (поля check_type + passed/value).
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
CHECK_TYPES = ["no_problems", "docs", "timing", "photos", "bankruptcy", "declaration"]
# Mapping of possible API field names → canonical check_type.
# Best-guess from DOM.РФ API field naming conventions (кейс camelCase).
_CHECK_FIELD_MAP: dict[str, str] = {
"noProblemObjects": "no_problems",
"noProblemFlg": "no_problems",
"hasDocuments": "docs",
"documentsFlg": "docs",
"meetsDeadlines": "timing",
"deadlinesFlg": "timing",
"hasPhotos": "photos",
"photosFlg": "photos",
"notBankrupt": "bankruptcy",
"bankruptcyFlg": "bankruptcy",
"hasDeclaration": "declaration",
"declarationFlg": "declaration",
}
# Canonical check_type name → possible API field name aliases
_CHECK_TYPE_ALIASES: dict[str, list[str]] = {
"no_problems": ["no_problems", "noProblemObjects", "noProblemFlg"],
"docs": ["docs", "hasDocuments", "documentsFlg"],
"timing": ["timing", "meetsDeadlines", "deadlinesFlg"],
"photos": ["photos", "hasPhotos", "photosFlg"],
"bankruptcy": ["bankruptcy", "notBankrupt", "bankruptcyFlg"],
"declaration": ["declaration", "hasDeclaration", "declarationFlg"],
}
# Строковые флаги, которые сторонний API может прислать вместо bool.
# Схема payload не верифицирована (см. docstring), поэтому приводим явно.
_TRUE_STRINGS = {"true", "1", "yes", "y", "да", "passed", "ok"}
_FALSE_STRINGS = {"false", "0", "no", "n", "нет", "failed", "not_passed"}
def _coerce_flag(value: Any) -> bool | None:
"""Привести значение флага проверки к bool либо None (UNKNOWN).
bool(value) ломается на строках ('false'/'0'/'нет' → True) и не отличает
отсутствие данных от False. Возвращаем None, если значение нераспознаваемо —
вызывающий код НЕ должен фабриковать False для UNKNOWN.
"""
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
s = value.strip().lower()
if s in _TRUE_STRINGS:
return True
if s in _FALSE_STRINGS:
return False
return None
return None
_UPSERT_CHECKS_SQL = text(
"""
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
VALUES (:obj_id, :check_type, :passed, NOW(), NOW())
ON CONFLICT (obj_id, check_type) DO UPDATE SET
passed = EXCLUDED.passed,
checked_at = NOW(),
scraped_at = NOW()
"""
)
def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
"""Извлечь 6 чекбоксов из payload endpoint /object/{obj_id}/checks.
Поддерживает два варианта payload:
1. dict с полями (ожидаемый API-формат): {"data": {"noProblemObjects": true, ...}}
2. list объектов: [{"checkType": "no_problems", "passed": true}, ...]
Для неизвестных полей и неподдерживаемых форматов — WARNING + пустой список.
"""
if not raw_payload:
return []
# Вариант 1: dict с data-обёрткой
data: Any = raw_payload
if isinstance(raw_payload, dict):
data = raw_payload.get("data") or raw_payload
results: list[dict[str, Any]] = []
if isinstance(data, dict):
# Map известных полей к canonical check_type
found: dict[str, bool] = {}
for field, value in data.items():
ct = _CHECK_FIELD_MAP.get(field)
if ct and ct not in found:
flag = _coerce_flag(value)
if flag is not None:
found[ct] = flag
# Также проверить canonical names напрямую
for ct in CHECK_TYPES:
if ct not in found and ct in data:
flag = _coerce_flag(data[ct])
if flag is not None:
found[ct] = flag
if found:
# Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
# а не FAILED — не фабрикуем passed=False для непришедших проверок.
for ct in CHECK_TYPES:
if ct in found:
results.append({"check_type": ct, "passed": found[ct]})
return results
# dict не содержит известных полей — попробуем как list-формат ниже
logger.warning(
"domrf obj_checks: dict payload has no known check fields: %s", list(data)[:10]
)
return []
if isinstance(data, list):
found_list: dict[str, bool] = {}
for item in data:
if not isinstance(item, dict):
continue
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
if ct_raw and str(ct_raw) in CHECK_TYPES:
# Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
# Берём первый ключ, который реально присутствует в item.
if "passed" in item:
passed_raw = item["passed"]
elif "value" in item:
passed_raw = item["value"]
elif "status" in item:
passed_raw = item["status"]
else:
passed_raw = None
flag = _coerce_flag(passed_raw)
if flag is not None:
found_list[str(ct_raw)] = flag
if found_list:
# См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
for ct in CHECK_TYPES:
if ct in found_list:
results.append({"check_type": ct, "passed": found_list[ct]})
return results
logger.warning(
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]
)
return []
logger.warning("domrf obj_checks: unexpected payload type %s", type(raw_payload))
return []
def upsert_obj_checks(db: Session, obj_id: int, checks: list[dict[str, Any]]) -> int:
"""UPSERT 6 чек-строк в domrf_obj_checks. Returns count of inserted/updated rows.
Использует SAVEPOINT (begin_nested) per-row — одна битая строка не откатывает
всю транзакцию.
"""
if not checks:
return 0
ok = 0
for c in checks:
try:
with db.begin_nested():
db.execute(
_UPSERT_CHECKS_SQL,
{
"obj_id": obj_id,
"check_type": c["check_type"],
"passed": c["passed"],
},
)
ok += 1
except Exception as exc:
logger.warning(
"upsert obj_checks obj=%s check_type=%s failed: %s",
obj_id,
c.get("check_type"),
exc,
)
db.commit()
return ok