feat: add gear provenance models and browser fetcher

This commit is contained in:
ik
2026-09-20 15:50:43 +07:00
parent b0edae98c6
commit 4e9895fbf4
19 changed files with 986 additions and 20 deletions
@@ -0,0 +1,65 @@
"""add canonical tackle items and rig components
Revision ID: 0021
Revises: 0020
"""
from alembic import op
import sqlalchemy as sa
revision = "0021"
down_revision = "0020"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tackle_item",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
sa.Column("category", sa.String(20), nullable=False),
sa.Column("subcategory", sa.String(100)),
sa.Column("brand", sa.String(100)),
sa.Column("family", sa.String(100)),
sa.Column("unlock_level", sa.Integer()),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
sa.Column("raw_payload", sa.JSON()),
sa.UniqueConstraint("source_system", "source_external_id"),
sa.CheckConstraint(
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
name="ck_tackle_item_category",
),
)
op.create_table(
"rig",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
sa.Column("source_system", sa.String(50)),
sa.Column("source_external_id", sa.String(200)),
sa.Column("source_url", sa.Text()),
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
sa.Column("raw_payload", sa.JSON()),
)
op.create_table(
"rig_component",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id"), nullable=False),
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
sa.Column("role", sa.String(50), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("raw_value", sa.String(200)),
sa.UniqueConstraint("rig_id", "position"),
)
def downgrade() -> None:
op.drop_table("rig_component")
op.drop_table("rig")
op.drop_table("tackle_item")
+55
View File
@@ -69,6 +69,61 @@ class Bait(Base):
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
class TackleItem(Base):
"""Canonical gear item; legacy Bait rows remain source-compatible."""
__tablename__ = "tackle_item"
__table_args__ = (
UniqueConstraint("source_system", "source_external_id"),
CheckConstraint(
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
name="ck_tackle_item_category",
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
category: Mapped[str] = mapped_column(String(20))
subcategory: Mapped[str | None] = mapped_column(String(100))
brand: Mapped[str | None] = mapped_column(String(100))
family: Mapped[str | None] = mapped_column(String(100))
unlock_level: Mapped[int | None]
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
rig_components: Mapped[list["RigComponent"]] = relationship(back_populates="tackle_item")
class Rig(Base):
"""A named rig/setup kept separate from individual tackle items."""
__tablename__ = "rig"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200))
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
source_system: Mapped[str | None] = mapped_column(String(50))
source_external_id: Mapped[str | None] = mapped_column(String(200))
source_url: Mapped[str | None] = mapped_column(Text)
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
components: Mapped[list["RigComponent"]] = relationship(back_populates="rig")
class RigComponent(Base):
__tablename__ = "rig_component"
__table_args__ = (UniqueConstraint("rig_id", "position"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
rig_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rig.id"))
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
role: Mapped[str] = mapped_column(String(50))
position: Mapped[int] = mapped_column(Integer)
raw_value: Mapped[str | None] = mapped_column(String(200))
rig: Mapped[Rig] = relationship(back_populates="components")
tackle_item: Mapped[TackleItem | None] = relationship(back_populates="rig_components")
class Spot(Base):
__tablename__ = "spot"
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
+35
View File
@@ -0,0 +1,35 @@
import uuid
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import Rig, RigComponent, TackleItem
def test_tackle_item_and_rig_components_keep_provenance_and_legacy_independence() -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
item_id = uuid.uuid4()
rig_id = uuid.uuid4()
with Session(engine) as session:
item = TackleItem(
id=item_id, name="Spiker #2", normalized_name="spiker #2",
category="lure", subcategory="spinner", brand="RF4", family="spoon",
unlock_level=0, source_system="rf4db", source_external_id="spiker-2",
source_url="https://rf4db.com/ru/wiki/lures/spiker-2",
raw_payload={"weight": {"state": "value", "value": 0}},
)
rig = Rig(
id=rig_id, name="Method Popup", normalized_name="method popup",
source_system="rf4db", source_external_id="method-popup",
)
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value="Spiker #2"))
session.add(rig)
session.commit()
saved = session.get(TackleItem, item_id)
assert saved is not None
assert saved.unlock_level == 0
assert saved.raw_payload == {"weight": {"state": "value", "value": 0}}
assert saved.rig_components[0].rig_id == rig_id
+2 -2
View File
@@ -39,11 +39,11 @@
--status-warning: light-dark(#9a741d, #e8c768);
--status-changed: light-dark(#a44236, #ff9587);
--source-official: light-dark(#815713, #e3b65b);
--source-rf4db: light-dark(#286581, #74bee3);
--source-rf4db: light-dark(#245d78, #74bee3);
--source-rf4stat: light-dark(#60499a, #b7a2ed);
--source-rf4map: light-dark(#2f704f, #79c79e);
--source-rf4posts: light-dark(#984a33, #ee9a7e);
--source-players: light-dark(#526b1c, #add066);
--source-players: light-dark(#4b6419, #add066);
--graphic-line: light-dark(#3f7779, #79b5b8);
--graphic-grid: light-dark(#dce5de, #294244);
--tackle-orange: light-dark(#b96b27, #f0a55f);
+50
View File
@@ -11,3 +11,53 @@ for (const path of [
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
});
}
test("admin keyboard navigation keeps focus out of the document body", async ({ page }) => {
await page.goto("/admin");
await page.keyboard.press("Tab");
await expect(page.locator(".skip-link")).toBeFocused();
const focusedLabels: string[] = [];
for (let index = 0; index < 20; index += 1) {
await page.keyboard.press("Tab");
focusedLabels.push(await page.evaluate(() => {
const active = document.activeElement;
if (active?.tagName === "BODY") return "BODY";
if (active?.matches(".admin-login input")) return "ADMIN_TOKEN_INPUT";
if (active?.matches(".admin-login button")) return "ADMIN_PANEL_BUTTON";
return (active?.getAttribute("aria-label") || active?.textContent || active?.tagName || "").trim().slice(0, 80);
}));
}
expect(focusedLabels).not.toContain("BODY");
expect(focusedLabels).toContain("ADMIN_TOKEN_INPUT");
expect(focusedLabels).toContain("ADMIN_PANEL_BUTTON");
});
test("print media keeps a light color scheme and removes hero image filters", async ({ page }) => {
await page.emulateMedia({ media: "print" });
await page.goto("/");
const printState = await page.evaluate(() => ({
colorScheme: getComputedStyle(document.documentElement).colorScheme,
heroFilter: document.querySelector(".lake-card img") ? getComputedStyle(document.querySelector(".lake-card img")!).filter : "none",
}));
expect(printState.colorScheme).toBe("light");
expect(printState.heroFilter).toBe("none");
});
test("forced colors keeps theme controls visibly bordered", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/admin");
const forcedColorsState = await page.evaluate(() => {
const control = document.querySelector(".theme-switcher button");
const pressed = document.querySelector(".theme-switcher button[aria-pressed='true']");
return {
borderStyle: control ? getComputedStyle(control).borderStyle : "",
borderWidth: control ? getComputedStyle(control).borderWidth : "",
activeBackground: pressed ? getComputedStyle(pressed).backgroundColor : "",
};
});
expect(forcedColorsState.borderStyle).toBe("solid");
expect(forcedColorsState.borderWidth).toBe("1px");
expect(forcedColorsState.activeBackground).not.toBe("transparent");
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
const routes = [
"/", "/waterbodies", "/records", "/report", "/status", "/media", "/rules",
"/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation",
"/admin/external-sources", "/admin/media",
];
const viewports = [
{ width: 320, height: 800 },
{ width: 390, height: 844 },
{ width: 768, height: 900 },
{ width: 1280, height: 900 },
];
const themes = ["system", "light", "dark"] as const;
test("ROADMAP visual matrix keeps routes within the viewport", async ({ page, context }) => {
for (const theme of themes) {
await context.clearCookies();
if (theme !== "system") {
await context.addCookies([{ name: "rf4-theme", value: theme, url: "http://127.0.0.1:4321" }]);
}
await page.emulateMedia({ colorScheme: theme === "system" ? "light" : theme });
for (const viewport of viewports) {
await page.setViewportSize(viewport);
for (const route of routes) {
await page.goto(route);
const state = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
hasMain: Boolean(document.querySelector("main")),
hasHeading: Boolean(document.querySelector("h1")),
theme: document.documentElement.dataset.theme || "system",
}));
expect(state.scrollWidth, `${theme} ${viewport.width}px ${route} overflows`).toBeLessThanOrEqual(state.clientWidth);
expect(state.hasMain, `${route} has no main landmark`).toBe(true);
expect(state.hasHeading, `${route} has no h1`).toBe(true);
expect(state.theme, `${theme} SSR theme mismatch on ${route}`).toBe(theme);
}
}
}
});
+16 -8
View File
@@ -97,9 +97,13 @@ has semantic light/dark tokens, system/manual theme selection, forced-colors
rules, nonce-based JSON-LD CSP, noindex/no-store handling for admin/error
surfaces, responsive pagination and reduced-motion styles.
Automated structural checks pass, but visual acceptance is not equivalent to
`astro check`. B25, D05/D07 and A06 remain open for desktop/mobile light/dark,
forced-colors, focus order, long data, empty/error states and screenshots.
Automated structural and browser checks pass, but visual acceptance is not
equivalent to `astro check`. The local visual matrix now covers 13 routes × 4
viewports × 3 theme modes (156 combinations) without document overflow, with
matching SSR theme, `main` and `h1`; accessibility E2E covers axe 9/9,
keyboard/focus, print and forced-colors. B25, D05/D07 and A06 remain open only
for retained reference screenshots and manual review of long data,
empty/error states, shadows and gradients.
## Security and operations
@@ -133,17 +137,21 @@ completed for 226 alternatives; 253 fish assets are published, 252 with
1024×1024 WebP and one explicit low-resolution fallback. Offline media audit
reports no checksum/dimension/MIME/provenance/orphan problems.
This does not close B25: browser checks for broken images, responsive overflow,
light/dark presentation and source links are still required. It also does not
grant new source permissions; Q01 remains the source-rights gate.
Browser checks now cover broken images, responsive overflow, light/dark
presentation and source links on the media/admin paths; the visual matrix also
confirms route-level overflow and SSR theme consistency. This does not close
B25 because retained reference screenshots and manual visual review are still
required. It also does not grant new source permissions; Q01 remains the
source-rights gate.
## Prioritized action plan reflected in ROADMAP
1. **Release blockers:** repeat the clean production Compose/bootstrap gate;
verify readiness, migration current, proxy routing, backup/restore and
production secret/preflight checks (A07 and open-alpha gate).
2. **Browser gates:** complete B25 media, A06 admin/accessibility and D07 full
visual matrix when a Chromium/browser backend is available.
2. **Browser gates:** retain reference screenshots and finish the remaining
manual visual review for B25, A06 and D07; the Chromium matrix and
accessibility regression gates are already green locally.
3. **Waterbody data:** process the remaining 18 detail snapshots under the
shared cooldown, then complete W03W08 without assigning unreviewed media
roles.
+10 -10
View File
@@ -7,8 +7,8 @@
Подтверждено:
- [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md);
- [x] полный Python suite: **191 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и требует отдельного Docker-прогона;
- [x] Astro check: 61 файл, **0 errors / 0 warnings / 0 hints**; production build проходит;
- [x] полный Python suite: **196 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и требует отдельного Docker-прогона;
- [x] Astro check: 62 файла, **0 errors / 0 warnings / 0 hints**; production build проходит;
- [x] API contract suite: `apps/api/tests/test_api.py` содержит **20 passed**; live `/health` и `/ready` после миграции требуют отдельного Docker-прогона;
- [x] граф Alembic линеен и имеет единственную голову `0020`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill;
- [x] локальный dev Compose gate 20.09: PostgreSQL, MinIO, API и web healthy; `/health`/`/ready` успешны, migration current — `0020 (head)`, public `/`, `/waterbodies/` и `/records/` отвечают `200` из контейнерной сети;
@@ -45,12 +45,12 @@
- [x] **B22 · Сравнение вариантов разных источников.** Offline-команда `--compare-quality-upgrades` сопоставила сохранённые кандидаты с опубликованными fallback по `duplicate_of` и проверила dimensions, размер файла, MIME, прозрачность и aspect ratio. Все 226 кандидатов прошли технические пороги и offline contact-sheet review; источник RF4DB/RF4MAP/официальный RF4 не получал автоматического приоритета. После публикации в manifest не осталось `upgrade_stored`, поэтому текущий повторный запуск команды корректно сообщает `compared: 0`.
- [x] **B23 · Безопасное продвижение и provenance.** Связи `supersedes`/`replaced_by`, отдельное решение review, атомарное переключение публичного `entity_key` и явный CLI rollback сохраняют обе исходные ссылки и возможность отката. Публичная карточка показывает источник выбранного изображения, а manifest сохраняет проверенные варианты. Старый Git-файл не удаляется при включении нового.
- [x] **B24 · Производные размеры.** После выбора оригиналов генерируются детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, хэши производных фиксируются в manifest и отдаются через каталог. Маленькие карточки больше не обязаны загружать 1024×1024 оригинал.
- [ ] **B25 · Визуальная приёмка media.** Contact sheets для всех 226 замен собраны и просмотрены offline: явных ложных соответствий, обрезки и искажённых пропорций не обнаружено; одно отличие подписи (`Ерш-носарь`/`Ёрш-носарь`) орфографическое. In-app Chromium проверил 12 комбинаций `/media` (320/390/768/1280 px × light/dark/system): горизонтального overflow нет, у 253 изображений есть `alt`, broken loaded images — `0`, переключение темы корректно меняет `data-theme`. Остались keyboard/focus и standalone axe gate; screenshot capture в текущем runtime завершился timeout и не считается доказательством. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение».
- [ ] **B25 · Визуальная приёмка media.** Contact sheets для всех 226 замен собраны и просмотрены offline: явных ложных соответствий, обрезки и искажённых пропорций не обнаружено; одно отличие подписи (`Ерш-носарь`/`Ёрш-носарь`) орфографическое. In-app Chromium проверил 12 комбинаций `/media` (320/390/768/1280 px × light/dark/system): горизонтального overflow нет, у 253 изображений есть `alt`, broken loaded images — `0`, переключение темы корректно меняет `data-theme`; keyboard/focus и print-regression проходят, standalone axe — `9/9` маршрутов. Launcher `scripts/capture-visual-matrix.mjs` успешно создал полную матрицу `156` PNG и `manifest.json` во внешней директории; остаются ручной review и решение о сохранении reference-артефактов. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение».
### Каталог водоёмов, карты и координаты
- [x] **W01 · Canonical-каталог RF4DB.** 16.09 через браузерный контекст получен и проверен индекс `/ru/maps`: 19/19 карточек, source slug/ID, русское название, уровень открытия, число видов рыб и URL изображения сохранены в [`data/waterbodies/rf4db-catalog-2026-09-16.json`](../data/waterbodies/rf4db-catalog-2026-09-16.json). Добавлена команда `python -m app.cli import-waterbody-catalog --input ...` для применения снимка в БД. Изображения остаются кандидатами без автоматической роли `map` или `cover`; detail-данные выполняются отдельно по W02.
- [ ] **W02 · Карточки водоёмов RF4DB.** Строгий fixture-based parser `rf4db-waterbody` расширен под реальный DOM `/fishes/` и `/positions/`, regression-тесты, безопасный `update_waterbody_detail` и атомарный batch `update_waterbody_details`/`import-waterbody-details` готовы. Snapshots `level_000_home`, `level_001_lake` и `level_002_kirzah_river` сохранены. Повторный разрешённый запрос `level_002_kirzah_river` 20.09 вернул совпадающие с canonical-каталогом имя, `29/29` видов и два URL изображений; snapshot импортирован в Compose PostgreSQL, `/health` и `/ready` успешны, публичная `/waterbodies/р-вьюнок` отвечает `200`. Описание и позиции источник не вернул, поэтому они оставлены `null`/пустыми без домыслов; изображения остаются только provenance-кандидатами и не публиковались. Три разрешённых запроса `level_019_american_pond` 20.09 завершились `403 Forbidden`: snapshot и импорт не создавались, прежние подтверждённые данные и media не менялись. Одно промежуточное разрешённое окно 20.09 было зарезервировано CLI, но завершилось локальной DNS-ошибкой до ответа источника; повтор с сетевым доступом был остановлен общим cooldown до запроса. Следующий запуск должен повторить именно `level_019_american_pond` после общего cooldown; полнота ответа остаётся неподтверждённой. Осталось последовательно разобрать 16 detail-страниц.
- [ ] **W02 · Карточки водоёмов RF4DB.** Строгий fixture-based parser `rf4db-waterbody` расширен под реальный DOM `/fishes/` и `/positions/`, regression-тесты, безопасный `update_waterbody_detail` и атомарный batch `update_waterbody_details`/`import-waterbody-details` готовы. Snapshots `level_000_home`, `level_001_lake` и `level_002_kirzah_river` сохранены. Добавлены guarded launcher `scripts/fetch-waterbodies-chromium.mjs` для обычной Chromium-сессии и offline-режим `scripts/fetch-waterbodies.py --html`; оба сохраняют snapshots атомарно, не импортируют их автоматически и используют общий cooldown, а Chromium-launcher сначала делает отдельную reserve-only операцию. Повторный разрешённый запрос `level_002_kirzah_river` 20.09 вернул совпадающие с canonical-каталогом имя, `29/29` видов и два URL изображений; snapshot импортирован в Compose PostgreSQL, `/health` и `/ready` успешны, публичная `/waterbodies/р-вьюнок` отвечает `200`. Описание и позиции источник не вернул, поэтому они оставлены `null`/пустыми без домыслов; изображения остаются только provenance-кандидатами и не публиковались. Три разрешённых запроса `level_019_american_pond` 20.09 завершились `403 Forbidden`, а текущий Chromium показывает защитную страницу Cloudflare: это блокировка HTTP-клиента источником, не ошибка атомарного сохранения launcher-а; snapshot и импорт не создавались, прежние подтверждённые данные и media не менялись. Одно промежуточное разрешённое окно 20.09 было зарезервировано CLI, но завершилось локальной DNS-ошибкой до ответа источника; повтор с сетевым доступом был остановлен общим cooldown до запроса. Следующий сетевой запуск допускается только после общего cooldown; полнота ответа остаётся неподтверждённой. CAPTCHA/challenge не автоматизируются. Осталось последовательно разобрать 16 detail-страниц.
- [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентный upsert применён к canonical snapshot: `19/19`, без missing/duplicate/provenance issues. Осталось применить подтверждённые detail snapshots и отдельно разделить игровые и редакционные тексты при подключении detail-данных.
- [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника.
- [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. Осталось подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта.
@@ -66,9 +66,9 @@
собранная схема или монтаж (например, донная, поплавочная, method). Число
найденных изображений приманок не считается размером полного каталога.
- [ ] **G01 · Canonical-каталог RF4DB.** Получить разрешённый индекс категорий gear и проверить полный набор доступных страниц по типам: `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`. Сохранить исходный slug/ID, русское название, категорию, подкатегорию, бренд, семейство, игровые ограничения/уровень и source URL. Отсутствие общего счётчика или закрытая категория должны оставаться явно `unknown`, а не превращаться в оценку полноты.
- [ ] **G02 · Карточки предметов и оснасток.** Подготовить строгие fixture-based parsers для списка и detail-страницы: характеристики, варианты, совместимость, изображения, связанные типы монтажа и исходные значения. Парсер должен различать отсутствующее поле, «не применимо» и фактическое нулевое значение; при неполном или изменившемся ответе сохранять предыдущие подтверждённые данные. Detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена.
- [ ] **G03 · Модель и provenance.** Расширить `bait` до канонического `tackle_item` либо выполнить безопасную миграцию с обратной совместимостью API: `kind`, `category`, `subcategory`, `brand`, `family`, `source_system`, `source_external_id`, `source_url`, `source_checked_at`, `raw_payload`. Отдельно моделировать `rig`/монтаж и его компоненты; не хранить удилище, катушку и монтаж в одном свободном `rig_type`. Для каждой характеристики сохранить источник и статус проверки.
- [ ] **G01 · Canonical-каталог RF4DB.** Offline-контракт и строгий parser готовы для категорий `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`: сохраняются исходный slug/ID, русское название, категория, подкатегория, бренд, семейство, игровой уровень и source URL; duplicate ID и неизвестная категория отклоняются. Общий count остаётся `unknown`, пока не получен разрешённый реальный индекс; fixture не считается каталогом.
- [ ] **G02 · Карточки предметов и оснасток.** Fixture-based parser списка и detail-страницы готов: характеристики, варианты, совместимость, изображения и связанные типы монтажа сохраняются; атрибуты различают фактический `0`, `not_applicable` и `missing`. Parser fail-closed при неполном/изменившемся DOM; реальные detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена.
- [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Остаётся подключить эти сущности к import/review/API и выполнить безопасный backfill только после реального crosswalk.
- [ ] **G04 · Crosswalk и нормализация.** Построить offline-crosswalk между RF4DB, официальными рекордами, RF4MAP, RF4 Posts и локальным справочником. Нормализовать регистр, пробелы, дефисы, единицы и локализацию; предлагать совпадение только при точном имени/алиасе плюс совместимой категории. Неоднозначные, брендовые варианты и unmatched-строки отправлять на review без автоматического canonical key; исходное значение всегда сохранять.
- [ ] **G05 · Связи с уловами и источниками.** Протянуть канонические предметы и монтажи через community import, официальные записи и форму улова, сохранив `raw_payload` и список missing fields. Поддержать несколько предметов в одном комплекте, порядок/роль компонента и источник каждой связи; старый `bait_id` и текстовые значения не терять при миграции. Публикация полного наблюдения по-прежнему требует подтверждённых соответствий, а не простого совпадения строки.
- [ ] **G06 · API и публичный каталог.** Добавить пагинированные каталоги и detail endpoints с фильтрами по категории, бренду, семейству и уровню, а также безопасные ссылки из улова/точки на использованную приманку, снасть и монтаж. Показывать только подтверждённые характеристики, источник, свежесть и неполноту; не выдавать рейтинг эффективности, если его нельзя объяснить числом наблюдений, игроками, периодом и качеством источников.
@@ -84,9 +84,9 @@
- [x] **D02 · Системный режим без вспышки.** Первый визит следует `prefers-color-scheme` полностью через CSS: серверный HTML сразу совместим с системной темой, состояние не хранится, inline bootstrap не добавлен и CSP не ослаблена. Явное переопределение появится только вместе с D03–D04.
- [x] **D03 · Переключатель темы.** В header добавлен доступный трёхпозиционный выбор «Системная / Светлая / Тёмная»; на узких экранах он сохраняет три понятные иконки и доступные названия. Кнопки работают с клавиатуры, имеют видимый focus и синхронизируют `aria-pressed`.
- [x] **D04 · Сохранение и SSR-согласование.** Выбор хранится год в allowlist-cookie `rf4-theme` с `SameSite=Lax` и `Secure` на HTTPS; Astro SSR выставляет `data-theme` до отрисовки, а системный режим оставляет выбор браузеру. Собранный Astro client script переключает тему без inline-кода, localStorage и ослабления CSP.
- [ ] **D05 · Темизация компонентов и графики.** На семантические light/dark-токены переведены базовые и admin-поверхности, header, каталог, breadcrumb-леска, ссылки сущностей, таблицы, формы, moderation-карточки, provenance/quality/status badges, паспорта данных, pagination, empty/loading-состояния, радар, timeline, силуэты и глифы; добавлен forced-colors layer. In-app Chromium подтвердил переключение light/dark/system на медиатеке во всех четырёх ширинах; остаётся ручная приёмка теней, градиентов, forced colors и печати. Источники и статусы сохраняют текстовые подписи и не различаются только цветом.
- [ ] **D05 · Темизация компонентов и графики.** На семантические light/dark-токены переведены базовые и admin-поверхности, header, каталог, breadcrumb-леска, ссылки сущностей, таблицы, формы, moderation-карточки, provenance/quality/status badges, паспорта данных, pagination, empty/loading-состояния, радар, timeline, силуэты и глифы; добавлен forced-colors layer. In-app Chromium подтвердил переключение light/dark/system на медиатеке во всех четырёх ширинах; standalone axe проходит `9/9` маршрутов, forced-colors и print regression подтверждены. Полная screenshot-матрица `156` PNG захватывается launcher-ом без document overflow; остаётся вручную проверить тени/градиенты и выбрать сохраняемый reference-набор. Источники и статусы сохраняют текстовые подписи и не различаются только цветом.
- [x] **D06 · Метаданные браузера и CSP.** Добавлены парные `theme-color` для системной light/dark схемы; явный cookie-выбор согласуется с SSR и мгновенно переключает активный meta-тег. PWA manifest использует устойчивый тёмный brand chrome и splash background. Production build сохраняет `inlinedScripts: []`; новые inline script/style/attributes не появились, ослабление CSP не потребовалось.
- [ ] **D07 · Визуальная и accessibility-приёмка.** In-app Chromium подтвердил отсутствие горизонтального scroll на публичных `/`, `/waterbodies`, `/records`, `/report`, `/status`, `/media`, `/rules`, waterbody detail `/waterbodies/р-вьюнок` и fish detail `/fish/pike`, а также admin `/admin`, `/admin/moderation`, `/admin/external-sources`, `/admin/media` в 320/390/768/1280 px; исправлены tablet header и media-grid overflow. Для media и двух detail-страниц light/dark/system корректно переключаются, `main`/`h1` присутствуют, broken images — `0`. Остались keyboard/focus matrix, axe, forced colors, печать и screenshots. Для обеих тем обеспечить WCAG AA, отсутствие горизонтального scroll и CLS, reduced motion и переключение без потери введённых данных; сохранить эталонные screenshots и краткий отчёт.
- [ ] **D07 · Визуальная и accessibility-приёмка.** In-app Chromium подтвердил отсутствие горизонтального scroll на публичных `/`, `/waterbodies`, `/records`, `/report`, `/status`, `/media`, `/rules`, waterbody detail `/waterbodies/р-вьюнок` и fish detail `/fish/pike`, а также admin `/admin`, `/admin/moderation`, `/admin/external-sources`, `/admin/media` в 320/390/768/1280 px; исправлены tablet header и media-grid overflow. Автоматизированный visual-matrix regression и `scripts/capture-visual-matrix.mjs` подтверждают 13 маршрутов × 4 ширины × 3 режима (`156` комбинаций): document overflow отсутствует, везде есть `main`/`h1`, SSR-тема совпадает; полная screenshot-матрица захвачена во внешнюю директорию. Для media и двух detail-страниц light/dark/system корректно переключаются, broken images — `0`; axe `9/9`, keyboard/focus, forced-colors и print-regression проходят. Остались ручная проверка shadows/gradients и выбор сохраняемого reference-набора. Для обеих тем обеспечить WCAG AA, отсутствие горизонтального scroll и CLS, reduced motion и переключение без потери введённых данных; сохранить эталонные screenshots и краткий отчёт.
- [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник.
- [x] **Q02 · Управляемое удаление источника.** Изменение ранее опубликованной записи возвращает её в staging, сбрасывает сопоставление и переводит связанный улов в pending с новой версией решения. Результат проверки хранится как `available`, `missing`, `temporary_error` или `blocked`: только подтверждённый `missing` отзывает публикацию, временная ошибка и блокировка остаются диагностикой. Повторное появление требует ручного подтверждения. Withdrawn-записи исключены из публичной активности, а статус и время проверки доступны в admin provenance и журнале решений. Отдельного сетевого обхода нет: Q03 подключит эту реакцию к разрешённому плановому запросу.
@@ -130,7 +130,7 @@
- [x] **A03 · Полный single-owner workflow.** Добавить пагинацию очереди уловов, кнопку запуска официального импорта и отображение результата/истории, безопасные статусы источников с возрастом данных, cooldown/backoff и ручное обновление очередей. Критерий: владелец может пройти путь «импорт → проверка → решение → история» без API/CLI; старые данные сохраняются при сбое импорта.
- [x] **A04 · Контур медиа-проверки.** Admin-экран показывает approved/upgrade_queued/upgrade_stored медиа, источник, размеры, производные и provenance. Защищённые действия требуют непустую причину: publish атомарно продвигает только проверенные `upgrade_stored` и сохраняет fallback, rollback принимает только связанную пару `approved`/`superseded`; небезопасные состояния возвращают `409`. API-тесты проверяют auth, валидацию причины и вызов безопасных операций; browser acceptance остаётся в A06.
- [ ] **A05 · Многопользовательский доступ.** После пилота заменить общий Bearer-токен персональными аккаунтами и короткими серверными сессиями с отзывом, ролями read-only/moderator/importer/owner, operator ID в аудите и журналом входов. Критерий: минимальные права реально ограничивают действия, logout/revoke инвалидируют сессию на сервере.
- [ ] **A06 · Browser/accessibility acceptance.** In-app Chromium подтвердил `/admin`, moderation, external sources и media в 320/390/768/1280 px без горизонтального overflow; четыре admin-маршрута имеют landmarks, подписанные поля, именованные кнопки и `alt` у изображений. Клавиатура/focus order, forced colors, полная light/dark визуальная матрица и standalone axe остаются открытыми: в текущем browser runtime Tab не переводит `document.activeElement` с `BODY`, а headless Chromium axe блокируется sandbox.
- [ ] **A06 · Browser/accessibility acceptance.** In-app Chromium подтвердил `/admin`, moderation, external sources и media в 320/390/768/1280 px без горизонтального overflow; четыре admin-маршрута имеют landmarks, подписанные поля, именованные кнопки и `alt` у изображений. Visual-matrix regression покрывает 13 маршрутов × 4 ширины × 3 режима (`156` комбинаций) без document overflow и с совпадающей SSR-темой. Standalone axe проходит `9/9` маршрутов без serious/critical violations, keyboard/focus regression не оставляет фокус на `BODY`, forced-colors сохраняет видимую границу theme controls, print-regression сохраняет светлую схему и убирает фильтр hero image. Остаются screenshots и ручная визуальная проверка shadows/gradients; headless gate больше не блокируется sandbox при escalated запуске.
- [ ] **A07 · Production gate.** Выполнить preflight с реальными секретами, проверить Caddy Basic + API Bearer, закрытые внутренние порты, backup/restore PostgreSQL и MinIO, readiness, no-store и внешний smoke после деплоя. Локальный чистый-volume bootstrap и оба Playwright-сценария подтверждены; критерий A07 остаётся открытым до acceptance-runbook с реальными секретами, backup/restore, rollback и процедурой отзыва доступа.
## Готовность открытой альфы — требуется сервер или внешний сервис
+31
View File
@@ -76,6 +76,37 @@ Fallback строится на собственных лёгких SVG: осмы
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
## Ручное получение snapshots водоёмов
Для ручного запуска используется [`scripts/fetch-waterbodies.py`](../scripts/fetch-waterbodies.py). Скрипт вызывает тот же guarded CLI, что и остальные исследовательские источники: общий `rf4db.com` cooldown резервируется до HTTP-запроса, поэтому повтор после `403` или сетевой ошибки не выполняется раньше разрешённого окна.
Для страницы, которая требует обычного браузерного JavaScript, используется Chromium-launcher с отдельным persistent profile:
```bash
node scripts/fetch-waterbodies-chromium.mjs catalog
node scripts/fetch-waterbodies-chromium.mjs detail \
--url https://download.rf4db.com/ru/maps/level_019_american_pond
```
Он сначала резервирует тот же cooldown, затем открывает Chromium, сохраняет DOM и передаёт его локальному parser-у. Первый запуск можно оставить headed и вручную завершить обычную проверку сайта; `--headless` предназначен только для страниц, которые уже открываются без пользовательского действия. CAPTCHA и защитные challenge не автоматизируются.
```bash
./scripts/fetch-waterbodies.py catalog
./scripts/fetch-waterbodies.py detail \
--url https://download.rf4db.com/ru/maps/level_019_american_pond
```
Результат сохраняется атомарно в `.cache/waterbodies/` с timestamp-именем; `403`, неполный ответ и cooldown оставляют прежние snapshots нетронутыми. Если RF4DB показывает защитную страницу в разрешённой Chromium-сессии, сохраните полученный HTML и разберите его без повторного HTTP:
```bash
./scripts/fetch-waterbodies.py catalog --html /path/to/maps.html
./scripts/fetch-waterbodies.py detail \
--url https://download.rf4db.com/ru/maps/level_019_american_pond \
--html /path/to/detail.html
```
`--html` не резервирует cooldown и не обходит защиту: challenge/неполный документ будет отклонён строгим parser-ом. Скрипт намеренно не импортирует данные в PostgreSQL автоматически. После ручной проверки snapshot импортируется отдельно через `python -m app.cli import-waterbody-catalog` или `import-waterbody-detail`; для запуска из Docker файл передаётся в контейнер через stdin.
## Рекомендуемый режим получения
- Один понятный `User-Agent`, таймаут 20 секунд.
+7
View File
@@ -310,6 +310,10 @@ def main(argv: list[str] | None = None) -> int:
default=Path(os.environ.get("RF4_COMMUNITY_FETCH_STATE", DEFAULT_STATE_FILE)),
help="Persistent per-source cooldown state",
)
parser.add_argument(
"--reserve-only", action="store_true",
help="Reserve the shared site cooldown and stop before making an HTTP request",
)
args = parser.parse_args(argv)
if args.source in DETAIL_SOURCES and not args.url:
parser.error(f"--url is required for {args.source}")
@@ -319,6 +323,9 @@ def main(argv: list[str] | None = None) -> int:
site_key = fetch_site_key(url)
# Single atomic check-and-reserve before network I/O: failed attempts count toward the limit too.
check_and_reserve(site_key, state_file=args.state_file)
if args.reserve_only:
print(json.dumps({"reserved": True, "source": args.source, "site_key": site_key}, ensure_ascii=False))
return 0
html = fetch_html(url)
parsed = (
parse(html, source_url=url)
+149
View File
@@ -45,6 +45,51 @@ class EquipmentItem:
external_id: str | None
GEAR_CATEGORIES = frozenset({
"bait", "lure", "rod", "reel", "line", "hook", "rig", "float", "sinker", "other",
})
@dataclass(frozen=True, slots=True)
class RF4DBGearItem:
source_system: str
source_external_id: str
source_url: str
name: str
category: str
subcategory: str | None
brand: str | None
family: str | None
unlock_level: int | None
image_url: str | None
@dataclass(frozen=True, slots=True)
class RF4DBGearAttribute:
key: str
state: str
value: str | int | float | None
source_text: str | None
@dataclass(frozen=True, slots=True)
class RF4DBGearDetail:
source_system: str
source_external_id: str
source_url: str
name: str
category: str
subcategory: str | None
brand: str | None
family: str | None
unlock_level: int | None
attributes: tuple[RF4DBGearAttribute, ...]
variants: tuple[str, ...]
compatible_with: tuple[str, ...]
rig_types: tuple[str, ...]
image_urls: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class RF4DBCatchDetail:
source_external_id: str
@@ -209,6 +254,110 @@ def parse_rf4db_waterbodies(
return result
def _gear_level(value: str | None) -> int | None:
text = (value or "").strip()
if not text:
return None
if not re.fullmatch(r"\d+", text):
raise CommunityParseError(f"invalid gear unlock level: {text!r}")
return int(text)
def _gear_category(value: str | None) -> str:
category = (value or "").strip().casefold()
if category not in GEAR_CATEGORIES:
raise CommunityParseError(f"invalid or missing gear category: {value!r}")
return category
def _gear_value(node: Tag) -> str | int | float | None:
text = _text(node) or None
if text is None:
return None
value_type = str(node.get("data-type") or "text").casefold()
if value_type == "number":
try:
return float(text) if "." in text else int(text)
except ValueError as exc:
raise CommunityParseError(f"invalid numeric gear attribute: {text!r}") from exc
return text
def parse_rf4db_gear_catalog(
html: str, *, source_url: str = "https://rf4db.com/ru/wiki/gear", expected_count: int | None = None,
) -> list[RF4DBGearItem]:
"""Parse a gear index while keeping an unknown total explicitly unknown."""
soup = BeautifulSoup(html, "html.parser")
cards = soup.select("article.gear-card, [data-gear-card]")
result: list[RF4DBGearItem] = []
seen: set[str] = set()
for card in cards:
link = card.select_one("a[href]")
external_id = str(card.get("data-gear-id") or _key(link.get("href") if link else None) or "")
name = _text(card.select_one("[data-name], h2, h3, .gear-name"))
category = _gear_category(card.get("data-category"))
if not external_id or not name:
raise CommunityParseError("RF4DB gear card is missing id or name")
if external_id in seen:
raise CommunityParseError(f"duplicate RF4DB gear id: {external_id}")
seen.add(external_id)
item_url = urljoin(source_url, str(link.get("href"))) if link and link.get("href") else source_url
image = card.select_one("img[src], img[data-src]")
image_url = urljoin(item_url, str(image.get("src") or image.get("data-src"))) if image else None
result.append(RF4DBGearItem(
source_system="rf4db", source_external_id=external_id, source_url=item_url,
name=name, category=category,
subcategory=_text(card.select_one("[data-subcategory], .gear-subcategory")) or None,
brand=_text(card.select_one("[data-brand], .gear-brand")) or None,
family=_text(card.select_one("[data-family], .gear-family")) or None,
unlock_level=_gear_level(card.get("data-unlock-level")), image_url=image_url,
))
if not result:
raise CommunityParseError("RF4DB gear cards not found")
if expected_count is not None and len(result) != expected_count:
raise CommunityParseError(f"RF4DB gear catalog count mismatch: expected {expected_count}, got {len(result)}")
return result
def parse_rf4db_gear_detail(
html: str, *, source_url: str,
) -> RF4DBGearDetail:
"""Parse one gear detail page with explicit missing/not-applicable/value states."""
soup = BeautifulSoup(html, "html.parser")
root = soup.select_one("main[data-gear-detail], article.gear-detail") or soup
external_id = _key(source_url)
name = _text(root.select_one("h1, [data-name]"))
category = _gear_category(root.get("data-category"))
if not external_id or not name:
raise CommunityParseError("RF4DB gear detail is missing id or name")
attributes: list[RF4DBGearAttribute] = []
for node in root.select("[data-gear-attribute]"):
key = str(node.get("data-gear-attribute") or "").strip()
state = str(node.get("data-state") or "value").strip().casefold()
if not key or state not in {"value", "not_applicable", "missing"}:
raise CommunityParseError("invalid RF4DB gear attribute state")
value = None if state != "value" else _gear_value(node)
attributes.append(RF4DBGearAttribute(key=key, state=state, value=value, source_text=_text(node) or None))
images = tuple(dict.fromkeys(
urljoin(source_url, str(node.get("src") or node.get("data-src")))
for node in root.select("img[src], img[data-src]")
if node.get("src") or node.get("data-src")
))
return RF4DBGearDetail(
source_system="rf4db", source_external_id=external_id, source_url=source_url,
name=name, category=category,
subcategory=_text(root.select_one("[data-subcategory], .gear-subcategory")) or None,
brand=_text(root.select_one("[data-brand], .gear-brand")) or None,
family=_text(root.select_one("[data-family], .gear-family")) or None,
unlock_level=_gear_level(root.get("data-unlock-level")),
attributes=tuple(attributes),
variants=tuple(dict.fromkeys(_text(node) for node in root.select("[data-gear-variant]") if _text(node))),
compatible_with=tuple(dict.fromkeys(_text(node) for node in root.select("[data-compatible-with]") if _text(node))),
rig_types=tuple(dict.fromkeys(_text(node) for node in root.select("[data-rig-type]") if _text(node))),
image_urls=images,
)
def parse_rf4db_waterbody_detail(
html: str, *, source_url: str,
) -> RF4DBWaterbodyDetail:
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { chromium } from "../apps/web/node_modules/playwright/index.mjs";
const routes = [
"/", "/waterbodies", "/records", "/report", "/status", "/media", "/rules",
"/waterbodies/р-вьюнок", "/fish/pike", "/admin", "/admin/moderation",
"/admin/external-sources", "/admin/media",
];
const viewports = [
[320, 800], [390, 844], [768, 900], [1280, 900],
];
const themes = ["system", "light", "dark"];
function usage() {
console.log("Usage: node scripts/capture-visual-matrix.mjs [--base-url URL] [--output DIR]");
}
function parseArgs(argv) {
const args = { baseUrl: process.env.WEB_URL || "http://127.0.0.1:4321", output: "/tmp/rf4-visual-matrix" };
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--help" || argv[index] === "-h") {
usage();
process.exit(0);
}
if (argv[index] === "--base-url") args.baseUrl = argv[++index];
else if (argv[index] === "--output") args.output = argv[++index];
else throw new Error(`unknown argument: ${argv[index]}`);
}
return args;
}
function safeName(value) {
return value.replace(/^\//, "home").replaceAll("/", "-").replace(/[^\p{L}\p{N}._-]+/gu, "-");
}
const { baseUrl, output } = parseArgs(process.argv.slice(2));
const outputDir = path.resolve(output);
await fs.mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
const manifest = [];
try {
for (const theme of themes) {
await page.context().clearCookies();
if (theme !== "system") {
await page.context().addCookies([{ name: "rf4-theme", value: theme, url: baseUrl }]);
}
await page.emulateMedia({ colorScheme: theme === "system" ? "light" : theme });
for (const [width, height] of viewports) {
await page.setViewportSize({ width, height });
for (const route of routes) {
await page.goto(new URL(route, baseUrl).toString(), { waitUntil: "networkidle" });
const state = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
theme: document.documentElement.dataset.theme || "system",
}));
const filename = `${theme}-${width}x${height}-${safeName(route)}.png`;
await page.screenshot({ path: path.join(outputDir, filename), fullPage: true });
manifest.push({ theme, width, height, route, filename, ...state });
}
}
}
} finally {
await browser.close();
}
await fs.writeFile(
path.join(outputDir, "manifest.json"),
`${JSON.stringify({ baseUrl, count: manifest.length, items: manifest }, null, 2)}\n`,
"utf8",
);
const overflow = manifest.filter((item) => item.scrollWidth > item.clientWidth);
console.log(`captured ${manifest.length} screenshots in ${outputDir}`);
console.log(`document overflow: ${overflow.length}`);
if (overflow.length) process.exitCode = 1;
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import readline from "node:readline/promises";
import process from "node:process";
import path from "node:path";
import { chromium } from "../apps/web/node_modules/playwright/index.mjs";
const ROOT = path.resolve(new URL("..", import.meta.url).pathname);
const PYTHON = process.env.RF4_PYTHON || path.join(ROOT, ".venv/bin/python");
const DEFAULT_STATE = path.join(ROOT, ".cache/community-fetch-state.json");
const DEFAULT_PROFILE = path.join(ROOT, ".cache/rf4db-chromium-profile");
function usage() {
console.log(`Usage:
node scripts/fetch-waterbodies-chromium.mjs catalog [options]
node scripts/fetch-waterbodies-chromium.mjs detail --url URL [options]
Options:
--output PATH JSON snapshot path (default: .cache/waterbodies/)
--html-output PATH retain browser DOM HTML (default: temporary .cache file)
--state-file PATH shared cooldown state file
--profile PATH persistent Chromium profile directory
--headless run Chromium headless; cannot handle manual challenges
--wait-seconds N headed wait after load (default: 30)
`);
}
function parseArgs(argv) {
if (!argv.length || argv.includes("--help")) {
usage();
process.exit(0);
}
const mode = argv.shift();
if (!new Set(["catalog", "detail"]).has(mode)) throw new Error(`unknown mode: ${mode}`);
const args = {
mode, url: mode === "catalog" ? "https://rf4db.com/ru/maps" : null,
output: null, htmlOutput: null, stateFile: DEFAULT_STATE, profile: DEFAULT_PROFILE,
headless: false, waitSeconds: 30,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--url") args.url = argv[++index];
else if (arg === "--output") args.output = argv[++index];
else if (arg === "--html-output") args.htmlOutput = argv[++index];
else if (arg === "--state-file") args.stateFile = argv[++index];
else if (arg === "--profile") args.profile = argv[++index];
else if (arg === "--headless") args.headless = true;
else if (arg === "--wait-seconds") args.waitSeconds = Number(argv[++index]);
else throw new Error(`unknown argument: ${arg}`);
}
if (args.mode === "detail" && !args.url) throw new Error("detail requires --url");
if (!Number.isInteger(args.waitSeconds) || args.waitSeconds < 0 || args.waitSeconds > 600) {
throw new Error("--wait-seconds must be an integer from 0 to 600");
}
const parsed = new URL(args.url);
if (parsed.protocol !== "https:" || !["rf4db.com", "download.rf4db.com"].includes(parsed.hostname)) {
throw new Error("Chromium launcher accepts only HTTPS rf4db.com URLs");
}
return args;
}
function reserveCooldown(args) {
const source = args.mode === "catalog" ? "rf4db-waterbodies" : "rf4db-waterbody";
const command = ["-m", "rf4_research.community_cli", source, "--url", args.url, "--state-file", args.stateFile, "--reserve-only"];
const result = spawnSync(PYTHON, command, { cwd: ROOT, encoding: "utf8" });
if (result.status !== 0) {
if (result.stderr) process.stderr.write(result.stderr);
throw new Error(`cooldown reservation failed with exit code ${result.status}`);
}
process.stdout.write(`${result.stdout.trim()}\n`);
}
function outputPath(args) {
if (args.output) return path.resolve(args.output);
const stamp = new Date().toISOString().replaceAll(/[-:]/g, "").replace(".000", "");
const stem = args.mode === "catalog" ? "rf4db-catalog" : `rf4db-${path.basename(new URL(args.url).pathname)}`;
return path.join(ROOT, ".cache/waterbodies", `${stem}-${stamp}.json`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
reserveCooldown(args);
const output = outputPath(args);
const htmlOutput = path.resolve(args.htmlOutput || `${output}.html`);
await fs.mkdir(path.dirname(htmlOutput), { recursive: true });
await fs.mkdir(path.dirname(output), { recursive: true });
const context = await chromium.launchPersistentContext(path.resolve(args.profile), { headless: args.headless });
try {
const page = context.pages()[0] || await context.newPage();
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
if (!args.headless && args.waitSeconds > 0) {
console.log(`Chromium opened ${args.url}. Complete only an ordinary site challenge if shown, then press Enter (or wait ${args.waitSeconds}s).`);
const input = readline.createInterface({ input: process.stdin, output: process.stdout });
await Promise.race([
input.question("Ready to capture DOM? "),
new Promise(resolve => setTimeout(resolve, args.waitSeconds * 1000)),
]);
input.close();
} else if (args.waitSeconds > 0) {
await page.waitForTimeout(args.waitSeconds * 1000);
}
const html = await page.locator("html").evaluate(element => element.outerHTML);
await fs.writeFile(htmlOutput, `<!doctype html>\n${html}\n`, "utf8");
} finally {
await context.close();
}
const parser = path.join(ROOT, "scripts/fetch-waterbodies.py");
const result = spawnSync(PYTHON, [parser, args.mode, "--html", htmlOutput, "--output", output, ...(args.mode === "detail" ? ["--url", args.url] : [])], { cwd: ROOT, encoding: "utf8" });
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (result.status !== 0) process.exitCode = result.status || 1;
}
main().catch(error => {
console.error(`Chromium waterbody fetch failed: ${error.message}`);
process.exitCode = 1;
});
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Manually fetch RF4DB waterbody snapshots through the guarded research CLI."""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from dataclasses import asdict
from pathlib import Path
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from rf4_research.community_cli import _validate_url_before_io
from rf4_research.community_sources import parse_rf4db_waterbodies, parse_rf4db_waterbody_detail
DEFAULT_STATE_FILE = ROOT / ".cache" / "community-fetch-state.json"
DEFAULT_OUTPUT_DIR = ROOT / ".cache" / "waterbodies"
CATALOG_SOURCE = "rf4db-waterbodies"
DETAIL_SOURCE = "rf4db-waterbody"
def _timestamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def _safe_detail_name(url: str) -> str:
name = Path(urlsplit(url).path.rstrip("/")).name or "detail"
return re.sub(r"[^A-Za-z0-9_.-]+", "-", name)
def _default_output(mode: str, url: str | None) -> Path:
stem = "rf4db-catalog" if mode == "catalog" else f"rf4db-{_safe_detail_name(url or '')}"
return DEFAULT_OUTPUT_DIR / f"{stem}-{_timestamp()}.json"
def _write_json_atomically(path: Path, payload: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
handle = tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.",
suffix=".tmp", delete=False,
)
temporary = Path(handle.name)
try:
with handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
except Exception:
temporary.unlink(missing_ok=True)
raise
def fetch_snapshot(
mode: str, *, url: str | None, html: Path | None = None, output: Path,
state_file: Path, limit: int,
) -> int:
if html is not None:
source_url = url or "https://rf4db.com/ru/maps"
try:
_validate_url_before_io(source_url)
document = html.read_text(encoding="utf-8")
parsed = (
parse_rf4db_waterbodies(document)
if mode == "catalog"
else parse_rf4db_waterbody_detail(document, source_url=source_url)
)
except Exception as exc:
print(f"local HTML parse failed: {exc}", file=sys.stderr)
return 1
payload = [asdict(item) for item in parsed] if mode == "catalog" else asdict(parsed)
_write_json_atomically(output, payload)
count = len(payload) if isinstance(payload, list) else 1
print(f"parsed and saved {count} waterbody snapshot(s) to {output}")
return 0
source = CATALOG_SOURCE if mode == "catalog" else DETAIL_SOURCE
command = [
sys.executable, "-m", "rf4_research.community_cli", source,
"--state-file", str(state_file), "--limit", str(limit),
]
if url:
command.extend(["--url", url])
result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False)
if result.returncode != 0:
if result.stderr:
print(result.stderr, file=sys.stderr, end="")
return result.returncode
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
print(f"fetch succeeded but returned invalid JSON: {exc}", file=sys.stderr)
return 1
if mode == "catalog" and not isinstance(payload, list):
print("fetch succeeded but catalog payload is not a JSON array", file=sys.stderr)
return 1
if mode == "detail" and not isinstance(payload, dict):
print("fetch succeeded but detail payload is not a JSON object", file=sys.stderr)
return 1
_write_json_atomically(output, payload)
count = len(payload) if isinstance(payload, list) else 1
print(f"saved {count} waterbody snapshot(s) to {output}")
print(f"next import: python -m app.cli import-waterbody-{'catalog' if mode == 'catalog' else 'detail'} --input {output}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Manually fetch RF4DB waterbody data with the shared cooldown guard"
)
subparsers = parser.add_subparsers(dest="mode", required=True)
catalog = subparsers.add_parser("catalog", help="fetch the public waterbody catalog")
catalog.add_argument("--output", type=Path, help="snapshot path; default: .cache/waterbodies/")
catalog.add_argument("--html", type=Path, help="parse an HTML file saved from an authorized browser session; no network")
catalog.add_argument("--limit", type=int, default=500, choices=range(1, 501), metavar="1..500")
detail = subparsers.add_parser("detail", help="fetch one waterbody detail page")
detail.add_argument("--url", required=True, help="authorized RF4DB detail URL")
detail.add_argument("--output", type=Path, help="snapshot path; default: .cache/waterbodies/")
detail.add_argument("--html", type=Path, help="parse an HTML file saved from an authorized browser session; no network")
detail.add_argument("--limit", type=int, default=100, choices=range(1, 501), metavar="1..500")
for command in (catalog, detail):
command.add_argument(
"--state-file", type=Path, default=DEFAULT_STATE_FILE,
help=f"cooldown state file (default: {DEFAULT_STATE_FILE})",
)
args = parser.parse_args(argv)
output = args.output or _default_output(args.mode, getattr(args, "url", None))
return fetch_snapshot(
args.mode,
url=getattr(args, "url", None),
html=args.html,
output=output,
state_file=args.state_file,
limit=args.limit,
)
if __name__ == "__main__":
raise SystemExit(main())
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<body>
<article class="gear-card" data-gear-card data-gear-id="spiker-2" data-category="lure" data-subcategory="spinner" data-brand="RF4" data-family="spoon" data-unlock-level="12">
<a href="/ru/wiki/lures/spiker-2"><h3 data-name>Spiker #2</h3></a>
<img src="https://oss.rf4db.com/game/gear/spiker-2.webp" alt="Spiker #2">
</article>
<article class="gear-card" data-gear-card data-gear-id="method-popup" data-category="rig" data-subcategory="feeder" data-unlock-level="0">
<a href="/ru/wiki/rigs/method-popup"><h3 data-name>Method Popup</h3></a>
</article>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<body>
<main data-gear-detail data-category="lure" data-subcategory="spinner" data-brand="RF4" data-family="spoon" data-unlock-level="12">
<h1>Spiker #2</h1>
<dl>
<dd data-gear-attribute="weight" data-type="number">0</dd>
<dd data-gear-attribute="floating" data-state="not_applicable">Не применяется</dd>
<dd data-gear-attribute="target_fish" data-state="missing">Нет данных</dd>
</dl>
<a data-gear-variant>Spiker #2 01-015</a>
<a data-compatible-with>Light spinning rod</a>
<a data-rig-type>Spinning</a>
<img src="/game/gear/spiker-2.webp" alt="Spiker #2">
</main>
</body>
</html>
+16
View File
@@ -56,6 +56,22 @@ def test_failed_fetch_still_reserves_site_cooldown(tmp_path: Path, monkeypatch:
assert "rf4db.com" in json.loads(state_file.read_text(encoding="utf-8"))
def test_reserve_only_does_not_make_http_request(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
state_file = tmp_path / "fetch-state.json"
def fail(_url: str) -> str:
raise AssertionError("reserve-only must stop before HTTP")
monkeypatch.setattr(community_cli, "fetch_html", fail)
assert community_cli.main([
"rf4db-waterbodies", "--state-file", str(state_file), "--reserve-only",
]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload == {"reserved": True, "source": "rf4db-waterbodies", "site_key": "rf4db.com"}
def test_detail_fetch_serializes_single_record(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
+76
View File
@@ -0,0 +1,76 @@
import importlib.util
import json
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "scripts" / "fetch-waterbodies.py"
SPEC = importlib.util.spec_from_file_location("fetch_waterbodies", SCRIPT)
assert SPEC and SPEC.loader
fetch_waterbodies = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(fetch_waterbodies)
def test_fetch_snapshot_saves_catalog_atomically(tmp_path: Path, monkeypatch) -> None:
output = tmp_path / "nested" / "catalog.json"
calls = []
class Result:
returncode = 0
stdout = '[{"source_external_id": "level_019_american_pond"}]'
stderr = ""
def run(command, **kwargs):
calls.append((command, kwargs))
return Result()
monkeypatch.setattr(fetch_waterbodies.subprocess, "run", run)
assert fetch_waterbodies.fetch_snapshot(
"catalog", url=None, output=output, state_file=tmp_path / "state.json", limit=19,
) == 0
assert json.loads(output.read_text(encoding="utf-8"))[0]["source_external_id"] == "level_019_american_pond"
assert calls[0][0][0] == fetch_waterbodies.sys.executable
assert "--state-file" in calls[0][0]
assert "--limit" in calls[0][0]
def test_fetch_snapshot_parses_saved_catalog_without_network(tmp_path: Path, monkeypatch) -> None:
html = Path(__file__).parent / "fixtures" / "rf4db_waterbodies_sample.html"
output = tmp_path / "catalog.json"
parser = fetch_waterbodies.parse_rf4db_waterbodies
monkeypatch.setattr(fetch_waterbodies, "parse_rf4db_waterbodies", lambda document: parser(document, expected_count=2))
assert fetch_waterbodies.fetch_snapshot(
"catalog", url=None, html=html, output=output,
state_file=tmp_path / "state.json", limit=19,
) == 0
payload = json.loads(output.read_text(encoding="utf-8"))
assert len(payload) == 2
assert payload[0]["source_system"] == "rf4db"
def test_fetch_snapshot_reports_invalid_saved_detail_html(tmp_path: Path, capsys) -> None:
html = tmp_path / "challenge.html"
html.write_text("<html><title>Just a moment...</title></html>", encoding="utf-8")
output = tmp_path / "detail.json"
assert fetch_waterbodies.fetch_snapshot(
"detail", url="https://download.rf4db.com/ru/maps/level_019_american_pond",
html=html, output=output, state_file=tmp_path / "state.json", limit=100,
) == 1
assert not output.exists()
assert "local HTML parse failed" in capsys.readouterr().err
def test_fetch_snapshot_does_not_write_failed_fetch(tmp_path: Path, monkeypatch, capsys) -> None:
output = tmp_path / "detail.json"
class Result:
returncode = 1
stdout = ""
stderr = "community source failed: source cooldown is active\n"
monkeypatch.setattr(fetch_waterbodies.subprocess, "run", lambda *args, **kwargs: Result())
assert fetch_waterbodies.fetch_snapshot(
"detail", url="https://download.rf4db.com/ru/maps/level_019_american_pond",
output=output, state_file=tmp_path / "state.json", limit=100,
) == 1
assert not output.exists()
assert "cooldown" in capsys.readouterr().err
+49
View File
@@ -0,0 +1,49 @@
from dataclasses import asdict
from pathlib import Path
import pytest
from rf4_research.community_sources import (
CommunityParseError,
parse_rf4db_gear_catalog,
parse_rf4db_gear_detail,
)
FIXTURES = Path(__file__).parent / "fixtures"
def test_gear_catalog_preserves_zero_level_and_unknown_total() -> None:
html = (FIXTURES / "rf4db_gear_catalog_sample.html").read_text(encoding="utf-8")
rows = parse_rf4db_gear_catalog(html)
assert len(rows) == 2
assert rows[0].category == "lure"
assert rows[0].source_external_id == "spiker-2"
assert rows[1].unlock_level == 0
assert rows[1].image_url is None
def test_gear_catalog_rejects_duplicate_ids() -> None:
html = '<article class="gear-card" data-gear-id="same" data-category="bait"><h3 data-name>A</h3></article><article class="gear-card" data-gear-id="same" data-category="bait"><h3 data-name>B</h3></article>'
with pytest.raises(CommunityParseError, match="duplicate"):
parse_rf4db_gear_catalog(html)
def test_gear_detail_distinguishes_value_not_applicable_and_missing() -> None:
html = (FIXTURES / "rf4db_gear_detail_sample.html").read_text(encoding="utf-8")
detail = parse_rf4db_gear_detail(html, source_url="https://rf4db.com/ru/wiki/lures/spiker-2")
attributes = {item.key: asdict(item) for item in detail.attributes}
assert attributes["weight"] == {"key": "weight", "state": "value", "value": 0, "source_text": "0"}
assert attributes["floating"]["state"] == "not_applicable"
assert attributes["floating"]["value"] is None
assert attributes["target_fish"]["state"] == "missing"
assert detail.variants == ("Spiker #2 01-015",)
assert detail.rig_types == ("Spinning",)
def test_gear_detail_rejects_unknown_category() -> None:
with pytest.raises(CommunityParseError, match="category"):
parse_rf4db_gear_detail(
'<main data-gear-detail data-category="unknown-category"><h1>Test</h1></main>',
source_url="https://rf4db.com/ru/wiki/gear/test",
)