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.
This commit is contained in:
lekss361 2026-04-27 17:58:40 +03:00
parent 8d3a0874ef
commit 79070bfe99
17 changed files with 1374 additions and 7 deletions

View file

@ -56,6 +56,10 @@ COPY --from=builder --chown=app:app /app/app /app/app
COPY --from=builder --chown=app:app /app/alembic.ini /app/alembic.ini COPY --from=builder --chown=app:app /app/alembic.ini /app/alembic.ini
COPY --from=builder --chown=app:app /app/alembic /app/alembic COPY --from=builder --chown=app:app /app/alembic /app/alembic
# Playwright Chromium for /сервисы/api/kn/* WAF-bootstrap (used only by worker).
# --with-deps installs ~80MB of system libs needed by Chromium headless.
RUN /app/.venv/bin/playwright install --with-deps chromium
USER app USER app
EXPOSE 8000 EXPOSE 8000

View file

@ -0,0 +1,101 @@
"""Admin trigger for ad-hoc kn-API sweeps.
POST /api/v1/admin/scrape/kn
Body: {"region_code": 66, "developers": ["6208_0"]?, "fetch_flats": true?}
Header: X-Admin-Token: <SCRAPE_ADMIN_TOKEN env var>
Disabled if SCRAPE_ADMIN_TOKEN is empty (default).
Returns the Celery task id; poll /api/v1/admin/scrape/runs/{run_id} to track DB-side progress.
"""
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
router = APIRouter()
class TriggerKnRequest(BaseModel):
region_code: int = Field(..., ge=1, le=99)
developers: list[str] | None = Field(default=None, max_length=20)
fetch_flats: bool = True
def _check_token(x_admin_token: str | None) -> None:
if not settings.scrape_admin_token:
raise HTTPException(
status_code=503,
detail="admin scrape disabled — set SCRAPE_ADMIN_TOKEN",
)
if x_admin_token != settings.scrape_admin_token:
raise HTTPException(status_code=401, detail="invalid admin token")
@router.post("/kn")
def trigger_kn_sweep(
payload: TriggerKnRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> dict[str, Any]:
_check_token(x_admin_token)
# Defer Celery import so the API works without a broker in dev.
from app.workers.tasks.scrape_kn import scrape_kn_region
result = scrape_kn_region.apply_async(
args=[payload.region_code, payload.developers],
kwargs={"skip_jitter": True},
)
return {
"task_id": result.id,
"region_code": payload.region_code,
"developers": payload.developers,
"queued_at": result.date_done.isoformat() if result.date_done else None,
}
@router.get("/runs")
def list_runs(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
limit: int = 20,
) -> list[dict[str, Any]]:
_check_token(x_admin_token)
rows = (
db.execute(
text(
"""
SELECT run_id, started_at, finished_at, region_codes, developer_ids,
objects_count, flats_count, requests_count, status, error, snapshot_date
FROM kn_scrape_runs
ORDER BY started_at DESC
LIMIT :lim
"""
),
{"lim": min(limit, 100)},
)
.mappings()
.all()
)
return [
{
"run_id": r["run_id"],
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
"finished_at": r["finished_at"].isoformat() if r["finished_at"] else None,
"region_codes": list(r["region_codes"]) if r["region_codes"] else [],
"developer_ids": list(r["developer_ids"]) if r["developer_ids"] else [],
"objects_count": r["objects_count"],
"flats_count": r["flats_count"],
"requests_count": r["requests_count"],
"status": r["status"],
"error": r["error"],
"snapshot_date": r["snapshot_date"].isoformat() if r["snapshot_date"] else None,
}
for r in rows
]

View file

@ -14,5 +14,21 @@ class Settings(BaseSettings):
rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1" rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1"
overpass_url: str = "https://overpass-api.de/api/interpreter" overpass_url: str = "https://overpass-api.de/api/interpreter"
# Scraper schedule (наш.дом.рф kn-API).
# Crontab format: "minute hour day_of_month month day_of_week" (Celery crontab).
# Default: каждый понедельник в окне 04:0005:00 по МСК.
scrape_kn_cron: str = "15 4 * * mon"
# Random delay window in seconds added on top of scheduled start so the worker
# does not hit DOM.РФ at exactly the same minute every cycle. 0 = disabled.
scrape_kn_jitter_seconds: int = 1800
# Default region(s) for scheduled sweeps. Comma-separated.
scrape_kn_default_regions: str = "66"
# Path to a pre-captured Playwright storage_state.json (committed in repo,
# used by worker to skip cold-start WAF challenge).
scrape_kn_state_path: str = "data/playwright_state.json"
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
# Empty string = endpoint disabled.
scrape_admin_token: str = ""
settings = Settings() settings = Settings()

View file

@ -5,7 +5,7 @@ import sentry_sdk
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import analytics, concepts, parcels from app.api.v1 import admin_scrape, analytics, concepts, parcels
from app.core.config import settings from app.core.config import settings
@ -33,6 +33,7 @@ app.add_middleware(
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"]) app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"]) app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"]) app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"])
@app.get("/health") @app.get("/health")

View file

@ -0,0 +1,537 @@
"""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()

View file

@ -0,0 +1,182 @@
"""Stealth-fetch utilities for наш.дом.рф scraping.
Pure-httpx hits ServicePipe WAF JS-challenge even with Playwright-exported
cookies (different TLS-fingerprint = blocked). The proven pattern from
data/sql/30_scrape_domrf.py is to issue `fetch()` calls *inside* the live
Chromium page same JA3 fingerprint as a real browser, cookies/CSP/CORS
all handled natively.
BrowserSession keeps the browser+page open for the lifetime of a sweep and
exposes get_json() that calls page.evaluate(fetch_in_browser).
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
from typing import Any
from urllib.parse import urlencode
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
logger = logging.getLogger(__name__)
BASE_URL = "https://xn--80az8a.xn--d1aqf.xn--p1ai"
# Real Chrome 147 fingerprint (Win10 x64). Keep in sync with data/sql/30_scrape_domrf.py.
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
)
# Маппинг region_code → URL-сегмент города для реалистичного Referer.
# Не обязан быть исчерпывающим — fallback на /новостройки/строящиеся/.
REGION_LANDING_PATH = {
66: "/новостройки/строящиеся/екатеринбург/",
77: "/новостройки/строящиеся/москва/",
78: "/новостройки/строящиеся/санкт-петербург/",
50: "/новостройки/строящиеся/московская-область/",
47: "/новостройки/строящиеся/ленинградская-область/",
}
def make_referer(region_code: int) -> str:
path = REGION_LANDING_PATH.get(region_code, "/новостройки/строящиеся/")
return BASE_URL + path
async def jitter_sleep(min_ms: int = 600, max_ms: int = 1500) -> None:
delay = random.uniform(min_ms / 1000, max_ms / 1000)
await asyncio.sleep(delay)
# JS executed inside the live page. Returns {ok, status, body, contentType}.
# Auth header is hardcoded `Basic MTpxd2U=` (decodes to `1:qwe`, the public
# debug auth shipped in the site's frontend bundle for the kn API).
_FETCH_JS = """
async ({url, auth}) => {
const headers = {'Accept': 'application/json, text/plain, */*'};
if (auth) headers['Authorization'] = auth;
try {
const r = await fetch(url, {credentials: 'include', headers});
const ctype = r.headers.get('content-type') || '';
const body = await r.text();
return {ok: r.ok, status: r.status, body, contentType: ctype};
} catch (e) {
return {ok: false, status: 0, body: String(e), contentType: ''};
}
}
"""
class WafBlockedError(RuntimeError):
"""Server returned non-JSON (likely ServicePipe JS-challenge)."""
class BrowserSession:
"""Keeps a live Playwright Chromium page; issues kn-API fetches inside it.
Use as async context manager. Concurrency is bounded by an asyncio.Semaphore
of size 3 (the same page can serve multiple concurrent fetches via JS-await).
"""
def __init__(
self,
region_code: int = 66,
headed: bool = False,
auth: str | None = "Basic MTpxd2U=",
load_state: str | None = None,
save_state: str | None = None,
) -> None:
self.region_code = region_code
self.headed = headed
self.auth = auth
# Path to a Playwright storage_state JSON (cookies + localStorage).
# If load_state is set, the context is created with these cookies preloaded —
# ServicePipe sees a returning user, no fresh JS-challenge.
# If save_state is set, the post-bootstrap state is dumped to that path
# so it can be committed and reused on a server.
self.load_state = load_state
self.save_state = save_state
self._pw: Any = None
self._browser: Browser | None = None
self._context: BrowserContext | None = None
self._page: Page | None = None
self._sem = asyncio.Semaphore(3)
self._request_count = 0
async def __aenter__(self) -> BrowserSession:
await self._bootstrap()
return self
async def __aexit__(self, *exc: Any) -> None:
if self._browser is not None:
await self._browser.close()
if self._pw is not None:
await self._pw.stop()
async def _bootstrap(self) -> None:
landing_url = make_referer(self.region_code)
logger.info("bootstrap: opening %s", landing_url)
self._pw = await async_playwright().start()
self._browser = await self._pw.chromium.launch(headless=not self.headed)
ctx_kwargs: dict[str, Any] = {
"user_agent": USER_AGENT,
"locale": "ru-RU",
"viewport": {"width": 1920, "height": 1080},
}
if self.load_state:
ctx_kwargs["storage_state"] = self.load_state
logger.info("bootstrap: loading saved storage_state from %s", self.load_state)
self._context = await self._browser.new_context(**ctx_kwargs)
self._page = await self._context.new_page()
await self._page.goto(landing_url, wait_until="domcontentloaded", timeout=30_000)
try:
await self._page.wait_for_load_state("networkidle", timeout=10_000)
except Exception:
pass
if self.save_state:
await self._context.storage_state(path=self.save_state)
logger.info("bootstrap: storage_state saved to %s", self.save_state)
logger.info("bootstrap: page ready, WAF challenge passed")
async def get_json(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
"""Fetch JSON via fetch() in browser. Retries with backoff on transient errors."""
if self._page is None:
raise RuntimeError("BrowserSession not bootstrapped")
url = BASE_URL + path + "?" + urlencode(params)
last_err: Exception | None = None
for attempt in range(5):
async with self._sem:
await jitter_sleep()
try:
self._request_count += 1
result = await self._page.evaluate(_FETCH_JS, {"url": url, "auth": self.auth})
except Exception as e:
last_err = e
logger.warning("evaluate err attempt=%d: %r", attempt, e)
await asyncio.sleep(2**attempt)
continue
status = result.get("status", 0)
body = result.get("body", "")
ctype = result.get("contentType", "")
if status in (429,) or status >= 500 or status == 0:
last_err = RuntimeError(f"transient status={status}")
logger.warning("transient status=%d attempt=%d, backing off", status, attempt)
await asyncio.sleep(2**attempt)
continue
if "application/json" not in ctype:
# WAF returned the JS challenge HTML; re-bootstrap could help, but for now fail.
raise WafBlockedError(
f"non-JSON response: status={status} ctype={ctype} body[:120]={body[:120]!r}"
)
if status != 200:
raise RuntimeError(f"http {status}: {body[:200]}")
return json.loads(body)
raise RuntimeError(f"max retries exhausted: {last_err!r}")
@property
def request_count(self) -> int:
return self._request_count

View file

@ -1,11 +1,46 @@
"""Celery app + beat schedule. """Celery app + beat schedule."""
Stage 2a: daily PKK Rosreestr sync.
"""
from celery import Celery from celery import Celery
from celery.schedules import crontab
from app.core.config import settings from app.core.config import settings
celery_app = Celery("gendesign", broker=settings.redis_url, backend=settings.redis_url)
def _parse_cron(spec: str) -> crontab:
"""Parse 'M H DoM Mon DoW' crontab string into Celery crontab. Empty fields default to '*'."""
parts = spec.strip().split()
if len(parts) != 5:
raise ValueError(f"crontab spec must have 5 fields, got: {spec!r}")
minute, hour, dom, month, dow = parts
return crontab(
minute=minute,
hour=hour,
day_of_month=dom,
month_of_year=month,
day_of_week=dow,
)
def _default_regions() -> list[int]:
return [int(x.strip()) for x in settings.scrape_kn_default_regions.split(",") if x.strip()]
celery_app = Celery(
"gendesign",
broker=settings.redis_url,
backend=settings.redis_url,
include=["app.workers.tasks.scrape_kn"],
)
celery_app.conf.timezone = "Europe/Moscow" celery_app.conf.timezone = "Europe/Moscow"
# Расписание задаётся через SCRAPE_KN_CRON env var (см. app.core.config).
# Каждый региональный sweep оборачивается случайной задержкой 0..SCRAPE_KN_JITTER_SECONDS
# внутри самой задачи (см. tasks.scrape_kn) — чтобы не бить ровно в одну минуту.
celery_app.conf.beat_schedule = {
f"kn-region-{rc}": {
"task": "tasks.scrape_kn.scrape_kn_region",
"schedule": _parse_cron(settings.scrape_kn_cron),
"args": [rc, None],
}
for rc in _default_regions()
}

View file

View file

@ -0,0 +1,52 @@
"""Celery task wrapper for naш.дом.рф kn-API sweeps."""
from __future__ import annotations
import asyncio
import logging
import random
import time
from pathlib import Path
from typing import Any
from app.core.config import settings
from app.services.scrapers.domrf_kn import run_region_sweep
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="tasks.scrape_kn.scrape_kn_region", max_retries=2)
def scrape_kn_region(
self: Any,
region_code: int,
developers: list[str] | None = None,
skip_jitter: bool = False,
) -> dict[str, Any]:
"""Run a full kn-API sweep for one region. Returns summary dict.
For scheduled runs (skip_jitter=False), sleeps a random 0..SCRAPE_KN_JITTER_SECONDS
interval before starting so multiple workers / repeated weekly cycles don't hit
DOM.РФ at exactly the same minute. Manual triggers pass skip_jitter=True for
immediate execution.
"""
if not skip_jitter and settings.scrape_kn_jitter_seconds > 0:
delay = random.uniform(0, settings.scrape_kn_jitter_seconds)
logger.info("jitter delay %.0fs before scrape", delay)
time.sleep(delay)
state_path: str | None = settings.scrape_kn_state_path or None
if state_path and not Path(state_path).exists():
logger.warning("storage_state %s not found — bootstrap will run cold", state_path)
state_path = None
logger.info(
"scrape_kn_region start region=%s devs=%s state=%s", region_code, developers, state_path
)
return asyncio.run(
run_region_sweep(
region_code=region_code,
developers=developers,
load_state=state_path,
)
)

View file

@ -18,6 +18,8 @@ dependencies = [
"httpx>=0.27.0", "httpx>=0.27.0",
"redis>=5.0.0", "redis>=5.0.0",
"celery>=5.4.0", "celery>=5.4.0",
"playwright>=1.45.0",
"tenacity>=9.0.0",
"weasyprint>=62.0", "weasyprint>=62.0",
"ezdxf>=1.3.0", "ezdxf>=1.3.0",
"openpyxl>=3.1.0", "openpyxl>=3.1.0",

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,47 @@
-- Расширения схемы для регулярного парсинга наш.дом.рф /сервисы/api/kn/{flats,objects}.
-- См. план C:\Users\user\.claude\plans\crispy-waddling-swing.md.
-- ── Версионирование строк через UNIQUE (id, snapshot_date) ────────────────
-- Текущий PK на flats.id и objects.obj_id мешает сохранять несколько срезов
-- одного и того же ЖК/квартиры. Меняем на UNIQUE по (id, snapshot_date)
-- чтобы UPSERT идемпотентно записывал свежий snapshot.
ALTER TABLE domrf_kn_flats DROP CONSTRAINT IF EXISTS domrf_kn_flats_pkey;
ALTER TABLE domrf_kn_flats
ADD CONSTRAINT uq_kn_flats_id_snap UNIQUE (id, snapshot_date);
ALTER TABLE domrf_kn_objects DROP CONSTRAINT IF EXISTS domrf_kn_objects_pkey;
ALTER TABLE domrf_kn_objects
ADD CONSTRAINT uq_kn_objects_id_snap UNIQUE (obj_id, snapshot_date);
-- ── Backfill region_cd для Антоновых flats где obj_id матчится с Свердл ──
UPDATE domrf_kn_flats f
SET region_cd = 66
FROM domrf_kn_objects o
WHERE f.obj_id = o.obj_id
AND o.region_cd = 66
AND f.region_cd IS NULL;
-- ── Композитные индексы для выборок последнего среза ──────────────────────
CREATE INDEX IF NOT EXISTS idx_kn_flats_region_snap
ON domrf_kn_flats (region_cd, snapshot_date);
CREATE INDEX IF NOT EXISTS idx_kn_objects_region_snap
ON domrf_kn_objects (region_cd, snapshot_date);
-- ── Журнал прогонов скрейпера ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kn_scrape_runs (
run_id BIGSERIAL PRIMARY KEY,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
region_codes INT[] NOT NULL,
developer_ids TEXT[],
objects_count INT,
flats_count INT,
requests_count INT,
status TEXT NOT NULL DEFAULT 'running', -- running | done | failed
error TEXT,
snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE
);
CREATE INDEX IF NOT EXISTS idx_kn_runs_status ON kn_scrape_runs (status, started_at DESC);
COMMENT ON TABLE kn_scrape_runs IS 'Журнал запусков парсера наш.дом.рф kn-API. Один прогон = один (regions, developers) sweep.';

View file

@ -0,0 +1,94 @@
"""CLI for ad-hoc naш.дом.рф kn-API sweeps.
Usage:
PYTHONIOENCODING=utf-8 python data/sql/50_scrape_kn_flats.py --regions 66
python data/sql/50_scrape_kn_flats.py --regions 66 --developers 6208_0 --headed
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import sys
from pathlib import Path
# allow `from app.services.scrapers.domrf_kn import ...` when run from repo root
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
from app.services.scrapers.domrf_kn import run_region_sweep # noqa: E402
def main() -> None:
parser = argparse.ArgumentParser(description="naш.дом.рф kn-API scraper (CLI)")
parser.add_argument(
"--regions",
type=int,
nargs="+",
required=True,
help="Region codes (1..89). e.g. --regions 66 77",
)
parser.add_argument(
"--developers",
type=str,
nargs="*",
help="Optional developer IDs in '<id>_0' format. e.g. --developers 6208_0",
)
parser.add_argument("--headed", action="store_true", help="Show Chromium window during bootstrap")
parser.add_argument(
"--save-state",
type=str,
default=None,
help="Path to dump Playwright storage_state.json after bootstrap (commit to git for server reuse)",
)
parser.add_argument(
"--load-state",
type=str,
default=None,
help="Path to load Playwright storage_state.json (server uses fingerprint captured locally)",
)
parser.add_argument(
"--bootstrap-only",
action="store_true",
help="Just bootstrap a session and exit — useful for capturing storage_state without scraping",
)
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if not os.environ.get("DATABASE_URL"):
print("WARNING: DATABASE_URL not set; using default from app.core.config")
async def _run() -> None:
if args.bootstrap_only:
from app.services.scrapers.stealth import BrowserSession
async with BrowserSession(
region_code=args.regions[0],
headed=args.headed,
load_state=args.load_state,
save_state=args.save_state,
):
print(f"bootstrap-only: state saved to {args.save_state}")
return
for region in args.regions:
summary = await run_region_sweep(
region_code=region,
developers=args.developers or None,
headed=args.headed,
load_state=args.load_state,
save_state=args.save_state if region == args.regions[0] else None,
)
print(f"region={region} -> {summary}")
asyncio.run(_run())
if __name__ == "__main__":
main()

View file

@ -54,6 +54,28 @@ services:
depends_on: depends_on:
- backend - backend
worker:
build: ./backend
env_file: ./backend/.env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
volumes:
- ./backend/app:/app/app
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
beat:
build: ./backend
env_file: ./backend/.env
depends_on:
redis:
condition: service_started
volumes:
- ./backend/app:/app/app
command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info"]
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:

View file

@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
interface RunRow {
run_id: number;
started_at: string | null;
finished_at: string | null;
region_codes: number[];
developer_ids: string[];
objects_count: number | null;
flats_count: number | null;
requests_count: number | null;
status: string;
error: string | null;
snapshot_date: string | null;
}
interface TriggerResp {
task_id: string;
region_code: number;
developers: string[] | null;
}
export default function ScrapeAdminPage() {
const [token, setToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [region, setRegion] = useState(66);
const [devs, setDevs] = useState("");
const [fetchFlats, setFetchFlats] = useState(true);
const queryClient = useQueryClient();
const runs = useQuery({
queryKey: ["scrape-runs", token],
queryFn: () =>
apiFetch<RunRow[]>("/api/v1/admin/scrape/runs?limit=20", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
refetchInterval: 5000,
});
const trigger = useMutation({
mutationFn: () =>
apiFetch<TriggerResp>("/api/v1/admin/scrape/kn", {
method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({
region_code: region,
developers: devs
? devs
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null,
fetch_flats: fetchFlats,
}),
}),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["scrape-runs"] }),
});
const saveToken = (v: string) => {
setToken(v);
localStorage.setItem("admin_token", v);
};
return (
<main style={{ padding: 24, maxWidth: 1100, margin: "0 auto" }}>
<h1 style={{ marginTop: 0 }}>Скрапер наш.дом.рф ручной запуск</h1>
<p style={{ color: "#5b6066", fontSize: 13 }}>
Запуск sweep-а вне расписания (расписание: см. SCRAPE_KN_CRON env, по
умолчанию пн 04:15 МСК с jitter 030мин).
</p>
<section style={cardStyle}>
<label style={{ display: "block", marginBottom: 12 }}>
<span style={labelStyle}>Admin Token</span>
<input
type="password"
value={token}
onChange={(e) => saveToken(e.target.value)}
placeholder="SCRAPE_ADMIN_TOKEN"
style={inputStyle}
/>
</label>
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
<label>
<span style={labelStyle}>Регион</span>
<input
type="number"
value={region}
min={1}
max={99}
onChange={(e) => setRegion(Number(e.target.value))}
style={{ ...inputStyle, width: 100 }}
/>
</label>
<label style={{ flex: 1 }}>
<span style={labelStyle}>Девелоперы (csv, опц.)</span>
<input
value={devs}
onChange={(e) => setDevs(e.target.value)}
placeholder="6208_0,5791_0"
style={inputStyle}
/>
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 22,
}}
>
<input
type="checkbox"
checked={fetchFlats}
onChange={(e) => setFetchFlats(e.target.checked)}
/>
<span>Также собрать квартиры</span>
</label>
</div>
<div style={{ marginTop: 16 }}>
<button
onClick={() => trigger.mutate()}
disabled={!token || trigger.isPending}
style={primaryBtn}
>
{trigger.isPending ? "Запускаю…" : "Запустить сейчас"}
</button>
{trigger.isError ? (
<span style={{ color: "#b3261e", marginLeft: 12 }}>
Ошибка: {String(trigger.error)}
</span>
) : null}
{trigger.isSuccess ? (
<span style={{ color: "#0a7a3a", marginLeft: 12 }}>
Поставлено в очередь: {trigger.data.task_id}
</span>
) : null}
</div>
</section>
<section style={{ ...cardStyle, marginTop: 16 }}>
<h2 style={{ marginTop: 0, fontSize: 16 }}>Последние прогоны</h2>
{!token ? (
<p style={{ color: "#5b6066" }}>
Введите admin token чтобы увидеть журнал.
</p>
) : runs.isLoading ? (
<p>Загрузка</p>
) : runs.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(runs.error)}</p>
) : (
<table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"#",
"Старт",
"Регион",
"Dev",
"Status",
"Объектов",
"Квартир",
"Req",
"Ошибка",
].map((h) => (
<th key={h} style={th}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{(runs.data ?? []).map((r) => (
<tr
key={r.run_id}
style={{ borderBottom: "1px solid #eef0f3" }}
>
<td style={td}>{r.run_id}</td>
<td style={td}>
{r.started_at?.slice(0, 19).replace("T", " ")}
</td>
<td style={td}>{r.region_codes.join(",")}</td>
<td style={td}>{r.developer_ids?.join(",") || "—"}</td>
<td
style={{
...td,
color:
r.status === "done"
? "#0a7a3a"
: r.status === "failed"
? "#b3261e"
: "#9a6700",
fontWeight: 600,
}}
>
{r.status}
</td>
<td style={td}>{r.objects_count ?? "—"}</td>
<td style={td}>{r.flats_count ?? "—"}</td>
<td style={td}>{r.requests_count ?? "—"}</td>
<td
style={{
...td,
color: "#b3261e",
maxWidth: 240,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{r.error ?? ""}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</main>
);
}
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
const labelStyle = {
display: "block",
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase" as const,
letterSpacing: 0.4,
};
const inputStyle = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: "100%",
boxSizing: "border-box" as const,
};
const primaryBtn = {
padding: "10px 18px",
background: "#1d4ed8",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
};
const th = {
padding: "8px 10px",
textAlign: "left" as const,
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
};
const td = { padding: "8px 10px" };

File diff suppressed because one or more lines are too long