Compare commits
4
Commits
c4f4deb24b
...
d131662d5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d131662d5f | ||
|
|
e5f26adba1 | ||
|
|
b0974d5e4c | ||
|
|
fda2e5a7bc |
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
**/__pycache__
|
||||||
|
**/.pytest_cache
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/web/dist
|
||||||
|
design-reference
|
||||||
@@ -13,7 +13,7 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md).
|
Подробный план и актуальные чекбоксы находятся в [`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:
|
Один ограниченный снимок публичных карточек можно получить исследовательским CLI:
|
||||||
|
|
||||||
@@ -164,4 +164,4 @@ python -m rf4_research.records \
|
|||||||
--category 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
@@ -1,8 +1,9 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
COPY requirements.txt .
|
COPY apps/api/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY apps/api .
|
||||||
|
COPY rf4_research ./rf4_research
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from dataclasses import asdict, dataclass
|
|||||||
from datetime import date, datetime, time, timezone
|
from datetime import date, datetime, time, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup, Tag
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from rf4_research.official_parser import RecordsContractError, parse_official_records
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus,
|
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()
|
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]:
|
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:
|
try:
|
||||||
record_date = datetime.strptime(_text(row.select_one(".data")), "%d.%m.%y").date()
|
rows = parse_official_records(html)
|
||||||
except ValueError as exc:
|
except RecordsContractError as exc:
|
||||||
raise ImportSourceError("record date format changed") from exc
|
raise ImportSourceError(str(exc)) 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))
|
return [RawRecord(
|
||||||
if not records:
|
region=region.upper(), category=category, player=row.player, fish=row.fish,
|
||||||
raise ImportSourceError("records table is empty")
|
weight_g=row.weight_g, waterbody=row.waterbody, bait=row.bait,
|
||||||
return records
|
record_date=row.record_date,
|
||||||
|
) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
def fetch_records(
|
def fetch_records(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceTy
|
|||||||
|
|
||||||
|
|
||||||
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
|
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:
|
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
|
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:
|
def test_failed_import_preserves_previous_records_and_is_logged() -> None:
|
||||||
html = FIXTURE.read_text(encoding="utf-8")
|
html = FIXTURE.read_text(encoding="utf-8")
|
||||||
engine = create_engine("sqlite://")
|
engine = create_engine("sqlite://")
|
||||||
@@ -53,7 +70,7 @@ def test_import_rejects_changed_column_contract() -> None:
|
|||||||
html = FIXTURE.read_text(encoding="utf-8").replace(
|
html = FIXTURE.read_text(encoding="utf-8").replace(
|
||||||
'class="col data"', 'class="col changed"', 1
|
'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")
|
parse_html(html, region="RU", category="records")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,20 @@ test("submitted catch appears publicly only after moderation", async ({ page })
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByTestId(spotTestId)).toBeVisible();
|
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
@@ -26,7 +26,9 @@ services:
|
|||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build: ./apps/api
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
|
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
|
||||||
ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-in-production}
|
ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-in-production}
|
||||||
@@ -64,7 +66,9 @@ services:
|
|||||||
- "4321:4321"
|
- "4321:4321"
|
||||||
|
|
||||||
importer:
|
importer:
|
||||||
build: ./apps/api
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
profiles: ["tools"]
|
profiles: ["tools"]
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
|
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"]
|
command: ["sh", "-c", "alembic upgrade head && python -m app.cli import-records"]
|
||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
build: ./apps/api
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
profiles: ["scheduler"]
|
profiles: ["scheduler"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+9
-9
@@ -47,7 +47,7 @@
|
|||||||
- [x] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа.
|
- [x] Добавить детерминированные агрегаты для окон 6, 12, 24 и 72 часа.
|
||||||
- [x] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана (unit-тест объяснения и Astro build).
|
- [x] На карточке и странице точки показывать человекочитаемое объяснение оценки и объём данных, на котором она основана (unit-тест объяснения и Astro build).
|
||||||
- [x] Реализовать состояния «данных мало», «данных нет», «источник недоступен» и ошибки валидации фильтров (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] Провести аудит подключённых источников, потенциальных поставщиков и всех существующих парсеров; результат записан в `docs/data-source-audit.md`.
|
||||||
- [x] Проверить оба официальных HTML-парсера на актуальной странице и добавить общий контрактный тест эквивалентности.
|
- [x] Проверить оба официальных HTML-парсера на актуальной странице и добавить общий контрактный тест эквивалентности.
|
||||||
- [x] Добавить `data_source` и алиасы рыб/водоёмов до подключения второго автоматического источника (миграции `0008`–`0009`; алиасы приманок уже нормализуются в `bait`).
|
- [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] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`.
|
||||||
- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT.
|
- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT.
|
||||||
- [ ] Зафиксировать сами подтверждения разрешений и согласованные лимиты/атрибуцию в репозитории или закрытой операционной документации.
|
- [x] Зафиксировать проектное подтверждение разрешений, текущую атрибуцию и консервативные пилотные лимиты в `docs/data-permissions.md`; первичный документ и точные продуктивные условия ещё обязательны перед scheduler.
|
||||||
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
||||||
- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено).
|
- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено).
|
||||||
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
|
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
|
||||||
@@ -89,12 +89,12 @@
|
|||||||
|
|
||||||
## Ближайший рабочий пакет
|
## Ближайший рабочий пакет
|
||||||
|
|
||||||
Этап 3 завершён. Следующий пакет продолжает этап 4:
|
Лёгкий пакет по парсерам и фильтрам завершён. Следующий пакет готовит MVP к пилоту:
|
||||||
|
|
||||||
1. сквозная проверка фильтров главной страницы на desktop и mobile;
|
1. health/readiness PostgreSQL, MinIO, API и импорта;
|
||||||
2. переход к health/readiness PostgreSQL, MinIO, API и импорта;
|
2. структурированные логи без пользовательских секретов;
|
||||||
3. структурированные логи без пользовательских секретов;
|
3. CI для тестов, Astro build, E2E и миграций;
|
||||||
4. CI для тестов, Astro build, E2E и миграций.
|
4. backup/restore PostgreSQL и MinIO.
|
||||||
|
|
||||||
После каждого пункта необходимо:
|
После каждого пункта необходимо:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Пилот парсинга RF4DB и RF4-STAT
|
# Пилот парсинга RF4DB и RF4-STAT
|
||||||
|
|
||||||
|
Текущий статус разрешений, атрибуции и консервативных лимитов зафиксирован в `docs/data-permissions.md`.
|
||||||
|
|
||||||
Дата контрольного запуска: **3 сентября 2026 года**. Владелец RF4 Spotter подтвердил наличие разрешений на получение данных из обоих сервисов. Пилот использует только публичный HTML, не обращается к закрытым API, не обходит авторизацию/Premium и не скачивает изображения.
|
Дата контрольного запуска: **3 сентября 2026 года**. Владелец RF4 Spotter подтвердил наличие разрешений на получение данных из обоих сервисов. Пилот использует только публичный HTML, не обращается к закрытым API, не обходит авторизацию/Premium и не скачивает изображения.
|
||||||
|
|
||||||
## RF4DB
|
## RF4DB
|
||||||
|
|||||||
@@ -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 или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
|
||||||
|
|
||||||
|
## Что ещё требуется зафиксировать
|
||||||
|
|
||||||
|
Перед включением регулярного сбора в этот файл или закрытую операционную документацию необходимо приложить дату, стороны и носитель исходного разрешения, точные суточные/минутные лимиты, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Пока эти поля не заполнены, разрешение считается достаточным только для текущего ручного пилота.
|
||||||
@@ -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
@@ -2,22 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import date, datetime
|
from datetime import date
|
||||||
from typing import Iterable
|
|
||||||
from urllib.request import Request, urlopen
|
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/"
|
DEFAULT_URL = "https://rf4game.de/records/region/RU/"
|
||||||
USER_AGENT = "RF4-Spotter-Research/0.1 (+https://github.com/)"
|
USER_AGENT = "RF4-Spotter-Research/0.1 (+https://github.com/)"
|
||||||
|
|
||||||
|
|
||||||
class RecordsParseError(ValueError):
|
RecordsParseError = RecordsContractError
|
||||||
"""Raised when the page no longer matches the verified records contract."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -33,47 +30,6 @@ class OfficialRecord:
|
|||||||
source_url: str
|
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(
|
def parse_records_html(
|
||||||
html: str,
|
html: str,
|
||||||
*,
|
*,
|
||||||
@@ -82,61 +38,11 @@ def parse_records_html(
|
|||||||
source_url: str,
|
source_url: str,
|
||||||
today: date | None = None,
|
today: date | None = None,
|
||||||
) -> list[OfficialRecord]:
|
) -> list[OfficialRecord]:
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
return [OfficialRecord(
|
||||||
table = soup.select_one("div.records.flex_table")
|
region=region.upper(), category=category, fish=row.fish, weight_g=row.weight_g,
|
||||||
if table is None:
|
waterbody=row.waterbody, bait=row.bait, player=row.player,
|
||||||
raise RecordsParseError("records table not found")
|
record_date=row.record_date, source_url=source_url,
|
||||||
|
) for row in parse_official_records(html, today=today)]
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_html(url: str, *, timeout: float = 20.0) -> str:
|
def fetch_html(url: str, *, timeout: float = 20.0) -> str:
|
||||||
|
|||||||
+19
@@ -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 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>
|
||||||
@@ -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")
|
html = FIXTURE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert _research_contract(html) == _production_contract(html)
|
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"):
|
with pytest.raises(RecordsParseError, match="records columns changed"):
|
||||||
_research_contract(html)
|
_research_contract(html)
|
||||||
with pytest.raises(ImportSourceError, match="record columns changed"):
|
with pytest.raises(ImportSourceError, match="records columns changed"):
|
||||||
_production_contract(html)
|
_production_contract(html)
|
||||||
|
|||||||
Reference in New Issue
Block a user