feat(tradein): yandex_helpers.py — JSON-LD + regex + date + NLP + money helpers #456

Merged
lekss361 merged 1 commit from feat/tradein-yandex-helpers into main 2026-05-23 13:14:12 +00:00
Owner

Motivation

Stage 2 of 9 for YandexRealtyScraper v1 (Stage 1 = PR #453 SQL migrations 030-032, merged). Shared parsing utilities used by Stages 3-6 (SERP / detail / newbuilding / valuation parsers).

Public API

JSON-LD extraction

  • extract_json_ld(html) -> list[dict] — parses every <script type="application/ld+json">, tolerates JSON arrays, skips invalid blocks
  • find_ld_by_type(html, type_name) -> dict | None — handles both string @type and list @type

Regex constants (compiled at module level)

  • URL: RE_OFFER_ID, RE_JK_ID, RE_STREET_ID
  • Card text: RE_TITLE_AREA, RE_TITLE_ROOMS, RE_FLOOR, RE_PRICE, RE_PPM2, RE_VIEWS
  • Detail NLP: RE_METRO_WALK, RE_YEAR, RE_AGENCY_FOUNDED, RE_AGENCY_OBJECTS

Date parsing

  • parse_ru_date("9 мая 2026") → date(2026, 5, 9) — genitive month form (SERP / detail)
  • parse_dmy("17.02.2026") → date(2026, 2, 17) — used by Valuation tool history

NLP helpers

  • parse_house_typepanel | monolith_brick | monolith | brick | block | wood | None (order-sensitive; monolith_brick checked before monolith and brick alone)
  • parse_house_classelite | premium | business | comfort_plus | comfort | economy | None

Money

  • parse_rub → handles "4 399 000 ₽", "4,4 млн ₽", "4.4 млн ₽", raw digits, None / empty

Spec fixes applied by worker

  1. RE_TITLE_ROOMS — the original spec regex (\d+|студи[яюй])\s*-?\s*комнатн could never match standalone "Студия 28 м²" because "Студия" is not followed by "комнатн". Split into two alternations: (\d+)\s*-?\s*комнатн|(студи[яюй]) — group(1) = numbered rooms, group(2) = studio token.
  2. parse_house_class premium — original pattern класса?\s+премиум missed the reversed phrasing "премиум-класса". Added |премиум.{0,5}класс to cover both word orders.

Test results

36 passed, 0 failed

Coverage: house_type (5 cases), house_class (6 cases), parse_rub (6 cases), parse_ru_date (5 cases), parse_dmy (4 cases), JSON-LD (5 cases — single / multiple / array wrapper / invalid skipped / @type list form / missing), regex sanity (3 cases — offer_id / jk_id / title parsing incl. studio).

Lint

ruff check yandex_helpers.py test_yandex_helpers.py

Clean — 0 warnings. Line length ≤ 100, PEP 604 type unions (str | None), no print(), no import psycopg2, no requests.

Files

  • tradein-mvp/backend/app/services/scrapers/yandex_helpers.py (new)
  • tradein-mvp/backend/tests/test_yandex_helpers.py (new)

Reviewer note

Reviewer: deep-code-reviewer — only merge after APPROVE.

## Motivation Stage 2 of 9 for YandexRealtyScraper v1 (Stage 1 = PR #453 SQL migrations 030-032, merged). Shared parsing utilities used by Stages 3-6 (SERP / detail / newbuilding / valuation parsers). ## Public API **JSON-LD extraction** - `extract_json_ld(html) -> list[dict]` — parses every `<script type="application/ld+json">`, tolerates JSON arrays, skips invalid blocks - `find_ld_by_type(html, type_name) -> dict | None` — handles both string `@type` and list `@type` **Regex constants** (compiled at module level) - URL: `RE_OFFER_ID`, `RE_JK_ID`, `RE_STREET_ID` - Card text: `RE_TITLE_AREA`, `RE_TITLE_ROOMS`, `RE_FLOOR`, `RE_PRICE`, `RE_PPM2`, `RE_VIEWS` - Detail NLP: `RE_METRO_WALK`, `RE_YEAR`, `RE_AGENCY_FOUNDED`, `RE_AGENCY_OBJECTS` **Date parsing** - `parse_ru_date("9 мая 2026") → date(2026, 5, 9)` — genitive month form (SERP / detail) - `parse_dmy("17.02.2026") → date(2026, 2, 17)` — used by Valuation tool history **NLP helpers** - `parse_house_type` → `panel | monolith_brick | monolith | brick | block | wood | None` (order-sensitive; monolith_brick checked before monolith and brick alone) - `parse_house_class` → `elite | premium | business | comfort_plus | comfort | economy | None` **Money** - `parse_rub` → handles `"4 399 000 ₽"`, `"4,4 млн ₽"`, `"4.4 млн ₽"`, raw digits, `None` / empty ## Spec fixes applied by worker 1. **`RE_TITLE_ROOMS`** — the original spec regex `(\d+|студи[яюй])\s*-?\s*комнатн` could never match standalone "Студия 28 м²" because "Студия" is not followed by "комнатн". Split into two alternations: `(\d+)\s*-?\s*комнатн|(студи[яюй])` — group(1) = numbered rooms, group(2) = studio token. 2. **`parse_house_class` premium** — original pattern `класса?\s+премиум` missed the reversed phrasing "премиум-класса". Added `|премиум.{0,5}класс` to cover both word orders. ## Test results ``` 36 passed, 0 failed ``` Coverage: house_type (5 cases), house_class (6 cases), parse_rub (6 cases), parse_ru_date (5 cases), parse_dmy (4 cases), JSON-LD (5 cases — single / multiple / array wrapper / invalid skipped / @type list form / missing), regex sanity (3 cases — offer_id / jk_id / title parsing incl. studio). ## Lint ``` ruff check yandex_helpers.py test_yandex_helpers.py ``` Clean — 0 warnings. Line length ≤ 100, PEP 604 type unions (`str | None`), no `print()`, no `import psycopg2`, no `requests`. ## Files - `tradein-mvp/backend/app/services/scrapers/yandex_helpers.py` (new) - `tradein-mvp/backend/tests/test_yandex_helpers.py` (new) ## Reviewer note Reviewer: **deep-code-reviewer** — only merge after APPROVE.
lekss361 added 1 commit 2026-05-23 13:09:01 +00:00
Foundation helpers for YandexRealtyScraper v1 Stages 3-6:
- JSON-LD extraction (extract_json_ld, find_ld_by_type) — Product/Offer/BreadcrumbList
- Regex constants (RE_OFFER_ID, RE_JK_ID, RE_TITLE_AREA, RE_TITLE_ROOMS, RE_FLOOR,
  RE_PRICE, RE_PPM2, RE_VIEWS, RE_METRO_WALK, RE_YEAR, RE_AGENCY_*)
- Russian date parsers (parse_ru_date "9 мая 2026" + parse_dmy "17.02.2026")
- NLP helpers (parse_house_type → panel/monolith/brick/block/wood;
  parse_house_class → elite/premium/business/comfort_plus/comfort/economy)
- Money parser (parse_rub: "4 399 000 ₽" / "4,4 млн ₽" / "117500" / None)

Spec fixes: RE_TITLE_ROOMS split into two alternations to handle standalone
"Студия" (group 2) vs "N-комнатная" (group 1); parse_house_class premium
pattern extended to cover reversed word order "премиум-класса".

Test coverage: 36 unit tests across all helpers (uv run pytest passes,
ruff check clean).
lekss361 merged commit 962daf2d47 into main 2026-05-23 13:14:12 +00:00
Author
Owner

Merged via deep-code-reviewer — verdict APPROVE.

Verification performed locally

  • 36/36 pytest PASS (re-ran всю suite в isolated layout app.services.scrapers.yandex_helpers — 0.12s).
  • __all__ consistency — все 22 exported names резолвятся (import не falls).
  • Regex sanity — все 13 паттернов проверены на real-world inputs:
    • RE_OFFER_ID короткий и длинный (19-digit) offer id
    • RE_JK_ID tatlin-1592987 → slug + id
    • RE_STREET_ID st-lenina-12345 → slug + id
    • RE_TITLE_AREA integer / 75,5 / 75.5 с decimal
    • RE_TITLE_ROOMS N-комнатная (group 1) + студи[яюй] (group 2) — 3 формы (я/ю/й/ий через Студий)
    • RE_FLOOR 6 этаж из 15 (NOT 5/10 этаж)
    • RE_PRICE 4 399 000 ₽ / 4399000 ₽
    • RE_PPM2 117 500 ₽ за м² (captures trailing space — parse_rub cleanup устраняет)
    • RE_VIEWS просмотр/просмотра/просмотров (все 3 формы — словарь содержит "просмотр")
    • RE_METRO_WALK ходьбы / пешком / неспешной прогулки
    • RE_YEAR 1900-2029 (strict 4-digit, \b)
    • RE_AGENCY_FOUNDED Год основания 1995
    • RE_AGENCY_OBJECTS "1234 объекта" + "150 объектов"
  • Date parsingparse_ru_date("9 мая 2026") , parse_ru_date("9 МАЯ 2026") (lower-case lookup), invalid month → None, nominative form "9 май 2026" → None (correct strict behavior), short year "9 мая 26" → None.
  • parse_dmy — strict \d{2}\.\d{2}\.\d{4} — отвергает 1.2.2026 и 17.02.26 (correct).
  • NLP orderpanel > monolith_brick > monolith > brick > block > wood. Verified что "панельный дом из монолита" → panel (first match wins). monolith_brick корректно матчит "кирпично-монолитный", "монолитно-кирпичный", "монолит-кирпич".
  • parse_house_class premium fix — оба word order работают: "класс премиум" + "премиум-класса".
  • parse_rub4,4 млн ₽ / 4.4 млн ₽ → 4_400_000, raw digits, prefix ₽4399000, mixed sentence, "4 МЛН ₽" (uppercase) — все pass.
  • selectolax в tradein-mvp/backend/pyproject.toml (selectolax>=0.3.0).
  • No conflictyandex_realty.py существует, но helpers — отдельный модуль; импорта pollution нет.
  • CIdeploy-tradein.yml тригернётся по tradein-mvp/backend/** paths-filter.

Minor observations (non-blocking, для Stage 3-6)

  • RE_RUB_RAW / RE_RU_DATE / RE_DMY / RE_RUB_MLN корректно НЕ в __all__ — internal use.
  • parse_rub(int_input) крашится AttributeError (нет .strip()). Defensive: if not isinstance(text, str): return None. Не критично — все callers будут передавать str | None per type hint.
  • RE_AGENCY_FOUNDED strict pattern "Год основания NNNN" не покроет "Агентство основано в 2010 году" — может быть relevant если Yandex DOM эту форму использует. Reviewer Stage 4/5 проверит на real fixtures.
  • Regex constants (10+) имеют только 3 sanity tests; остальные exercised через Stages 3-6 parsers — acceptable для shared lib.
  • extract_json_ld использует selectolax (правильно — уже в deps, fast), не regex.

Stage 2 unblocks: 3 (SERP refactor), 4 (detail), 5 (newbuilding), 6 (valuation) parsers.

Merged via **deep-code-reviewer** — verdict ✅ APPROVE. ### Verification performed locally - **36/36 pytest** PASS (re-ran всю suite в isolated layout `app.services.scrapers.yandex_helpers` — 0.12s). - **`__all__` consistency** — все 22 exported names резолвятся (import не falls). - **Regex sanity** — все 13 паттернов проверены на real-world inputs: - `RE_OFFER_ID` ✅ короткий и длинный (19-digit) offer id - `RE_JK_ID` ✅ `tatlin-1592987` → slug + id - `RE_STREET_ID` ✅ `st-lenina-12345` → slug + id - `RE_TITLE_AREA` ✅ integer / `75,5` / `75.5` с decimal - `RE_TITLE_ROOMS` ✅ N-комнатная (group 1) + студи[яюй] (group 2) — 3 формы (я/ю/й/ий через `Студий`) - `RE_FLOOR` ✅ `6 этаж из 15` (NOT `5/10 этаж`) - `RE_PRICE` ✅ `4 399 000 ₽` / `4399000 ₽` - `RE_PPM2` ✅ `117 500 ₽ за м²` (captures trailing space — `parse_rub` cleanup устраняет) - `RE_VIEWS` ✅ просмотр/просмотра/просмотров (все 3 формы — словарь содержит "просмотр") - `RE_METRO_WALK` ✅ ходьбы / пешком / неспешной прогулки - `RE_YEAR` ✅ 1900-2029 (strict 4-digit, `\b`) - `RE_AGENCY_FOUNDED` ✅ `Год основания 1995` - `RE_AGENCY_OBJECTS` ✅ "1234 объекта" + "150 объектов" - **Date parsing** — `parse_ru_date("9 мая 2026")` ✅, `parse_ru_date("9 МАЯ 2026")` ✅ (lower-case lookup), invalid month → None, nominative form `"9 май 2026"` → None (correct strict behavior), short year `"9 мая 26"` → None. - **`parse_dmy`** — strict `\d{2}\.\d{2}\.\d{4}` — отвергает `1.2.2026` и `17.02.26` (correct). - **NLP order** — `panel` > `monolith_brick` > `monolith` > `brick` > `block` > `wood`. Verified что "панельный дом из монолита" → `panel` (first match wins). `monolith_brick` корректно матчит "кирпично-монолитный", "монолитно-кирпичный", "монолит-кирпич". - **`parse_house_class` premium fix** ✅ — оба word order работают: "класс премиум" + "премиум-класса". - **`parse_rub`** — `4,4 млн ₽` / `4.4 млн ₽` → 4_400_000, raw digits, prefix `₽4399000`, mixed sentence, "4 МЛН ₽" (uppercase) — все pass. - **selectolax** ✅ в `tradein-mvp/backend/pyproject.toml` (`selectolax>=0.3.0`). - **No conflict** — `yandex_realty.py` существует, но helpers — отдельный модуль; импорта pollution нет. - **CI** — `deploy-tradein.yml` тригернётся по `tradein-mvp/backend/**` paths-filter. ### Minor observations (non-blocking, для Stage 3-6) - `RE_RUB_RAW` / `RE_RU_DATE` / `RE_DMY` / `RE_RUB_MLN` корректно НЕ в `__all__` — internal use. - `parse_rub(int_input)` крашится `AttributeError` (нет `.strip()`). Defensive: `if not isinstance(text, str): return None`. Не критично — все callers будут передавать `str | None` per type hint. - `RE_AGENCY_FOUNDED` strict pattern "Год основания NNNN" не покроет "Агентство основано в 2010 году" — может быть relevant если Yandex DOM эту форму использует. Reviewer Stage 4/5 проверит на real fixtures. - Regex constants (10+) имеют только 3 sanity tests; остальные exercised через Stages 3-6 parsers — acceptable для shared lib. - `extract_json_ld` использует `selectolax` (правильно — уже в deps, fast), не regex. ### Stage 2 unblocks: 3 (SERP refactor), 4 (detail), 5 (newbuilding), 6 (valuation) parsers.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#456
No description provided.