Compare commits

..
4 Commits
14 changed files with 237 additions and 169 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.venv
**/__pycache__
**/.pytest_cache
apps/web/node_modules
apps/web/dist
design-reference
+2 -2
View File
@@ -13,7 +13,7 @@ RF4 Spotter — неофициальный сервис свежих точек
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md); данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
Один ограниченный снимок публичных карточек можно получить исследовательским CLI:
@@ -164,4 +164,4 @@ python -m rf4_research.records \
--category records
```
Команда делает один HTTP-запрос и печатает типизированные записи в JSON. Это исследовательский инструмент этапа 0; продуктивный адаптер находится в `apps/api/app/importer.py` и добавляет ограниченные повторы, условные HTTP-запросы, транзакции, дедупликацию и журнал запусков. Подтверждённая структура источника и риски описаны в [docs/data-sources.md](docs/data-sources.md).
Команда делает один HTTP-запрос и печатает типизированные записи в JSON. Исследовательский и продуктивный адаптеры используют общий fail-closed DOM-парсер `rf4_research/official_parser.py`; продуктивный слой добавляет ограниченные повторы, условные HTTP-запросы, транзакции, дедупликацию и журнал запусков. Подтверждённая структура источника и риски описаны в [docs/data-sources.md](docs/data-sources.md).
+3 -2
View File
@@ -1,8 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
COPY apps/api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY apps/api .
COPY rf4_research ./rf4_research
EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+9 -47
View File
@@ -7,9 +7,9 @@ from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timezone
import httpx
from bs4 import BeautifulSoup, Tag
from sqlalchemy import select
from sqlalchemy.orm import Session
from rf4_research.official_parser import RecordsContractError, parse_official_records
from .models import (
Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus,
@@ -60,54 +60,16 @@ def external_id(record: RawRecord) -> str:
return hashlib.sha256("|".join(normalize(part) for part in parts).encode()).hexdigest()
def parse_weight(raw: str) -> int:
value = " ".join(raw.replace("\xa0", " ").split()).lower()
match = re.fullmatch(r"([\d .,']+)\s*(kg|g)", value)
if not match:
raise ImportSourceError(f"unsupported weight {raw!r}")
number, unit = match.groups()
number = number.replace(" ", "").replace("'", "").replace("", "")
if unit == "g":
return int(number.replace(".", "").replace(",", ""))
if "," in number and "." not in number:
number = number.replace(",", ".")
return round(float(number) * 1000)
def _text(node: Tag | None) -> str:
return node.get_text(" ", strip=True) if node else ""
def parse_html(html: str, *, region: str, category: str) -> list[RawRecord]:
soup = BeautifulSoup(html, "html.parser")
table = soup.select_one("div.records.flex_table")
if table is None:
raise ImportSourceError("records table not found")
header = table.select_one(":scope > .row.header")
expected = ["fish", "weight", "location", "bait", "gamername", "data"]
cells = header.find_all("div", recursive=False) if header else []
actual = [next((key for key in expected if key in cell.get("class", [])), "") for cell in cells]
if actual != expected:
raise ImportSourceError(f"record columns changed: {actual}")
records: list[RawRecord] = []
for group in table.select(":scope > .rows > .row > .records_subtable"):
group_header = group.select_one(":scope > .row.header")
if group_header is None:
continue
fish = _text(group_header.select_one(".fish .text"))
rows = [group_header, *group.select(":scope > .rows > .row")]
for row in rows:
bait_node = row.select_one(".bait_icon")
bait = bait_node.get("title", "").strip() if bait_node else ""
try:
record_date = datetime.strptime(_text(row.select_one(".data")), "%d.%m.%y").date()
except ValueError as exc:
raise ImportSourceError("record date format changed") from exc
records.append(RawRecord(region.upper(), category, _text(row.select_one(".gamername")) or None, fish, parse_weight(_text(row.select_one(".weight"))), _text(row.select_one(".location")), bait or None, record_date))
if not records:
raise ImportSourceError("records table is empty")
return records
rows = parse_official_records(html)
except RecordsContractError as exc:
raise ImportSourceError(str(exc)) from exc
return [RawRecord(
region=region.upper(), category=category, player=row.player, fish=row.fish,
weight_g=row.weight_g, waterbody=row.waterbody, bait=row.bait,
record_date=row.record_date,
) for row in rows]
def fetch_records(
+18 -1
View File
@@ -12,6 +12,7 @@ from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceTy
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
WEEKLY_FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "weekly_records_sample.html"
def test_parser_and_import_are_idempotent() -> None:
@@ -31,6 +32,22 @@ def test_parser_and_import_are_idempotent() -> None:
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
def test_manual_weekly_category_import_uses_its_own_identity() -> None:
html = WEEKLY_FIXTURE.read_text(encoding="utf-8")
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
run = import_records(
db, url="fixture://weekly-records", region="RU",
category="weekly-ultralight", html=html,
)
report = db.scalar(select(CatchReport).where(CatchReport.source_type == SourceType.official_record))
assert (run.rows_seen, run.rows_created, run.rows_updated) == (1, 1, 0)
assert report.weight_g == 8_023
assert report.raw_payload["category"] == "weekly-ultralight"
assert report.raw_payload["fish"] == "Wochenfisch"
def test_failed_import_preserves_previous_records_and_is_logged() -> None:
html = FIXTURE.read_text(encoding="utf-8")
engine = create_engine("sqlite://")
@@ -53,7 +70,7 @@ def test_import_rejects_changed_column_contract() -> None:
html = FIXTURE.read_text(encoding="utf-8").replace(
'class="col data"', 'class="col changed"', 1
)
with pytest.raises(ImportSourceError, match="record columns changed"):
with pytest.raises(ImportSourceError, match="records columns changed"):
parse_html(html, region="RU", category="records")
+17
View File
@@ -38,3 +38,20 @@ test("submitted catch appears publicly only after moderation", async ({ page })
await page.goto("/");
await expect(page.getByTestId(spotTestId)).toBeVisible();
});
for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "mobile", width: 390, height: 844 }]) {
test(`home filters work on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto("/");
await page.getByLabel("Водоём").selectOption("kuori");
await page.getByLabel("Рыба").selectOption("lake-trout");
await page.getByLabel("Период").selectOption("72");
await page.getByLabel("Сначала").selectOption("freshness");
await page.getByRole("button", { name: "⌕ Найти клёв" }).click();
await expect(page).toHaveURL(/waterbody=kuori.*fish=lake-trout.*hours=72.*sort=freshness/);
await expect(page.getByTestId("spot-85-92")).toBeVisible();
await expect(page.getByTestId("spot-110-103")).toHaveCount(0);
const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width);
});
}
+9 -3
View File
@@ -26,7 +26,9 @@ services:
- minio_data:/data
api:
build: ./apps/api
build:
context: .
dockerfile: apps/api/Dockerfile
environment:
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-in-production}
@@ -64,7 +66,9 @@ services:
- "4321:4321"
importer:
build: ./apps/api
build:
context: .
dockerfile: apps/api/Dockerfile
profiles: ["tools"]
environment:
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
@@ -74,7 +78,9 @@ services:
command: ["sh", "-c", "alembic upgrade head && python -m app.cli import-records"]
scheduler:
build: ./apps/api
build:
context: .
dockerfile: apps/api/Dockerfile
profiles: ["scheduler"]
restart: unless-stopped
environment:
+9 -9
View File
@@ -47,7 +47,7 @@
- [x] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа.
- [x] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана (unit-тест объяснения и Astro build).
- [x] Реализовать состояния «данных мало», «данных нет», «источник недоступен» и ошибки валидации фильтров (Astro build; браузерная проверка войдёт в общий прогон фильтров).
- [ ] Проверить фильтры главной страницы сквозным тестом на desktop и mobile.
- [x] Проверить фильтры главной страницы сквозным тестом на desktop и mobile (Playwright, 1280 px и 390 px).
Критерий готовности: оценка объяснима, воспроизводима тестами и никогда не маскирует недостаток или устаревание данных.
@@ -69,11 +69,11 @@
- [x] Провести аудит подключённых источников, потенциальных поставщиков и всех существующих парсеров; результат записан в `docs/data-source-audit.md`.
- [x] Проверить оба официальных HTML-парсера на актуальной странице и добавить общий контрактный тест эквивалентности.
- [x] Добавить `data_source` и алиасы рыб/водоёмов до подключения второго автоматического источника (миграции `0008``0009`; алиасы приманок уже нормализуются в `bait`).
- [ ] Вынести общий официальный DOM-парсер, устранив дублирование исследовательской и продуктивной реализации.
- [ ] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории.
- [x] Вынести общий официальный DOM-парсер, устранив дублирование исследовательской и продуктивной реализации (`rf4_research/official_parser.py`).
- [x] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории.
- [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`.
- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT.
- [ ] Зафиксировать сами подтверждения разрешений и согласованные лимиты/атрибуцию в репозитории или закрытой операционной документации.
- [x] Зафиксировать проектное подтверждение разрешений, текущую атрибуцию и консервативные пилотные лимиты в `docs/data-permissions.md`; первичный документ и точные продуктивные условия ещё обязательны перед scheduler.
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено).
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
@@ -89,12 +89,12 @@
## Ближайший рабочий пакет
Этап 3 завершён. Следующий пакет продолжает этап 4:
Лёгкий пакет по парсерам и фильтрам завершён. Следующий пакет готовит MVP к пилоту:
1. сквозная проверка фильтров главной страницы на desktop и mobile;
2. переход к health/readiness PostgreSQL, MinIO, API и импорта;
3. структурированные логи без пользовательских секретов;
4. CI для тестов, Astro build, E2E и миграций.
1. health/readiness PostgreSQL, MinIO, API и импорта;
2. структурированные логи без пользовательских секретов;
3. CI для тестов, Astro build, E2E и миграций;
4. backup/restore PostgreSQL и MinIO.
После каждого пункта необходимо:
+2
View File
@@ -1,5 +1,7 @@
# Пилот парсинга RF4DB и RF4-STAT
Текущий статус разрешений, атрибуции и консервативных лимитов зафиксирован в `docs/data-permissions.md`.
Дата контрольного запуска: **3 сентября 2026 года**. Владелец RF4 Spotter подтвердил наличие разрешений на получение данных из обоих сервисов. Пилот использует только публичный HTML, не обращается к закрытым API, не обходит авторизацию/Premium и не скачивает изображения.
## RF4DB
+26
View File
@@ -0,0 +1,26 @@
# Реестр разрешений и эксплуатационных ограничений источников
Этот файл фиксирует только подтверждённые внутри проекта решения. Он не заменяет письмо или иной первичный документ правообладателя.
## RF4DB и RF4-STAT
- Статус: владелец проекта подтвердил наличие разрешения использовать данные RF4DB и RF4-STAT для этого проекта.
- Зафиксировано: 2 сентября 2026 года в рабочей переписке проекта.
- Разрешённый технический контур: получение публичного HTML, извлечение текстовых сведений об уловах, хранение исходной ссылки и нормализованных полей в закрытом staging.
- Изображения: не скачиваются, не проксируются и не архивируются без отдельного явно зафиксированного условия.
- Атрибуция: опубликованный улов обязан сохранять `source_system`, внешний ID и прямой `source_url` в provenance.
- Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора.
- Публикация: только вручную, после канонического сопоставления; обязательны рыба, водоём, координаты и вес.
## Лимиты запросов
До приложения первичного подтверждения действуют более строгие технические ограничения:
- RF4-STAT: не чаще одного запроса за пять секунд; один ограниченный снимок за ручной запуск.
- RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода.
- регулярные scheduler-задачи для обоих источников выключены;
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
## Что ещё требуется зафиксировать
Перед включением регулярного сбора в этот файл или закрытую операционную документацию необходимо приложить дату, стороны и носитель исходного разрешения, точные суточные/минутные лимиты, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Пока эти поля не заполнены, разрешение считается достаточным только для текущего ручного пилота.
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date, datetime
from typing import Iterable
from bs4 import BeautifulSoup, Tag
class RecordsContractError(ValueError):
"""The official records page no longer matches the verified DOM contract."""
@dataclass(frozen=True, slots=True)
class ParsedOfficialRecord:
player: str | None
fish: str
weight_g: int
waterbody: str
bait: str | None
record_date: date
def _text(node: Tag | None) -> str:
return node.get_text(" ", strip=True) if node else ""
def _direct_child(parent: Tag, classes: Iterable[str]) -> Tag | None:
wanted = set(classes)
for child in parent.find_all("div", recursive=False):
if wanted.issubset(set(child.get("class", []))):
return child
return None
def parse_weight_g(raw: str) -> int:
normalized = " ".join(raw.replace("\xa0", " ").split()).lower()
match = re.fullmatch(r"([\d .,']+)\s*(kg|g)", normalized)
if not match:
raise RecordsContractError(f"unsupported weight: {raw!r}")
number, unit = match.groups()
number = number.replace(" ", "").replace("'", "").replace("", "")
if unit == "g":
return int(number.replace(".", "").replace(",", ""))
if "," in number and "." not in number:
number = number.replace(",", ".")
return round(float(number) * 1000)
def parse_record_date(raw: str, *, today: date | None = None) -> date:
today = today or date.today()
try:
parsed = datetime.strptime(raw.strip(), "%d.%m.%y").date()
except ValueError as exc:
raise RecordsContractError(f"unsupported record date: {raw!r}") from exc
if parsed > today.replace(year=today.year + 1):
raise RecordsContractError(f"record date is implausibly far in the future: {raw!r}")
return parsed
def parse_official_records(html: str, *, today: date | None = None) -> list[ParsedOfficialRecord]:
soup = BeautifulSoup(html, "html.parser")
table = soup.select_one("div.records.flex_table")
if table is None:
raise RecordsContractError("records table not found")
top_rows = _direct_child(table, ["rows"])
header = _direct_child(table, ["row", "header"])
expected = ["fish", "weight", "location", "bait", "gamername", "data"]
actual = [
next((name for name in expected if name in cell.get("class", [])), "")
for cell in (header.find_all("div", recursive=False) if header else [])
]
if actual != expected:
raise RecordsContractError(f"records columns changed: expected {expected}, got {actual}")
if top_rows is None:
raise RecordsContractError("records rows container not found")
result: list[ParsedOfficialRecord] = []
for wrapper in top_rows.find_all("div", class_="row", recursive=False):
group = _direct_child(wrapper, ["records_subtable", "flex_table"])
if group is None:
continue
group_header = _direct_child(group, ["row", "header"])
more_rows = _direct_child(group, ["rows"])
if group_header is None:
continue
fish = _text(group_header.select_one(".fish .text"))
rows = [group_header]
if more_rows is not None:
rows.extend(more_rows.find_all("div", class_="row", recursive=False))
for row in rows:
bait_node = row.select_one(".bait .bait_icon")
bait = bait_node.get("title", "").strip() if bait_node else ""
result.append(ParsedOfficialRecord(
player=_text(row.select_one(".gamername")) or None,
fish=fish,
weight_g=parse_weight_g(_text(row.select_one(".weight"))),
waterbody=_text(row.select_one(".location")),
bait=bait or None,
record_date=parse_record_date(_text(row.select_one(".data")), today=today),
))
if not result:
raise RecordsContractError("records table is present but contains no records")
return result
+8 -102
View File
@@ -2,22 +2,19 @@ from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from datetime import date, datetime
from typing import Iterable
from datetime import date
from urllib.request import Request, urlopen
from bs4 import BeautifulSoup, Tag
from .official_parser import RecordsContractError, parse_official_records, parse_record_date, parse_weight_g
DEFAULT_URL = "https://rf4game.de/records/region/RU/"
USER_AGENT = "RF4-Spotter-Research/0.1 (+https://github.com/)"
class RecordsParseError(ValueError):
"""Raised when the page no longer matches the verified records contract."""
RecordsParseError = RecordsContractError
@dataclass(frozen=True, slots=True)
@@ -33,47 +30,6 @@ class OfficialRecord:
source_url: str
def _text(node: Tag | None) -> str:
return node.get_text(" ", strip=True) if node else ""
def _direct_child(parent: Tag, classes: Iterable[str]) -> Tag | None:
wanted = set(classes)
for child in parent.find_all("div", recursive=False):
if wanted.issubset(set(child.get("class", []))):
return child
return None
def parse_weight_g(raw: str) -> int:
normalized = " ".join(raw.replace("\xa0", " ").split()).lower()
match = re.fullmatch(r"([\d .,']+)\s*(kg|g)", normalized)
if not match:
raise RecordsParseError(f"unsupported weight: {raw!r}")
number, unit = match.groups()
number = number.replace(" ", "").replace("'", "").replace("", "")
if unit == "g":
return int(number.replace(".", "").replace(",", ""))
# The verified pages use a dot as the kg decimal separator and spaces as
# thousands separators (for example, "2 519.264 kg").
if "," in number and "." not in number:
number = number.replace(",", ".")
return round(float(number) * 1000)
def parse_record_date(raw: str, *, today: date | None = None) -> date:
today = today or date.today()
try:
parsed = datetime.strptime(raw.strip(), "%d.%m.%y").date()
except ValueError as exc:
raise RecordsParseError(f"unsupported record date: {raw!r}") from exc
if parsed > today.replace(year=today.year + 1):
raise RecordsParseError(f"record date is implausibly far in the future: {raw!r}")
return parsed
def parse_records_html(
html: str,
*,
@@ -82,61 +38,11 @@ def parse_records_html(
source_url: str,
today: date | None = None,
) -> list[OfficialRecord]:
soup = BeautifulSoup(html, "html.parser")
table = soup.select_one("div.records.flex_table")
if table is None:
raise RecordsParseError("records table not found")
top_rows = _direct_child(table, ["rows"])
header = _direct_child(table, ["row", "header"])
expected_classes = ["fish", "weight", "location", "bait", "gamername", "data"]
actual_classes = [
next((name for name in expected_classes if name in cell.get("class", [])), "")
for cell in (header.find_all("div", recursive=False) if header else [])
]
if actual_classes != expected_classes:
raise RecordsParseError(
f"records columns changed: expected {expected_classes}, got {actual_classes}"
)
if top_rows is None:
raise RecordsParseError("records rows container not found")
result: list[OfficialRecord] = []
for group_wrapper in top_rows.find_all("div", class_="row", recursive=False):
group = _direct_child(group_wrapper, ["records_subtable", "flex_table"])
if group is None:
continue
group_header = _direct_child(group, ["row", "header"])
more_rows = _direct_child(group, ["rows"])
if group_header is None:
continue
fish = _text(group_header.select_one(".fish .text"))
rows = [group_header]
if more_rows is not None:
rows.extend(more_rows.find_all("div", class_="row", recursive=False))
for row in rows:
bait_node = row.select_one(".bait .bait_icon")
bait = bait_node.get("title", "").strip() if bait_node else ""
player = _text(row.select_one(".gamername"))
result.append(
OfficialRecord(
region=region.upper(),
category=category,
fish=fish,
weight_g=parse_weight_g(_text(row.select_one(".weight"))),
waterbody=_text(row.select_one(".location")),
bait=bait or None,
player=player or None,
record_date=parse_record_date(_text(row.select_one(".data")), today=today),
source_url=source_url,
)
)
if not result:
raise RecordsParseError("records table is present but contains no records")
return result
return [OfficialRecord(
region=region.upper(), category=category, fish=row.fish, weight_g=row.weight_g,
waterbody=row.waterbody, bait=row.bait, player=row.player,
record_date=row.record_date, source_url=source_url,
) for row in parse_official_records(html, today=today)]
def fetch_html(url: str, *, timeout: float = 20.0) -> str:
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="de"><body>
<!-- Reduced, anonymized fixture for one weekly-record category. -->
<div class="records flex_table">
<div class="row header">
<div class="col fish">Fisch</div><div class="col weight">Gewicht</div>
<div class="col location">Gewässer</div><div class="col bait">Köder</div>
<div class="col gamername">Spieler</div><div class="col data">Datum</div>
</div>
<div class="rows"><div class="row"><div class="records_subtable flex_table">
<div class="row header">
<div class="col fish"><div class="text">Wochenfisch</div></div>
<div class="col weight">8.023&nbsp;kg</div><div class="col location">Wochengewässer</div>
<div class="col bait"><div class="bait_icon" title="Wochenköder"></div></div>
<div class="col gamername">Spieler Woche</div><div class="col data">01.09.26</div>
</div>
</div></div></div>
</div>
</body></html>
+2 -2
View File
@@ -30,7 +30,7 @@ def _production_contract(html: str) -> list[tuple[object, ...]]:
]
def test_research_and_production_parsers_emit_the_same_contract() -> None:
def test_research_and_production_adapters_emit_the_same_shared_contract() -> None:
html = FIXTURE.read_text(encoding="utf-8")
assert _research_contract(html) == _production_contract(html)
@@ -41,5 +41,5 @@ def test_both_parsers_reject_a_changed_column_contract() -> None:
with pytest.raises(RecordsParseError, match="records columns changed"):
_research_contract(html)
with pytest.raises(ImportSourceError, match="record columns changed"):
with pytest.raises(ImportSourceError, match="records columns changed"):
_production_contract(html)