fix(tradein): working cian ЖК-url resolver via cat.php SERP (#972)
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
Deploy Trade-In / test (push) Successful in 31s
Deploy Trade-In / build-backend (push) Successful in 1m20s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 46s

The legacy resolve_cian_zhk_url hit /zhk/<id>/ which now 404s, leaving the
318 geo-matched cian houses (house_sources.ext_id set, cian_zhk_url NULL)
unfetchable -> the newbuilding-enrichment backfill matched 0 houses.

Add resolve_cian_zhk_url_via_search(nb_id): fetch the cat.php newbuilding SERP
and extract the canonical zhk-<slug>.cian.ru url, ANCHORED on the
<h1 data-name="Title"> header anchor (NOT naive first-match — the SERP carries
promo/recommendation zhk-* links before the title that would otherwise resolve
the wrong ЖК and silently corrupt enrichment). Validated against the real
2.81MB prod SERP + an adversarial poisoned-recommendation test.

Wire into newbuilding_enrich_backfill: broaden selection to "has ext_id OR
cian_zhk_url", resolve+persist the url under a SAVEPOINT before enriching,
rate-limited + resumable + idempotent. Keep the old resolver (deprecated).

Bounded prod proof (5 houses, direct): 5/5 urls resolved+persisted, 3/5 fully
enriched (+18 price_dynamics, +3 reliability); the 2 misses were direct-mode
anti-bot on the 2nd fetch. Full 318-run gated on the cian mobile proxy
(mproxy.site) being restored. code-reviewer APPROVE (SQL/idempotency) +
resolver hardened against wrong-ЖК. 19 tests green.

Refs #972.
This commit is contained in:
Light1YT 2026-06-08 11:13:06 +05:00
parent 8206a0b067
commit b0fe292a63
6 changed files with 615 additions and 30 deletions

View file

@ -18,6 +18,7 @@ from __future__ import annotations
import json import json
import logging import logging
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@ -28,6 +29,43 @@ from app.services.scrapers.cian_state_parser import extract_all_states, extract_
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Canonical ЖК-slug subdomain URL embedded in any Cian SERP/page that references a
# newbuilding (e.g. https://zhk-parkovyy-kvartal-ekb-i.cian.ru). The cat.php SERP for a
# single newobject[] id puts this slug in its title block — extracting it is the WORKING
# nb_id → ЖК-url resolution (#972; see resolve_cian_zhk_url_via_search).
_ZHK_SLUG_URL_RE = re.compile(r"https://zhk-[a-z0-9-]+\.cian\.ru")
# Title-anchored extraction (#972 hardening). The cat.php newbuilding-SERP renders the
# canonical ЖК-slug of the QUERIED newobject as the <a> href inside its page <h1>:
#
# <h1 data-name="Title" ...>Купить квартиру в
# <a href="https://zhk-<slug>.cian.ru/" ...>ЖК «...»</a></h1>
#
# (verified on the live ~2.8 MB prod SERP for nb 48853 — the header sits inside a
# `NewbuildingHeaderSeo` block; there is NO <link rel=canonical> / og:url on this page
# type, so the H1 anchor is the only reliable canonical carrier). Anchoring on it avoids
# the WRONG-ЖК hazard of a naive first-match: cat.php pages also carry banner / "похожие
# ЖК" / recommendation blocks that can embed OTHER zhk-*.cian.ru urls, and those blocks
# may render BEFORE the title — a plain first-match could then resolve a foreign ЖК and
# silently enrich the house with another building's data.
#
# Matches the FIRST zhk-slug url that occurs inside the page's <h1 data-name="Title"> …
# </h1>. `[^>]*` keeps the attr scan within the opening tag; `.*?` (DOTALL) spans the
# inner markup up to the slug href. Falls back to naive first-match only when this finds
# nothing (markup drift), preserving the previous behaviour as a safety net.
_ZHK_TITLE_ANCHOR_RE = re.compile(
r'<h1[^>]*data-name="Title"[^>]*>.*?(https://zhk-[a-z0-9-]+\.cian\.ru)',
re.DOTALL,
)
# cat.php newbuilding-SERP template: one row per `newobject[0]=<nb_id>`. ekb. host is the
# Свердловская-обл. mirror tradein targets; the slug URL it returns is host-agnostic
# (the canonical zhk-*.cian.ru subdomain), so this resolves ЕКБ newbuildings correctly.
_CATPH_NEWBUILDING_SERP = (
"https://ekb.cian.ru/cat.php?deal_type=sale&engine_version=2"
"&offer_type=flat&object_type[0]=2&newobject[0]={nb_id}"
)
@dataclass @dataclass
class NewbuildingEnrichment: class NewbuildingEnrichment:
@ -593,14 +631,16 @@ async def resolve_cian_zhk_url(
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> str | None: ) -> str | None:
"""Resolve canonical ZHK slug URL from a Cian internal house ID. """Resolve canonical ZHK slug URL from a Cian internal house ID (LEGACY — BROKEN).
Cian redirects https://cian.ru/zhk/<id>/ canonical slug URL .. deprecated::
(e.g. https://zhk-park-ekb-i.cian.ru/). We follow the redirect and The redirect path this relies on ``https://cian.ru/zhk/<id>/`` canonical
return the final URL. slug NO LONGER EXISTS. Cian now serves HTTP **404** for ``/zhk/<id>/``
(verified 8/8 on prod, #972), so this returns None for every real id. Use
Used by backfill scripts (scripts/backfill_cian_zhk_url.py) for houses :func:`resolve_cian_zhk_url_via_search` instead: it fetches the cat.php
that have cian_internal_house_id set but cian_zhk_url IS NULL. newbuilding-SERP and extracts the canonical ``zhk-*.cian.ru`` slug from its
markup (the WORKING path). Kept only for backward-compat / reference; do not
wire new callers to it.
Args: Args:
cian_internal_house_id: Cian's numeric ЖК identifier. cian_internal_house_id: Cian's numeric ЖК identifier.
@ -651,3 +691,112 @@ async def resolve_cian_zhk_url(
finally: finally:
if close_session: if close_session:
await session.close() await session.close()
def _extract_zhk_url_from_serp(html: str) -> str | None:
"""Extract the canonical ЖК-slug URL of the QUERIED newobject from a cat.php SERP.
Pure helper (no I/O) so the extraction can be unit-tested against a saved fixture.
The cat.php SERP for a single ``newobject[0]=<nb_id>`` renders that ЖК's canonical
``https://zhk-<slug>.cian.ru`` URL as the ``<a>`` href inside its page heading
``<h1 data-name="Title">`` (the ``NewbuildingHeaderSeo`` header). We anchor on that
heading rather than taking the first ``zhk-*.cian.ru`` match in the document, because
cat.php pages also carry banner / "похожие ЖК" / recommendation blocks that can embed
OTHER ЖК slug URLs and some of those blocks render BEFORE the title. A naive
first-match could pick a foreign ЖК and enrich the house with the wrong building's
price_dynamics / reliability (and the backfill's idempotency would then lock the
corruption in). Anchoring on the title guarantees we resolve the queried ЖК.
Falls back to a naive first ``zhk-*.cian.ru`` match ONLY when the title anchor finds
nothing (markup drift / unexpected page shape), preserving the previous behaviour as
a last-resort safety net.
Returns the slug URL string, or None when the SERP carries no such anchor at all
(empty / no-results page, or markup drift).
"""
anchored = _ZHK_TITLE_ANCHOR_RE.search(html)
if anchored:
return anchored.group(1)
# Fallback: no title-anchored slug found — degrade to naive first-match so a markup
# drift that moves/renames the header still resolves *something* rather than failing.
match = _ZHK_SLUG_URL_RE.search(html)
return match.group(0) if match else None
async def resolve_cian_zhk_url_via_search(
nb_id: int,
*,
session: AsyncSession | None = None,
) -> str | None:
"""Resolve the canonical ЖК-slug URL for a Cian newbuilding id (the WORKING path).
Fetches the cat.php newbuilding-SERP for a single ``newobject[0]=<nb_id>`` and
extracts the canonical ``https://zhk-<slug>.cian.ru`` URL from its markup. This
replaces the legacy :func:`resolve_cian_zhk_url`, whose ``/zhk/<id>/`` redirect path
now 404s (verified on prod, #972). The returned slug URL is exactly what
:func:`fetch_newbuilding` parses, so the enrichment chain is
``nb_id cat.php SERP zhk-slug-url fetch_newbuilding enrich``.
Verified live (HTTP 200, slug extracted):
nb 48853 https://zhk-parkovyy-kvartal-ekb-i.cian.ru (ЖК «Парковый квартал»)
nb 102791 https://zhk-izumrudnyy-bor-ekb-i.cian.ru
nb 24991 https://zhk-baltym-park-ekb-i.cian.ru
Args:
nb_id: Cian newbuilding id (``house_sources.ext_id`` for cian houses).
session: optional shared curl_cffi AsyncSession (caller owns lifecycle). When
None, an own session is created using the same mobile-proxy wiring as the
rest of the Cian scrapers (``settings.cian_proxy_url`` when set, else direct).
Returns:
The canonical ЖК-slug URL string, or None on non-200 / empty SERP / no match /
request failure (each logs a warning).
Note:
Makes ONE real HTTP request and does NOT sleep the CALLER enforces the
anti-bot delay (matching scraper_settings 'cian'). At scale this needs the
mobile proxy; low-volume direct fetches work for the bounded proof.
"""
close_session = False
if session is None:
# Mobile proxy wiring (#806): Cian блокирует datacenter-IP. proxy=None → прямое
# подключение (dev / proxy-down fallback — single fetches survive direct).
_proxy_url = settings.cian_proxy_url
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
session = AsyncSession(
impersonate="chrome120",
timeout=30.0,
proxies=_proxies,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
},
)
close_session = True
serp_url = _CATPH_NEWBUILDING_SERP.format(nb_id=nb_id)
try:
resp = await session.get(serp_url, allow_redirects=True)
if resp.status_code != 200:
logger.warning(
"resolve_cian_zhk_url_via_search nb_id=%s: cat.php → HTTP %d",
nb_id,
resp.status_code,
)
return None
zhk_url = _extract_zhk_url_from_serp(resp.text)
if zhk_url is None:
logger.warning(
"resolve_cian_zhk_url_via_search nb_id=%s: no zhk-slug URL in SERP "
"(empty results / markup drift?)",
nb_id,
)
return None
logger.info("resolve_cian_zhk_url_via_search nb_id=%s%s", nb_id, zhk_url)
return zhk_url
except Exception as exc:
logger.warning("resolve_cian_zhk_url_via_search nb_id=%s failed: %s", nb_id, exc)
return None
finally:
if close_session:
await session.close()

View file

@ -9,14 +9,26 @@ newbuilding "Phase 4" enrichment never backfilled the existing house cache:
- house_reliability_checks (наш.дом.рф checkStatus + details[]) - house_reliability_checks (наш.дом.рф checkStatus + details[])
- house_reviews (ЖК resident reviews) - house_reviews (ЖК resident reviews)
Only the ~283 houses linked to ext_source='cian_newbuilding' (via house_sources) The houses linked to ext_source='cian_newbuilding' (via house_sources) are the target
carry a fetchable Cian ЖК page (cian_zhk_url). This task selects those houses and set. Each carries `house_sources.ext_id` (= cian newbuilding id `nb_id`); most have NULL
runs the EXISTING enrichment over each one: `cian_zhk_url`. This task resolves the url from the id when missing, then runs the
EXISTING enrichment over each one:
resolve_cian_zhk_url_via_search(nb_id)-> cian_zhk_url (#972 — see note below)
fetch_newbuilding(zhk_url) -> NewbuildingEnrichment fetch_newbuilding(zhk_url) -> NewbuildingEnrichment
save_newbuilding_enrichment(db, ...) -> houses_price_dynamics + house_reliability_checks save_newbuilding_enrichment(db, ...) -> houses_price_dynamics + house_reliability_checks
_save_cian_reviews(db, ...) -> house_reviews (added here see note below) _save_cian_reviews(db, ...) -> house_reviews (added here see note below)
ЖК-url resolution (#972)
------------------------
The 318 geo-matched cian houses have `ext_id` but NULL `cian_zhk_url`. The legacy
resolver hit https://cian.ru/zhk/<id>/ which now 404s. The working path fetches the
cat.php newbuilding-SERP (newobject[0]=<nb_id>) and extracts the canonical
zhk-<slug>.cian.ru URL from its markup that slug is exactly what fetch_newbuilding
parses. Resolved urls are persisted to houses.cian_zhk_url (CAST + SAVEPOINT) so a
resolved-but-not-enriched house resumes without re-fetching the SERP. The full 318-house
run is gated on the mobile proxy being up; low-volume direct fetches work for proofs.
Why a dedicated task (not just the existing cian_history_backfill houses block): Why a dedicated task (not just the existing cian_history_backfill houses block):
- That block keys off `houses.cian_zhk_url IS NOT NULL` only; this one anchors on - That block keys off `houses.cian_zhk_url IS NOT NULL` only; this one anchors on
the canonical `ext_source='cian_newbuilding'` link (the 283 set the issue names) the canonical `ext_source='cian_newbuilding'` link (the 283 set the issue names)
@ -82,13 +94,15 @@ class NewbuildingEnrichBackfillResult:
# Population sizing (independent of `limit`). # Population sizing (independent of `limit`).
cian_houses_total: int = 0 # houses linked to ext_source='cian_newbuilding' cian_houses_total: int = 0 # houses linked to ext_source='cian_newbuilding'
cian_houses_fetchable: int = 0 # of those, with cian_zhk_url NOT NULL cian_houses_fetchable: int = 0 # of those, with cian_zhk_url OR resolvable ext_id
cian_houses_pending: int = 0 # fetchable AND not yet fully enriched (force=False) cian_houses_pending: int = 0 # fetchable AND not yet fully enriched (force=False)
# Processing (bounded by `limit`). # Processing (bounded by `limit`).
processed: int = 0 processed: int = 0
skipped_already_enriched: int = 0 skipped_already_enriched: int = 0
succeeded: int = 0 succeeded: int = 0
resolved_zhk_url: int = 0 # ext_id → cian_zhk_url resolved + persisted (#972)
failed_resolve: int = 0 # had only ext_id, resolver returned None
failed_fetch: int = 0 # fetch returned None / raised failed_fetch: int = 0 # fetch returned None / raised
failed_save: int = 0 # save raised after a good fetch failed_save: int = 0 # save raised after a good fetch
@ -103,8 +117,17 @@ class NewbuildingEnrichBackfillResult:
return {f.name: int(getattr(self, f.name)) for f in fields(self)} return {f.name: int(getattr(self, f.name)) for f in fields(self)}
# SQL: anchor on the canonical cian_newbuilding link (the 283), require a fetchable # SQL: anchor on the canonical cian_newbuilding link, require a house we can FETCH, and
# zhk_url, and (unless force) skip houses already enriched. # (unless force) skip houses already enriched.
#
# "Fetchable" (#972 fix): a house is fetchable when it has a cian_zhk_url already OR a
# cian newbuilding id we can resolve into one. The 318 geo-matched cian houses carry
# `house_sources.ext_id` (= cian newbuilding id `nb_id`) but NULL `cian_zhk_url` — the
# old `cian_zhk_url IS NOT NULL` predicate matched 0 of them, so the backfill could never
# run. We now also accept `hs.ext_id IS NOT NULL` and resolve the url per-house at
# runtime (resolve_cian_zhk_url_via_search → cat.php SERP → zhk-slug). hs.ext_id is TEXT;
# the resolver casts to int. We carry both columns through so the loop can decide whether
# to resolve first.
# #
# "Enriched" = has price_dynamics AND reliability rows. We deliberately do NOT require # "Enriched" = has price_dynamics AND reliability rows. We deliberately do NOT require
# house_reviews here: the Cian newbuilding initialState rarely carries the reviews list # house_reviews here: the Cian newbuilding initialState rarely carries the reviews list
@ -112,13 +135,13 @@ class NewbuildingEnrichBackfillResult:
# not call), so requiring reviews would leave virtually every house perpetually "pending" # not call), so requiring reviews would leave virtually every house perpetually "pending"
# and make every re-run re-fetch + append a duplicate reliability row. Reviews are written # and make every re-run re-fetch + append a duplicate reliability row. Reviews are written
# opportunistically when present; their absence must not block the skip. # opportunistically when present; their absence must not block the skip.
# DISTINCT — a house can have >1 house_sources row in theory. # DISTINCT ON (h.id) — a house can have >1 house_sources row; pick any non-null ext_id.
_SELECT_PENDING_HOUSES = """ _SELECT_PENDING_HOUSES = """
SELECT DISTINCT h.id AS house_id, h.cian_zhk_url SELECT DISTINCT ON (h.id) h.id AS house_id, h.cian_zhk_url, hs.ext_id
FROM houses h FROM houses h
JOIN house_sources hs ON hs.house_id = h.id JOIN house_sources hs ON hs.house_id = h.id
WHERE hs.ext_source = 'cian_newbuilding' WHERE hs.ext_source = 'cian_newbuilding'
AND h.cian_zhk_url IS NOT NULL AND (h.cian_zhk_url IS NOT NULL OR hs.ext_id IS NOT NULL)
AND ( AND (
CAST(:force AS boolean) = TRUE CAST(:force AS boolean) = TRUE
OR NOT ( OR NOT (
@ -126,7 +149,7 @@ _SELECT_PENDING_HOUSES = """
AND EXISTS (SELECT 1 FROM house_reliability_checks rc WHERE rc.house_id = h.id) AND EXISTS (SELECT 1 FROM house_reliability_checks rc WHERE rc.house_id = h.id)
) )
) )
ORDER BY h.id ORDER BY h.id, hs.ext_id NULLS LAST
LIMIT :lim LIMIT :lim
""" """
@ -142,7 +165,7 @@ _COUNT_FETCHABLE = """
FROM houses h FROM houses h
JOIN house_sources hs ON hs.house_id = h.id JOIN house_sources hs ON hs.house_id = h.id
WHERE hs.ext_source = 'cian_newbuilding' WHERE hs.ext_source = 'cian_newbuilding'
AND h.cian_zhk_url IS NOT NULL AND (h.cian_zhk_url IS NOT NULL OR hs.ext_id IS NOT NULL)
""" """
_COUNT_PENDING = """ _COUNT_PENDING = """
@ -150,13 +173,23 @@ _COUNT_PENDING = """
FROM houses h FROM houses h
JOIN house_sources hs ON hs.house_id = h.id JOIN house_sources hs ON hs.house_id = h.id
WHERE hs.ext_source = 'cian_newbuilding' WHERE hs.ext_source = 'cian_newbuilding'
AND h.cian_zhk_url IS NOT NULL AND (h.cian_zhk_url IS NOT NULL OR hs.ext_id IS NOT NULL)
AND NOT ( AND NOT (
EXISTS (SELECT 1 FROM houses_price_dynamics pd WHERE pd.house_id = h.id) EXISTS (SELECT 1 FROM houses_price_dynamics pd WHERE pd.house_id = h.id)
AND EXISTS (SELECT 1 FROM house_reliability_checks rc WHERE rc.house_id = h.id) AND EXISTS (SELECT 1 FROM house_reliability_checks rc WHERE rc.house_id = h.id)
) )
""" """
# UPDATE houses with the resolved ЖК url + the cian newbuilding id, idempotently. Run
# under a SAVEPOINT before the enrich fetch so a resolved-but-not-enriched house resumes
# on the next pass (the url is now persisted). psycopg v3: CAST(:x AS type), never `::`.
_UPDATE_RESOLVED_ZHK_URL = """
UPDATE houses SET
cian_zhk_url = :url,
cian_internal_house_id = COALESCE(CAST(:nb_id AS bigint), cian_internal_house_id)
WHERE id = CAST(:hid AS bigint)
"""
def count_cian_newbuilding_houses(db: Session) -> dict[str, int]: def count_cian_newbuilding_houses(db: Session) -> dict[str, int]:
"""Sizing helper: how many cian_newbuilding houses exist / are fetchable / pending. """Sizing helper: how many cian_newbuilding houses exist / are fetchable / pending.
@ -296,15 +329,21 @@ async def backfill_newbuilding_enrichment(
) -> NewbuildingEnrichBackfillResult: ) -> NewbuildingEnrichBackfillResult:
"""Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses. """Backfill the 3 newbuilding-enrichment tables over cian_newbuilding houses.
Per house: resolve cian_zhk_url from house_sources.ext_id when missing (#972 —
cat.php SERP zhk-slug, persisted under a SAVEPOINT), then fetch + save. Resumable
(a resolved-but-not-enriched house picks up on the next pass), idempotent, and
rate-limited between BOTH the resolve fetch and the enrich fetch.
Args: Args:
db: tradein-mvp SQLAlchemy session (caller-owned; NOT gendesign's DB). db: tradein-mvp SQLAlchemy session (caller-owned; NOT gendesign's DB).
limit: max houses to process this call. DEFAULT 5 bounded test/proof mode; limit: max houses to process this call. DEFAULT 5 bounded test/proof mode;
raise to ~283 for the full run once the proof looks good. raise to ~318 for the full run once the proof looks good AND the proxy is up.
force: re-process houses already enriched (UPSERT/dedup-guarded). Default False force: re-process houses already enriched (UPSERT/dedup-guarded). Default False
skips houses that already have price_dynamics AND reliability rows (reviews skips houses that already have price_dynamics AND reliability rows (reviews
are optional see _SELECT_PENDING_HOUSES note). are optional see _SELECT_PENDING_HOUSES note).
request_delay_sec: seconds between fetches. None -> get_scraper_delay('cian') request_delay_sec: seconds between fetches. None -> get_scraper_delay('cian')
(default 5s). Applied with ±20% jitter; anti-bot politeness. (default 5s). Applied with ±20% jitter; anti-bot politeness. A house needing
a resolve incurs TWO delays (resolve fetch + enrich fetch).
dry_run: count the population + log the pending list, fetch nothing, write nothing. dry_run: count the population + log the pending list, fetch nothing, write nothing.
Returns: Returns:
@ -316,6 +355,7 @@ async def backfill_newbuilding_enrichment(
# unit tests able to patch fetch_newbuilding cheaply. # unit tests able to patch fetch_newbuilding cheaply.
from app.services.scrapers.cian_newbuilding import ( from app.services.scrapers.cian_newbuilding import (
fetch_newbuilding, fetch_newbuilding,
resolve_cian_zhk_url_via_search,
save_newbuilding_enrichment, save_newbuilding_enrichment,
) )
@ -359,7 +399,8 @@ async def backfill_newbuilding_enrichment(
for idx, row in enumerate(rows): for idx, row in enumerate(rows):
house_id: int = row["house_id"] house_id: int = row["house_id"]
zhk_url: str = row["cian_zhk_url"] zhk_url: str | None = row["cian_zhk_url"]
ext_id: str | None = row["ext_id"]
result.processed += 1 result.processed += 1
# Idempotency fast-path: with force=False the SELECT already excludes enriched # Idempotency fast-path: with force=False the SELECT already excludes enriched
@ -378,6 +419,67 @@ async def backfill_newbuilding_enrichment(
) )
continue continue
# ── Resolve ЖК url from ext_id when missing (#972) ──────────────────
# The 318 geo-matched cian houses carry house_sources.ext_id (= nb_id) but NULL
# cian_zhk_url; resolve it via the cat.php SERP, then PERSIST it (CAST + SAVEPOINT)
# so a resolved-but-not-enriched house resumes on the next pass without re-fetching
# the SERP. The legacy /zhk/<id>/ resolver 404s — search-based is the working path.
if not zhk_url:
nb_id = _parse_nb_id(ext_id)
if nb_id is None:
logger.warning(
"house_id=%s has neither cian_zhk_url nor a numeric ext_id (%r) — skip",
house_id,
ext_id,
)
result.failed_resolve += 1
continue
try:
resolved = await resolve_cian_zhk_url_via_search(nb_id)
except Exception as exc: # defensive — resolver already catches internally
logger.warning(
"zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc
)
resolved = None
# Rate-limit the resolve fetch itself (anti-bot): always sleep after a SERP
# hit, whether or not it yielded a url, before the next network call.
await _sleep_with_jitter(delay, idx, len(rows), force=True)
if not resolved:
logger.warning(
"zhk-url unresolved house_id=%s nb_id=%s (404 / empty SERP / block) — skip",
house_id,
nb_id,
)
result.failed_resolve += 1
continue
# Persist under a SAVEPOINT so one bad UPDATE can't poison the batch and the
# resolved url survives for the next resume even if the enrich fetch later fails.
sp = db.begin_nested()
try:
db.execute(
text(_UPDATE_RESOLVED_ZHK_URL),
{"url": resolved, "nb_id": nb_id, "hid": house_id},
)
sp.commit()
db.commit()
except Exception as exc:
sp.rollback()
logger.warning(
"persist resolved zhk-url failed house_id=%s nb_id=%s: %s",
house_id,
nb_id,
exc,
)
result.failed_resolve += 1
continue
zhk_url = resolved
result.resolved_zhk_url += 1
# ── Fetch (network; anti-bot surface) ────────────────────────────── # ── Fetch (network; anti-bot surface) ──────────────────────────────
enrichment = None enrichment = None
try: try:
@ -474,11 +576,14 @@ async def backfill_newbuilding_enrichment(
result.duration_sec = time.time() - t0 result.duration_sec = time.time() - t0
logger.info( logger.info(
"newbuilding-enrich backfill done: processed=%d ok=%d skip=%d " "newbuilding-enrich backfill done: processed=%d ok=%d skip=%d resolved=%d "
"fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d | %.1fs", "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d "
"| %.1fs",
result.processed, result.processed,
result.succeeded, result.succeeded,
result.skipped_already_enriched, result.skipped_already_enriched,
result.resolved_zhk_url,
result.failed_resolve,
result.failed_fetch, result.failed_fetch,
result.failed_save, result.failed_save,
result.price_dynamics_rows, result.price_dynamics_rows,
@ -511,8 +616,29 @@ def _dedup_reliability(db: Session, house_id: int) -> None:
) )
async def _sleep_with_jitter(delay: float, idx: int, total: int) -> None: def _parse_nb_id(ext_id: str | None) -> int | None:
"""Polite anti-bot sleep with ±20% jitter; skipped after the last item.""" """Parse house_sources.ext_id (TEXT) into a Cian newbuilding id (int), or None.
if delay <= 0 or idx >= total - 1:
ext_id is stored as text; cian_newbuilding links hold the numeric nb_id. Anything
non-numeric (or NULL) is unusable for resolution None.
"""
if ext_id is None:
return None
try:
return int(str(ext_id).strip())
except (TypeError, ValueError):
return None
async def _sleep_with_jitter(delay: float, idx: int, total: int, *, force: bool = False) -> None:
"""Polite anti-bot sleep with ±20% jitter.
Skipped after the LAST item (no further fetch follows) UNLESS force=True, used
for the intra-iteration resolveenrich gap where a second network call still follows
within the same house even on the last row.
"""
if delay <= 0:
return
if not force and idx >= total - 1:
return return
await asyncio.sleep(delay * random.uniform(0.8, 1.2)) await asyncio.sleep(delay * random.uniform(0.8, 1.2))

View file

@ -0,0 +1,15 @@
<!--
Trimmed Cian cat.php newbuilding-SERP fixture for resolve_cian_zhk_url_via_search (#972).
Captured live from prod 2026-06-08 (tradein-backend, curl_cffi impersonate=chrome120):
GET https://ekb.cian.ru/cat.php?deal_type=sale&engine_version=2&offer_type=flat
&object_type[0]=2&newobject[0]=48853
→ HTTP 200, full body ~2.8 MB. This is a trimmed window around the canonical
ЖК-slug anchor (the breadcrumb/title block) — enough to exercise the regex
extraction without committing the megabyte SERP.
The full SERP contained the slug URL ~30×; the FIRST match is the canonical one
(nb 48853 → https://zhk-parkovyy-kvartal-ekb-i.cian.ru — ЖК «Парковый квартал»).
The legacy resolver's path https://cian.ru/zhk/48853/ returns HTTP 404 (verified).
-->
<div data-name="Breadcrumbs"><span class="breadcrumbs-item"><a data-name="breadcrumbs-link" href="https://www.cian.ru/kupit/" title="Продажа"><span>Продажа</span></a></span></div><div data-name="NewbuildingHeaderPanel" class="x31de4314--_9ca1f--left"><div class="x31de4314--_9ca1f--content"><div data-name="NewbuildingHeaderSeo" class="x31de4314--c5a909--content"><h1 data-name="Title" class="x31de4314--_7735e--color_surface-neutral-pressed x31de4314--_2697e--lineHeight_4u">Купить квартиру в <a href="https://zhk-parkovyy-kvartal-ekb-i.cian.ru/" target="_blank" rel="noreferrer" class="x31de4314--_9d71c--link">ЖК «Парковый квартал»</a></h1><span data-name="Description" class="x31de4314--_7735e--color_surface-neutral-pressed">Продажа квартир в новостройке от застройщика. Актуальные цены, планировки и фотографии.</span></div></div></div>

View file

@ -0,0 +1,145 @@
"""Tests for resolve_cian_zhk_url_via_search (#972) — the WORKING nb_id → ЖК-url resolver.
The legacy resolve_cian_zhk_url hit https://cian.ru/zhk/<id>/ which now 404s; the
search-based resolver fetches the cat.php newbuilding-SERP and extracts the canonical
zhk-<slug>.cian.ru URL from its markup. No real network: the curl_cffi session is mocked;
the regex is exercised against a trimmed real-prod SERP fixture.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
# DATABASE_URL required by config before any app import (cian_newbuilding → app.core.config).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint stub — not installed in CI without GTK; pulled in transitively by app imports.
sys.modules.setdefault("weasyprint", MagicMock())
import pytest # noqa: E402
from app.services.scrapers.cian_newbuilding import ( # noqa: E402
_extract_zhk_url_from_serp,
resolve_cian_zhk_url_via_search,
)
_FIXTURE = (
Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html"
)
def _serp_fixture() -> str:
return _FIXTURE.read_text(encoding="utf-8")
def _mock_session(*, status_code: int, text: str) -> MagicMock:
"""A curl_cffi AsyncSession stand-in returning one canned response."""
session = MagicMock()
response = MagicMock()
response.status_code = status_code
response.text = text
session.get = AsyncMock(return_value=response)
session.close = AsyncMock()
return session
# ---- pure regex extractor -------------------------------------------------
def test_extract_zhk_url_from_real_serp_fixture() -> None:
"""The trimmed prod cat.php SERP yields the canonical ЖК-slug URL (nb 48853).
The fixture carries the real <h1 data-name="Title"> header block, so this exercises
the title-anchored extraction path against real prod markup.
"""
url = _extract_zhk_url_from_serp(_serp_fixture())
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
def test_extract_zhk_url_anchors_on_title_not_recommendation() -> None:
"""Adversarial (#972 hardening): a recommendation / "похожие ЖК" block embedding a
FOREIGN zhk-slug appears BEFORE the title block whose anchor is the queried ЖК.
A naive first-match would resolve the recommendation's `zhk-other-ad` and then enrich
the house with the WRONG building's price_dynamics/reliability (which the backfill's
idempotency would lock in). The title-anchored extractor must return the QUERIED ЖК
(`zhk-target`), proving the foreign url before the title is ignored.
"""
html = (
'<div data-name="Breadcrumbs">'
'<a href="https://www.cian.ru/kupit/">Продажа</a></div>'
# Banner / recommendation block rendered BEFORE the title, carrying a foreign slug.
'<div data-name="Recommendations">'
'<a href="https://zhk-other-ad-ekb-i.cian.ru/">похожий ЖК (реклама)</a>'
'and a bare one too https://zhk-another-promo-ekb-i.cian.ru in text</div>'
# The canonical title block — its anchor is the queried ЖК.
'<div data-name="NewbuildingHeaderSeo">'
'<h1 data-name="Title" class="x31de4314--_7735e">Купить квартиру в '
'<a href="https://zhk-target-ekb-i.cian.ru/" target="_blank" '
'rel="noreferrer" class="x31de4314--_9d71c--link">ЖК «Целевой»</a></h1></div>'
# Trailing offer cards repeating the target slug (как на реальном SERP).
'<a data-name="LinkArea" href="https://zhk-target-ekb-i.cian.ru/kvartiry/">x</a>'
)
assert _extract_zhk_url_from_serp(html) == "https://zhk-target-ekb-i.cian.ru"
def test_extract_zhk_url_fallback_first_match_when_no_title() -> None:
"""Markup drift: no <h1 data-name="Title"> header → degrade to naive first-match.
The title anchor is the primary path; when it is absent (header renamed/moved) the
extractor falls back to the first zhk-slug occurrence so resolution still yields
*something* rather than failing outright.
"""
html = (
'noise <a href="https://zhk-first-ekb-i.cian.ru/">A</a> '
'and later https://zhk-second-ekb-i.cian.ru more'
)
assert _extract_zhk_url_from_serp(html) == "https://zhk-first-ekb-i.cian.ru"
def test_extract_zhk_url_no_match_returns_none() -> None:
"""A SERP with no zhk-slug anchor (empty results / drift) → None."""
assert _extract_zhk_url_from_serp("<html><body>Ничего не найдено</body></html>") is None
# A bare cian.ru link that is NOT a zhk-* subdomain must not match.
assert _extract_zhk_url_from_serp('<a href="https://www.cian.ru/kupit/">x</a>') is None
# ---- async resolver (session mocked) --------------------------------------
@pytest.mark.asyncio
async def test_resolve_via_search_happy_path() -> None:
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
session = _mock_session(status_code=200, text=_serp_fixture())
url = await resolve_cian_zhk_url_via_search(48853, session=session)
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
# caller owns a passed-in session — resolver must NOT close it.
session.close.assert_not_awaited()
# the requested URL targets the cat.php newbuilding SERP for this nb_id.
called_url = session.get.call_args.args[0]
assert "cat.php" in called_url
assert "newobject[0]=48853" in called_url
@pytest.mark.asyncio
async def test_resolve_via_search_no_match_returns_none() -> None:
"""HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
session = _mock_session(status_code=200, text="<html>no results</html>")
assert await resolve_cian_zhk_url_via_search(999, session=session) is None
@pytest.mark.asyncio
async def test_resolve_via_search_non_200_returns_none() -> None:
"""A non-200 (block / 404) → None without attempting extraction."""
session = _mock_session(status_code=429, text="too many requests")
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None
@pytest.mark.asyncio
async def test_resolve_via_search_swallows_request_error() -> None:
"""A transport error inside the fetch → None (logged), never propagates."""
session = MagicMock()
session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
session.close = AsyncMock()
assert await resolve_cian_zhk_url_via_search(48853, session=session) is None

View file

@ -56,13 +56,17 @@ class FakeDB:
""" """
def __init__(self, pending_rows): def __init__(self, pending_rows):
self._pending_rows = pending_rows # list[dict house_id, cian_zhk_url] # list[dict house_id, cian_zhk_url, (optional) ext_id]; default ext_id=None so
# existing tests that omit it still model "url already present".
self._pending_rows = [{"ext_id": None, **r} for r in pending_rows]
# price_dynamics keyed by (house_id, month_date, source, room_count, prices_type, period) # price_dynamics keyed by (house_id, month_date, source, room_count, prices_type, period)
self.price_dynamics: set[tuple] = set() self.price_dynamics: set[tuple] = set()
# reviews keyed by (source, ext_review_id) # reviews keyed by (source, ext_review_id)
self.reviews: dict[tuple, dict] = {} self.reviews: dict[tuple, dict] = {}
# reliability: list of (house_id, source) # reliability: list of (house_id, source)
self.reliability: list[tuple] = [] self.reliability: list[tuple] = []
# houses.cian_zhk_url written by the resolve step: {house_id: (url, nb_id)}
self.resolved_urls: dict[int, tuple] = {}
# -- query router ------------------------------------------------------ # -- query router ------------------------------------------------------
def execute(self, statement, params=None): def execute(self, statement, params=None):
@ -79,9 +83,21 @@ class FakeDB:
# _COUNT_TOTAL # _COUNT_TOTAL
return _FakeResult(scalar=len(self._pending_rows)) return _FakeResult(scalar=len(self._pending_rows))
if "SELECT DISTINCT h.id AS house_id" in sql: if "SELECT DISTINCT ON (h.id) h.id AS house_id" in sql:
lim = params.get("lim", len(self._pending_rows)) lim = params.get("lim", len(self._pending_rows))
return _FakeResult(rows=self._pending_rows[:lim]) # When a row was resolved on a prior pass, surface the persisted url (mirrors
# the real houses.cian_zhk_url UPDATE so a re-run sees it set).
out = []
for r in self._pending_rows[:lim]:
resolved = self.resolved_urls.get(r["house_id"])
if resolved is not None and not r.get("cian_zhk_url"):
r = {**r, "cian_zhk_url": resolved[0]}
out.append(r)
return _FakeResult(rows=out)
if "UPDATE houses SET" in sql and "cian_zhk_url" in sql and "cian_internal_house_id" in sql:
self.resolved_urls[params["hid"]] = (params["url"], params["nb_id"])
return _FakeResult()
if "COUNT(*) FROM houses_price_dynamics" in sql: if "COUNT(*) FROM houses_price_dynamics" in sql:
h = params["h"] h = params["h"]
@ -366,3 +382,137 @@ async def test_backfill_fetch_failure_does_not_abort_batch() -> None:
assert result.failed_fetch == 1 assert result.failed_fetch == 1
assert result.succeeded == 1 assert result.succeeded == 1
assert len(db.reviews) == 2 # only house 2 enriched assert len(db.reviews) == 2 # only house 2 enriched
# ---------------------------------------------------------------------------
# #972 ЖК-url resolution wiring: ext_id (NULL cian_zhk_url) → resolve → persist → enrich.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_backfill_resolves_zhk_url_from_ext_id_then_enriches() -> None:
"""A house with ext_id but NULL cian_zhk_url is resolved FIRST (cat.php SERP), the
url is persisted, and only THEN is the enrichment fetched + saved.
"""
db = FakeDB(pending_rows=[{"house_id": 1, "cian_zhk_url": None, "ext_id": "48853"}])
enr = _enrichment_with_everything()
async def _resolve(nb_id, **kwargs):
assert nb_id == 48853 # ext_id parsed to int
return "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
fetched_urls: list[str] = []
async def _fetch(url, **kwargs):
fetched_urls.append(url)
return enr
with (
patch(
"app.services.scrapers.cian_newbuilding.resolve_cian_zhk_url_via_search",
side_effect=_resolve,
),
patch(
"app.services.scrapers.cian_newbuilding.fetch_newbuilding",
side_effect=_fetch,
),
patch(
"app.services.scrapers.cian_newbuilding.save_newbuilding_enrichment",
side_effect=_fake_save_newbuilding_enrichment,
),
):
result = await backfill_newbuilding_enrichment(db, limit=5, request_delay_sec=0)
# url resolved + persisted to houses (CAST/UPDATE), with the nb_id carried through.
assert db.resolved_urls[1] == ("https://zhk-parkovyy-kvartal-ekb-i.cian.ru", 48853)
# enrich fetched the RESOLVED url (not the NULL original).
assert fetched_urls == ["https://zhk-parkovyy-kvartal-ekb-i.cian.ru"]
assert result.resolved_zhk_url == 1
assert result.failed_resolve == 0
assert result.succeeded == 1
# enrichment landed.
assert len(db.price_dynamics) == 1
assert len(db.reliability) == 1
@pytest.mark.asyncio
async def test_backfill_unresolved_ext_id_counts_fail_and_skips_fetch() -> None:
"""Resolver returns None (404 / empty SERP / block) → failed_resolve, no fetch."""
db = FakeDB(pending_rows=[{"house_id": 1, "cian_zhk_url": None, "ext_id": "999"}])
fetch_calls = {"n": 0}
async def _fetch(url, **kwargs):
fetch_calls["n"] += 1
return _enrichment_with_everything()
with (
patch(
"app.services.scrapers.cian_newbuilding.resolve_cian_zhk_url_via_search",
new_callable=AsyncMock,
return_value=None,
),
patch(
"app.services.scrapers.cian_newbuilding.fetch_newbuilding",
side_effect=_fetch,
),
patch(
"app.services.scrapers.cian_newbuilding.save_newbuilding_enrichment",
side_effect=_fake_save_newbuilding_enrichment,
),
):
result = await backfill_newbuilding_enrichment(db, limit=5, request_delay_sec=0)
assert result.failed_resolve == 1
assert result.resolved_zhk_url == 0
assert result.succeeded == 0
assert fetch_calls["n"] == 0 # never fetched without a url
assert 1 not in db.resolved_urls
@pytest.mark.asyncio
async def test_backfill_resolve_then_idempotent_rerun_no_reresolve() -> None:
"""After a resolve+enrich pass the house is enriched; a plain re-run skips it — no
second resolve, no second fetch, no duplicate rows.
"""
db = FakeDB(pending_rows=[{"house_id": 1, "cian_zhk_url": None, "ext_id": "48853"}])
enr = _enrichment_with_everything()
enr.reviews = [] # the common Cian case (initialState carries no reviews)
resolve_calls = {"n": 0}
fetch_calls = {"n": 0}
async def _resolve(nb_id, **kwargs):
resolve_calls["n"] += 1
return "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
async def _fetch(url, **kwargs):
fetch_calls["n"] += 1
return enr
with (
patch(
"app.services.scrapers.cian_newbuilding.resolve_cian_zhk_url_via_search",
side_effect=_resolve,
),
patch(
"app.services.scrapers.cian_newbuilding.fetch_newbuilding",
side_effect=_fetch,
),
patch(
"app.services.scrapers.cian_newbuilding.save_newbuilding_enrichment",
side_effect=_fake_save_newbuilding_enrichment,
),
):
r1 = await backfill_newbuilding_enrichment(db, limit=5, request_delay_sec=0)
# Second pass: the house now has pd+reliability, so the per-house guard skips it.
r2 = await backfill_newbuilding_enrichment(db, limit=5, request_delay_sec=0)
assert r1.resolved_zhk_url == 1
assert r1.succeeded == 1
assert resolve_calls["n"] == 1 # not re-resolved on the second pass
assert fetch_calls["n"] == 1 # not re-fetched on the second pass
assert r2.skipped_already_enriched == 1
assert r2.succeeded == 0
assert len(db.price_dynamics) == 1 # no duplicate
assert len(db.reliability) == 1