gendesign/tradein-mvp/backend/app/services/scrapers/yandex_helpers.py
Light1YT 99a5a0f73b
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / deploy (push) Successful in 36s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 47s
fix(tradein): Yandex listing_date — keyword + absolute-no-year (#602)
2026-05-27 13:11:22 +00:00

355 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Yandex Realty parsing helpers — JSON-LD + regex + DOM utilities for SERP, detail, newbuilding,
and valuation parsers."""
from __future__ import annotations
import json
import re
from datetime import date, timedelta
from typing import Any
from selectolax.parser import HTMLParser
__all__ = [
"RE_AGENCY_FOUNDED",
"RE_AGENCY_OBJECTS",
"RE_FLOOR",
"RE_JK_ID",
"RE_METRO_WALK",
"RE_OFFER_ID",
"RE_PPM2",
"RE_PRICE",
"RE_STREET_ID",
"RE_TITLE_AREA",
"RE_TITLE_ROOMS",
"RE_VIEWS",
"RE_YEAR",
"RU_MONTHS",
"RU_MONTH_NOMINATIVE",
"extract_json_ld",
"find_ld_by_type",
"parse_dmy",
"parse_house_class",
"parse_house_type",
"parse_listing_date",
"parse_ru_date",
"parse_rub",
]
# ---------------------------------------------------------------------------
# Section 1 — JSON-LD extraction
# ---------------------------------------------------------------------------
def extract_json_ld(html: str) -> list[dict[str, Any]]:
"""Extract all <script type='application/ld+json'> blocks from HTML.
Returns a list of parsed JSON-LD dicts. Skips blocks that fail to parse.
"""
tree = HTMLParser(html)
results: list[dict[str, Any]] = []
for script in tree.css('script[type="application/ld+json"]'):
text = script.text() or ""
if not text.strip():
continue
try:
parsed = json.loads(text)
except json.JSONDecodeError:
continue
if isinstance(parsed, dict):
results.append(parsed)
elif isinstance(parsed, list):
# Some pages wrap multiple LDs in a JSON array
results.extend(item for item in parsed if isinstance(item, dict))
return results
def find_ld_by_type(html: str, type_name: str) -> dict[str, Any] | None:
"""Return the first JSON-LD dict whose @type equals type_name.
If @type is a list, returns the dict if type_name is contained in it.
"""
for ld in extract_json_ld(html):
t = ld.get("@type")
if t == type_name:
return ld
if isinstance(t, list) and type_name in t:
return ld
return None
# ---------------------------------------------------------------------------
# Section 2 — Regex extractors (module-level compiled)
# ---------------------------------------------------------------------------
# URL pattern extractors
RE_OFFER_ID = re.compile(r"/offer/(\d+)/?")
RE_JK_ID = re.compile(r"/novostrojka/([\w-]+?)-(\d+)/?") # group(1)=slug, group(2)=id
RE_STREET_ID = re.compile(r"/kupit/kvartira/st-([\w-]+?)-(\d+)/?")
# Card / title text extractors
RE_TITLE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м²")
RE_TITLE_ROOMS = re.compile(r"(\d+)\s*-?\s*комнатн|(студи[яюй])", re.IGNORECASE)
RE_FLOOR = re.compile(r"(\d+)\s+этаж\s+из\s+(\d+)", re.IGNORECASE)
RE_PRICE = re.compile(r"(\d[\d\s]+\d)\s*₽")
RE_PPM2 = re.compile(r"(\d[\d\s]+)\s*₽\s*за\s*м²", re.IGNORECASE)
RE_VIEWS = re.compile(r"(\d+)\s+просмотр", re.IGNORECASE)
# Detail / NLP extractors
RE_METRO_WALK = re.compile(
r"(\d+)\s+минут\s+(?:неспешной\s+прогулки|ходьбы|пешком)", re.IGNORECASE
)
RE_YEAR = re.compile(r"\b(19\d{2}|20[0-2]\d)\b")
# Agency block ("Год основания 1995", "150 объектов")
RE_AGENCY_FOUNDED = re.compile(r"Год\s+основания\s+(\d{4})", re.IGNORECASE)
RE_AGENCY_OBJECTS = re.compile(r"(\d+)\s+объект", re.IGNORECASE)
# ---------------------------------------------------------------------------
# Section 3 — Russian date parsing
# ---------------------------------------------------------------------------
RU_MONTHS: dict[str, int] = {
"января": 1, "февраля": 2, "марта": 3, "апреля": 4, "мая": 5, "июня": 6,
"июля": 7, "августа": 8, "сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
}
RU_MONTH_NOMINATIVE: dict[str, int] = {
"январь": 1, "февраль": 2, "март": 3, "апрель": 4, "май": 5, "июнь": 6,
"июль": 7, "август": 8, "сентябрь": 9, "октябрь": 10, "ноябрь": 11, "декабрь": 12,
}
RE_RU_DATE = re.compile(r"(\d{1,2})\s+(\w+)\s+(\d{4})")
RE_DMY = re.compile(r"(\d{2})\.(\d{2})\.(\d{4})")
def parse_ru_date(text: str | None) -> date | None:
"""Parse Russian date string like '9 мая 2026' → date(2026, 5, 9). Genitive month form only."""
if not text:
return None
m = RE_RU_DATE.search(text)
if not m:
return None
day_s, month_ru, year_s = m.groups()
month = RU_MONTHS.get(month_ru.lower())
if not month:
return None
try:
return date(int(year_s), month, int(day_s))
except (ValueError, TypeError):
return None
def parse_dmy(text: str | None) -> date | None:
"""Parse DD.MM.YYYY format (used by Yandex Valuation tool history) → date."""
if not text:
return None
m = RE_DMY.search(text)
if not m:
return None
try:
return date(int(m.group(3)), int(m.group(2)), int(m.group(1)))
except (ValueError, TypeError):
return None
# Relative date parsing (Yandex SERP shows "N дней назад" / "N часов назад")
_RE_REL_DATE = re.compile(
r"(?P<n>\d+)\s+(?P<unit>"
r"секунд[ауы]?|"
r"минут[ауы]?|"
r"час(?:а|ов)?|"
r"день|дн(?:я|ей?)|"
r"недел[июяь]+|"
r"месяц(?:а|ев)?|"
r"год(?:а|ов)?"
r")\s+назад",
flags=re.I,
)
_REL_UNIT_DAYS: dict[str, int] = {
"секунд": 0, "минут": 0, "час": 0,
"день": 1, "дн": 1,
"недел": 7,
"месяц": 30,
"год": 365,
}
def _parse_relative_date_yandex(s: str | None) -> date | None:
"""Parse 'N дней/недель/месяцев назад' → date. Returns None if not matched."""
if not s:
return None
m = _RE_REL_DATE.search(s)
if not m:
return None
n = int(m["n"])
unit_raw = m["unit"].lower()
for prefix, days in _REL_UNIT_DAYS.items():
if unit_raw.startswith(prefix):
return date.today() - timedelta(days=n * days)
return None
RE_RU_DATE_NO_YEAR = re.compile(
r"(\d{1,2})\s+(январ\w*|феврал\w*|март\w*|апрел\w*|ма[яй]|"
r"июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)"
r"(?!\s+\d{4})",
flags=re.I,
)
_RU_MONTH_PREFIXES: dict[str, int] = {
"январ": 1, "феврал": 2, "март": 3, "апрел": 4,
"ма": 5, "июн": 6, "июл": 7, "август": 8,
"сентябр": 9, "октябр": 10, "ноябр": 11, "декабр": 12,
}
def parse_listing_date(text: str | None) -> date | None:
"""Parse listing date from Yandex card text.
Tries in order:
1. Keyword shortcut: 'сегодня' / 'вчера' / 'позавчера' → relative day
2. Absolute Russian date with year: '9 мая 2026' → date(2026, 5, 9)
3. Absolute without year: '18 мая' → current year (rollback to prev
year if month >30 дней в будущем)
4. Relative date: 'N дней назад' → date.today() - N days
Returns None if none match.
"""
if not text:
return None
text_lower = text.lower()
# 1. Keyword shortcuts (common on Yandex SERP for recent cards).
if "позавчера" in text_lower:
return date.today() - timedelta(days=2)
if "вчера" in text_lower:
return date.today() - timedelta(days=1)
if "сегодня" in text_lower or "только что" in text_lower:
return date.today()
# 2. Absolute with year (existing).
result = parse_ru_date(text)
if result is not None:
return result
# 3. Absolute without year — infer current, rollback to prev if future.
m = RE_RU_DATE_NO_YEAR.search(text)
if m:
day = int(m.group(1))
month_raw = m.group(2).lower()
month: int | None = None
for prefix, num in _RU_MONTH_PREFIXES.items():
if month_raw.startswith(prefix):
month = num
break
if month is not None and 1 <= day <= 31:
today = date.today()
try:
parsed = date(today.year, month, day)
except ValueError:
parsed = None
if parsed and (parsed - today).days > 30:
try:
parsed = date(today.year - 1, month, day)
except ValueError:
parsed = None
if parsed:
return parsed
# 4. Relative numeric.
return _parse_relative_date_yandex(text)
# ---------------------------------------------------------------------------
# Section 4 — House type / class NLP from description text
# ---------------------------------------------------------------------------
def parse_house_type(text: str | None) -> str | None:
"""Extract house material type from free-form description.
Returns: 'panel' | 'monolith_brick' | 'monolith' | 'brick' | 'block' | 'wood' | None.
Order matters: 'monolith_brick' is checked before 'monolith' and 'brick' alone.
"""
if not text:
return None
t = text.lower()
if "панельн" in t:
return "panel"
if "монолит" in t and "кирпич" in t:
return "monolith_brick"
if "монолит" in t:
return "monolith"
if "кирпичн" in t:
return "brick"
if "блочн" in t:
return "block"
if "деревянн" in t:
return "wood"
return None
def parse_house_class(text: str | None) -> str | None:
"""Extract ЖК class from description.
Returns: 'elite' | 'premium' | 'business' | 'comfort_plus' | 'comfort' | 'economy' | None.
"""
if not text:
return None
t = text.lower()
if re.search(r"класса?\s+элит|элитный\s+класс", t):
return "elite"
if re.search(r"класса?\s+премиум|премиум.{0,5}класс", t):
return "premium"
if re.search(r"класса?\s+бизнес", t):
return "business"
if re.search(r"класса?\s+комфорт\s*\+", t):
return "comfort_plus"
if re.search(r"класса?\s+комфорт(?!\s*\+)", t):
return "comfort"
if re.search(r"класса?\s+эконом", t):
return "economy"
return None
# ---------------------------------------------------------------------------
# Section 5 — Money parsing
# ---------------------------------------------------------------------------
RE_RUB_MLN = re.compile(r"([\d,.]+)\s*млн\s*₽", re.IGNORECASE)
RE_RUB_RAW = re.compile(r"(\d[\d\s]*\d|\d)")
def parse_rub(text: str | None) -> int | None:
"""Parse Russian price string → integer rubles.
Supports:
'4 399 000 ₽' → 4_399_000
'4,4 млн ₽' → 4_400_000
'4.4 млн ₽' → 4_400_000
'117 500' → 117_500 (raw digits without currency, e.g. ppm2)
None / 'без цены' / '' → None
"""
if not text:
return None
text = text.strip()
if not text:
return None
m = RE_RUB_MLN.search(text)
if m:
try:
# `\s` strips also NBSP (\xa0), thin space, и т.д. — Yandex SERP
# форматирует «9\xa0800\xa0000» через NBSP, не regular space.
cleaned = re.sub(r"\s", "", m.group(1)).replace(",", ".")
return int(float(cleaned) * 1_000_000)
except (ValueError, TypeError):
pass
m = RE_RUB_RAW.search(text)
if m:
# Same NBSP issue: `.replace(" ", "")` оставляет \xa0 → int() raises.
cleaned = re.sub(r"\s", "", m.group(1))
try:
return int(cleaned)
except ValueError:
return None
return None