Compare commits

..
7 Commits
Author SHA1 Message Date
ik 4042c80f00 feat: audit catalog media coverage
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-12 21:57:07 +07:00
ik fcc26a5b34 feat: add moderation decision history 2026-09-12 21:52:25 +07:00
ik 9681bdbd36 feat: show moderation provenance 2026-09-12 21:51:09 +07:00
ik def5c39aa1 feat: expose safe moderation provenance 2026-09-12 21:49:45 +07:00
ik 699a7c3857 feat: add safe moderation shortcuts 2026-09-12 21:47:49 +07:00
ik 0f078ab482 feat: prioritize risky moderation records 2026-09-12 21:45:29 +07:00
ik ba4c42f8f6 feat: streamline moderation decisions 2026-09-12 21:43:41 +07:00
13 changed files with 628 additions and 104 deletions
+11 -1
View File
@@ -40,6 +40,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов. Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
Текущее media-покрытие каталога — 0 approved соответствий для 2 рыб, 2 водоёмов и 3 приманок локальной альфы. Userguide дал 69 кандидатов, преимущественно справочные скриншоты; обычный HTML официальной fish gallery отдаёт только элементы оформления, поскольку содержимое галереи динамическое. Поэтому сайт продолжает использовать честные фирменные силуэты/отпечатки/глифы, пока конкретное изображение не сохранено и не сопоставлено вручную.
Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline. Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline.
В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые. В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые.
@@ -246,7 +248,15 @@ curl -H "Authorization: Bearer change-me-in-production" \
В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью. В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью.
Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести до применения пагинации. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита. Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести либо риску до применения пагинации; риск поднимает неполные и несопоставленные записи. Во время решения вся карточка блокируется; при ошибке введённая причина остаётся на месте, а после успеха интерфейс сообщает результат и переводит фокус к следующей записи. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
Для карточки с клавиатурным фокусом доступны подсказанные в интерфейсе быстрые клавиши одобрения, сопоставления и публикации. Они не срабатывают в полях ввода; отклонение и удаление требуют явного нажатия кнопки.
Административный API внешней очереди возвращает безопасный provenance: время первого и последнего обнаружения, время проверки, отсутствующие поля и только разрешённые скалярные поля исходной записи. Неизвестные ключи и вложенные служебные структуры в ответ не попадают.
В карточке внешнего наблюдения provenance доступен в отдельном раскрываемом блоке: временная линия, отсутствующие и исходные разрешённые поля видны до сопоставления и публикации.
`GET /api/v1/admin/moderation-history` объединяет историю решений по пользовательским уловам и внешним наблюдениям. Ответ содержит только тип и UUID сущности, время, действие, оператора и причину — без ников, исходных URL и parser payload.
## Эксплуатация production ## Эксплуатация production
+61 -5
View File
@@ -15,7 +15,7 @@ import httpx
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import func, or_, select from sqlalchemy import case, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@@ -31,8 +31,9 @@ from .routers.activity import router as activity_router
from .routers.catalog import router as catalog_router from .routers.catalog import router as catalog_router
from .routers.public_data import router as public_data_router from .routers.public_data import router as public_data_router
from .routers.submissions import router as submissions_router from .routers.submissions import router as submissions_router
from .time_utils import aware
from .public_cache import public_cache from .public_cache import public_cache
from .schemas import ActivityOut, AdminCatchReportOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate from .schemas import ActivityOut, AdminCatchReportOut, AdminModerationHistoryOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
from .submission_security import check_rate_limit from .submission_security import check_rate_limit
from .submission_security import is_trusted_proxy as _is_trusted_proxy from .submission_security import is_trusted_proxy as _is_trusted_proxy
@@ -133,6 +134,34 @@ def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONRespons
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"}) return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
@app.get("/api/v1/admin/moderation-history", response_model=list[AdminModerationHistoryOut])
def admin_moderation_history(
db: Db,
_: Annotated[str, Depends(_admin)],
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> list[AdminModerationHistoryOut]:
report_events = list(db.scalars(
select(ModerationEvent).order_by(ModerationEvent.created_at.desc()).limit(limit + offset)
))
external_events = list(db.scalars(
select(ExternalObservation).where(ExternalObservation.reviewed_at.is_not(None))
.order_by(ExternalObservation.reviewed_at.desc()).limit(limit + offset)
))
history = [AdminModerationHistoryOut(
entity_type="catch_report", entity_id=event.catch_report_id,
decided_at=event.created_at, action=event.new_status.value,
moderator=event.moderator, reason=event.reason,
) for event in report_events]
history.extend(AdminModerationHistoryOut(
entity_type="external_observation", entity_id=observation.id,
decided_at=observation.reviewed_at, action=observation.status,
moderator=None, reason=observation.review_note,
) for observation in external_events if observation.reviewed_at is not None)
history.sort(key=lambda event: aware(event.decided_at), reverse=True)
return history[offset:offset + limit]
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut]) @app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
def admin_imports( def admin_imports(
db: Db, db: Db,
@@ -162,15 +191,29 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O
def _external_out(item: ExternalObservation) -> ExternalObservationOut: def _external_out(item: ExternalObservation) -> ExternalObservationOut:
allowed_payload = {
key: value for key, value in (item.payload or {}).items()
if key in {
"bait", "fishing_method", "rig_type", "retrieve_method", "retrieve_speed",
"player_name", "published_at", "region", "category",
} and (value is None or isinstance(value, (str, int, float, bool)))
}
missing_fields = []
if item.x is None or item.y is None:
missing_fields.append("coordinates")
if item.weight_g is None:
missing_fields.append("weight_g")
return ExternalObservationOut( return ExternalObservationOut(
id=item.id, source_system=item.source_system, source_external_id=item.source_external_id, id=item.id, source_system=item.source_system, source_external_id=item.source_external_id,
source_url=item.source_url, fish_name=item.fish_name, fish_external_id=item.fish_external_id, source_url=item.source_url, fish_name=item.fish_name, fish_external_id=item.fish_external_id,
waterbody_name=item.waterbody_name, waterbody_external_id=item.waterbody_external_id, waterbody_name=item.waterbody_name, waterbody_external_id=item.waterbody_external_id,
x=item.x, y=item.y, weight_g=item.weight_g, published_at=item.published_at, x=item.x, y=item.y, weight_g=item.weight_g, published_at=item.published_at,
last_seen_at=item.last_seen_at, status=item.status, first_seen_at=item.first_seen_at, last_seen_at=item.last_seen_at, reviewed_at=item.reviewed_at,
status=item.status,
fish_slug=item.fish.slug if item.fish else None, fish_slug=item.fish.slug if item.fish else None,
waterbody_slug=item.waterbody.slug if item.waterbody else None, waterbody_slug=item.waterbody.slug if item.waterbody else None,
catch_report_id=item.catch_report_id, review_note=item.review_note, catch_report_id=item.catch_report_id, review_note=item.review_note,
missing_fields=missing_fields, source_payload=allowed_payload,
) )
@@ -180,7 +223,7 @@ def admin_external_observations(
status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None, status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None,
source_system: str | None = None, source_system: str | None = None,
completeness: Literal["all", "complete", "incomplete"] = "all", completeness: Literal["all", "complete", "incomplete"] = "all",
order: Literal["newest", "oldest"] = "newest", order: Literal["newest", "oldest", "risk"] = "newest",
q: str | None = Query(None, max_length=100), q: str | None = Query(None, max_length=100),
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
) -> list[ExternalObservationOut]: ) -> list[ExternalObservationOut]:
@@ -209,8 +252,21 @@ def admin_external_observations(
ExternalObservation.fish_name.icontains(term, autoescape=True), ExternalObservation.fish_name.icontains(term, autoescape=True),
ExternalObservation.waterbody_name.icontains(term, autoescape=True), ExternalObservation.waterbody_name.icontains(term, autoescape=True),
)) ))
if order == "risk":
incomplete = case(
(or_(ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None)), 0),
else_=1,
)
workflow = case(
(ExternalObservation.status == "staged", 0),
(ExternalObservation.status == "mapped", 1),
else_=2,
)
ordering = (incomplete, workflow, ExternalObservation.last_seen_at.asc(), ExternalObservation.id.desc())
else:
direction = ExternalObservation.last_seen_at.asc() if order == "oldest" else ExternalObservation.last_seen_at.desc() direction = ExternalObservation.last_seen_at.asc() if order == "oldest" else ExternalObservation.last_seen_at.desc()
items = db.scalars(query.order_by(direction, ExternalObservation.id.desc()).offset(offset).limit(limit)) ordering = (direction, ExternalObservation.id.desc())
items = db.scalars(query.order_by(*ordering).offset(offset).limit(limit))
return [_external_out(item) for item in items] return [_external_out(item) for item in items]
+13
View File
@@ -226,12 +226,16 @@ class ExternalObservationOut(BaseModel):
y: int | None y: int | None
weight_g: int | None weight_g: int | None
published_at: datetime | None published_at: datetime | None
first_seen_at: datetime
last_seen_at: datetime last_seen_at: datetime
reviewed_at: datetime | None
status: str status: str
fish_slug: str | None fish_slug: str | None
waterbody_slug: str | None waterbody_slug: str | None
catch_report_id: UUID | None catch_report_id: UUID | None
review_note: str | None review_note: str | None
missing_fields: list[str]
source_payload: dict[str, str | int | float | bool | None]
class ExternalObservationMapping(BaseModel): class ExternalObservationMapping(BaseModel):
@@ -243,6 +247,15 @@ class ExternalObservationMapping(BaseModel):
class ExternalAliasSuggestionOut(BaseModel): class ExternalAliasSuggestionOut(BaseModel):
fish_slug: str | None fish_slug: str | None
waterbody_slug: str | None waterbody_slug: str | None
class AdminModerationHistoryOut(BaseModel):
entity_type: str
entity_id: UUID
decided_at: datetime
action: str
moderator: str | None
reason: str | None
requires_confirmation: bool = True requires_confirmation: bool = True
+186 -7
View File
@@ -199,6 +199,65 @@
"title": "AdminCatchReportOut", "title": "AdminCatchReportOut",
"type": "object" "type": "object"
}, },
"AdminModerationHistoryOut": {
"properties": {
"action": {
"title": "Action",
"type": "string"
},
"decided_at": {
"format": "date-time",
"title": "Decided At",
"type": "string"
},
"entity_id": {
"format": "uuid",
"title": "Entity Id",
"type": "string"
},
"entity_type": {
"title": "Entity Type",
"type": "string"
},
"moderator": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Moderator"
},
"reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reason"
},
"requires_confirmation": {
"default": true,
"title": "Requires Confirmation",
"type": "boolean"
}
},
"required": [
"entity_type",
"entity_id",
"decided_at",
"action",
"moderator",
"reason"
],
"title": "AdminModerationHistoryOut",
"type": "object"
},
"BaitOut": { "BaitOut": {
"properties": { "properties": {
"id": { "id": {
@@ -565,11 +624,6 @@
], ],
"title": "Fish Slug" "title": "Fish Slug"
}, },
"requires_confirmation": {
"default": true,
"title": "Requires Confirmation",
"type": "boolean"
},
"waterbody_slug": { "waterbody_slug": {
"anyOf": [ "anyOf": [
{ {
@@ -648,6 +702,11 @@
], ],
"title": "Catch Report Id" "title": "Catch Report Id"
}, },
"first_seen_at": {
"format": "date-time",
"title": "First Seen At",
"type": "string"
},
"fish_external_id": { "fish_external_id": {
"anyOf": [ "anyOf": [
{ {
@@ -684,6 +743,13 @@
"title": "Last Seen At", "title": "Last Seen At",
"type": "string" "type": "string"
}, },
"missing_fields": {
"items": {
"type": "string"
},
"title": "Missing Fields",
"type": "array"
},
"published_at": { "published_at": {
"anyOf": [ "anyOf": [
{ {
@@ -707,10 +773,45 @@
], ],
"title": "Review Note" "title": "Review Note"
}, },
"reviewed_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Reviewed At"
},
"source_external_id": { "source_external_id": {
"title": "Source External Id", "title": "Source External Id",
"type": "string" "type": "string"
}, },
"source_payload": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
}
]
},
"title": "Source Payload",
"type": "object"
},
"source_system": { "source_system": {
"title": "Source System", "title": "Source System",
"type": "string" "type": "string"
@@ -796,12 +897,16 @@
"y", "y",
"weight_g", "weight_g",
"published_at", "published_at",
"first_seen_at",
"last_seen_at", "last_seen_at",
"reviewed_at",
"status", "status",
"fish_slug", "fish_slug",
"waterbody_slug", "waterbody_slug",
"catch_report_id", "catch_report_id",
"review_note" "review_note",
"missing_fields",
"source_payload"
], ],
"title": "ExternalObservationOut", "title": "ExternalObservationOut",
"type": "object" "type": "object"
@@ -2002,7 +2107,8 @@
"default": "newest", "default": "newest",
"enum": [ "enum": [
"newest", "newest",
"oldest" "oldest",
"risk"
], ],
"title": "Order", "title": "Order",
"type": "string" "type": "string"
@@ -2457,6 +2563,79 @@
"summary": "Admin Start Official Import" "summary": "Admin Start Official Import"
} }
}, },
"/api/v1/admin/moderation-history": {
"get": {
"operationId": "admin_moderation_history_api_v1_admin_moderation_history_get",
"parameters": [
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 50,
"maximum": 200,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"in": "query",
"name": "offset",
"required": false,
"schema": {
"default": 0,
"minimum": 0,
"title": "Offset",
"type": "integer"
}
},
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AdminModerationHistoryOut"
},
"title": "Response Admin Moderation History Api V1 Admin Moderation History Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Moderation History"
}
},
"/api/v1/baits": { "/api/v1/baits": {
"get": { "get": {
"operationId": "baits_api_v1_baits_get", "operationId": "baits_api_v1_baits_get",
+12
View File
@@ -158,6 +158,11 @@ def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
serialized = response.text.lower() serialized = response.text.lower()
for forbidden in ("player_name", "source_url", "error_summary", "raw_payload", "admin_token", "s3_"): for forbidden in ("player_name", "source_url", "error_summary", "raw_payload", "admin_token", "s3_"):
assert forbidden not in serialized assert forbidden not in serialized
assert client.get("/api/v1/admin/moderation-history").status_code == 401
history = client.get("/api/v1/admin/moderation-history", headers={"Authorization": "Bearer change-me-in-production"})
assert history.status_code == 200
for forbidden in ("player_name", "source_url", "raw_payload", "screenshot"):
assert forbidden not in history.text.lower()
def test_spot_detail_and_catches() -> None: def test_spot_detail_and_catches() -> None:
@@ -342,8 +347,15 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
headers = {"Authorization": "Bearer change-me-in-production"} headers = {"Authorization": "Bearer change-me-in-production"}
incomplete = client.get("/api/v1/admin/external-observations?status=review&completeness=incomplete&q=Pike", headers=headers) incomplete = client.get("/api/v1/admin/external-observations?status=review&completeness=incomplete&q=Pike", headers=headers)
assert any(item["id"] == str(observation_id) for item in incomplete.json()) assert any(item["id"] == str(observation_id) for item in incomplete.json())
provenance = next(item for item in incomplete.json() if item["id"] == str(observation_id))
assert provenance["missing_fields"] == ["weight_g"]
assert provenance["first_seen_at"] and provenance["last_seen_at"]
assert set(provenance["source_payload"]) <= {"bait", "fishing_method", "rig_type", "retrieve_method", "retrieve_speed", "player_name", "published_at", "region", "category"}
complete = client.get("/api/v1/admin/external-observations?status=review&completeness=complete&q=Pike", headers=headers) complete = client.get("/api/v1/admin/external-observations?status=review&completeness=complete&q=Pike", headers=headers)
assert all(item["id"] != str(observation_id) for item in complete.json()) assert all(item["id"] != str(observation_id) for item in complete.json())
prioritized = client.get("/api/v1/admin/external-observations?status=review&order=risk", headers=headers)
assert prioritized.status_code == 200
assert prioritized.json()[0]["weight_g"] is None
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"]) assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
mapped = client.patch( mapped = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers, f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
@@ -12,21 +12,23 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<label>Поиск<input name="q" type="search" maxlength="100" placeholder="Рыба или водоём" /></label> <label>Поиск<input name="q" type="search" maxlength="100" placeholder="Рыба или водоём" /></label>
<label>Источник<select name="source"><option value="">Все источники</option></select></label> <label>Источник<select name="source"><option value="">Все источники</option></select></label>
<label>Полнота<select name="completeness"><option value="all">Все записи</option><option value="complete">Только полные</option><option value="incomplete">Только неполные</option></select></label> <label>Полнота<select name="completeness"><option value="all">Все записи</option><option value="complete">Только полные</option><option value="incomplete">Только неполные</option></select></label>
<label>Порядок<select name="order"><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label> <label>Порядок<select name="order"><option value="risk">Сначала требуют внимания</option><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label>
<button data-action="primary" type="submit">Применить</button> <button data-action="primary" type="submit">Применить</button>
</form> </form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
<nav data-pages aria-label="Страницы очереди" hidden> <nav data-pages aria-label="Страницы очереди" hidden>
<button data-action="secondary" type="button" data-previous>Предыдущая</button> <button data-action="secondary" type="button" data-previous>Предыдущая</button>
<span data-page-number aria-live="polite"></span> <span data-page-number aria-live="polite"></span>
<button data-action="secondary" type="button" data-next>Следующая</button> <button data-action="secondary" type="button" data-next>Следующая</button>
</nav> </nav>
<p class="admin-shortcuts"><kbd>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p>
</section> </section>
<script> <script>
const root = document.querySelector<HTMLElement>(".moderation-app"); const root = document.querySelector<HTMLElement>(".moderation-app");
const login = document.querySelector<HTMLFormElement>(".admin-login"); const login = document.querySelector<HTMLFormElement>(".admin-login");
const list = document.querySelector<HTMLElement>("[data-external-list]"); const list = document.querySelector<HTMLElement>("[data-external-list]");
const error = document.querySelector<HTMLElement>("[data-admin-error]"); const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]");
const pages = document.querySelector<HTMLElement>("[data-pages]"); const pages = document.querySelector<HTMLElement>("[data-pages]");
const previous = document.querySelector<HTMLButtonElement>("[data-previous]"); const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
const next = document.querySelector<HTMLButtonElement>("[data-next]"); const next = document.querySelector<HTMLButtonElement>("[data-next]");
@@ -41,7 +43,8 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading)); const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char)); const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } }; const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } }; const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); }; const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join(""); const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
@@ -61,6 +64,19 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; } if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl>${sourceUrl ? `<a href="${sourceUrl}" target="_blank" rel="noreferrer">Открыть первоисточник</a>` : '<span class="privacy">Ссылка источника имеет небезопасный формат</span>'}</div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join(""); list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl>${sourceUrl ? `<a href="${sourceUrl}" target="_blank" rel="noreferrer">Открыть первоисточник</a>` : '<span class="privacy">Ссылка источника имеет небезопасный формат</span>'}</div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
const payloadLabels: Record<string, string> = {bait:"Приманка",fishing_method:"Метод ловли",rig_type:"Оснастка",retrieve_method:"Проводка",retrieve_speed:"Скорость проводки",player_name:"Игрок",published_at:"Опубликовано источником",region:"Регион",category:"Категория"};
const missingLabels: Record<string, string> = {coordinates:"координаты",weight_g:"вес"};
pending.forEach((row: Record<string, unknown>, index: number) => {
const card = list.children[index] as HTMLElement | undefined;
const summary = card?.querySelector<HTMLElement>(".moderation-summary");
if (!summary) return;
const payload = Object.entries((row.source_payload as Record<string, unknown>) ?? {});
const missing = ((row.missing_fields as string[]) ?? []).map(key => missingLabels[key] ?? key);
const details = document.createElement("details");
details.className = "moderation-provenance";
details.innerHTML = `<summary>Происхождение и исходные поля</summary><dl><div><dt>Впервые замечено</dt><dd>${esc(new Date(String(row.first_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Последний раз</dt><dd>${esc(new Date(String(row.last_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Проверено</dt><dd>${row.reviewed_at ? esc(new Date(String(row.reviewed_at)).toLocaleString("ru-RU")) : "ещё нет"}</dd></div><div><dt>Не хватает</dt><dd>${missing.length ? esc(missing.join(", ")) : "ничего"}</dd></div>${payload.map(([key,value]) => `<div><dt>${esc(payloadLabels[key] ?? key)}</dt><dd>${esc(value)}</dd></div>`).join("")}</dl><p>Показаны только разрешённые поля, сохранённые парсером. Перед публикацией сверьте их с первоисточником.</p>`;
summary.append(details);
});
} }
async function changePage(delta: number) { async function changePage(delta: number) {
const oldOffset = offset; const oldOffset = offset;
@@ -75,7 +91,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } }); filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели.")); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try { list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true); const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try {
if (button.hasAttribute("data-suggest")) { if (button.hasAttribute("data-suggest")) {
const suggestion = await json(`${base}/alias-suggestions`, {headers}); const suggestion = await json(`${base}/alias-suggestions`, {headers});
const fish = fishes.find(item => item.slug === suggestion.fish_slug); const fish = fishes.find(item => item.slug === suggestion.fish_slug);
@@ -84,9 +100,10 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (message) message.textContent = fish || water if (message) message.textContent = fish || water
? `Ранее подтверждено: рыба — ${fish?.name_ru ?? "нет соответствия"}, водоём — ${water?.name_ru ?? "нет соответствия"}. Проверьте и выберите значения перед сопоставлением.` ? `Ранее подтверждено: рыба — ${fish?.name_ru ?? "нет соответствия"}, водоём — ${water?.name_ru ?? "нет соответствия"}. Проверьте и выберите значения перед сопоставлением.`
: "Для этого источника подтверждённых соответствий пока нет."; : "Для этого источника подтверждённых соответствий пока нет.";
button.disabled = false; cardButtons.forEach(item => item.disabled = false);
return; return;
} }
if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } }); let message = "Наблюдение обновлено."; if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null})}); message = "Соответствия сохранены."; } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); message = "Наблюдение опубликовано."; } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); message = "Наблюдение отклонено."; } await loadQueue(); succeed(message); list.querySelector<HTMLButtonElement>("button")?.focus(); } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
document.addEventListener("keydown", event => { const target = event.target as HTMLElement; if (target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-observation-id]"); const shortcuts: Record<string, string> = {s:"[data-suggest]",m:"[data-map]",p:"[data-publish]"}; const button = card?.querySelector<HTMLButtonElement>(shortcuts[event.key.toLowerCase()] ?? "[data-no-shortcut]"); if (button && !button.disabled) { event.preventDefault(); button.click(); } });
</script> </script>
</Layout> </Layout>
+8 -4
View File
@@ -8,13 +8,15 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form> <form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p> <p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div> <div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
</section> </section>
<script> <script>
const root = document.querySelector<HTMLElement>(".moderation-app"); const root = document.querySelector<HTMLElement>(".moderation-app");
const login = document.querySelector<HTMLFormElement>(".admin-login"); const login = document.querySelector<HTMLFormElement>(".admin-login");
const list = document.querySelector<HTMLElement>("[data-moderation-list]"); const list = document.querySelector<HTMLElement>("[data-moderation-list]");
const error = document.querySelector<HTMLElement>("[data-admin-error]"); const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar"); const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]"); const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = ""; let token = "";
@@ -23,7 +25,8 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading)); const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char)); const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } }; const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } }; const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); }; const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
async function loadQueue() { async function loadQueue() {
@@ -45,8 +48,9 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null; const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null;
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; } if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return; if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
button.disabled = true; const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true);
try { const deleting = button.hasAttribute("data-delete"); const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (!response.ok) throw new Error("Не удалось сохранить решение."); keepSession(); card.remove(); if (list && !list.children.length) list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (!response.ok) throw new Error("Не удалось сохранить решение."); keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
}); });
document.addEventListener("keydown", event => { const target = event.target as HTMLElement; if (event.key.toLowerCase() !== "a" || target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-report-id]"); const approve = card?.querySelector<HTMLButtonElement>('[data-decision="approved"]'); if (approve && !approve.disabled) { event.preventDefault(); approve.click(); } });
</script> </script>
</Layout> </Layout>
+2
View File
@@ -15,6 +15,8 @@
.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55} .moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55}
.admin-session-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 18px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--muted);font-size:13px}.admin-session-bar[hidden]{display:none}.admin-session-bar button{min-height:38px} .admin-session-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 18px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--muted);font-size:13px}.admin-session-bar[hidden]{display:none}.admin-session-bar button{min-height:38px}
.admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px} .admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px}
.admin-shortcuts{margin:14px 0;color:var(--text-muted);font-size:12px}.admin-shortcuts kbd{padding:2px 6px;border:1px solid var(--line);border-bottom-width:2px;border-radius:5px;background:var(--surface);font:700 11px Inter,sans-serif}.moderation-card:focus-within{outline:2px solid var(--focus);outline-offset:3px}
.moderation-provenance{margin-top:18px;border-top:1px solid var(--border-soft);padding-top:14px}.moderation-provenance summary{cursor:pointer;color:var(--deep);font-weight:700}.moderation-provenance dl{margin-top:14px}.moderation-provenance p{margin:12px 0 0;color:var(--text-muted);font-size:12px;line-height:1.5}
@media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}} @media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}}
.admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)} .admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)}
@media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}} @media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}}
+281 -72
View File
@@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"updated_at": "2026-09-12T08:50:41.276898+00:00", "updated_at": "2026-09-12T14:55:17.283768+00:00",
"assets": [ "assets": [
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -17,7 +17,10 @@
"bytes": 53123, "bytes": 53123,
"width": 602, "width": 602,
"height": 208, "height": 208,
"fetched_at": "2026-09-12T14:24:59.865233+00:00" "fetched_at": "2026-09-12T14:24:59.865233+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -27,7 +30,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -37,7 +43,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -47,7 +56,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -57,7 +69,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -67,7 +82,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -77,7 +95,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -87,7 +108,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -97,7 +121,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -107,7 +134,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -117,7 +147,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -127,7 +160,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -137,7 +173,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -147,7 +186,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -157,7 +199,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -167,7 +212,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -177,7 +225,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -187,7 +238,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -197,7 +251,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -207,7 +264,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -217,7 +277,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -227,7 +290,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -237,7 +303,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -247,7 +316,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -257,7 +329,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -267,7 +342,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -277,7 +355,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -287,7 +368,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -297,7 +381,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -307,7 +394,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -317,7 +407,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -327,7 +420,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -337,7 +433,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -347,7 +446,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -357,7 +459,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -367,7 +472,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -377,7 +485,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -387,7 +498,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -397,7 +511,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -407,7 +524,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -417,7 +537,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -427,7 +550,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -437,7 +563,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -447,7 +576,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -457,7 +589,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -467,7 +602,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -477,7 +615,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -487,7 +628,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -497,7 +641,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -507,7 +654,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -517,7 +667,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -527,7 +680,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -537,7 +693,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -547,7 +706,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -557,7 +719,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -567,7 +732,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -577,7 +745,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -587,7 +758,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -597,7 +771,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -607,7 +784,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -617,7 +797,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -627,7 +810,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -637,7 +823,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -647,7 +836,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -657,7 +849,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -667,7 +862,10 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/userguide/",
@@ -677,27 +875,38 @@
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T08:50:41.276898+00:00",
"source_pages": [
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/media/fische/",
"asset_url": "https://rf4game.de/wp-content/plugins/qtranslate-xt-master/flags/de.png", "asset_url": "https://rf4game.de/wp-content/plugins/qtranslate-xt-master/flags/de.png",
"entity_type": "reference", "entity_type": "reference",
"label": null, "label": null,
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T14:55:17.283768+00:00",
"source_pages": [
"https://rf4game.de/media/fische/",
"https://rf4game.de/userguide/"
]
}, },
{ {
"source_page": "https://rf4game.de/userguide/", "source_page": "https://rf4game.de/media/fische/",
"asset_url": "https://rf4game.de/wp-content/themes/rf4/img/banner_de.png", "asset_url": "https://rf4game.de/wp-content/themes/rf4/img/banner_de.png",
"entity_type": "reference", "entity_type": "reference",
"label": null, "label": null,
"external_id": null, "external_id": null,
"status": "queued", "status": "queued",
"first_seen_at": "2026-09-12T08:50:41.276898+00:00", "first_seen_at": "2026-09-12T08:50:41.276898+00:00",
"last_seen_at": "2026-09-12T08:50:41.276898+00:00" "last_seen_at": "2026-09-12T14:55:17.283768+00:00",
"source_pages": [
"https://rf4game.de/media/fische/",
"https://rf4game.de/userguide/"
]
} }
] ]
} }
+3 -3
View File
@@ -30,7 +30,7 @@
- [x] **B08 · Семейства силуэтов рыб.** Три случайных hash-варианта заменены классификатором и отдельными формами `pike`, `salmonid`, `cyprinid`, `perch`, `catfish`, `eel`, `flatfish`, `marine`, плюс честный `generic`. Компонент допускает ручное переопределение family; название остаётся главным идентификатором. - [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] **B09 · Глифы снастей и приманок.** Добавлены SVG-глифы `spinner`, `wobbler`, `soft`, `boilie`, `worm`, `rig`, `unknown` и стабильная палитра по normalized name. Классификация срабатывает только по явным словам; глиф сопровождает текст в activity, лидере, уловах, рекордах и списке лучших приманок, не выдавая категорию за точную модель.
- [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география. - [x] **B10 · Визуальные отпечатки водоёмов.** Для каждого slug воспроизводимо выбираются один из восьми береговых контуров, число волн, положение точки и двухсимвольный индекс. Знак используется в каталоге и detail-hero; это явно абстрактный отпечаток, а не карта или игровая география.
- [ ] **B11 · Разрешённый media pipeline — в работе.** Получено явное разрешение на локальный сбор. Добавлены allowlist, индекс кандидатов, provenance-manifest, очередь и content-addressed хранение без hotlink; первый официальный обход нашёл 69 кандидатов. Downloader проверяет реальный формат/MIME, лимит 40 Мп, сохраняет dimensions и не блокирует очередь постоянными `missing`/`blocked`/`invalid`. Review допускает `approved` только для сохранённого файла с типом и каноническим ключом сущности; доступен явный `rejected`. Осталось постепенно скачать очередь с общим cooldown 30 минут и проверить соответствия перед публикацией. - [ ] **B11 · Разрешённый media pipeline — в работе.** Получено явное разрешение на локальный сбор. Первый обход userguide нашёл 69 кандидатов, из которых 1 сохранён, 68 ожидают. Аудит покрытия выявил отсутствие approved-соответствий для всех 2 рыб, 2 водоёмов и 3 приманок локальной альфы. Прямой HTML `/media/fische/` содержит только флаг и banner: галерея динамическая, рыб через текущий HTML-parser не отдаёт. Классификатор учитывает provenance страницы, но исключает theme/flag/logo chrome; manifest сохраняет все страницы обнаружения дубликата. Далее исследовать официальный gallery endpoint без обхода ограничений, отдельно индексировать `/media/levels/` и проверить `.bait_icon` records DOM в следующие 30-минутные окна. Публикация возможна только после ручного canonical review.
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений. - [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`. - [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания. - [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
@@ -64,8 +64,8 @@
- [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage. - [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage.
- [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений. - [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений.
- [ ] **M03 · Эффективность очередей — в работе.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку от новых или старых; параметры работают до пагинации. Далее: приоритет риска, понятное подтверждение результата, восстановление черновика причины, переход к следующей карточке и доступные горячие клавиши. - [x] **M03 · Эффективность очередей.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку по свежести или риску; проблемный порядок поднимает неполные и несопоставленные записи, а параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Безопасные горячие клавиши работают только внутри карточки с фокусом и отключены в полях ввода; отклонение и удаление намеренно оставлены только на кнопках.
- [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных. - [ ] **M04 · Полный provenance и история решений — в работе.** Admin API отдаёт время первого/последнего обнаружения и проверки, явный список missing fields и allowlist безопасных скалярных полей исходной записи; карточка показывает их перед публикацией. Единый read-only журнал объединяет решения по пользовательским и внешним записям без ников, URL и исходных payload. Далее: показать историю в dashboard и добавить обезличенный экспорт.
- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором. - [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее. - [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.
+4
View File
@@ -50,6 +50,10 @@
Проверка 12 сентября 2026 года не обнаружила стабильного публичного каталога вида `канонический slug → изображение` для рыб, водоёмов или снастей. Официальная [галерея водоёмов](https://rf4game.de/media/levels/) и категория [рыб](https://rf4game.de/media/fische/) содержат тематические публикации, а [руководство](https://rf4game.de/userguide/) — иллюстрации интерфейса, оснасток и карт. Это медиа-галереи, а не полный справочник с устойчивыми entity ID. Проверка 12 сентября 2026 года не обнаружила стабильного публичного каталога вида `канонический slug → изображение` для рыб, водоёмов или снастей. Официальная [галерея водоёмов](https://rf4game.de/media/levels/) и категория [рыб](https://rf4game.de/media/fische/) содержат тематические публикации, а [руководство](https://rf4game.de/userguide/) — иллюстрации интерфейса, оснасток и карт. Это медиа-галереи, а не полный справочник с устойчивыми entity ID.
Повторная сверка локальной альфы показала 2 рыбы, 2 водоёма и 3 приманки при нулевом числе approved media mappings. В userguide-manifest находится 69 кандидатов: 67 общих reference и 2 изображения карт. Поэтому наличие картинки в очереди не считается покрытием каталога; coverage появляется только после сохранения, визуальной проверки и назначения canonical `entity_key`.
Прямой индекс `/media/fische/` вернул только общие изображения оформления (`flags/de.png`, `banner_de.png`): содержимое галереи загружается динамически. Эти файлы остаются `reference`, а не ошибочно маркируются как рыбы. Исследование допустимого gallery endpoint должно использовать обычный публичный контракт сайта и тот же общий cooldown, без обхода защит.
Страница рекордов визуально использует `.bait_icon`, но подтверждённый parser-контракт извлекает только `.bait_icon[title]`. Ни parser, ни `Fish`/`Waterbody`/`Bait` модели и API-схемы сейчас не сохраняют image URL. Даже если URL удастся извлечь из CSS или обновлённого DOM, он может быть presentation asset, меняться независимо от названия и покрывать только приманки, попавшие в рекорды. Страница рекордов визуально использует `.bait_icon`, но подтверждённый parser-контракт извлекает только `.bait_icon[title]`. Ни parser, ни `Fish`/`Waterbody`/`Bait` модели и API-схемы сейчас не сохраняют image URL. Даже если URL удастся извлечь из CSS или обновлённого DOM, он может быть presentation asset, меняться независимо от названия и покрывать только приманки, попавшие в рекорды.
До отдельного разрешения на медиапубликацию изображения официального сайта не скачиваются, не хотлинкаются и не отображаются как собственные. Разрешение использовать фактические данные не трактуется как разрешение републиковать графические произведения. До отдельного разрешения на медиапубликацию изображения официального сайта не скачиваются, не хотлинкаются и не отображаются как собственные. Разрешение использовать фактические данные не трактуется как разрешение републиковать графические произведения.
+13 -5
View File
@@ -39,9 +39,11 @@ def _label(node: Tag) -> str | None:
def _kind(url: str, label: str | None, context: str) -> str: def _kind(url: str, label: str | None, context: str) -> str:
path = urlsplit(url).path.casefold() path = urlsplit(url).path.casefold()
value = f"{label or ''} {context}".casefold() value = f"{label or ''} {context}".casefold()
if re.search(r"/(?:fish|fishes|fische|species)/", path) or any(word in value for word in ("рыба ", "fish: ", "fisch: ")): if "/flags/" in path or "/themes/" in path or path.endswith(("/logo.png", "/logo.svg")):
return "reference"
if re.search(r"/(?:fish|fishes|fische|species)/", path) or "/media/fische/" in value or any(word in value for word in ("рыба ", "fish: ", "fisch: ")):
return "fish" return "fish"
if re.search(r"/(?:maps?|levels?|lakes?|waterbodies)/", path) or any(word in value for word in ("карта ", "map of ", "gewässerkarte")): if re.search(r"/(?:maps?|levels?|lakes?|waterbodies)/", path) or "/media/levels/" in value or any(word in value for word in ("карта ", "map of ", "gewässerkarte")):
return "waterbody" return "waterbody"
if re.search(r"(?:^|[/_-])(?:bait|lure|rig|rod|reel|hook|tackle|köder|rute|rolle)(?:[/_.-]|$)", path): if re.search(r"(?:^|[/_-])(?:bait|lure|rig|rod|reel|hook|tackle|köder|rute|rolle)(?:[/_.-]|$)", path):
return "tackle" return "tackle"
@@ -63,10 +65,10 @@ def extract_media_candidates(html: str, *, source_page: str) -> list[MediaCandid
context = " ".join(node.parent.get_text(" ", strip=True).split())[:500] if node.parent else "" context = " ".join(node.parent.get_text(" ", strip=True).split())[:500] if node.parent else ""
link = node.find_parent("a", href=True) link = node.find_parent("a", href=True)
external_id = str(link.get("href")).rstrip("/").rsplit("/", 1)[-1] if link else None external_id = str(link.get("href")).rstrip("/").rsplit("/", 1)[-1] if link else None
result.setdefault(url, MediaCandidate(source_page, url, _kind(url, label, context), label, external_id)) result.setdefault(url, MediaCandidate(source_page, url, _kind(url, label, f"{source_page} {context}"), label, external_id))
for match in re.finditer(r"url\((['\"]?)(https://[^)'\"]+)\1\)", html, re.I): for match in re.finditer(r"url\((['\"]?)(https://[^)'\"]+)\1\)", html, re.I):
url = match.group(2) url = match.group(2)
result.setdefault(url, MediaCandidate(source_page, url, _kind(url, None, ""), None, None)) result.setdefault(url, MediaCandidate(source_page, url, _kind(url, None, source_page), None, None))
return list(result.values()) return list(result.values())
@@ -76,7 +78,12 @@ def merge_manifest(path: Path, candidates: list[MediaCandidate]) -> dict:
seen_at = datetime.now(timezone.utc).isoformat() seen_at = datetime.now(timezone.utc).isoformat()
for candidate in candidates: for candidate in candidates:
item = assets.get(candidate.asset_url, {}) item = assets.get(candidate.asset_url, {})
source_pages = set(item.get("source_pages", []))
if item.get("source_page"):
source_pages.add(item["source_page"])
source_pages.add(candidate.source_page)
item.update(asdict(candidate)) item.update(asdict(candidate))
item["source_pages"] = sorted(source_pages)
item.setdefault("status", "queued") item.setdefault("status", "queued")
item.setdefault("first_seen_at", seen_at) item.setdefault("first_seen_at", seen_at)
item["last_seen_at"] = seen_at item["last_seen_at"] = seen_at
@@ -90,7 +97,8 @@ def merge_manifest(path: Path, candidates: list[MediaCandidate]) -> dict:
def reclassify_manifest(path: Path) -> dict: def reclassify_manifest(path: Path) -> dict:
manifest = json.loads(path.read_text(encoding="utf-8")) manifest = json.loads(path.read_text(encoding="utf-8"))
for item in manifest.get("assets", []): for item in manifest.get("assets", []):
item["entity_type"] = _kind(item["asset_url"], item.get("label"), "") item.setdefault("source_pages", [item["source_page"]])
item["entity_type"] = _kind(item["asset_url"], item.get("label"), item.get("source_page", ""))
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return manifest return manifest
+10
View File
@@ -25,11 +25,21 @@ def test_does_not_mistake_seafishing_screenshot_for_fish_entity() -> None:
assert items[0].entity_type == "reference" assert items[0].entity_type == "reference"
def test_gallery_page_provenance_classifies_generic_asset_urls() -> None:
fish = extract_media_candidates('<img src="/uploads/gallery/123.jpg" alt="Hecht">', source_page="https://rf4game.de/media/fische/")
water = extract_media_candidates('<img src="/uploads/gallery/456.jpg" alt="Kuori">', source_page="https://rf4game.de/media/levels/")
assert fish[0].entity_type == "fish"
assert water[0].entity_type == "waterbody"
chrome = extract_media_candidates('<img src="/wp-content/themes/rf4/img/banner_de.png">', source_page="https://rf4game.de/media/fische/")
assert chrome[0].entity_type == "reference"
def test_manifest_merges_and_binary_store_is_content_addressed(tmp_path: Path) -> None: def test_manifest_merges_and_binary_store_is_content_addressed(tmp_path: Path) -> None:
item = extract_media_candidates('<img src="/bait/worm.png" alt="Bait worm">', source_page="https://example.test")[0] item = extract_media_candidates('<img src="/bait/worm.png" alt="Bait worm">', source_page="https://example.test")[0]
first = merge_manifest(tmp_path / "manifest.json", [item]) first = merge_manifest(tmp_path / "manifest.json", [item])
second = merge_manifest(tmp_path / "manifest.json", [item]) second = merge_manifest(tmp_path / "manifest.json", [item])
assert len(first["assets"]) == len(second["assets"]) == 1 assert len(first["assets"]) == len(second["assets"]) == 1
assert second["assets"][0]["source_pages"] == ["https://example.test"]
image = io.BytesIO() image = io.BytesIO()
Image.new("RGB", (3, 2), "green").save(image, format="PNG") Image.new("RGB", (3, 2), "green").save(image, format="PNG")
digest, relative, width, height, mime = store_asset(tmp_path, image.getvalue(), content_type="image/png", source_url=item.asset_url) digest, relative, width, height, mime = store_asset(tmp_path, image.getvalue(), content_type="image/png", source_url=item.asset_url)