355 lines
12 KiB
Python
355 lines
12 KiB
Python
"""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
|