fix(tradein/avito): парсер карточки читал только первый блок параметров — параметры дома терялись молча #3048

Merged
lekss361 merged 3 commits from fix/avito-detail-fields into main 2026-08-21 20:54:36 +00:00
4 changed files with 946 additions and 27 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,138 @@
"""Тесты на реальной разметке для #3047 (5 полей detail-парсера Avito).
Фикстуры две живые detail-карточки, снятые через прод-браузер 2026-08-21:
- avito_detail_card_owner_66m2.html item_id 8322506679, продавец "Частное лицо"
- avito_detail_card_agency_95m2.html item_id 8343481647, продавец "Агентство"
Разные значения seller-info/label в двух фикстурах намеренно: это единственный
способ проверить и True, и False ветку is_homeowner на реальной разметке, а не
придуманным HTML.
Что покрыто (см. докстринг detail.py для полного разбора по каждому полю):
- sale_type: способ чтения таблицы параметров (item-view/item-params два блока
с ОДНИМ маркером, не один блок с двумя <ul>)
- metro_stations: структурная разметка (#item-view-address) вместо текста описания
- is_homeowner: [data-marker='seller-info/label']
- days_on_market: производное от исправленного publish_date
([data-marker='item-view/item-date'], а не текст item-id-блока)
- cadastral_number: подтверждение ОТСУТСТВИЯ на этих карточках (regression guard
если Avito когда-то начнёт отдавать значение, эти тесты не должны молча зелёнеть
на None, если код начнёт что-то парсить без проверки)
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
from scraper_kit.providers.avito.detail import parse_detail_html
FIXTURES = Path(__file__).parent / "fixtures"
def _load(name: str) -> str:
return (FIXTURES / name).read_text(encoding="utf-8")
CARD_OWNER = "avito_detail_card_owner_66m2.html"
CARD_AGENCY = "avito_detail_card_agency_95m2.html"
class TestSaleTypeParamsReading:
"""item-view/item-params: два <div> с ОДНИМ маркером ("О квартире"/"О доме"),
каждый со своим <ul> раньше css_first брал только первый."""
def test_sale_type_owner_card(self) -> None:
e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1")
assert e.sale_type == "free"
def test_sale_type_agency_card(self) -> None:
e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2")
assert e.sale_type == "free"
def test_house_params_from_second_block_not_dropped(self) -> None:
"""Regression guard: house_type/total_floors_house из ВТОРОГО
item-view/item-params блока больше не теряются молча."""
e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1")
assert e.house_type == "monolith_brick"
assert e.total_floors_house == 16
def test_house_params_second_card(self) -> None:
e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2")
assert e.house_type == "brick"
assert e.total_floors_house == 17
class TestMetroStructured:
"""#item-view-address: имя станции + время читаются из структурной разметки,
а не из regex по тексту описания."""
def test_metro_owner_card(self) -> None:
e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1")
names = [s["name"] for s in e.metro_stations]
# "Машиностроителей" и "Уралмаш" не подходят под старый METRO_RE
# (регекс требовал суффикс -ская/-инская и т.п.) — структурный путь их не
# теряет.
assert names == ["Уральская", "Машиностроителей", "Уралмаш"]
assert e.metro_stations[0] == {
"name": "Уральская",
"min_to": 20,
"min_from": 16,
"mode": "walk",
}
assert e.metro_stations[2] == {
"name": "Уралмаш",
"min_to": 30,
"min_from": 21,
"mode": "walk",
}
def test_metro_agency_card_open_ended_time(self) -> None:
"""'от 31 мин.' -> min_from=None, min_to=31 (открытый диапазон)."""
e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2")
names = [s["name"] for s in e.metro_stations]
assert names == ["Площадь 1905 года", "Геологическая", "Чкаловская"]
for station in e.metro_stations:
assert station["min_from"] is None
assert station["min_to"] == 31
assert station["mode"] == "walk"
class TestIsHomeowner:
"""[data-marker='seller-info/label']: 'Частное лицо' -> True, 'Агентство' ->
False. Обе ветки подтверждены на разных эталонах (не выдумано)."""
def test_private_seller_is_homeowner_true(self) -> None:
e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1")
assert e.is_homeowner is True
def test_agency_seller_is_homeowner_false(self) -> None:
e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2")
assert e.is_homeowner is False
class TestDaysOnMarketFromPublishDate:
"""publish_date раньше искался ВНУТРИ item-id-блока — на этой вёрстке дата лежит
в соседнем [data-marker='item-view/item-date'] и всегда уходила None.
days_on_market честно выведенное производное (обе карточки: 'сегодня')."""
def test_publish_date_today_owner_card(self) -> None:
e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1")
assert e.publish_date == date.today()
assert e.days_on_market == 0
def test_publish_date_today_agency_card(self) -> None:
e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2")
assert e.publish_date == date.today()
assert e.days_on_market == 0
class TestCadastralNumberNotAvailable:
"""Regression guard: на этих карточках кадастрового номера нет НИГДЕ —
ни в тексте, ни в структурной разметке (domotekaReportTeaser.cadastralNumber
пустая строка). DetailEnrichment не имеет поля cadastral_number вовсе код
не должен его придумывать."""
def test_no_cadastral_field_on_dataclass(self) -> None:
e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1")
assert not hasattr(e, "cadastral_number")

View file

@ -1,9 +1,10 @@
"""Avito detail page parser — Stage 2b.
Извлекает 30+ полей из страницы объявления:
- Identity (item_id, title, price, publish_date, views)
- Identity (item_id, title, price, publish_date, days_on_market, views)
- Apartment params (rooms, area, floor, balcony_loggia, room_layout,
bathroom_type, windows_view, repair_state, sale_type, mortgage_available)
- Seller (is_homeowner data-marker='seller-info/label': "Частное лицо"/"Агентство")
- Location (lat, lon, avito_location_id, metro_stations[], address_full)
- House params (house_type, total_floors_house, lifts, concierge, closed_yard,
house_catalog_url) house_type/total_floors/concierge/closed_yard доезжают в
@ -16,6 +17,28 @@
Точка входа для batch-обогащения:
enrichment = await fetch_detail(item_url)
saved = save_detail_enrichment(db, enrichment)
#3047 — сверка с эталонной разметкой (2 живые карточки, 2026-08-21), что реально
парсится и что нет:
- sale_type: не переименование, было сломано ЧТЕНИЕ таблицы параметров Avito отдаёт
"О квартире" и "О доме" как ДВА ОТДЕЛЬНЫХ <div> с ОДНИМ И ТЕМ ЖЕ
data-marker='item-view/item-params', код брал только ПЕРВЫЙ (css_first) и терял
всё из второго. sale_type это не задевало (он в первом блоке), но house_type/
total_floors_house/лифты терялись молча. Починено читаем <ul> из ВСЕХ блоков
с этим маркером.
- metro_stations: было ТОЛЬКО эвристика по тексту описания (_extract_metro,
28 строк из 54 855). Теперь основной источник структурная разметка
(div#item-view-address, иконка "Пешком до метро"), описание — фолбэк.
- is_homeowner: не парсился вовсе. Структурный признак [data-marker=
'seller-info/label'] ("Частное лицо"/"Агентство"), НЕ текст описания.
- days_on_market: на странице В ЯВНОМ ВИДЕ нет ("дней"/"Размещено" 0 вхождений),
но есть дата публикации в [data-marker='item-view/item-date'] ("сегодня в HH:MM"),
из которой дата и days_on_market честно выводятся. Раньше publish_date тоже не
читался искали дату ВНУТРИ item-id-блока, а она лежит в СОСЕДНЕМ элементе.
- cadastral_number: НЕ извлекается на странице нет ни текста "кадастр", ни
заполненного значения. Единственный след пустая строка в JSON-блоке
domotekaReportTeaser.cadastralNumber (схема зарезервирована Avito, но не
заполняется на наблюдаемых карточках). Не парсим, источника нет.
"""
from __future__ import annotations
@ -26,7 +49,7 @@ import logging
import random
import re
from dataclasses import dataclass, field
from datetime import date
from datetime import date, timedelta
from typing import TYPE_CHECKING, Any
from urllib.parse import quote, urljoin, urlparse
@ -98,6 +121,12 @@ _PUBLISH_DATE_RE = re.compile(
r"июля|августа|сентября|октября|ноября|декабря)\s+в",
re.IGNORECASE,
)
# Относительные варианты даты публикации (#3047: на эталонах 2026-08-21 marker
# item-view/item-date отдаёт "· сегодня в HH:MM" — раньше эта дата не читалась вовсе,
# см. _extract_meta). "вчера" не встретилась в эталонах, но это тот же УИ-паттерн —
# добавлена по аналогии, не подтверждена живьём.
_PUBLISH_DATE_TODAY_RE = re.compile(r"\bсегодня\b", re.IGNORECASE)
_PUBLISH_DATE_YESTERDAY_RE = re.compile(r"\bвчера\b", re.IGNORECASE)
_FLOAT_RE = re.compile(r"[\d.,]+")
_INT_RE = re.compile(r"\d+")
_FLOOR_SPLIT_RE = re.compile(r"(\d+)\s+из\s+(\d+)")
@ -113,6 +142,13 @@ METRO_RE = re.compile(
re.IGNORECASE,
)
# Время до метро из СТРУКТУРНОЙ разметки (#item-view-address) — без ограничения на
# суффикс имени станции (METRO_RE ловит только "-ская"/"-инская" и т.п. из текста
# описания; "Машиностроителей", "Уралмаш" под него не подходят, но встречаются на
# эталонах). Имя станции здесь достаётся отдельно, не регексом — см.
# _extract_metro_structured.
_METRO_TIME_RE = re.compile(r"(?:(\d+)[\-](\d+)|от\s+(\d+))\s*мин")
# ── Маппинг RUS полей params-блока ───────────────────────────────────────────
# (rus_label) -> (field_name, conversion_type)
RUS_FIELD_MAP: dict[str, tuple[str, str | None]] = {
@ -211,6 +247,7 @@ class DetailEnrichment:
title: str | None = None
price_rub: int | None = None
publish_date: date | None = None
days_on_market: int | None = None
views_total: int | None = None
views_today: int | None = None
@ -231,6 +268,9 @@ class DetailEnrichment:
sale_type: str | None = None
mortgage_available: bool | None = None
# Seller
is_homeowner: bool | None = None
# House params (собираем, но НЕ сохраняем в listings — Stage 2c)
house_type: str | None = None
total_floors_house: int | None = None
@ -622,6 +662,12 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
title = _text(tree, "[data-marker='item-view/title-info']")
price_rub = _extract_price(tree)
publish_date, views_total, views_today = _extract_meta(tree)
days_on_market: int | None = None
if publish_date is not None:
days_on_market = max((date.today() - publish_date).days, 0)
# ── Seller ──────────────────────────────────────────────────────────────
is_homeowner = _extract_is_homeowner(tree)
# ── Location ────────────────────────────────────────────────────────────
map_el = tree.css_first("[data-marker='item-map-wrapper']")
@ -655,11 +701,28 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
# ── Description ─────────────────────────────────────────────────────────
description = _text(tree, "[data-marker='item-view/item-description']")
# ── Metro from description ───────────────────────────────────────────────
# ── Metro ────────────────────────────────────────────────────────────────
# #3047: раньше метро бралось ТОЛЬКО из текста описания (_extract_metro) — 28
# строк из 54 855 avito-листингов. На эталонах 2026-08-21 метро есть в структурной
# разметке (div#item-view-address, по одному <span> на станцию с иконкой
# "Пешком до метро") — читаем оттуда в первую очередь, оно покрывает и станции
# с именами, не подходящими под regex-суффиксы описания ("Машиностроителей",
# "Уралмаш"). Описание остаётся фолбэком — вдруг добавит станции, которых нет
# в структурном блоке (например, из другого конца текста).
metro_stations: list[dict[str, Any]] = []
if description:
address_block = tree.css_first("#item-view-address")
if address_block is not None:
metro_stations = _extract_metro_structured(address_block)
if not metro_stations and description:
metro_stations = _extract_metro(description)
# ── Cadastral number ────────────────────────────────────────────────────
# #3047: НЕ извлекается — на эталонах 2026-08-21 нет ни текста "кадастров"/
# "Кадастровый" на странице, ни заполненного значения. Единственный след поля —
# пустая строка в JSON-блоке domotekaReportTeaser.cadastralNumber ("":"" — схема
# у Avito зарезервирована, но не заполняется на наблюдаемых карточках). Не
# парсим и не выдумываем источник — cadastral_number остаётся None для avito.
# ── House catalog link ───────────────────────────────────────────────────
# Новостройки отдают кнопку ЖК (nd-jk-details-button); вторичка — ссылку
# «узнать больше о доме» под другим маркером. Fallback на любой /catalog/houses/
@ -683,18 +746,22 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
photo_urls.append(src)
# ── Params blocks ────────────────────────────────────────────────────────
params_block = tree.css_first("[data-marker='item-view/item-params']")
apt_params: dict[str, str] = {}
house_params: dict[str, str] = {}
if params_block is not None:
ul_els = params_block.css("ul")
if len(ul_els) >= 1:
apt_params = _parse_params_ul(ul_els[0])
if len(ul_els) >= 2:
house_params = _parse_params_ul(ul_els[1])
# Merge house_params into apt_params — некоторые страницы совмещают всё в одном ul
all_params = {**apt_params, **house_params}
# #3047: на эталонах 2026-08-21 "О квартире" и "О доме" — ДВА ОТДЕЛЬНЫХ <div>
# с ОДНИМ И ТЕМ ЖЕ data-marker='item-view/item-params' (каждый со своим <ul>),
# а не один div с двумя <ul> внутри, как предполагал старый код (комментарий
# "некоторые страницы совмещают всё в одном ul" ниже описывал ИМЕННО эту старую
# вёрстку). tree.css_first брал только ПЕРВЫЙ такой div — если это апартаментный
# блок (он идёт первым на эталонах), sale_type и прочие его поля читались верно,
# но house-блок читался мёртвой веткой `len(ul_els) >= 2` внутри ПЕРВОГО div —
# она никогда не срабатывает (в первом div всегда ровно один <ul>), поэтому
# house_type/total_floors_house/лифты молча терялись на каждой карточке.
# Собираем <ul> из ВСЕХ блоков с этим маркером — устойчиво и к старой (один
# div/два ul), и к новой (два div/по одному ul) вёрстке, независимо от порядка
# apartment/house-блоков.
all_params: dict[str, str] = {}
for params_block in tree.css("[data-marker='item-view/item-params']"):
for ul in params_block.css("ul"):
all_params.update(_parse_params_ul(ul))
# ── Parse apartment fields ───────────────────────────────────────────────
rooms: int | None = None
@ -821,6 +888,7 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
title=title,
price_rub=price_rub,
publish_date=publish_date,
days_on_market=days_on_market,
views_total=views_total,
views_today=views_today,
rooms=rooms,
@ -838,6 +906,7 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
repair_state=repair_state,
sale_type=sale_type,
mortgage_available=mortgage_available,
is_homeowner=is_homeowner,
house_type=house_type,
total_floors_house=total_floors_house,
passenger_elevators=passenger_elevators,
@ -949,9 +1018,11 @@ def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
windows_view = COALESCE(:windows_view, windows_view),
sale_type = COALESCE(:sale_type, sale_type),
mortgage_available = COALESCE(:mortgage_available, mortgage_available),
is_homeowner = COALESCE(:is_homeowner, is_homeowner),
views_total = COALESCE(:views_total, views_total),
views_today = COALESCE(:views_today, views_today),
publish_date = COALESCE(:publish_date, publish_date),
days_on_market = COALESCE(:days_on_market, days_on_market),
description = COALESCE(:description, description),
owners_count = COALESCE(:owners_count, owners_count),
owners_at_least = COALESCE(:owners_at_least, owners_at_least),
@ -986,9 +1057,11 @@ def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
"windows_view": e.windows_view,
"sale_type": e.sale_type,
"mortgage_available": e.mortgage_available,
"is_homeowner": e.is_homeowner,
"views_total": e.views_total,
"views_today": e.views_today,
"publish_date": e.publish_date,
"days_on_market": e.days_on_market,
"description": e.description,
"owners_count": e.owners_count,
"owners_at_least": e.owners_at_least,
@ -1112,17 +1185,20 @@ def _extract_price(tree: HTMLParser) -> int | None:
return _try_int(content)
def _extract_meta(tree: HTMLParser) -> tuple[date | None, int | None, int | None]:
"""Извлечь publish_date, views_total, views_today из item-id блока."""
el = tree.css_first("[data-marker='item-view/item-id']")
if el is None:
return None, None, None
def _parse_publish_date(text_val: str) -> date | None:
"""Распарсить дату публикации из текста item-date-блока.
# Поиск по всему тексту включая дочерние элементы
full_text = el.text(strip=False)
Поддержаны: относительное "сегодня" (ПОДТВЕРЖДЕНО на эталонах 2026-08-21
"· сегодня в HH:MM"), относительное "вчера" (тот же UI-паттерн, добавлено по
аналогии живьём не встречалось) и абсолютное "D месяц в" (RUS_MONTHS).
"""
today = date.today()
if _PUBLISH_DATE_TODAY_RE.search(text_val):
return today
if _PUBLISH_DATE_YESTERDAY_RE.search(text_val):
return today - timedelta(days=1)
publish_date: date | None = None
m_date = _PUBLISH_DATE_RE.search(full_text)
m_date = _PUBLISH_DATE_RE.search(text_val)
if m_date:
try:
day = int(m_date.group(1))
@ -1135,13 +1211,56 @@ def _extract_meta(tree: HTMLParser) -> tuple[date | None, int | None, int | None
# дату в будущем (завышает свежесть лота). Если получившаяся дата
# оказалась в будущем относительно момента парсинга — откатываем
# на год назад (это дата из прошлого года).
today = date.today()
candidate = date(today.year, month, day)
if candidate > today:
candidate = date(today.year - 1, month, day)
publish_date = candidate
return candidate
except (ValueError, KeyError):
pass
return None
def _extract_is_homeowner(tree: HTMLParser) -> bool | None:
"""Тип продавца из [data-marker='seller-info/label'].
Подтверждено на ОБОИХ эталонах 2026-08-21: card1 "Частное лицо" -> True
(собственник), card2 "Агентство" -> False (посредник). Других значений не
наблюдали если текст незнакомый (партнёрские бейджи и т.п.), возвращаем None,
не гадаем.
"""
label = _text(tree, "[data-marker='seller-info/label']")
if label is None:
return None
label_lower = label.strip().lower()
if label_lower == "частное лицо":
return True
if "агентство" in label_lower:
return False
return None
def _extract_meta(tree: HTMLParser) -> tuple[date | None, int | None, int | None]:
"""Извлечь publish_date, views_total, views_today.
publish_date (#3047): раньше искалась ТОЛЬКО внутри текста item-id-блока
([data-marker='item-view/item-id']) на эталонах 2026-08-21 этот блок содержит
ИСКЛЮЧИТЕЛЬНО "№ <id>", а дата лежит в СОСЕДНЕМ элементе
[data-marker='item-view/item-date'] (" · сегодня в HH:MM"), который .text()
item-id-блока не видит publish_date всегда уходил None. Теперь читаем
item-date-маркер как основной источник, текст item-id-блока fallback (вдруг
где-то ещё встречается старая вёрстка со вложенной датой).
"""
el = tree.css_first("[data-marker='item-view/item-id']")
if el is None:
return None, None, None
# Поиск по всему тексту включая дочерние элементы
full_text = el.text(strip=False)
date_el = tree.css_first("[data-marker='item-view/item-date']")
publish_date = _parse_publish_date(date_el.text(strip=True)) if date_el is not None else None
if publish_date is None:
publish_date = _parse_publish_date(full_text)
views_total: int | None = None
m_total = _VIEWS_TOTAL_RE.search(full_text)
@ -1213,3 +1332,59 @@ def _extract_metro(text_val: str) -> list[dict[str, Any]]:
}
)
return stations
def _extract_metro_structured(address_block: Node) -> list[dict[str, Any]]:
"""Станции метро из структурной разметки (div#item-view-address), а не из
текста описания.
Каждая станция на эталонах 2026-08-21 <span>-обёртка вокруг
svg[data-icon-name='walkingroute'] (иконка "Пешком до метро"): время лежит в
тексте родителя иконки ('1620 мин.' / 'от 31 мин.'), имя станции в тексте
общей span-обёртки МИНУС текст времени. Разбор через "текст минус хвост",
а не через хэшированные CSS-классы Avito (`_22d8cf68e753a9b9` и т.п.) они
меняются между релизами, structure (svg parent parent) стабильнее.
Покрывает имена без ограничения на суффикс (в отличие от METRO_RE, заточенного
под текст описания) например "Машиностроителей", "Уралмаш" из card1.
"""
stations: list[dict[str, Any]] = []
for svg in address_block.css("svg[data-icon-name='walkingroute']"):
icon_span = svg.parent
if icon_span is None:
continue
time_wrapper = icon_span.parent
if time_wrapper is None:
continue
station_span = time_wrapper.parent
if station_span is None:
continue
time_text = time_wrapper.text(strip=True)
full_text = station_span.text(strip=True)
if not time_text or not full_text.endswith(time_text):
continue
name = full_text[: -len(time_text)].strip()
if not name:
continue
m = _METRO_TIME_RE.search(time_text)
if not m:
continue
if m.group(1) and m.group(2):
min_from: int | None = int(m.group(1))
min_to = int(m.group(2))
elif m.group(3):
min_from = None
min_to = int(m.group(3))
else:
continue
stations.append(
{
"name": name,
"min_to": min_to,
"min_from": min_from,
"mode": "walk",
}
)
return stations