"""#726 — avito SERP listing_date extraction (sortTimeStamp JSON + DOM fallback). Offline: парсит фикстуру через AvitoScraper._parse_html, без сети/curl_cffi/БД. Регрессия на дрейф разметки: дата у lazy-карточек (без DOM item-date) берётся из per-item JSON sortTimeStamp; у верхних карточек — из DOM data-marker="item-date". """ import os from datetime import date, datetime, timedelta, timezone from pathlib import Path os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services.scrapers.avito import ( AvitoScraper, _build_sort_timestamp_map, ) FIXTURE = Path(__file__).parent / "fixtures" / "avito_serp_sample.html" # Из фикстуры (sortTimeStamp, epoch-ms). _TS_LAZY = 1700000000000 _TS_TOP = 1700086400000 # #726: дата берётся в MSK (UTC+3), чтобы совпадать с тем, что Avito показывает на # сайте (sortTimeStamp — UTC epoch, но отображается по Москве). _MSK = timezone(timedelta(hours=3)) def _expected(ms: int) -> date: return datetime.fromtimestamp(ms / 1000, tz=_MSK).date() def test_sort_timestamp_map_built_from_json() -> None: html = FIXTURE.read_text(encoding="utf-8") ts_map = _build_sort_timestamp_map(html) assert ts_map["8043936560"] == _expected(_TS_LAZY) assert ts_map["8163084615"] == _expected(_TS_TOP) def test_parse_html_sets_listing_date_for_all_cards() -> None: """Обе карточки получают listing_date != None (DoD #726).""" html = FIXTURE.read_text(encoding="utf-8") lots = AvitoScraper()._parse_html(html, "https://www.avito.ru/ekaterinburg/kvartiry") assert len(lots) == 2 by_url = {lot.source_url: lot for lot in lots} assert all(lot.listing_date is not None for lot in lots), "every card must carry a date" # Lazy card (нет DOM item-date) → дата из JSON sortTimeStamp. lazy = next(lot for lot in lots if "8043936560" in (lot.source_url or "")) assert lazy.listing_date == _expected(_TS_LAZY) assert by_url # touch to keep linter calm if structure changes def test_lazy_card_date_comes_from_json_not_dom() -> None: """Карточка без data-marker='item-date' всё равно датируется (JSON primary).""" html = FIXTURE.read_text(encoding="utf-8") assert 'data-item-id="8043936560"' in html # у этой карточки в фикстуре НЕТ DOM item-date — проверяем, что дата не потерялась lots = AvitoScraper()._parse_html(html, "https://www.avito.ru/x") lazy = next(lot for lot in lots if "8043936560" in (lot.source_url or "")) assert lazy.listing_date == _expected(_TS_LAZY) # ── listing_segment по DOM-маркеру item-development-name ─────────────────────── # Каждый avito-URL квартиры содержит /kvartiry/ (вкл. новостройки), поэтому # сегмент определяется не по URL, а по наличию data-marker="item-development-name" # (название ЖК/застройщика рендерится только у карточек новостроек). def _serp_card(item_id: str, *, with_dev_name: bool, with_zhk_anchor: bool = False) -> str: dev = '
ЖК «Федерация»
' if with_dev_name else "" zhk = ( 'ЖК Федерация' if with_zhk_anchor else "" ) return f"""
2-к. квартира, 50 м², 5/20 эт. {dev} {zhk}
""" def test_segment_novostroyki_when_development_name_present() -> None: """Карточка с data-marker='item-development-name' → listing_segment='novostroyki'.""" html = f"{_serp_card('8043003287', with_dev_name=True)}" lots = AvitoScraper()._parse_html(html, "https://www.avito.ru/ekaterinburg/kvartiry") assert len(lots) == 1 assert lots[0].listing_segment == "novostroyki" def test_segment_vtorichka_when_no_development_name() -> None: """Карточка без маркера ЖК → listing_segment='vtorichka' (даже с /kvartiry/ в URL).""" html = f"{_serp_card('8043936560', with_dev_name=False)}" lots = AvitoScraper()._parse_html(html, "https://www.avito.ru/ekaterinburg/kvartiry") assert len(lots) == 1 assert lots[0].listing_segment == "vtorichka" # ── newbuilding (ЖК) id/url из zhk-сабдомена ────────────────────────────────── def test_newbuilding_captured_from_zhk_anchor() -> None: """Новостройка с якорем zhk-*.avito.ru → newbuilding_url без ?context, id = slug.""" card = _serp_card("8043003287", with_dev_name=True, with_zhk_anchor=True) html = f"{card}" lots = AvitoScraper()._parse_html(html, "https://www.avito.ru/ekaterinburg/kvartiry") assert len(lots) == 1 assert lots[0].newbuilding_url == "https://zhk-fed-ekaterinburg.avito.ru" assert lots[0].newbuilding_id == "fed-ekaterinburg" def test_newbuilding_none_when_novostroyka_without_anchor() -> None: """Новостройка без zhk-якоря → оба поля None (сегмент по dev-name остаётся).""" card = _serp_card("8043003287", with_dev_name=True, with_zhk_anchor=False) html = f"{card}" lots = AvitoScraper()._parse_html(html, "https://www.avito.ru/ekaterinburg/kvartiry") assert len(lots) == 1 assert lots[0].listing_segment == "novostroyki" assert lots[0].newbuilding_id is None assert lots[0].newbuilding_url is None def test_newbuilding_none_for_vtorichka() -> None: """Вторичка → newbuilding_id/url оба None.""" html = f"{_serp_card('8043936560', with_dev_name=False)}" lots = AvitoScraper()._parse_html(html, "https://www.avito.ru/ekaterinburg/kvartiry") assert len(lots) == 1 assert lots[0].newbuilding_id is None assert lots[0].newbuilding_url is None