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