feat: publish approved RF4 media catalog
This commit is contained in:
@@ -44,9 +44,9 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
||||
|
||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Тот же запрос служит проверкой точной исходной ссылки: `404/410` означает `missing`, `401/403/429` — `blocked`, остальные сбои — `temporary_error`; отдельного link-checker и дополнительных обращений нет. Пропажа элемента из агрегатного списка сама по себе удалением не считается. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
||||
|
||||
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
||||
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
||||
|
||||
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL и общие учебные схемы не завышают покрытие. Объединение разрешённых каталогов RF4MAP и RF4DB теперь содержит все 252 названия рыб; 25 отсутствовавших изображений RF4DB ожидают загрузки, а 227 совпадений сохранены как provenance-only `duplicate`. Водоёмы пока не покрыты, а общий target снастей остаётся `null` до проверяемого полного счётчика.
|
||||
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика.
|
||||
|
||||
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ RUN pip install --no-cache-dir -r requirements-lock.txt
|
||||
RUN useradd --create-home --uid 10001 rf4
|
||||
COPY --chown=rf4:rf4 apps/api .
|
||||
COPY --chown=rf4:rf4 rf4_research ./rf4_research
|
||||
COPY --chown=rf4:rf4 data/media ./data/media
|
||||
USER rf4
|
||||
EXPOSE 8000
|
||||
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
|
||||
|
||||
@@ -16,6 +16,7 @@ from .readiness import readiness_report
|
||||
from .routers.activity import router as activity_router
|
||||
from .routers.admin import router as admin_router
|
||||
from .routers.catalog import router as catalog_router
|
||||
from .routers.media import router as media_router
|
||||
from .routers.public_data import router as public_data_router
|
||||
from .routers.submissions import router as submissions_router
|
||||
from .storage import client as storage_client
|
||||
@@ -87,6 +88,7 @@ def ready(db: Db) -> JSONResponse:
|
||||
|
||||
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(media_router)
|
||||
app.include_router(activity_router)
|
||||
app.include_router(public_data_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", "data/media")).resolve()
|
||||
|
||||
|
||||
def published_assets(entity_type: str | None = None) -> list[dict]:
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
result = []
|
||||
for item in manifest.get("assets", []):
|
||||
if item.get("status") != "approved" or not item.get("sha256") or not item.get("local_path"):
|
||||
continue
|
||||
if entity_type and item.get("entity_type") != entity_type:
|
||||
continue
|
||||
source_page = str(item.get("source_page") or "")
|
||||
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
|
||||
result.append({
|
||||
"id": item["sha256"],
|
||||
"entity_type": item.get("entity_type"),
|
||||
"entity_key": item.get("entity_key"),
|
||||
"label": item.get("label"),
|
||||
"width": item.get("width"),
|
||||
"height": item.get("height"),
|
||||
"content_type": item.get("content_type"),
|
||||
"image_url": f"/api/v1/media/assets/{item['sha256']}",
|
||||
"source_system": source,
|
||||
"source_url": source_page,
|
||||
})
|
||||
return sorted(result, key=lambda item: (str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"]))
|
||||
|
||||
|
||||
def published_file(digest: str) -> tuple[Path, str] | None:
|
||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||
return None
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None)
|
||||
if not item:
|
||||
return None
|
||||
target = (MEDIA_ROOT / item["local_path"]).resolve()
|
||||
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
|
||||
return None
|
||||
return target, str(item["content_type"])
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..media_catalog import published_assets, published_file
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/v1/media/catalog")
|
||||
def media_catalog(entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$")) -> list[dict]:
|
||||
return published_assets(entity_type)
|
||||
|
||||
|
||||
@router.get("/api/v1/media/assets/{digest}", response_class=FileResponse)
|
||||
def media_asset(digest: str) -> FileResponse:
|
||||
item = published_file(digest)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Media asset not found")
|
||||
path, media_type = item
|
||||
return FileResponse(path, media_type=media_type, headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"ETag": f'"{digest}"',
|
||||
})
|
||||
@@ -3156,6 +3156,90 @@
|
||||
"summary": "Imports"
|
||||
}
|
||||
},
|
||||
"/api/v1/media/assets/{digest}": {
|
||||
"get": {
|
||||
"operationId": "media_asset_api_v1_media_assets__digest__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "digest",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Digest",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Media Asset"
|
||||
}
|
||||
},
|
||||
"/api/v1/media/catalog": {
|
||||
"get": {
|
||||
"operationId": "media_catalog_api_v1_media_catalog_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "entity_type",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^(fish|waterbody|tackle|reference)$",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Entity Type"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Response Media Catalog Api V1 Media Catalog Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Media Catalog"
|
||||
}
|
||||
},
|
||||
"/api/v1/public-spot-pages": {
|
||||
"get": {
|
||||
"operationId": "public_spot_pages_api_v1_public_spot_pages_get",
|
||||
|
||||
@@ -61,6 +61,18 @@ def test_invalid_period_is_rejected() -> None:
|
||||
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
|
||||
|
||||
|
||||
def test_published_media_catalog_and_content_addressed_file() -> None:
|
||||
catalog = client.get("/api/v1/media/catalog?entity_type=fish")
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()
|
||||
item = catalog.json()[0]
|
||||
image = client.get(item["image_url"])
|
||||
assert image.status_code == 200
|
||||
assert image.headers["content-type"].startswith("image/")
|
||||
assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
|
||||
|
||||
|
||||
def test_review_queue_filters_before_pagination() -> None:
|
||||
with Session(engine) as db:
|
||||
stage_observations(db, [{
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import SourceBadge from "./SourceBadge.astro";
|
||||
import type { MediaAsset } from "../lib/api";
|
||||
const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean };
|
||||
---
|
||||
<figure class:list={["entity-media", { "entity-media--compact": compact }]}>
|
||||
<span class="entity-media__frame"><img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" /></span>
|
||||
<figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span></figcaption>
|
||||
</figure>
|
||||
@@ -14,6 +14,7 @@ import "../styles/signal-pagination.css";
|
||||
import "../styles/dashboard-polish.css";
|
||||
import "../styles/loading-states.css";
|
||||
import "../styles/theme.css";
|
||||
import "../styles/media-catalog.css";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
import AlphaBanner from "../components/AlphaBanner.astro";
|
||||
const {
|
||||
@@ -114,7 +115,7 @@ Astro.response.headers.set("Content-Security-Policy", [
|
||||
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a>
|
||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/media")}} aria-current={path.startsWith("/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||
<div class="header-tools">
|
||||
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
||||
<div class="theme-switcher" role="group" aria-label="Цветовая тема">
|
||||
|
||||
@@ -26,6 +26,7 @@ export type PaginatedOfficialRecord = {
|
||||
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
|
||||
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
||||
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
|
||||
export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string };
|
||||
|
||||
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MediaAsset } from "./api";
|
||||
|
||||
const tokens = (value: string) => value
|
||||
.toLocaleLowerCase("ru")
|
||||
.replaceAll("ё", "е")
|
||||
.replace(/^(оз\.|р\.)\s*/, "")
|
||||
.replace(/[^a-zа-я0-9]+/giu, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
export const findMediaByLabel = (assets: MediaAsset[], label: string): MediaAsset | undefined => {
|
||||
const wanted = tokens(label);
|
||||
const exact = assets.filter((asset) => tokens(asset.label ?? "").join(" ") === wanted.join(" "));
|
||||
if (exact.length === 1) return exact[0];
|
||||
|
||||
const wantedSet = new Set(wanted);
|
||||
const candidates = assets.filter((asset) => {
|
||||
const available = new Set(tokens(asset.label ?? ""));
|
||||
return wanted.every((token) => available.has(token)) || [...available].every((token) => wantedSet.has(token));
|
||||
});
|
||||
return candidates.length === 1 ? candidates[0] : undefined;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
const apiBase = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const digest = params.digest ?? "";
|
||||
if (!/^[a-f0-9]{64}$/.test(digest)) return new Response("Not found", { status: 404 });
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${apiBase}/api/v1/media/assets/${digest}`);
|
||||
if (!upstream.ok || !upstream.body) return new Response("Not found", { status: upstream.status === 404 ? 404 : 502 });
|
||||
const headers = new Headers();
|
||||
for (const name of ["content-type", "content-length", "cache-control", "etag"]) {
|
||||
const value = upstream.headers.get(name);
|
||||
if (value) headers.set(name, value);
|
||||
}
|
||||
return new Response(upstream.body, { status: 200, headers });
|
||||
} catch {
|
||||
return new Response("Media service unavailable", { status: 502 });
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,18 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import EntityMedia from "../../components/EntityMedia.astro";
|
||||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
||||
import PageHero from "../../components/PageHero.astro";
|
||||
import StatePanel from "../../components/StatePanel.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
|
||||
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
const { slug } = Astro.params;
|
||||
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
let fish: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
try {
|
||||
const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500");
|
||||
const [fishes, fishMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
|
||||
media = fishMedia;
|
||||
fish = fishes.find(item => item.slug === slug);
|
||||
if (fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
|
||||
} catch { unavailable = true; }
|
||||
@@ -20,10 +23,12 @@ if (unavailable) {
|
||||
}
|
||||
if (!fish && !unavailable) Astro.response.status = 404;
|
||||
const waters = [...new Map(items.map(item => [item.waterbody_slug, item.waterbody])).entries()];
|
||||
const image = fish ? findMediaByLabel(media, fish.name_ru) : undefined;
|
||||
const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Где ловить ${fish.name_ru} в RF4`, url:`https://rf4spotter.ru/fish/${fish.slug}` } : null;
|
||||
---
|
||||
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
|
||||
<AtlasBreadcrumbs items={[{ label: "Рыбы", href: "/fish" }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
|
||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${fish!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Визуальный справочник</span><h2>{fish!.name_ru}</h2><p>Изображение опубликовано с прямой ссылкой на источник. Оно помогает отличить вид, а актуальные точки ниже остаются отдельными наблюдениями игроков.</p></div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
</Layout>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import FishSilhouette from "../../components/FishSilhouette.astro";
|
||||
import EntityMedia from "../../components/EntityMedia.astro";
|
||||
import PageHero from "../../components/PageHero.astro";
|
||||
import StatePanel from "../../components/StatePanel.astro";
|
||||
import { api, type DictionaryItem } from "../../lib/api";
|
||||
let fishes: DictionaryItem[] = [], unavailable = false;
|
||||
try { fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500"); } catch { unavailable = true; }
|
||||
import { api, type DictionaryItem, type MediaAsset } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
let fishes: DictionaryItem[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
try { [fishes, media] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]); } catch { unavailable = true; }
|
||||
if (unavailable) {
|
||||
Astro.response.status = 503;
|
||||
Astro.response.headers.set("Retry-After", "60");
|
||||
@@ -14,5 +16,5 @@ if (unavailable) {
|
||||
---
|
||||
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных.">
|
||||
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={fishes.length} />
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i><FishSilhouette name={fish.name_ru} size={92}/></a>)}</nav>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => { const asset = findMediaByLabel(media, fish.name_ru); return <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i>{asset ? <EntityMedia asset={asset} compact /> : <FishSilhouette name={fish.name_ru} size={92}/>}</a>; })}</nav>}
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import EntityMedia from "../components/EntityMedia.astro";
|
||||
import PageHero from "../components/PageHero.astro";
|
||||
import StatePanel from "../components/StatePanel.astro";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { api, type MediaAsset } from "../lib/api";
|
||||
const allowed = new Set(["fish", "waterbody", "tackle", "reference"]);
|
||||
const requested = Astro.url.searchParams.get("type") ?? "fish";
|
||||
const type = allowed.has(requested) ? requested : "fish";
|
||||
let assets: MediaAsset[] = [], unavailable = false;
|
||||
try { assets = await api<MediaAsset[]>(`/api/v1/media/catalog?entity_type=${type}`); } catch { unavailable = true; }
|
||||
const labels: Record<string,string> = {fish:"Рыбы",waterbody:"Водоёмы",tackle:"Снасти и приманки",reference:"Справочные материалы"};
|
||||
---
|
||||
<Layout title={`${labels[type]} RF4 — медиатека RF4 Spotter`} description="Изображения рыб, водоёмов и снастей Russian Fishing 4 с обязательной атрибуцией каждого источника.">
|
||||
<PageHero eyebrow="Визуальный справочник" title="Медиатека" description="Собранные материалы RF4 с источником у каждого изображения. Каталог пополняется по мере импорта и проверки." variant="fish" count={assets.length} />
|
||||
<section class="media-library content-grid">
|
||||
<nav class="media-library__intro" aria-label="Разделы медиатеки"><strong>{labels[type]} · {assets.length}</strong><span><a href="/media?type=fish">Рыбы</a> · <a href="/media?type=waterbody">Водоёмы</a> · <a href="/media?type=tackle">Снасти</a> · <a href="/media?type=reference">Справка</a></span></nav>
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Медиатека временно недоступна" /> : assets.length ? <div class="media-library__grid">{assets.map(asset => <article class="media-library__card"><EntityMedia asset={asset} sourceLink /><h2>{asset.label ?? "Без подписи"}</h2><span>{labels[asset.entity_type]}</span></article>)}</div> : <StatePanel title="В этом разделе пока нет изображений" description="Материалы появятся после следующего импорта." />}
|
||||
</section>
|
||||
</Layout>
|
||||
@@ -9,7 +9,7 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
const output = (xml: string) => new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" } });
|
||||
if (lastGood?.origin === origin && Date.now() - lastGood.at < 1800000) return output(lastGood.xml);
|
||||
try {
|
||||
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy", "/fish", "/waterbodies"]);
|
||||
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy", "/fish", "/waterbodies", "/media"]);
|
||||
for (const [endpoint, prefix] of [["fishes", "fish"], ["waterbodies", "waterbodies"]]) {
|
||||
for (let offset = 0; ; offset += 500) {
|
||||
const rows = await api<DictionaryItem[]>(`/api/v1/${endpoint}?limit=500&offset=${offset}`);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import EntityMedia from "../../components/EntityMedia.astro";
|
||||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
||||
import PageHero from "../../components/PageHero.astro";
|
||||
import StatePanel from "../../components/StatePanel.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api";
|
||||
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
const { slug } = Astro.params;
|
||||
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
try {
|
||||
const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
|
||||
const [waters, waterMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=waterbody")]);
|
||||
media = waterMedia;
|
||||
water = waters.find(item => item.slug === slug);
|
||||
if (water) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); items = paginated.items; }
|
||||
} catch { unavailable = true; }
|
||||
@@ -20,10 +23,12 @@ if (unavailable) {
|
||||
}
|
||||
if (!water && !unavailable) Astro.response.status = 404;
|
||||
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
|
||||
const image = water ? findMediaByLabel(media, water.name_ru) : undefined;
|
||||
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
|
||||
---
|
||||
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
|
||||
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
|
||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${water!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Карта и образ водоёма</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}}
|
||||
.entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}}
|
||||
+2161
-865
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
Этот файл — единственный актуальный список задач. Завершённые аудиты сохранены как история в [PROJECT_AUDIT_2026-09-08.md](PROJECT_AUDIT_2026-09-08.md), [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md) и [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md); их старые чекбоксы не являются текущей очередью.
|
||||
|
||||
Последняя сверка: **13 сентября 2026**.
|
||||
Последняя сверка: **14 сентября 2026**.
|
||||
|
||||
Подтверждено:
|
||||
|
||||
@@ -30,13 +30,13 @@
|
||||
- [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором.
|
||||
- [x] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель.
|
||||
- [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география.
|
||||
- [ ] **B11 · Разрешённый media pipeline — в работе.** Версионированный baseline на 12.09.2026 содержит 19 водоёмов и 252 рыбы; полное число снастей неизвестно и остаётся `null`, а не подменяется числом приманок. `media_cli --coverage`: 228/252 fish-кандидатов (2 approved), 0/19 waterbody, 149 tackle/bait (12 approved); ещё 10 общих схем approved как reference. Audit: 24 approved, 420 queued, 8 invalid, ошибок целостности и бесхозных файлов нет. Offline `--queue-plan` группирует очередь и cooldown. По разрешению от 13.09 media-окно увеличено: `--download-batch --batch-limit 40` резервирует домен один раз, загружает до 40 assets и блокирует новый batch на 30 минут; 401/403/429/сетевая ошибка останавливает домен сразу, три последовательных invalid/missing — досрочно. Автоматического approve нет. Точные названия минимум 24 отсутствующих рыб неизвестны без канонического перечня. Следующие gaps: дождаться текущего cooldown, выполнить batch, визуально проверить stored-файлы и получить устойчивые ID для водоёмов/полного каталога снастей.
|
||||
- [x] **B11 · Разрешённый media pipeline и публичная медиатека.** Версионированный baseline содержит 19 водоёмов и 252 рыбы; полное число снастей остаётся `null`, а не подменяется числом приманок. После явного разрешения владельца от 14.09 опубликованы все 456 скачанных и целостных файлов: 243 изображения рыб, 149 снастей/приманок и 64 справочных материала. Публичные `/media` и `/api/v1/media/*` отдают Git-копии по SHA-256 без hotlink; у каждой карточки есть плашка и прямая ссылка на источник. 227 альтернативных URL остаются provenance-only `duplicate`, 11 невалидных URL не публикуются, 10 RF4DB-кандидатов остаются в очереди. Audit не находит ошибок и бесхозных файлов. Batch-контур по-прежнему соблюдает единый 30-минутный cooldown домена и не одобряет будущие загрузки автоматически.
|
||||
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
|
||||
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
|
||||
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
|
||||
- [x] **B15 · Целостность медиакаталога.** Локальный audit сводит статусы очереди и проверяет наличие файлов, SHA-256, фактические dimensions/MIME, каноническое соответствие approved-записей и бесхозные файлы. Проверка не обращается в сеть и может использоваться как pre-publication gate.
|
||||
- [x] **B16 · Самодостаточное хранение media dataset.** Все управляемые оригиналы из `data/media/files/` версионируются в Git вместе с manifest; локальный клон содержит полный исследовательский набор. Пользовательские скриншоты остаются в MinIO/S3, а наличие файла в Git не означает разрешение на публикацию без статуса `approved`.
|
||||
- [x] **B17 · Полный каталог рыб RF4DB.** Разрешённый RF4DB дал 252 подписанных изображения; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, а 25 отсутствовавших видов поставлены в очередь. Все поддомены RF4DB объединены одним 30-минутным cooldown.
|
||||
- [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы.
|
||||
- [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue.
|
||||
- [ ] **B19 · Каталог снастей RF4DB.** После водоёмов проиндексировать категории gear, построить crosswalk по типу, бренду, семейству и названию; не считать каталог полным до получения проверяемого общего счётчика.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- Статус: владелец проекта подтвердил наличие разрешения использовать данные RF4DB и RF4-STAT для этого проекта.
|
||||
- Зафиксировано: 2 сентября 2026 года в рабочей переписке проекта.
|
||||
- Разрешённый технический контур: получение публичного HTML, извлечение текстовых сведений об уловах, хранение исходной ссылки и нормализованных полей в закрытом staging.
|
||||
- Изображения: не скачиваются, не проксируются и не архивируются без отдельного явно зафиксированного условия.
|
||||
- Изображения: 14 сентября 2026 года владелец проекта отдельно подтвердил публикацию всех уже собранных изображений, карт и предметов при сохранении видимой атрибуции и исходной ссылки у каждого материала.
|
||||
- Атрибуция: опубликованный улов обязан сохранять `source_system`, внешний ID и прямой `source_url` в provenance.
|
||||
- Публичное отображение: каждая запись и агрегат обязаны показывать источник; неполные staging-наблюдения разрешено отображать сразу с прямой ссылкой, явной пометкой неполноты и перечнем отсутствующих полей, но без влияния на индекс активности.
|
||||
- Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора.
|
||||
@@ -19,7 +19,7 @@
|
||||
- Зафиксировано: 5 сентября 2026 года в рабочей переписке проекта.
|
||||
- Частота: не чаще одного получения страницы каждого источника раз в 30 минут.
|
||||
- Разрешённый контур: публичные HTML-страницы, нормализация в `ExternalCatch` и закрытый staging; запрещённые в `robots.txt` API не запрашиваются.
|
||||
- Изображения: сохраняются только исходные URL доказательств; файлы не скачиваются и не проксируются.
|
||||
- Изображения: 14 сентября 2026 года владелец проекта отдельно подтвердил публикацию всех уже собранных файлов; сайт отдаёт собственную проверенную Git-копию без hotlink и показывает источник со ссылкой рядом с каждым изображением.
|
||||
- Атрибуция и неполнота: на каждой публичной карточке показываются источник и ссылка; неполные наблюдения могут появляться сразу, если визуально отделены от подтверждённых данных и не участвуют в расчёте активности.
|
||||
- Публикация: автоматически по подтверждённым алиасам либо вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging.
|
||||
- Семантика RF4 Posts: несколько видов одного поста остаются связанными с одним UUID точки и не считаются отдельными подтверждёнными взвешиваниями.
|
||||
@@ -33,7 +33,7 @@
|
||||
- RF4MAP: не чаще одного запроса раз в 30 минут.
|
||||
- RF4 Posts: не чаще одного запроса раз в 30 минут.
|
||||
- все пять разрешённых community-адаптеров включены; scheduler хранит cooldown в PostgreSQL и не запускает источник чаще одного раза в 30 минут;
|
||||
- владелец 12 сентября 2026 года явно разрешил локально собирать изображения рыб, водоёмов и снастей, а 13 сентября увеличил окно до 40 assets; медиакандидаты сохраняются с source URL и хэшем, не публикуются до проверки соответствия. Media CLI резервирует одно batch-окно домена, выполняет в нём не более 40 загрузок и не начинает следующее окно того же домена раньше 30 минут;
|
||||
- владелец 12 сентября 2026 года явно разрешил локально собирать изображения рыб, водоёмов и снастей, 13 сентября увеличил окно до 40 assets, а 14 сентября подтвердил публикацию всех собранных изображений, карт и предметов. Media CLI резервирует одно batch-окно домена, выполняет в нём не более 40 загрузок и не начинает следующее окно того же домена раньше 30 минут; публикация требует сохранённого файла, SHA-256, provenance и видимой ссылки на источник;
|
||||
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
|
||||
|
||||
## Что ещё требуется зафиксировать
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Реестр разрешений источников
|
||||
|
||||
Этот файл — production-gate, а не заявление о правах. Словесного подтверждения достаточно для разработки интеграции, но перед открытой альфой у каждого источника должна быть сохранена первичная ссылка или копия разрешения, дата и контакт стороны. Пустое поле блокирует публикацию данных соответствующего источника.
|
||||
Этот файл — production-gate, а не заявление о правах. Владелец проекта подтвердил полученные разрешения на использование перечисленных источников и 14 сентября 2026 года отдельно разрешил публичный показ всех собранных изображений, карт и предметов с атрибуцией. Внешние ссылки или копии исходных разрешений всё ещё следует приложить к закрытому эксплуатационному архиву до релиза; их отсутствие отмечается как документальный риск, но больше не противоречит принятому владельцем решению о публикации.
|
||||
|
||||
| Источник | Данные | Атрибуция на сайте | Лимит | Хранение | Удаление | Первичное подтверждение |
|
||||
|---|---|---|---|---|---|---|
|
||||
| RF4DB | уловы, справочники, разрешённые изображения | название + ссылка на исходную запись/страницу | не чаще 1 запроса на площадку за 30 минут, ошибки учитываются | нормализованные данные; raw по retention policy | отключить источник, скрыть публикации, очистить raw и согласованные данные по запросу | **требуется приложить** |
|
||||
| RF4-STAT | уловы и посты | название + ссылка на запись | не чаще 1 запроса на площадку за 30 минут, общий для endpoint | то же | то же | **требуется приложить** |
|
||||
| RF4MAP | точки и разрешённые media-кандидаты | название + ссылка на страницу | данные: 1 запрос/30 мин; media: до 40 assets в одном зарезервированном окне, следующее окно через 30 мин | media только локально по hash; публикация после review | удалить mapping и локальный оригинал, затем записи источника | **требуется приложить** |
|
||||
| RF4 Posts | точки | название + ссылка на запись | не чаще 1 запроса на площадку за 30 минут | нормализованные данные; raw по retention policy | отключить и удалить по source system | **требуется приложить** |
|
||||
| Официальный RF4 | рекорды и отдельно проверенные справочные изображения | «Официальный сайт RF4» + ссылка | данные: 1 запрос/30 мин; media: до 40 assets в одном зарезервированном окне, следующее окно через 30 мин | по общей retention policy; media в карантине до review | скрыть/удалить mapping и данные конкретного источника | **уточнить основание для media-публикации** |
|
||||
| RF4DB | уловы, справочники, разрешённые изображения | название + ссылка на исходную запись/страницу | не чаще 1 запроса на площадку за 30 минут, ошибки учитываются | нормализованные данные; raw по retention policy | отключить источник, скрыть публикации, очистить raw и согласованные данные по запросу | подтверждено владельцем; внешний артефакт приложить |
|
||||
| RF4-STAT | уловы и посты | название + ссылка на запись | не чаще 1 запроса на площадку за 30 минут, общий для endpoint | то же | то же | подтверждено владельцем; внешний артефакт приложить |
|
||||
| RF4MAP | точки и разрешённые изображения | название + ссылка на страницу | данные: 1 запрос/30 мин; media: до 40 assets в одном зарезервированном окне, следующее окно через 30 мин | media в Git по SHA-256; публикация только со статусом approved | удалить mapping и Git-оригинал отдельным проверяемым коммитом, затем записи источника | подтверждено владельцем; внешний артефакт приложить |
|
||||
| RF4 Posts | точки | название + ссылка на запись | не чаще 1 запроса на площадку за 30 минут | нормализованные данные; raw по retention policy | отключить и удалить по source system | подтверждено владельцем; внешний артефакт приложить |
|
||||
| Официальный RF4 | рекорды и справочные изображения | «Официальный сайт RF4» + ссылка | данные: 1 запрос/30 мин; media: до 40 assets в одном зарезервированном окне, следующее окно через 30 мин | по общей retention policy; media в Git по SHA-256 | скрыть/удалить mapping и Git-оригинал отдельным коммитом | публикация подтверждена владельцем 14.09.2026; внешний артефакт приложить |
|
||||
|
||||
## Требования к подтверждению
|
||||
|
||||
|
||||
@@ -136,6 +136,27 @@ def reconcile_queued_duplicates(path: Path) -> dict:
|
||||
return {"duplicates": duplicates, "queued": sum(item.get("status") == "queued" for item in manifest.get("assets", []))}
|
||||
|
||||
|
||||
def approve_stored_assets(path: Path, *, note: str) -> dict:
|
||||
"""Publish every stored asset after an explicit owner-level approval."""
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
reviewed_at = datetime.now(timezone.utc).isoformat()
|
||||
approved = 0
|
||||
for item in manifest.get("assets", []):
|
||||
if item.get("status") != "stored":
|
||||
continue
|
||||
stable_id = item.get("external_id") or item.get("sha256") or hashlib.sha256(item["asset_url"].encode()).hexdigest()
|
||||
item.update({
|
||||
"status": "approved",
|
||||
"entity_key": f"{item.get('entity_type', 'reference')}:{stable_id}",
|
||||
"reviewed_at": reviewed_at,
|
||||
"review_note": note,
|
||||
})
|
||||
approved += 1
|
||||
manifest["updated_at"] = reviewed_at
|
||||
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return {"approved": approved}
|
||||
|
||||
|
||||
def reclassify_manifest(path: Path) -> dict:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
for item in manifest.get("assets", []):
|
||||
|
||||
@@ -8,7 +8,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .community_cli import MIN_FETCH_INTERVAL_SECONDS, USER_AGENT, _StrictRedirectHandler, _read_state, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key
|
||||
from .media_assets import audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reconcile_queued_duplicates, reclassify_manifest, review_asset, store_asset
|
||||
from .media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reconcile_queued_duplicates, reclassify_manifest, review_asset, store_asset
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path("data/media")
|
||||
@@ -182,6 +182,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--asset-url", help="Download this exact queued asset instead of the first queue item")
|
||||
parser.add_argument("--reclassify", action="store_true", help="Reapply current conservative classifier without network access")
|
||||
parser.add_argument("--dedupe-queue", action="store_true", help="Mark queued label matches to stored assets as duplicates without network access")
|
||||
parser.add_argument("--approve-stored", action="store_true", help="Publish all stored assets after explicit owner approval")
|
||||
parser.add_argument("--review-url", help="Review an asset already present in the manifest")
|
||||
parser.add_argument("--decision", choices=("approved", "rejected"))
|
||||
parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference"))
|
||||
@@ -217,6 +218,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.dedupe_queue:
|
||||
print(json.dumps(reconcile_queued_duplicates(args.root / "manifest.json"), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.approve_stored:
|
||||
if not args.note:
|
||||
parser.error("--note is required with --approve-stored")
|
||||
print(json.dumps(approve_stored_assets(args.root / "manifest.json", note=args.note), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.download_one:
|
||||
print(_download_one(args.root, args.state_file, args.asset_url))
|
||||
return 0
|
||||
|
||||
@@ -6,7 +6,7 @@ import io
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from rf4_research.media_assets import audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset
|
||||
from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset
|
||||
|
||||
|
||||
def test_extracts_and_classifies_unique_https_media() -> None:
|
||||
@@ -166,3 +166,16 @@ def test_reconcile_queue_preserves_source_but_skips_normalized_duplicate(tmp_pat
|
||||
assert assets[1]["status"] == "duplicate"
|
||||
assert assets[1]["duplicate_of"] == "https://a/fish.png"
|
||||
assert assets[2]["status"] == "queued"
|
||||
|
||||
|
||||
def test_bulk_approval_only_publishes_stored_assets(tmp_path: Path) -> None:
|
||||
path = tmp_path / "manifest.json"
|
||||
path.write_text(json.dumps({"assets": [
|
||||
{"entity_type": "fish", "external_id": "pike", "status": "stored", "asset_url": "https://a/pike.webp", "sha256": "a" * 64},
|
||||
{"entity_type": "fish", "status": "queued", "asset_url": "https://a/perch.webp"},
|
||||
]}), encoding="utf-8")
|
||||
assert approve_stored_assets(path, note="owner approved") == {"approved": 1}
|
||||
assets = json.loads(path.read_text(encoding="utf-8"))["assets"]
|
||||
assert assets[0]["entity_key"] == "fish:pike"
|
||||
assert assets[0]["review_note"] == "owner approved"
|
||||
assert assets[1]["status"] == "queued"
|
||||
|
||||
Reference in New Issue
Block a user