fix(tradein/avito): парсер карточки читал только первый блок параметров — параметры дома терялись молча #3048
4 changed files with 946 additions and 27 deletions
303
tradein-mvp/backend/tests/fixtures/avito_detail_card_agency_95m2.html
vendored
Normal file
303
tradein-mvp/backend/tests/fixtures/avito_detail_card_agency_95m2.html
vendored
Normal file
File diff suppressed because one or more lines are too long
303
tradein-mvp/backend/tests/fixtures/avito_detail_card_owner_66m2.html
vendored
Normal file
303
tradein-mvp/backend/tests/fixtures/avito_detail_card_owner_66m2.html
vendored
Normal file
File diff suppressed because one or more lines are too long
138
tradein-mvp/backend/tests/test_avito_detail_fields_3047.py
Normal file
138
tradein-mvp/backend/tests/test_avito_detail_fields_3047.py
Normal 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")
|
||||
|
|
@ -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'] (иконка "Пешком до метро"): время лежит в
|
||||
тексте родителя иконки ('16–20 мин.' / 'от 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue