gendesign/backend/app/services/scrapers/domrf_kn.py
lekss361 79070bfe99 add interactive parser of наш.дом.рф ЖК+flats with stealth Playwright
- Discovered real endpoints via chrome-devtools: /сервисы/api/kn/object
  (offset/limit/place/objStatus) + /portal-kn/api/sales/portal/table.
  Server-side filter ignored — local filter by developer.companyGroup.
- Pure Playwright in-context fetch (httpx blocked by ServicePipe TLS-fp).
- Saved fingerprint state in data/playwright_state.json for server reuse.
- Celery beat (configurable cron + jitter) + admin trigger endpoint
  + /admin/scrape UI page.
- Schema migration: UNIQUE(id, snapshot_date) for versioned snapshots,
  kn_scrape_runs journal.
- Smoke-tested on PRINZIP: 28 ЖК / 826 квартир as expected.
2026-04-27 17:58:40 +03:00

537 lines
20 KiB
Python

"""Scraper for наш.дом.рф kn-API (objects + flats).
Endpoints discovered via chrome-devtools probe:
GET /сервисы/api/kn/object
params: offset, limit, place, objStatus, sortField=default, sortType=desc
place: "0-4" (city Екатеринбург) or "66" (region Свердл)
objStatus: 0=строящиеся, 1=архив-1, 2=сданные (sweep 0..2 for full coverage)
Returns {data: {list: [obj, ...], total: N}}.
GET /portal-kn/api/sales/portal/table?externalId={objId}
Per-object flat catalogue: nested entrances → floors → flats.
Auth header: `Authorization: Basic MTpxd2U=` (decodes to `1:qwe`, public debug
auth shipped in their frontend bundle).
"""
from __future__ import annotations
import json
import logging
from datetime import date, datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.scrapers.stealth import BrowserSession
logger = logging.getLogger(__name__)
PATH_OBJECTS = "/сервисы/api/kn/object"
PATH_FLATS_TABLE = "/portal-kn/api/sales/portal/table"
RAW_SECTION = "kn_api"
# Three statuses observed in production (Apr 2026):
# 0 — строящиеся ЖК
# 1 — небольшая выборка ~2 объекта на регион (legacy/archive flag)
# 2 — сданные / завершённые (самый большой бакет)
DEFAULT_STATUSES = (0, 1, 2)
# ── helpers ──────────────────────────────────────────────────────────────────
def _g(d: dict[str, Any], *keys: str, default: Any = None) -> Any:
for k in keys:
if k in d and d[k] is not None:
return d[k]
return default
def _to_bool(v: Any) -> bool | None:
"""Coerce DOM.РФ 0/1 ints into Python bool. Pass through actual bools/None."""
if v is None:
return None
if isinstance(v, bool):
return v
if isinstance(v, int | float):
return bool(v)
if isinstance(v, str):
s = v.strip().lower()
if s in ("true", "1", "y", "yes"):
return True
if s in ("false", "0", "n", "no", ""):
return False
return None
def _to_date(v: Any) -> date | None:
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'.
Quarter strings get mapped to the last day of the quarter.
"""
if v is None or v == "":
return None
if isinstance(v, date) and not isinstance(v, datetime):
return v
if isinstance(v, datetime):
return v.date()
if not isinstance(v, str):
return None
s = v.strip()
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%d-%m-%Y %H:%M:%S", "%d-%m-%Y", "%d.%m.%Y"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
pass
# Quarter-like 'IV кв. 2028' / '4 кв. 2028' / 'III квартал 2028'
quarter_end = {1: (3, 31), 2: (6, 30), 3: (9, 30), 4: (12, 31)}
roman = {"I": 1, "II": 2, "III": 3, "IV": 4}
parts = s.replace(".", " ").replace("кв", " ").replace("квартал", " ").split()
qq = year = None
for p in parts:
p = p.strip()
if p in roman and qq is None:
qq = roman[p]
elif p.isdigit():
n = int(p)
if 1 <= n <= 4 and qq is None:
qq = n
elif 2000 <= n <= 2050 and year is None:
year = n
if qq and year:
m, d = quarter_end[qq]
return date(year, m, d)
return None
def _problem_text(v: Any) -> str | None:
"""Coerce problem flag (sometimes int 0/1, sometimes text) to text or None."""
if v is None:
return None
if isinstance(v, bool):
return "1" if v else "0"
if isinstance(v, int | float):
return str(int(v))
if isinstance(v, str):
return v.strip() or None
return None
def _extract_total(payload: dict[str, Any]) -> int | None:
data = payload.get("data")
if isinstance(data, dict):
for key in ("total", "totalCount", "count"):
v = data.get(key)
if isinstance(v, int):
return v
return None
def _extract_list(payload: dict[str, Any]) -> list[dict[str, Any]]:
data = payload.get("data")
if isinstance(data, list):
return data
if isinstance(data, dict):
for key in ("list", "items", "objects", "content", "rows"):
v = data.get(key)
if isinstance(v, list):
return v
return []
# ── normalisation ────────────────────────────────────────────────────────────
def _norm_object(row: dict[str, Any], region_cd: int | None = None) -> dict[str, Any]:
"""Map naш.дом.рф /kn/object row → domrf_kn_objects column dict.
Real API field names:
objId, hobjId, developer{devId, shortName, fullName, groupName, companyGroup, devInn},
rpdRegionCd, objAddr, shortAddr, objCommercNm, objFloorMin, objFloorMax,
objElemLivingCnt, objSquareLiving, objReady100PercDt, objClass, latitude, longitude,
objProblemFlg, problemFlag, siteStatus, objGreenHouseFlg, objGuarantyEscrowFlg,
objStatus, freeFlatsInfo{priceMin, numberFlats}.
"""
dev = row.get("developer") if isinstance(row.get("developer"), dict) else {}
company_group = _g(dev, "companyGroup") if dev else None
# Our DB convention: dev_id="<companyGroup>_0" matches v_developer_full_metrics.
dev_id = f"{company_group}_0" if company_group else None
return {
"obj_id": _g(row, "objId", "obj_id", "id"),
"hobj_id": _g(row, "hobjId", "hobj_id"),
"comm_name": _g(row, "objCommercNm"),
"addr": _g(row, "objAddr"),
"short_addr": _g(row, "shortAddr"),
"region_cd": _g(row, "rpdRegionCd") or region_cd,
"dev_id": dev_id,
"dev_name": _g(dev, "groupName") or _g(dev, "shortName") or _g(dev, "fullName"),
"dev_inn": _g(dev, "devInn"),
"floor_min": _g(row, "objFloorMin"),
"floor_max": _g(row, "objFloorMax"),
"flat_count": _g(row, "objElemLivingCnt"),
"square_living": _g(row, "objSquareLiving"),
"ready_dt": _to_date(_g(row, "objReady100PercDt")),
"problem_flag": _problem_text(_g(row, "problemFlag", "objProblemFlg")),
"site_status": _g(row, "siteStatus"),
"green_house": _to_bool(_g(row, "objGreenHouseFlg")),
"escrow": _to_bool(_g(row, "objGuarantyEscrowFlg")),
"obj_class": _g(row, "objClass"),
"wall_type": _g(row, "wallType"),
"energy_eff": _g(row, "energyEff"),
"latitude": _g(row, "latitude"),
"longitude": _g(row, "longitude"),
"obj_status": _g(row, "objStatus"),
}
def _flatten_table(table_payload: dict[str, Any]) -> list[dict[str, Any]]:
"""Iterate nested entrances → floors → flats[] from /sales/portal/table response."""
out: list[dict[str, Any]] = []
ext_id = table_payload.get("externalId")
for entrance in table_payload.get("entrances") or []:
for floor in entrance.get("floors") or []:
for flat in floor.get("flats") or []:
# Inject obj_id and floor for normalisation
flat = {
**flat,
"_objId": ext_id,
"_floor": floor.get("floorNumber"),
"_entrance": entrance.get("entranceNumber"),
}
out.append(flat)
return out
def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
"""Map flat from /sales/portal/table → domrf_kn_flats columns.
Real fields: flatId|id (numeric, may be null), odsId, elemId (uuid hash),
type, number, isStudio, totalArea, livingArea, rooms, status (free|booked|sold),
price, pricePerSquareMeter, numberFloors. Plus injected _objId and _floor.
"""
flat_id = _g(row, "flatId", "id")
if flat_id is None:
# Fallback: hash of elemId (stable, fits BIGINT). Without an id we cannot UPSERT.
elem = _g(row, "elemId")
if elem:
flat_id = abs(hash(elem)) % (2**63 - 1)
return {
"id": flat_id,
"ods_id": _g(row, "odsId"),
"flat_type": _g(row, "type", "flatType"),
"flat_number": _g(row, "number", "flatNumber"),
"is_studio": _to_bool(_g(row, "isStudio")),
"total_area": _g(row, "totalArea"),
"living_area": _g(row, "livingArea"),
"rooms": _g(row, "rooms"),
"status": _g(row, "status"),
"price_rub": _g(row, "price"),
"price_per_m2": _g(row, "pricePerSquareMeter"),
"floor": _g(row, "_floor", "floor"),
"num_floors": _g(row, "numberFloors"),
"obj_id": _g(row, "_objId", "objId"),
"city": _g(row, "city"),
"region_cd": region_cd,
"obj_name": _g(row, "objName") or _g(row.get("objInfo") or {}, "objCommercNm"),
}
# ── DB writes ────────────────────────────────────────────────────────────────
UPSERT_OBJECT_SQL = text(
"""
INSERT INTO domrf_kn_objects (
obj_id, hobj_id, comm_name, addr, short_addr, region_cd,
dev_id, dev_name, dev_inn,
floor_min, floor_max, flat_count, square_living, ready_dt,
problem_flag, site_status, green_house, escrow,
obj_class, wall_type, energy_eff,
latitude, longitude, obj_status, snapshot_date
) VALUES (
:obj_id, :hobj_id, :comm_name, :addr, :short_addr, :region_cd,
:dev_id, :dev_name, :dev_inn,
:floor_min, :floor_max, :flat_count, :square_living, :ready_dt,
:problem_flag, :site_status, :green_house, :escrow,
:obj_class, :wall_type, :energy_eff,
:latitude, :longitude, :obj_status, :snapshot_date
)
ON CONFLICT (obj_id, snapshot_date) DO UPDATE SET
comm_name = EXCLUDED.comm_name,
addr = EXCLUDED.addr,
dev_id = EXCLUDED.dev_id,
dev_name = EXCLUDED.dev_name,
flat_count = EXCLUDED.flat_count,
square_living = EXCLUDED.square_living,
ready_dt = EXCLUDED.ready_dt,
site_status = EXCLUDED.site_status,
escrow = EXCLUDED.escrow,
obj_class = EXCLUDED.obj_class,
latitude = EXCLUDED.latitude,
longitude = EXCLUDED.longitude,
obj_status = EXCLUDED.obj_status
"""
)
UPSERT_FLAT_SQL = text(
"""
INSERT INTO domrf_kn_flats (
id, ods_id, flat_type, flat_number, is_studio, total_area, living_area,
rooms, status, price_rub, price_per_m2, floor, num_floors, obj_id,
city, region_cd, obj_name, snapshot_date
) VALUES (
:id, :ods_id, :flat_type, :flat_number, :is_studio, :total_area, :living_area,
:rooms, :status, :price_rub, :price_per_m2, :floor, :num_floors, :obj_id,
:city, :region_cd, :obj_name, :snapshot_date
)
ON CONFLICT (id, snapshot_date) DO UPDATE SET
status = EXCLUDED.status,
price_rub = EXCLUDED.price_rub,
price_per_m2 = EXCLUDED.price_per_m2,
obj_id = EXCLUDED.obj_id,
region_cd = EXCLUDED.region_cd,
obj_name = EXCLUDED.obj_name
"""
)
INSERT_RAW_SQL = text(
"""
INSERT INTO domrf_raw_endpoints (
snapshot_date, section, endpoint, source_url, payload, payload_size
) VALUES (
:snapshot_date, :section, :endpoint, :source_url,
CAST(:payload AS JSONB), :payload_size
)
ON CONFLICT (snapshot_date, section, endpoint) DO UPDATE SET
payload = EXCLUDED.payload,
payload_size = EXCLUDED.payload_size,
source_url = EXCLUDED.source_url
"""
)
def _insert_raw(db: Session, snapshot_date: date, endpoint_label: str, payload: Any) -> None:
body = json.dumps(payload, ensure_ascii=False)
db.execute(
INSERT_RAW_SQL,
{
"snapshot_date": snapshot_date,
"section": RAW_SECTION,
"endpoint": endpoint_label[:255],
"source_url": f"https://xn--80az8a.xn--d1aqf.xn--p1ai{endpoint_label}",
"payload": body,
"payload_size": len(body.encode("utf-8")),
},
)
def upsert_objects(
db: Session, raw: list[dict[str, Any]], snapshot_date: date, region_cd: int
) -> int:
inserted = 0
for row in raw:
norm = _norm_object(row, region_cd=region_cd)
if not norm["obj_id"]:
continue
norm["snapshot_date"] = snapshot_date
try:
db.execute(UPSERT_OBJECT_SQL, norm)
inserted += 1
except Exception as e:
logger.warning("upsert object %s failed: %s", norm.get("obj_id"), e)
db.rollback()
return inserted
def upsert_flats(
db: Session, raw: list[dict[str, Any]], snapshot_date: date, region_cd: int
) -> int:
inserted = 0
skipped_no_id = 0
for row in raw:
norm = _norm_flat(row, region_cd)
if norm["id"] is None:
skipped_no_id += 1
continue
norm["snapshot_date"] = snapshot_date
try:
db.execute(UPSERT_FLAT_SQL, norm)
inserted += 1
except Exception as e:
logger.warning("upsert flat %s failed: %s", norm.get("id"), e)
db.rollback()
if skipped_no_id:
logger.info("skipped %d flats without numeric id", skipped_no_id)
return inserted
# ── orchestrator ─────────────────────────────────────────────────────────────
def _place_str(region_code: int) -> str:
"""Map region code → DOM.РФ `place` parameter. By default uses the regionCode itself
(e.g. 66=Свердл region). City-level codes (e.g. '0-4'=ЕКБ) can be passed as-is via
PLACE_OVERRIDE if more granularity is needed.
"""
return str(region_code)
async def fetch_objects_for_status(
sess: BrowserSession, place: str, status: int
) -> list[dict[str, Any]]:
"""Fetch ALL objects for a given (place, objStatus) — server returns up to limit=999999."""
payload = await sess.get_json(
PATH_OBJECTS,
{
"offset": 0,
"limit": 999999,
"sortField": "default",
"sortType": "desc",
"place": place,
"objStatus": status,
},
)
rows = _extract_list(payload)
total = _extract_total(payload)
logger.info("kn/object place=%s status=%d -> %d/%s rows", place, status, len(rows), total)
return rows
async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict[str, Any]]:
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened)."""
try:
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
except Exception as e:
logger.warning("flats fetch obj=%s failed: %s", obj_id, e)
return []
# Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]}
return _flatten_table(payload)
async def run_region_sweep(
region_code: int,
developers: list[str] | None = None,
headed: bool = False,
snapshot_date: date | None = None,
load_state: str | None = None,
save_state: str | None = None,
statuses: tuple[int, ...] = DEFAULT_STATUSES,
fetch_flats: bool = True,
place_override: str | None = None,
) -> dict[str, Any]:
"""Full sweep for one region: bootstrap → fetch objects (each objStatus) → fetch flats.
`developers` filters the OBJECT set locally by `developer.companyGroup` matching the
"<group>_0" id pattern (since DOM.РФ doesn't honour developer filter on the API).
"""
snapshot_date = snapshot_date or date.today()
db = SessionLocal()
run_id = db.execute(
text(
"""
INSERT INTO kn_scrape_runs (region_codes, developer_ids, snapshot_date, status)
VALUES (ARRAY[:rc]::int[], CAST(:devs AS text[]), :snap, 'running')
RETURNING run_id
"""
),
{
"rc": region_code,
"devs": developers if developers else None,
"snap": snapshot_date,
},
).scalar_one()
db.commit()
place = place_override or _place_str(region_code)
try:
async with BrowserSession(
region_code=region_code,
headed=headed,
load_state=load_state,
save_state=save_state,
) as sess:
# --- objects across statuses -------------------------------------
all_objects: list[dict[str, Any]] = []
for status in statuses:
rows = await fetch_objects_for_status(sess, place, status)
all_objects.extend(rows)
logger.info("place=%s total objects across %s = %d", place, statuses, len(all_objects))
# local developer filter
if developers:
wanted_groups = {int(d.split("_")[0]) for d in developers if "_" in d}
all_objects = [
o
for o in all_objects
if isinstance(o.get("developer"), dict)
and o["developer"].get("companyGroup") in wanted_groups
]
logger.info("after developers filter: %d objects", len(all_objects))
# --- flats per object --------------------------------------------
all_flats: list[dict[str, Any]] = []
if fetch_flats:
for i, obj in enumerate(all_objects):
obj_id = obj.get("objId") or obj.get("obj_id")
if not obj_id:
continue
flats = await fetch_flats_for_object(sess, obj_id)
all_flats.extend(flats)
if (i + 1) % 25 == 0:
logger.info(
"flats progress: %d/%d objs, %d flats so far",
i + 1,
len(all_objects),
len(all_flats),
)
logger.info("total flats: %d (across %d objs)", len(all_flats), len(all_objects))
request_count = sess.request_count
# --- write all in one transaction ------------------------------------
obj_count = upsert_objects(db, all_objects, snapshot_date, region_code)
flat_count = upsert_flats(db, all_flats, snapshot_date, region_code) if fetch_flats else 0
# Save raw payloads (one row per status; flats are huge so we store the count, not bodies).
_insert_raw(db, snapshot_date, f"kn_object_place_{place}", all_objects)
db.execute(
text(
"""
UPDATE kn_scrape_runs
SET finished_at=NOW(), objects_count=:oc, flats_count=:fc,
requests_count=:rq, status='done'
WHERE run_id=:rid
"""
),
{"oc": obj_count, "fc": flat_count, "rq": request_count, "rid": run_id},
)
db.commit()
return {
"run_id": run_id,
"region_code": region_code,
"place": place,
"objects": obj_count,
"flats": flat_count,
"requests": request_count,
"snapshot_date": snapshot_date.isoformat(),
}
except Exception as e:
logger.exception("kn sweep failed for region=%d", region_code)
db.rollback()
db.execute(
text(
"""
UPDATE kn_scrape_runs
SET finished_at=NOW(), status='failed', error=:err
WHERE run_id=:rid
"""
),
{"err": str(e)[:2000], "rid": run_id},
)
db.commit()
raise
finally:
db.close()