domrf_kn_flat_plans было пусто (0 строк) — блокер для #299 (Potrace
production-wiring) и #300 (CubiCasa). domrf_catalog.py уже фетчит SSR-
страницы каталога квартир, где лежит планировка, но _TextCollector
сознательно дропал <img> как void-tag (#1608) — картинка никогда не
извлекалась.
parse_catalog_flat теперь возвращает plan_image_url: primary-стратегия
парсит __NEXT_DATA__ pageProps JSON (Next.js SSR-паттерн), fallback —
голый <img> по class-hint/соседству с текстом "Планировка". _TextCollector
получает аддитивный self.images-сбор ДО void-tag early-return — не трогает
существующий stack/buffer, регрессия по #1608 исключена (тест
test_img_capture_preserves_text_blocks).
download_plan_image_stub (NotImplementedError) заменён на рабочую
async-загрузку через существующий BrowserSession.download_binary
(те же cookies/TLS/throttle), Pillow для width/height, идемпотентна
(skip при уже существующем файле). Двухфазный upsert: URL пишется
первым, затем метаданные после скачивания — частичный прогресс
переживает падение загрузки.
Не заводит новый Celery beat — не подключено ни в один task (домрана
предпосылка issue #2440: scrape_one_flat/scrape_catalog_batch сейчас
нигде не вызываются). Ships inert до отдельного follow-up по wiring.
Refs #2440, #299, #300
_STATUS_BADGE_CLS_RE was too broad: `status|badge|tag|chip|label` matched
generic UI elements (e.g. <span class="label">, <span class="chip">) that
are unrelated to the sale-status badge, risking picking the wrong block.
Narrowed to `(?<![a-z])status(?![a-z])` — requires the literal token
"status" as a hyphen-delimited component of the CSS class (matches
`status-badge`, `flat-status-tag`, `object-status` but not bare `label`,
`tag`, `chip`, `badge`).
Adds regression test: page with generic label/tag/chip/badge elements
containing "В продаже" must NOT activate Level-1; only the real
`status-badge` block ("Продана") should be returned → status=sold.
Replace whole-HTML re.search for status with a 3-level block-scoped strategy:
1. CSS badge classes (status/badge/tag/chip/label) — highest precision.
2. Proximity to block labelled «Статус» via _find_text_near.
3. Full blocks scan where sold/reserved keywords always beat free —
preventing «в продаже» from nav/similar-flats sections from
misclassifying sold flats as free.
Add _STATUS_KW_RE and _classify_status_kw at module level with full
morphological coverage: продан/продана/продано, реализован[аоы]?,
забронирован[аоы]?, свободн[аоы]?.
Add 31 tests in test_domrf_catalog_parse.py covering all three
extraction levels plus regression for fem. word-form «Квартира продана».
Fallback flat_id в _norm_flat использовал abs(hash(elem)) % (2**63 - 1).
В CPython hash(str) РАНДОМИЗИРОВАН per-process (PYTHONHASHSEED нигде в
репо не зафиксирован — uvicorn/celery не выставляют его). Эффект:
- При resume упавшего sweep (resume_kn_run в новом процессе воркера) до
10 объектов после checkpoint перечитываются. Квартиры без flatId
получают ДРУГОЙ hash-id → ON CONFLICT (id, snapshot_date) не
срабатывает → дубли строк одной квартиры в одном snapshot.
- То же при повторном прогоне за ту же дату — каждый раз новый id.
- Дубли инфлируют все агрегации (units_sold/price медианы, supply_layers).
Patch:
- abs(hash(elem)) → int.from_bytes(sha256(elem)[:8], "big") % (2**63 - 1).
- sha256 стабилен между процессами/перезапусками. 8 байт → BIGINT-fit.
6 новых юнит-тестов (test_domrf_kn_normalize.py): formula matches sha256,
stable across calls, distinct elems→distinct ids, flatId wins over
fallback, no-id→None, BIGINT-fit. ruff clean.
Closes#1208