Spiker #2
+
+ diff --git a/apps/api/alembic/versions/0021_tackle_catalog.py b/apps/api/alembic/versions/0021_tackle_catalog.py new file mode 100644 index 0000000..81c8e7a --- /dev/null +++ b/apps/api/alembic/versions/0021_tackle_catalog.py @@ -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") diff --git a/apps/api/app/models.py b/apps/api/app/models.py index 69c8326..a51e8e5 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -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"),) diff --git a/apps/api/tests/test_tackle_models.py b/apps/api/tests/test_tackle_models.py new file mode 100644 index 0000000..a1a41bc --- /dev/null +++ b/apps/api/tests/test_tackle_models.py @@ -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 diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css index 01f65f4..4d6f48b 100644 --- a/apps/web/src/styles/theme.css +++ b/apps/web/src/styles/theme.css @@ -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); diff --git a/apps/web/tests/accessibility.spec.ts b/apps/web/tests/accessibility.spec.ts index f299031..d1e8776 100644 --- a/apps/web/tests/accessibility.spec.ts +++ b/apps/web/tests/accessibility.spec.ts @@ -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"); +}); diff --git a/apps/web/tests/visual-matrix.spec.ts b/apps/web/tests/visual-matrix.spec.ts new file mode 100644 index 0000000..fb75830 --- /dev/null +++ b/apps/web/tests/visual-matrix.spec.ts @@ -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); + } + } + } +}); diff --git a/docs/PROJECT_AUDIT_2026-09-20.md b/docs/PROJECT_AUDIT_2026-09-20.md index cd6d6c8..af03ebe 100644 --- a/docs/PROJECT_AUDIT_2026-09-20.md +++ b/docs/PROJECT_AUDIT_2026-09-20.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0f7c25b..2ec0d17 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 и процедурой отзыва доступа. ## Готовность открытой альфы — требуется сервер или внешний сервис diff --git a/docs/data-sources.md b/docs/data-sources.md index c83c056..655d627 100644 --- a/docs/data-sources.md +++ b/docs/data-sources.md @@ -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 секунд. diff --git a/rf4_research/community_cli.py b/rf4_research/community_cli.py index 0d8daf3..e2124b6 100644 --- a/rf4_research/community_cli.py +++ b/rf4_research/community_cli.py @@ -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) diff --git a/rf4_research/community_sources.py b/rf4_research/community_sources.py index 7c8db78..a3e4490 100644 --- a/rf4_research/community_sources.py +++ b/rf4_research/community_sources.py @@ -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: diff --git a/scripts/capture-visual-matrix.mjs b/scripts/capture-visual-matrix.mjs new file mode 100755 index 0000000..fc09785 --- /dev/null +++ b/scripts/capture-visual-matrix.mjs @@ -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; diff --git a/scripts/fetch-waterbodies-chromium.mjs b/scripts/fetch-waterbodies-chromium.mjs new file mode 100644 index 0000000..275eb5e --- /dev/null +++ b/scripts/fetch-waterbodies-chromium.mjs @@ -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, `\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; +}); diff --git a/scripts/fetch-waterbodies.py b/scripts/fetch-waterbodies.py new file mode 100755 index 0000000..dced23b --- /dev/null +++ b/scripts/fetch-waterbodies.py @@ -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()) diff --git a/tests/fixtures/rf4db_gear_catalog_sample.html b/tests/fixtures/rf4db_gear_catalog_sample.html new file mode 100644 index 0000000..0fdf0a7 --- /dev/null +++ b/tests/fixtures/rf4db_gear_catalog_sample.html @@ -0,0 +1,12 @@ + + +
+
+
+