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 logging
import re import re
from datetime import date, timedelta
from typing import Any from typing import Any
from urllib.parse import urlencode, urljoin from urllib.parse import urlencode, urljoin
@ -29,6 +30,42 @@ from app.services.scrapers.base import BaseScraper, ScrapedLot
logger = logging.getLogger(__name__) 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): class AvitoScraper(BaseScraper):
"""Avito vtorichka parser. Источник = 'avito'. """Avito vtorichka parser. Источник = 'avito'.
@ -216,6 +253,18 @@ class AvitoScraper(BaseScraper):
if len(photo_urls) >= 5: if len(photo_urls) >= 5:
break 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>"> # House link: <a href="/catalog/houses/<city>/<slug>/<id>">
# or /catalog/houses/<slug>/<id> # or /catalog/houses/<slug>/<id>
house_source: str | None = None house_source: str | None = None
@ -251,6 +300,7 @@ class AvitoScraper(BaseScraper):
total_floors=total, total_floors=total,
price_rub=price, price_rub=price,
photo_urls=photo_urls, photo_urls=photo_urls,
listing_date=listing_date,
raw_payload={"title": title, "address": address, "house_ext_id": house_ext_id}, raw_payload={"title": title, "address": address, "house_ext_id": house_ext_id},
house_source=house_source, house_source=house_source,
house_ext_id=house_ext_id, house_ext_id=house_ext_id,

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import json import json
import re import re
from datetime import date from datetime import date, timedelta
from typing import Any from typing import Any
from selectolax.parser import HTMLParser from selectolax.parser import HTMLParser
@ -31,6 +31,7 @@ __all__ = [
"parse_dmy", "parse_dmy",
"parse_house_class", "parse_house_class",
"parse_house_type", "parse_house_type",
"parse_listing_date",
"parse_ru_date", "parse_ru_date",
"parse_rub", "parse_rub",
] ]
@ -150,6 +151,57 @@ def parse_dmy(text: str | None) -> date | None:
return 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 # 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_PRICE,
RE_TITLE_AREA, RE_TITLE_AREA,
RE_TITLE_ROOMS, RE_TITLE_ROOMS,
parse_ru_date, parse_listing_date,
parse_rub, parse_rub,
) )
@ -156,7 +156,7 @@ class YandexRealtyScraper(BaseScraper):
price_per_m2 = parse_rub(ppm2_m.group(1)) if ppm2_m else None price_per_m2 = parse_rub(ppm2_m.group(1)) if ppm2_m else None
bargain = "торг" in text.lower() bargain = "торг" in text.lower()
listing_date = parse_ru_date(text) listing_date = parse_listing_date(text)
# Address — street aggregator link # Address — street aggregator link
street_link = card.css_first('a[href*="/kupit/kvartira/st-"]') 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