Stage 0 of the scraper_kit migration epic (#2277): shared test tool for issues #2305-#2310, which each need to prove their kit-path importer produces the same output as the legacy path on the same input. - tests/support/parity.py: assert_parity()/compare_outputs() normalize dataclass/pydantic outputs to dict/list/scalar before comparing, since legacy vs kit dataclasses (e.g. DetailEnrichment) are different classes and dataclass __eq__ always returns False across classes even when all field values match. Supports ignore_fields (drop non-deterministic fields like latency_ms/fetched_at) and numeric tolerance (math.isclose) for float fields, with an assertion listing every differing field (path + legacy value + kit value) on mismatch. - tests/support/test_parity.py: unit tests for the harness itself (identical outputs pass, differing outputs raise with informative diff, tolerance/ignore_fields options, cross-class dataclass parity). - tests/scrapers/test_avito_detail_kit_parity.py: end-to-end smoke proof against real code — app.services.scrapers.avito_detail.parse_detail_html (legacy, reached via admin.py's scrape_avito_detail debug endpoint through fetch_detail) vs scraper_kit.providers.avito.detail's copy, on a fixed HTML fixture. - tests/support/README.md: usage note for #2305-#2310 migration PRs. Found while implementing: tests/test_scraper_kit_*_parity.py (9 files, ~3400 lines) already do ad-hoc `dataclasses.asdict(old) == asdict(new)` parity checks for the already-migrated SERP scraper modules (avito/cian/ domclick/yandex/base/scheduler/pipeline) — this harness generalizes that repeated pattern for the remaining 12 non-scraper importers, adding ignore_fields/tolerance which those ad-hoc checks don't have.
102 lines
5.6 KiB
Python
102 lines
5.6 KiB
Python
"""Smoke-proof для parity-harness'а (tests/support/parity.py) — issue #2304.
|
||
|
||
Доказывает, что harness реально ловит эквивалентность legacy ↔ scraper_kit на
|
||
РЕАЛЬНОЙ паре функций, а не на синтетике: `parse_detail_html` — parsing-ядро,
|
||
которое `app/api/v1/admin.py::scrape_avito_detail` (debug-роут ручного запуска
|
||
парсера) вызывает косвенно через `fetch_detail()` (fetch_detail = HTTP GET +
|
||
`parse_detail_html(html, url)`; сам parse — чистая функция, без сети/БД, что и
|
||
позволяет сравнить её offline на фиксированной HTML-фикстуре).
|
||
|
||
`app/services/scrapers/avito_detail.py::parse_detail_html` (legacy) и
|
||
`scraper_kit/providers/avito/detail.py::parse_detail_html` (kit) на момент
|
||
написания теста ИДЕНТИЧНЫ по телу функции (см. audit
|
||
`Scraper_Kit_Legacy_Dependency_Audit_0703` в vault) — различаются только
|
||
import-пути модулей, из которых берутся зависимости (`app.services.scrapers.*`
|
||
vs `scraper_kit.*`) и Strangler-инжекция `ScraperConfig` в соседних функциях
|
||
(`fetch_detail`, `_build_detail_session`), которую сам `parse_detail_html` не
|
||
использует. Это НЕ миграция admin.py (issue #2305+) — только доказательство
|
||
инструмента.
|
||
|
||
HTML-фикстура — тот же minimal-marker набор, что и в
|
||
`tests/test_avito_detail_parse.py::test_minimal_parse` (независимая копия
|
||
литерала, чтобы этот тест не зависел от другого test-модуля).
|
||
"""
|
||
|
||
# ruff: noqa: E501 # HTML-фикстура содержит длинные строки разметки
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import os
|
||
|
||
# app.services.scrapers.avito_detail импортирует app.core.config.settings=Settings(),
|
||
# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен
|
||
# (mirror tests/test_scraper_kit_avito_golden_parity.py — тест должен жить и без
|
||
# CI-уровневого DATABASE_URL из ci-tradein.yml/deploy-tradein.yml).
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
from scraper_kit.providers.avito.detail import (
|
||
parse_detail_html as kit_parse_detail_html,
|
||
)
|
||
|
||
from app.services.scrapers.avito_detail import (
|
||
parse_detail_html as legacy_parse_detail_html,
|
||
)
|
||
from tests.support.parity import assert_parity
|
||
|
||
_DETAIL_HTML = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 7986882804 · 18 мая в 13:40 · 1899 просмотров (+ 12 сегодня)</div>
|
||
<span itemprop="price" content="11990000">11 990 000 ₽</span>
|
||
<div data-marker="item-map-wrapper" data-map-lat="56.797713" data-map-lon="60.609135" data-location-id="654070"></div>
|
||
<div data-marker="item-view/item-params">
|
||
<ul>
|
||
<li>Количество комнат: 3</li>
|
||
<li>Общая площадь: 75 м²</li>
|
||
<li>Площадь кухни: 15 м²</li>
|
||
<li>Этаж: 20 из 26</li>
|
||
<li>Балкон или лоджия: лоджия</li>
|
||
<li>Ремонт: евро</li>
|
||
<li>Способ продажи: свободная</li>
|
||
<li>Условия продажи: возможна ипотека</li>
|
||
</ul>
|
||
<ul>
|
||
<li>Тип дома: монолитный</li>
|
||
<li>Этажей в доме: 26</li>
|
||
</ul>
|
||
</div>
|
||
<a data-marker="nd-jk-details-button" href="/catalog/houses/ekaterinburg/ul_postovskogo_17a/3171365">Дом</a>
|
||
<div data-marker="domoteka-entry-block">
|
||
<p data-marker="TeaserData.item">1 собственник</p>
|
||
<p data-marker="TeaserData.item">Последняя смена собственника 5 мая 2020</p>
|
||
<p data-marker="TeaserData.item">Не найдены ограничения и обременения</p>
|
||
<p data-marker="TeaserData.item">Совпадают площадь, адрес и этаж</p>
|
||
</div>
|
||
<div data-marker="item-view/item-description">Отличная квартира в новом ЖК. Чкаловская 11-15 мин пешком.</div>
|
||
</body></html>
|
||
"""
|
||
_SOURCE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/test_7986882804"
|
||
|
||
|
||
def test_legacy_and_kit_parse_detail_html_are_class_distinct() -> None:
|
||
"""Sanity-check ДО harness'а: подтверждает, зачем harness вообще нужен.
|
||
|
||
legacy DetailEnrichment и kit DetailEnrichment — РАЗНЫЕ классы (разные
|
||
модули) → обычный `==` даёт False, хотя данные идентичны. Именно это
|
||
harness должен обходить через структурное сравнение (dataclasses.fields).
|
||
"""
|
||
legacy_result = legacy_parse_detail_html(_DETAIL_HTML, _SOURCE_URL)
|
||
kit_result = kit_parse_detail_html(_DETAIL_HTML, _SOURCE_URL)
|
||
|
||
assert type(legacy_result) is not type(kit_result)
|
||
assert legacy_result != kit_result # dataclass __eq__ проверяет class identity первым
|
||
assert dataclasses.asdict(legacy_result) == dataclasses.asdict(kit_result)
|
||
|
||
|
||
def test_parity_harness_confirms_avito_detail_parse_equivalence() -> None:
|
||
"""Реальное proof-of-concept использование harness'а (issues #2305-#2310 template)."""
|
||
assert_parity(
|
||
legacy_fn=legacy_parse_detail_html,
|
||
kit_fn=kit_parse_detail_html,
|
||
fixtures=[(_DETAIL_HTML, _SOURCE_URL)],
|
||
)
|