feat(tradein-scrapers): parse listing_date from Avito + Yandex (#508)
Some checks failed
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 39s
Deploy Trade-In / deploy (push) Has been cancelled

This commit is contained in:
lekss361 2026-05-24 11:17:13 +00:00
parent 7f23a9b3f1
commit d4cbd4bb9f
4 changed files with 126 additions and 3 deletions

View file

@ -17,6 +17,7 @@ from __future__ import annotations
import logging
import re
from datetime import date, timedelta
from typing import Any
from urllib.parse import urlencode, urljoin
@ -29,6 +30,42 @@ from app.services.scrapers.base import BaseScraper, ScrapedLot
logger = logging.getLogger(__name__)
# ── Relative date parsing ────────────────────────────────────────────────────
_REL_DATE_RE = re.compile(
r"(?P<n>\d+)\s+(?P<unit>"
r"секунд[ауы]?|"
r"минут[ауы]?|"
r"час(?:а|ов)?|"
r"день|дн(?:я|ей?)|"
r"недел[июяь]+|"
r"месяц(?:а|ев)?|"
r"год(?:а|ов)?"
r")\s+назад",
flags=re.I,
)
_UNIT_DAYS: dict[str, int] = {
"секунд": 0, "минут": 0, "час": 0,
"день": 1, "дн": 1,
"недел": 7,
"месяц": 30,
"год": 365,
}
def _parse_relative_date(s: str | None) -> date | None:
"""Avito показывает '5 дней назад' / 'час назад'. Конвертирует в date."""
if not s:
return None
m = _REL_DATE_RE.search(s)
if not m:
return None
n = int(m["n"])
unit_raw = m["unit"].lower()
for prefix, days in _UNIT_DAYS.items():
if unit_raw.startswith(prefix):
return date.today() - timedelta(days=n * days)
return None
class AvitoScraper(BaseScraper):
"""Avito vtorichka parser. Источник = 'avito'.
@ -216,6 +253,18 @@ class AvitoScraper(BaseScraper):
if len(photo_urls) >= 5:
break
# Дата публикации: data-marker="item-date" / data-marker="item-date-info"
listing_date: date | None = None
date_el = card.css_first('[data-marker="item-date"]')
if date_el is None:
date_el = card.css_first('[data-marker="item-date-info"]')
if date_el is not None:
listing_date = _parse_relative_date(date_el.text(strip=True))
if listing_date is None:
# Fallback: ищем любой текст «N дней/недель/месяцев назад» в карточке
card_text = card.text(strip=True)
listing_date = _parse_relative_date(card_text)
# House link: <a href="/catalog/houses/<city>/<slug>/<id>">
# or /catalog/houses/<slug>/<id>
house_source: str | None = None
@ -251,6 +300,7 @@ class AvitoScraper(BaseScraper):
total_floors=total,
price_rub=price,
photo_urls=photo_urls,
listing_date=listing_date,
raw_payload={"title": title, "address": address, "house_ext_id": house_ext_id},
house_source=house_source,
house_ext_id=house_ext_id,

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import json
import re
from datetime import date
from datetime import date, timedelta
from typing import Any
from selectolax.parser import HTMLParser
@ -31,6 +31,7 @@ __all__ = [
"parse_dmy",
"parse_house_class",
"parse_house_type",
"parse_listing_date",
"parse_ru_date",
"parse_rub",
]
@ -150,6 +151,57 @@ def parse_dmy(text: str | None) -> date | None:
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
def parse_listing_date(text: str | None) -> date | None:
"""Parse listing date from Yandex card text.
Tries in order:
1. Absolute Russian date: '9 мая 2026' date(2026, 5, 9)
2. Relative date: 'N дней назад' date.today() - N days
Returns None if neither matches.
"""
result = parse_ru_date(text)
if result is not None:
return result
return _parse_relative_date_yandex(text)
# ---------------------------------------------------------------------------
# Section 4 — House type / class NLP from description text
# ---------------------------------------------------------------------------

View file

@ -28,7 +28,7 @@ from app.services.scrapers.yandex_helpers import (
RE_PRICE,
RE_TITLE_AREA,
RE_TITLE_ROOMS,
parse_ru_date,
parse_listing_date,
parse_rub,
)
@ -156,7 +156,7 @@ class YandexRealtyScraper(BaseScraper):
price_per_m2 = parse_rub(ppm2_m.group(1)) if ppm2_m else None
bargain = "торг" in text.lower()
listing_date = parse_ru_date(text)
listing_date = parse_listing_date(text)
# Address — street aggregator link
street_link = card.css_first('a[href*="/kupit/kvartira/st-"]')

View file

@ -0,0 +1,21 @@
from datetime import date, timedelta
from app.services.scrapers.avito import _parse_relative_date
def test_days_ago():
assert _parse_relative_date("5 дней назад") == date.today() - timedelta(days=5)
def test_week_ago():
assert _parse_relative_date("2 недели назад") == date.today() - timedelta(days=14)
def test_hour_ago_is_today():
assert _parse_relative_date("3 часа назад") == date.today()
def test_none_passthrough():
assert _parse_relative_date(None) is None
assert _parse_relative_date("") is None
assert _parse_relative_date("random text") is None