diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py
new file mode 100644
index 00000000..f0b752ff
--- /dev/null
+++ b/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py
@@ -0,0 +1,102 @@
+"""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 = """
+
+№ 7986882804 · 18 мая в 13:40 · 1899 просмотров (+ 12 сегодня)
+11 990 000 ₽
+
+
+
+ - Количество комнат: 3
+ - Общая площадь: 75 м²
+ - Площадь кухни: 15 м²
+ - Этаж: 20 из 26
+ - Балкон или лоджия: лоджия
+ - Ремонт: евро
+ - Способ продажи: свободная
+ - Условия продажи: возможна ипотека
+
+
+ - Тип дома: монолитный
+ - Этажей в доме: 26
+
+
+Дом
+
+
1 собственник
+
Последняя смена собственника 5 мая 2020
+
Не найдены ограничения и обременения
+
Совпадают площадь, адрес и этаж
+
+Отличная квартира в новом ЖК. Чкаловская 11-15 мин пешком.
+
+"""
+_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)],
+ )
diff --git a/tradein-mvp/backend/tests/support/README.md b/tradein-mvp/backend/tests/support/README.md
new file mode 100644
index 00000000..1b48c877
--- /dev/null
+++ b/tradein-mvp/backend/tests/support/README.md
@@ -0,0 +1,80 @@
+# tests/support/ — общие test-инструменты (не сами тесты)
+
+## parity.py — legacy → scraper_kit parity harness (issue #2304)
+
+Инструмент для issues #2305-#2310 (миграция 12 неймигрированных importers
+`app/services/scrapers/*` → `scraper_kit` эквиваленты, см. audit
+`Scraper_Kit_Legacy_Dependency_Audit_0703` в vault). Каждая такая миграция
+должна доказать, что kit-путь даёт ТОТ ЖЕ результат, что и legacy-путь на
+одном и том же входе — для этого используйте `assert_parity`.
+
+### Быстрый старт
+
+```python
+from app.services.scrapers.cian_valuation import evaluate_via_cian as legacy_fn
+from scraper_kit.providers.cian.valuation import evaluate_via_cian as kit_fn
+from tests.support.parity import assert_parity
+
+
+def test_cian_valuation_parity() -> None:
+ assert_parity(
+ legacy_fn=legacy_fn,
+ kit_fn=kit_fn,
+ fixtures=[
+ (fixture_html_1, "https://cian.ru/flat/1"),
+ (fixture_html_2, "https://cian.ru/flat/2"),
+ ],
+ ignore_fields={"latency_ms", "fetched_at"}, # недетерминированные поля
+ tolerance=1e-6, # допуск для float-полей (напр. рассчитанные оценки)
+ )
+```
+
+### Как формировать `fixtures`
+
+Каждый элемент списка — один тестовый вход:
+
+- `tuple`/`list` → распаковывается как позиционные аргументы: `legacy_fn(*fixture)`;
+- любое другое значение (str, dict, ...) → передаётся как единственный
+ позиционный аргумент: `legacy_fn(fixture)`.
+
+Начните с 1-2 hardcoded HTML-фикстур/dict'ов (см.
+`tests/scrapers/test_avito_detail_kit_parity.py` — референсный пример).
+DB-фикстуры НЕ нужны для чистых parse/compute-функций — используйте их
+только если сама legacy/kit-функция реально требует Session.
+
+### Почему нельзя просто `==`
+
+legacy- и kit-версии одного и того же dataclass (напр. `DetailEnrichment`,
+`CianValuationResult`) — это РАЗНЫЕ Python-классы (живут в разных модулях),
+даже если поля идентичны. Дефолтный `dataclass.__eq__` сначала проверяет
+`other.__class__ is self.__class__` — для двух разных классов это всегда
+`False`, ДАЖЕ когда все значения полей совпадают. `assert_parity` / `compare_outputs`
+нормализуют оба вывода в dict/list/scalar (через `dataclasses.fields()` /
+`.model_dump()` рекурсивно) и сравнивают СТРУКТУРНО, по именам полей — эта
+проблема класс-идентичности не мешает.
+
+### ignore_fields vs tolerance
+
+- `ignore_fields={"latency_ms", "fetched_at", ...}` — поле целиком исключается
+ из сравнения на ЛЮБОМ уровне вложенности. Используйте для полей, у которых
+ даже приблизительное совпадение не гарантировано (timestamps, request-id).
+- `tolerance=1e-6` — числовой (`int`/`float`, НЕ `bool`) допуск через
+ `math.isclose(rel_tol=tolerance, abs_tol=tolerance)`. Используйте для
+ float-полей, где legacy/kit могут давать чуть разное значение из-за
+ порядка операций с плавающей точкой (не для timestamps/datetime — там
+ используйте `ignore_fields`).
+
+### При мисматче
+
+`assert_parity` кидает `ParityMismatchError` (подкласс `AssertionError`) со
+списком ВСЕХ различающихся полей: путь до поля + значение legacy + значение
+kit. Не просто "not equal" — сразу видно, что чинить.
+
+### Не входит в scope harness'а
+
+- Он НЕ загружает DB-фикстуры сам — если legacy/kit функция требует
+ `Session`, передавайте mock/session в fixture-tuple как обычно.
+ Live network/DB в parity-тестах избегайте — они должны быть
+ detereministic offline unit-тестами.
+- Он НЕ мигрирует сами importers — это делает каждый sub-issue #2305-#2310
+ отдельно (тесты для конкретной пары legacy/kit функций пишет тот sub-issue).
diff --git a/tradein-mvp/backend/tests/support/__init__.py b/tradein-mvp/backend/tests/support/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tradein-mvp/backend/tests/support/parity.py b/tradein-mvp/backend/tests/support/parity.py
new file mode 100644
index 00000000..f4927a3b
--- /dev/null
+++ b/tradein-mvp/backend/tests/support/parity.py
@@ -0,0 +1,178 @@
+"""Общий harness для parity-тестов legacy → scraper_kit (issue #2304).
+
+Каждая миграция importer'а legacy `app/services/scrapers/*` на его
+`scraper_kit` эквивалент (issues #2305-#2310) должна доказать, что kit-путь
+даёт ТОТ ЖЕ результат, что и legacy-путь на одном и том же входе. Это —
+переиспользуемый инструмент для такого доказательства, НЕ сама миграция.
+
+Usage (см. tests/support/README.md для подробностей)::
+
+ from tests.support.parity import assert_parity
+
+ def test_parse_detail_html_parity() -> None:
+ assert_parity(
+ legacy_fn=legacy_parse_detail_html,
+ kit_fn=kit_parse_detail_html,
+ fixtures=[(html, source_url)],
+ ignore_fields={"latency_ms", "fetched_at"},
+ tolerance=1e-6,
+ )
+
+Design note: legacy- и kit-версии одного и того же dataclass (напр.
+``DetailEnrichment``) — это РАЗНЫЕ классы (разные модули), поэтому обычный
+``==`` между их инстансами всегда False (dataclass ``__eq__`` сравнивает
+``other.__class__ is self.__class__`` первым делом). Поэтому сравнение
+нормализует оба вывода в dict/list/scalar (``_normalize``) и сравнивает
+СТРУКТУРНО, по полям — а не через identity классов.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import math
+from collections.abc import Callable, Iterable, Mapping, Sequence
+from typing import Any
+
+
+class ParityMismatchError(AssertionError):
+ """legacy_fn(...) и kit_fn(...) дали разный результат на одной фикстуре.
+
+ Подкласс AssertionError — совместим с `pytest.raises(AssertionError)`
+ и обычным assert-репортингом pytest.
+ """
+
+
+def _normalize(value: Any) -> Any:
+ """Приводит dataclass / pydantic-модель к сравнимому dict/list/scalar.
+
+ Рекурсивно — вложенные dataclass/pydantic-поля тоже нормализуются.
+ """
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
+ return {f.name: _normalize(getattr(value, f.name)) for f in dataclasses.fields(value)}
+ if hasattr(value, "model_dump") and callable(value.model_dump):
+ return _normalize(value.model_dump())
+ if isinstance(value, Mapping):
+ return {k: _normalize(v) for k, v in value.items()}
+ if isinstance(value, list | tuple):
+ return [_normalize(v) for v in value]
+ return value
+
+
+def _is_number(value: Any) -> bool:
+ # bool — подкласс int, но семантически не "число с допуском".
+ return isinstance(value, int | float) and not isinstance(value, bool)
+
+
+def _within_tolerance(legacy_val: Any, kit_val: Any, tolerance: float) -> bool:
+ if not tolerance or not _is_number(legacy_val) or not _is_number(kit_val):
+ return False
+ return math.isclose(float(legacy_val), float(kit_val), rel_tol=tolerance, abs_tol=tolerance)
+
+
+def _diff(
+ path: str,
+ legacy_val: Any,
+ kit_val: Any,
+ ignore_fields: frozenset[str],
+ tolerance: float,
+ diffs: list[str],
+) -> None:
+ legacy_val = _normalize(legacy_val)
+ kit_val = _normalize(kit_val)
+
+ legacy_is_map = isinstance(legacy_val, Mapping)
+ kit_is_map = isinstance(kit_val, Mapping)
+ if legacy_is_map or kit_is_map:
+ if not (legacy_is_map and kit_is_map):
+ diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
+ return
+ for key in sorted({*legacy_val.keys(), *kit_val.keys()}, key=str):
+ if key in ignore_fields:
+ continue
+ child_path = f"{path}.{key}"
+ if key not in legacy_val:
+ diffs.append(f"{child_path}: missing in legacy, kit={kit_val[key]!r}")
+ elif key not in kit_val:
+ diffs.append(f"{child_path}: legacy={legacy_val[key]!r}, missing in kit")
+ else:
+ _diff(child_path, legacy_val[key], kit_val[key], ignore_fields, tolerance, diffs)
+ return
+
+ legacy_is_seq = isinstance(legacy_val, list | tuple)
+ kit_is_seq = isinstance(kit_val, list | tuple)
+ if legacy_is_seq or kit_is_seq:
+ if not (legacy_is_seq and kit_is_seq):
+ diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
+ return
+ if len(legacy_val) != len(kit_val):
+ diffs.append(f"{path}: length differs — legacy={len(legacy_val)}, kit={len(kit_val)}")
+ return
+ for i, (lv, kv) in enumerate(zip(legacy_val, kit_val, strict=True)):
+ _diff(f"{path}[{i}]", lv, kv, ignore_fields, tolerance, diffs)
+ return
+
+ if legacy_val == kit_val:
+ return
+ if _within_tolerance(legacy_val, kit_val, tolerance):
+ return
+ diffs.append(f"{path}: legacy={legacy_val!r} != kit={kit_val!r}")
+
+
+def compare_outputs(
+ legacy_output: Any,
+ kit_output: Any,
+ *,
+ ignore_fields: Iterable[str] = (),
+ tolerance: float = 0.0,
+) -> list[str]:
+ """Возвращает список текстовых различий между двумя выводами (пусто = совпадают).
+
+ ignore_fields — имена полей (ключи dict / поля dataclass на ЛЮБОМ уровне
+ вложенности), которые пропускаются при сравнении — для недетерминированных
+ полей вроде ``latency_ms`` / ``fetched_at`` / ``request_id``.
+ tolerance — абсолютно-и-относительный допуск (``math.isclose``) для
+ числовых полей (int/float, НЕ bool). Не применяется к datetime/date —
+ такие поля, если недетерминированы, кладите в ignore_fields.
+ """
+ diffs: list[str] = []
+ _diff("$", legacy_output, kit_output, frozenset(ignore_fields), tolerance, diffs)
+ return diffs
+
+
+def assert_parity(
+ legacy_fn: Callable[..., Any],
+ kit_fn: Callable[..., Any],
+ fixtures: Sequence[Any],
+ *,
+ ignore_fields: Iterable[str] = (),
+ tolerance: float = 0.0,
+) -> None:
+ """Прогоняет legacy_fn и kit_fn на каждой фикстуре, сверяет вывод.
+
+ Args:
+ legacy_fn: функция из `app/services/scrapers/*` (legacy-путь).
+ kit_fn: соответствующая функция из `scraper_kit` (kit-путь).
+ fixtures: список входов. Каждый элемент фикстуры:
+ - tuple/list → распаковывается как позиционные аргументы (``*args``);
+ - любое другое значение (dict, str, ...) → передаётся как ЕДИНСТВЕННЫЙ
+ позиционный аргумент.
+ ignore_fields: имена полей, игнорируемых при сравнении (см. compare_outputs).
+ tolerance: числовой допуск для float-полей (см. compare_outputs).
+
+ Raises:
+ ParityMismatchError: если legacy_fn(...) != kit_fn(...) хотя бы на одной
+ фикстуре. Сообщение перечисляет ВСЕ различающиеся поля (path + old/new).
+ """
+ for i, fixture in enumerate(fixtures):
+ args = fixture if isinstance(fixture, tuple | list) else (fixture,)
+ legacy_result = legacy_fn(*args)
+ kit_result = kit_fn(*args)
+
+ diffs = compare_outputs(
+ legacy_result, kit_result, ignore_fields=ignore_fields, tolerance=tolerance
+ )
+ if diffs:
+ diff_text = "\n".join(f" - {d}" for d in diffs)
+ raise ParityMismatchError(
+ f"Parity mismatch on fixture #{i} (input={fixture!r}):\n{diff_text}"
+ )
diff --git a/tradein-mvp/backend/tests/support/test_parity.py b/tradein-mvp/backend/tests/support/test_parity.py
new file mode 100644
index 00000000..b1d47f81
--- /dev/null
+++ b/tradein-mvp/backend/tests/support/test_parity.py
@@ -0,0 +1,119 @@
+"""Unit-тесты для parity-harness'а самого по себе (tests/support/parity.py).
+
+Проверяет три сценария из issue #2304:
+- legacy/kit выводы идентичны → assert_parity проходит без исключения;
+- выводы различаются → ParityMismatchError с информативным диффом (не просто "not equal");
+- tolerance-опция гасит незначащие float-расхождения (напр. latency_ms).
+
+Также покрывает ignore_fields (второй способ игнорировать недетерминированные поля)
+и кейс, ради которого harness вообще нужен: dataclass-инстансы РАЗНЫХ классов
+(имитация legacy vs kit module) с одинаковыми полями должны сравниваться
+структурно, а не через identity классов.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import pytest
+
+from tests.support.parity import ParityMismatchError, assert_parity, compare_outputs
+
+
+def test_assert_parity_passes_when_outputs_identical() -> None:
+ def legacy_fn(x: int) -> dict[str, int]:
+ return {"value": x * 2}
+
+ def kit_fn(x: int) -> dict[str, int]:
+ return {"value": x * 2}
+
+ assert_parity(legacy_fn, kit_fn, fixtures=[1, 2, 3])
+
+
+def test_assert_parity_raises_with_informative_diff_when_outputs_differ() -> None:
+ def legacy_fn(x: int) -> dict[str, int]:
+ return {"value": x, "count": 10}
+
+ def kit_fn(x: int) -> dict[str, int]:
+ return {"value": x, "count": 999} # намеренное расхождение
+
+ with pytest.raises(ParityMismatchError) as exc_info:
+ assert_parity(legacy_fn, kit_fn, fixtures=[1])
+
+ message = str(exc_info.value)
+ # Диагностика должна называть КОНКРЕТНОЕ поле и оба значения, не просто "not equal".
+ assert "$.count" in message
+ assert "10" in message
+ assert "999" in message
+ # value совпало у обеих функций → не должно попасть в список различий.
+ assert "$.value" not in message
+
+
+def test_assert_parity_tolerance_ignores_small_float_drift() -> None:
+ def legacy_fn(x: int) -> dict[str, float]:
+ return {"latency_ms": 100.001, "score": 0.5}
+
+ def kit_fn(x: int) -> dict[str, float]:
+ return {"latency_ms": 100.004, "score": 0.5}
+
+ # Без tolerance — расхождение 0.003 ловится.
+ with pytest.raises(ParityMismatchError):
+ assert_parity(legacy_fn, kit_fn, fixtures=[1])
+
+ # С tolerance >= drift — проходит.
+ assert_parity(legacy_fn, kit_fn, fixtures=[1], tolerance=0.01)
+
+
+def test_ignore_fields_skips_named_field_entirely() -> None:
+ def legacy_fn(x: int) -> dict[str, object]:
+ return {"fetched_at": "2026-01-01T00:00:00Z", "value": x}
+
+ def kit_fn(x: int) -> dict[str, object]:
+ return {"fetched_at": "2026-07-03T12:00:00Z", "value": x} # timestamp всегда разный
+
+ with pytest.raises(ParityMismatchError):
+ assert_parity(legacy_fn, kit_fn, fixtures=[1])
+
+ assert_parity(legacy_fn, kit_fn, fixtures=[1], ignore_fields={"fetched_at"})
+
+
+def test_dataclass_instances_of_different_classes_compared_structurally() -> None:
+ # Имитация legacy vs kit: одинаковые поля, РАЗНЫЕ классы (разные модули).
+ @dataclass
+ class LegacyResult:
+ item_id: str
+ price: int
+
+ @dataclass
+ class KitResult:
+ item_id: str
+ price: int
+
+ legacy = LegacyResult(item_id="42", price=100)
+ kit = KitResult(item_id="42", price=100)
+
+ # Прямое == было бы False (dataclass __eq__ проверяет class identity первым).
+ assert legacy != kit
+ # compare_outputs сравнивает по полям, а не по классу → различий нет.
+ assert compare_outputs(legacy, kit) == []
+
+
+def test_dataclass_field_mismatch_reported_by_name() -> None:
+ @dataclass
+ class LegacyResult:
+ item_id: str
+ price: int
+
+ @dataclass
+ class KitResult:
+ item_id: str
+ price: int
+
+ legacy = LegacyResult(item_id="42", price=100)
+ kit = KitResult(item_id="42", price=200)
+
+ diffs = compare_outputs(legacy, kit)
+ assert len(diffs) == 1
+ assert "price" in diffs[0]
+ assert "100" in diffs[0]
+ assert "200" in diffs[0]