feat(tradein-scrapers): parse listing_date from Avito + Yandex (#508)
This commit is contained in:
parent
7f23a9b3f1
commit
d4cbd4bb9f
4 changed files with 126 additions and 3 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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-"]')
|
||||||
|
|
|
||||||
21
tradein-mvp/backend/tests/test_avito_relative_date.py
Normal file
21
tradein-mvp/backend/tests/test_avito_relative_date.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue