fix(tradein): Avito listing_date extraction (3 формата) #601
2 changed files with 117 additions and 8 deletions
|
|
@ -51,19 +51,80 @@ _UNIT_DAYS: dict[str, int] = {
|
|||
"год": 365,
|
||||
}
|
||||
|
||||
# Russian month names → number. Avito показывает абсолютные даты как «18 мая».
|
||||
_RU_MONTHS: dict[str, int] = {
|
||||
"январ": 1, "феврал": 2, "март": 3, "апрел": 4,
|
||||
"ма": 5, "июн": 6, "июл": 7, "август": 8,
|
||||
"сентябр": 9, "октябр": 10, "ноябр": 11, "декабр": 12,
|
||||
}
|
||||
# «18 мая», «5 июня в 12:30» — day + month (year inferred current).
|
||||
_ABS_DATE_RE = re.compile(
|
||||
r"(?P<day>\d{1,2})\s+(?P<month>январ\w*|феврал\w*|март\w*|апрел\w*|"
|
||||
r"ма[яй]|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)",
|
||||
flags=re.I,
|
||||
)
|
||||
|
||||
|
||||
def _parse_relative_date(s: str | None) -> date | None:
|
||||
"""Avito показывает '5 дней назад' / 'час назад'. Конвертирует в date."""
|
||||
"""Avito показывает дату публикации в трёх форматах:
|
||||
* '5 дней назад' / 'час назад' / 'неделю назад' — relative numeric
|
||||
* 'сегодня в 12:30' / 'вчера в 18:45' / 'позавчера' — relative keyword
|
||||
* '18 мая' / '5 июня в 12:30' — absolute day + month (year inferred)
|
||||
|
||||
PR M-followup 2026-05-27: расширено покрытие — раньше только relative numeric
|
||||
matched (regex `\\d+ unit назад`), 100% Avito listings заходило с listing_date=None
|
||||
т.к. Avito SERP для свежих cards использует keyword/absolute форматы. См.
|
||||
тесты для каждого case.
|
||||
"""
|
||||
if not s:
|
||||
return None
|
||||
text_lower = s.lower()
|
||||
|
||||
# Keyword shortcuts (no \d+).
|
||||
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()
|
||||
|
||||
# Relative numeric («5 дней назад»).
|
||||
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)
|
||||
if m:
|
||||
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)
|
||||
|
||||
# Absolute («18 мая», «5 июня в 12:30»). Year — current; если месяц
|
||||
# в будущем относительно сегодня (например сейчас декабрь, увидели
|
||||
# «5 января») — откатываем к прошлому году.
|
||||
m = _ABS_DATE_RE.search(s)
|
||||
if m:
|
||||
day = int(m["day"])
|
||||
month_raw = m["month"].lower()
|
||||
month: int | None = None
|
||||
for prefix, num in _RU_MONTHS.items():
|
||||
if month_raw.startswith(prefix):
|
||||
month = num
|
||||
break
|
||||
if month is not None and 1 <= day <= 31:
|
||||
today = date.today()
|
||||
year = today.year
|
||||
try:
|
||||
parsed = date(year, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
# Если parsed в будущем больше чем на 30 дней — Avito показывает
|
||||
# прошлогоднюю дату («декабрь» в феврале значит прошлый декабрь).
|
||||
if (parsed - today).days > 30:
|
||||
try:
|
||||
parsed = date(year - 1, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,3 +19,51 @@ 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
|
||||
|
||||
|
||||
# Keyword shortcuts — без \d+ (Avito SERP формат для свежих cards)
|
||||
def test_today_keyword():
|
||||
assert _parse_relative_date("сегодня в 12:30") == date.today()
|
||||
|
||||
|
||||
def test_yesterday_keyword():
|
||||
assert _parse_relative_date("вчера в 18:45") == date.today() - timedelta(days=1)
|
||||
|
||||
|
||||
def test_day_before_yesterday():
|
||||
assert _parse_relative_date("позавчера") == date.today() - timedelta(days=2)
|
||||
|
||||
|
||||
def test_just_now():
|
||||
assert _parse_relative_date("только что") == date.today()
|
||||
|
||||
|
||||
# Absolute dates — «18 мая», «5 июня в 12:30»
|
||||
def test_absolute_date_recent():
|
||||
today = date.today()
|
||||
# Take a day-month combo near current (last month) — gradening для CI stability
|
||||
if today.month >= 2:
|
||||
target = date(today.year, today.month - 1, 15)
|
||||
else:
|
||||
target = date(today.year - 1, 12, 15)
|
||||
ru_months = {
|
||||
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
|
||||
5: "мая", 6: "июня", 7: "июля", 8: "августа",
|
||||
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря",
|
||||
}
|
||||
text = f"{target.day} {ru_months[target.month]} в 12:30"
|
||||
assert _parse_relative_date(text) == target
|
||||
|
||||
|
||||
def test_absolute_date_future_month_rolls_back():
|
||||
"""Если today январь, а текст «5 декабря» — это прошлый год."""
|
||||
today = date.today()
|
||||
if today.month == 1:
|
||||
# Today already January, «декабря» → previous year December
|
||||
result = _parse_relative_date("5 декабря")
|
||||
assert result == date(today.year - 1, 12, 5)
|
||||
else:
|
||||
# Skip when not January — would change semantics
|
||||
result = _parse_relative_date("5 января")
|
||||
# Either matches current year January (если уже прошло) или previous
|
||||
assert result is not None and result.month == 1 and result.day == 5
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue