ceiling_height на detail-странице лежит в offer.building.ceilingHeight,
а не offer.ceilingHeight (всегда null) → было 0% покрытия. repairType
часто пустой, структурное значение приходит как offer.decoration
('preFine'/...) — читаем оба + добавлен preFine->needs_repair и
case-insensitive lookup в нормализаторе. kitchen/description пути уже
верны (offer.kitchenArea/offer.description) — низкое покрытие = мёртвый
CIAN_PROXY_URL, не маппинг. Регресс-тест на реальной карточке 330982715.
The geoportal serves a Russian national-CA (Минцифры) TLS cert absent from
httpx's default trust store → CERTIFICATE_VERIFY_FAILED, loader fetched 0.
Public read-only data → verify=False (same as the curl -k used in recon).
#1786 merged before the test fix landed → main red on
test_geocode_uses_cadastral_before_yandex. The new geoportal house-match
tier runs first in geocode(); this legacy cadastral test must mock it to
None so it exercises the cadastral forward path (else the MagicMock db row
leaks through the new tier as lat=1.0).
The realtime FK mirror (base.py:607-616) only landed 2026-06-17 (e63b21e).
Listings matched into house_sources before that — notably 9205 cian novostroyki
frozen at 2026-05-31 — never had house_id_fk mirrored back, breaking the
canonical "same-building via house_id_fk" estimator path (93% of active cian
novostroyki had NULL fk citywide).
Add data/sql/130_backfill_listings_house_id_fk.sql: deterministic source-id
backfill via house_sources (ext_source, ext_id → house_id), idempotent
(WHERE house_id_fk IS NULL), no fuzzy/address fallback. Prod (read-only):
9235/9235 target rows resolve deterministically, zero unresolvable. One-time
backlog drain; future rows linked in realtime by the e63b21e mirror.
The Cian Valuation enrichment was the only ungated external call in
estimate_quality() — its internal curl timeout (~25s) blocks the whole
/estimate request on every cache-miss, intermittently pushing latency
past the gateway/patience window (perceived as 'сервис не считает').
Wrap it in _with_budget() (mirrors the existing Yandex-valuation guard):
on timeout it degrades to None — the same graceful path as a network
error — instead of stalling. New setting estimate_cian_valuation_timeout_s
(default 8.0s, env ESTIMATE_CIAN_VALUATION_TIMEOUT_S).
Visible warn-banner trailing sentence no longer names the brand.
Code comments in the file docstring updated to be brand-neutral.
No logic or behaviour changed.
Tier A (same building) dropped cian listings tagged listing_segment='novostroyki'
even in delivered buildings where that tag marks owner resales/переуступки
(sale_type=free). Gated relaxation of the #1186 guard, Tier A ONLY: include
novostroyki rows iff the same house has secondary (vtorichka/NULL) listings AND
secondary_count >= primary_count — secondary-dominant signal of a delivered house.
Prevents a lone vtorichka + clustered developer-priced novostroyki from anchoring
on застройщик prices after MAD-clip drops the rare secondary (~27% overvaluation).
Pure-primary / primary-dominated houses keep the #1186 guard. Behind
estimate_sb_tier_a_allow_primary_if_secondary_present (default True).
Dedup duplicate active rows by (source, source_id) primary key (codebase canon,
namespaced id/url tags, source_url fallback) — collapses rows whose source_url
differs only by trailing slash / query-param (e.g. cian source_id=330047129 ×2).
Tier C / radius / asking_to_sold_ratio paths untouched. 84 unit tests pass.
Avito leaks addresses with house number glued to district marker without
separator («…Савкова, 29р-н Академический»), breaking house-number extraction
and same-house matching (41 unmatched avito listings on one street).
_clean_address now inserts ", " between a house-number token (digits +
optional Cyrillic letter + optional /N|сN|кN|корпус|стр) and a following
marker (р-н, мкр, район, г., пос, снт, …).
- avito.py: idempotent _deglue_house_marker step (CSS/tail-strip kept)
- tests: de-glue positive/negative/idempotency (24 passed)
- data/sql/124_deglue_avito_addresses.sql: idempotent backfill for stored rows
normalize.py НЕ тронут (общий fingerprint матчинга, #1534).
Закрывает avito-часть #1773 (cian-без-номера + дедуп — отдельно, зона cian).
Detail backfill (run 217) hit 5 consecutive HTTP 429s and aborted at 19 enriched
because the detail fetcher only had a same-session 429 short-retry (no reconnect).
429s are persistent: keep-alive reuses one backconnect exit IP across sequential
detail fetches -> avito rate-limits it. Mirror the merged SERP 429 fix (#1769):
widen short-retry 4->8 and add up to 3 ephemeral-session reconnects (fresh exit IP)
on 429 exhaustion before raising. Caller session never touched (as with 403 path).
scrape_schedules had no interval column -> every source ran daily. Add
interval_days from default_params (default 1, backward-compatible) so
compute_next_run_at can schedule N days ahead. Set avito_full_load_exhaustive
to interval_days=7 (weekly full pass for last_seen/delisting + silent price
edits); avito_full_load stays daily incremental.
run_avito_full_load gains incremental_days param -> passes since= to the
(merged) incremental SERP engine. avito_full_load schedule flipped to
incremental_days=2 (shallow, date early-stop -> avoids deep-pagination 429
bans). New avito_full_load_exhaustive source runs the full walk weekly to
refresh last_seen (10-day delisting TTL) and catch silent price edits.
Run 216 recovered 403s (merged fix) but still banned on a 429 at page 36
(deep pagination, backconnect conn-limit). Widen the 429 retry budget + one
reconnect on exhaustion in _fetch_serp_html, and make fetch_all_secondary skip
a blocked bucket and continue rather than aborting the run; only N consecutive
blocked buckets (real hard ban) re-raises. Preserves hard-ban detection.
fetch_detail raised on the first 403/429, aborting the detail backfill (run 191
enriched only 48 then zombied) -> avito detail coverage stuck at 14% of active.
Mirror the merged SERP fix (#1765): on 403/firewall under the backconnect proxy,
rebuild an ephemeral session (fresh exit IP) and retry up to N before raising;
short-retry 429. Never close/rebuild the caller-provided shared session. Also
add the missing HTTP-200 firewall-interstitial check on the curl detail path.
Adds listings.card_hash (SHA-256 of volatile SERP-card fields) computed and
stored on every scrape. Skips the redundant per-day listings_snapshots write
when a listing's card is unchanged. listings.last_seen_at/is_active still
bumped on every sighting (delisting TTL). Lays groundwork for detail-refresh
gating (C2).
Adds since=date mode to fetch_all_secondary: per room x seed-bracket, paginate
sequentially newest-first and stop once cards fall below the watermark, skipping
deep pagination (and the 403 clusters it triggers). since=None keeps the exact
exhaustive bisection walk. No scheduler/schedule change here — wiring follows
in a separate PR.
PR #1764's reconnect-retry was dead code in prod: the gate excluded configs
with avito_proxy_rotate_url set, but prod has BOTH the backconnect SCRAPER_PROXY_URL
(what curl_cffi egresses through) AND a leftover changeip URL for the separate
auv/browser proxy. With max_rotations=0 neither path fired -> a single SERP 403
still aborted the sweep (run 212). The curl session always uses scraper_proxy_url,
so session-rebuild rotates the exit IP regardless of changeip config.
Under a backconnect rotating proxy (no changeip URL) a single HTTP 403 or
firewall interstitial aborted the entire avito_full_load sweep via
AvitoBlockedError -> mark_banned. Each new connection through backconnect
gets a fresh exit IP, so retry by rebuilding the curl_cffi session up to
_AVITO_403_MAX_RETRIES times before giving up. Legacy single-IP changeip
rotation and browser-mode paths unchanged. Mirrors existing 429 short-retry.