feat: add gear provenance models and browser fetcher
This commit is contained in:
@@ -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")
|
||||
@@ -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"),)
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 W03–W08 without assigning unreviewed media
|
||||
roles.
|
||||
|
||||
+10
-10
@@ -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 и процедурой отзыва доступа.
|
||||
|
||||
## Готовность открытой альфы — требуется сервер или внешний сервис
|
||||
|
||||
@@ -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 секунд.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Executable
+81
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
Executable
+153
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user