feat: add tackle catalog and recommendation analytics
This commit is contained in:
@@ -14,6 +14,7 @@ from .dependencies import Db
|
|||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .readiness import readiness_report
|
from .readiness import readiness_report
|
||||||
from .routers.activity import router as activity_router
|
from .routers.activity import router as activity_router
|
||||||
|
from .routers.analytics import router as analytics_router
|
||||||
from .routers.admin import router as admin_router
|
from .routers.admin import router as admin_router
|
||||||
from .routers.catalog import router as catalog_router
|
from .routers.catalog import router as catalog_router
|
||||||
from .routers.media import router as media_router
|
from .routers.media import router as media_router
|
||||||
@@ -90,6 +91,7 @@ def ready(db: Db) -> JSONResponse:
|
|||||||
app.include_router(catalog_router)
|
app.include_router(catalog_router)
|
||||||
app.include_router(media_router)
|
app.include_router(media_router)
|
||||||
app.include_router(activity_router)
|
app.include_router(activity_router)
|
||||||
|
app.include_router(analytics_router)
|
||||||
app.include_router(public_data_router)
|
app.include_router(public_data_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(submissions_router)
|
app.include_router(submissions_router)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
from fastapi import APIRouter, HTTPException, Query, Response
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
from ..activity import activity_rows
|
from ..activity import activity_rows
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
@@ -91,8 +91,13 @@ def _report_source(report: CatchReport) -> str:
|
|||||||
@router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
|
@router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
|
||||||
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
||||||
_spot_or_404(db, spot_id)
|
_spot_or_404(db, spot_id)
|
||||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait), selectinload(CatchReport.tackle_components)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||||
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url) for report in reports]
|
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url, tackle_components=[{
|
||||||
|
"id": component.id, "role": component.role, "position": component.position,
|
||||||
|
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
|
||||||
|
"rig_id": component.rig_id, "source_system": component.source_system,
|
||||||
|
"source_url": component.source_url,
|
||||||
|
} for component in sorted(report.tackle_components, key=lambda value: value.position)]) for report in reports]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/spots/{spot_id}/timeline")
|
@router.get("/api/v1/spots/{spot_id}/timeline")
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
|
from ..dependencies import Db
|
||||||
|
from ..models import CatchReport, Fish, ModerationStatus, Spot, Waterbody
|
||||||
|
from ..schemas import TackleCombinationOut
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/analytics/tackle", response_model=list[TackleCombinationOut])
|
||||||
|
def tackle_combinations(
|
||||||
|
db: Db,
|
||||||
|
waterbody: str | None = None,
|
||||||
|
fish: str | None = None,
|
||||||
|
method: str | None = None,
|
||||||
|
hours: int = Query(72, ge=24, le=168),
|
||||||
|
min_samples: int = Query(3, ge=1, le=100),
|
||||||
|
min_players: int = Query(2, ge=1, le=100),
|
||||||
|
) -> list[TackleCombinationOut]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
query = select(CatchReport).options(
|
||||||
|
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
|
||||||
|
selectinload(CatchReport.tackle_components),
|
||||||
|
).where(
|
||||||
|
CatchReport.moderation_status == ModerationStatus.approved,
|
||||||
|
CatchReport.deleted_at.is_(None),
|
||||||
|
CatchReport.reported_at >= now - timedelta(hours=hours),
|
||||||
|
)
|
||||||
|
if waterbody:
|
||||||
|
query = query.join(Waterbody, CatchReport.waterbody_id == Waterbody.id).where(Waterbody.slug == waterbody)
|
||||||
|
if fish:
|
||||||
|
query = query.join(Fish, CatchReport.fish_id == Fish.id).where(Fish.slug == fish)
|
||||||
|
if method:
|
||||||
|
query = query.where(CatchReport.fishing_method == method)
|
||||||
|
|
||||||
|
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
|
||||||
|
for report in db.scalars(query):
|
||||||
|
for component in report.tackle_components:
|
||||||
|
if component.raw_value.strip():
|
||||||
|
groups[(component.role, component.raw_value.strip())].append(report)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for (role, value), reports in groups.items():
|
||||||
|
unique_reports = {report.id: report for report in reports}
|
||||||
|
players = {report.player_name.strip().casefold() for report in unique_reports.values() if report.player_name and report.player_name.strip()}
|
||||||
|
catches = len(unique_reports)
|
||||||
|
unique_players = len(players)
|
||||||
|
last_seen = max(report.reported_at for report in unique_reports.values())
|
||||||
|
enough = catches >= min_samples and unique_players >= min_players
|
||||||
|
result.append(TackleCombinationOut(
|
||||||
|
role=role, value=value, catches=catches, unique_players=unique_players,
|
||||||
|
last_seen_at=last_seen, status="recommendation" if enough else "insufficient_data",
|
||||||
|
explanation=(
|
||||||
|
"Достаточно независимых наблюдений для рекомендации."
|
||||||
|
if enough else
|
||||||
|
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока."
|
||||||
|
),
|
||||||
|
))
|
||||||
|
return sorted(result, key=lambda item: (item.status != "recommendation", -item.catches, -item.unique_players, item.role, item.value))
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
from fastapi import APIRouter, Query
|
from uuid import UUID
|
||||||
from sqlalchemy import select
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from ..dependencies import Db
|
from ..dependencies import Db
|
||||||
from ..models import Bait, CatchReport, Fish, ModerationStatus, Spot, Waterbody
|
from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, Spot, TackleItem, Waterbody
|
||||||
from ..schemas import BaitOut, FishOut, WaterbodyOut
|
from ..schemas import BaitOut, FishOut, PaginatedTackleItemOut, RigOut, TackleItemOut, WaterbodyOut
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -23,6 +26,68 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0,
|
|||||||
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
|
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
|
||||||
|
|
||||||
|
|
||||||
|
def _item_missing_fields(item: TackleItem) -> list[str]:
|
||||||
|
return [field for field, value in (
|
||||||
|
("subcategory", item.subcategory), ("brand", item.brand),
|
||||||
|
("family", item.family), ("unlock_level", item.unlock_level),
|
||||||
|
("source_url", item.source_url), ("source_checked_at", item.source_checked_at),
|
||||||
|
) if value is None]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/tackle/items", response_model=PaginatedTackleItemOut)
|
||||||
|
def tackle_items(
|
||||||
|
db: Db,
|
||||||
|
category: str | None = Query(None, pattern="^(bait|lure|rod|reel|line|hook|rig|float|sinker|other)$"),
|
||||||
|
brand: str | None = None,
|
||||||
|
family: str | None = None,
|
||||||
|
unlock_level: int | None = Query(None, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> PaginatedTackleItemOut:
|
||||||
|
query = select(TackleItem)
|
||||||
|
if category:
|
||||||
|
query = query.where(TackleItem.category == category)
|
||||||
|
if brand:
|
||||||
|
query = query.where(TackleItem.brand == brand)
|
||||||
|
if family:
|
||||||
|
query = query.where(TackleItem.family == family)
|
||||||
|
if unlock_level is not None:
|
||||||
|
query = query.where(TackleItem.unlock_level == unlock_level)
|
||||||
|
total = db.scalar(query.with_only_columns(func.count(TackleItem.id), maintain_column_froms=True).order_by(None)) or 0
|
||||||
|
items = list(db.scalars(query.order_by(TackleItem.name, TackleItem.id).offset(offset).limit(limit)))
|
||||||
|
return PaginatedTackleItemOut(
|
||||||
|
items=[TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)}) for item in items],
|
||||||
|
total=total, limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/tackle/items/{item_id}", response_model=TackleItemOut)
|
||||||
|
def tackle_item(item_id: UUID, db: Db) -> TackleItemOut:
|
||||||
|
item = db.get(TackleItem, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="tackle item not found")
|
||||||
|
return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/tackle/rigs/{rig_id}", response_model=RigOut)
|
||||||
|
def rig_detail(rig_id: UUID, db: Db) -> RigOut:
|
||||||
|
rig = db.scalar(select(Rig).options(selectinload(Rig.components)).where(Rig.id == rig_id))
|
||||||
|
if rig is None:
|
||||||
|
raise HTTPException(status_code=404, detail="rig not found")
|
||||||
|
missing = [field for field, value in (
|
||||||
|
("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at),
|
||||||
|
) if value is None]
|
||||||
|
return RigOut(
|
||||||
|
id=rig.id, name=rig.name, source_system=rig.source_system,
|
||||||
|
source_external_id=rig.source_external_id, source_url=rig.source_url,
|
||||||
|
source_checked_at=rig.source_checked_at, missing_fields=missing,
|
||||||
|
components=[{
|
||||||
|
"id": component.id, "role": component.role, "position": component.position,
|
||||||
|
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
|
||||||
|
} for component in sorted(rig.components, key=lambda value: value.position)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/public-spot-pages")
|
@router.get("/api/v1/public-spot-pages")
|
||||||
def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]:
|
def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]:
|
||||||
rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug)
|
rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug)
|
||||||
|
|||||||
@@ -40,6 +40,48 @@ class BaitOut(BaseModel):
|
|||||||
kind: str
|
kind: str
|
||||||
|
|
||||||
|
|
||||||
|
class TackleItemOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
subcategory: str | None
|
||||||
|
brand: str | None
|
||||||
|
family: str | None
|
||||||
|
unlock_level: int | None
|
||||||
|
source_system: str | None
|
||||||
|
source_external_id: str | None
|
||||||
|
source_url: str | None
|
||||||
|
source_checked_at: datetime | None
|
||||||
|
missing_fields: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedTackleItemOut(BaseModel):
|
||||||
|
items: list[TackleItemOut]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class RigComponentOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
role: str
|
||||||
|
position: int
|
||||||
|
raw_value: str | None
|
||||||
|
tackle_item_id: UUID | None
|
||||||
|
|
||||||
|
|
||||||
|
class RigOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
source_system: str | None
|
||||||
|
source_external_id: str | None
|
||||||
|
source_url: str | None
|
||||||
|
source_checked_at: datetime | None
|
||||||
|
missing_fields: list[str] = Field(default_factory=list)
|
||||||
|
components: list[RigComponentOut]
|
||||||
|
|
||||||
|
|
||||||
class ActivityOut(BaseModel):
|
class ActivityOut(BaseModel):
|
||||||
spot_id: UUID
|
spot_id: UUID
|
||||||
waterbody_slug: str
|
waterbody_slug: str
|
||||||
@@ -69,6 +111,16 @@ class PaginatedActivityOut(BaseModel):
|
|||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class TackleCombinationOut(BaseModel):
|
||||||
|
role: str
|
||||||
|
value: str
|
||||||
|
catches: int
|
||||||
|
unique_players: int
|
||||||
|
last_seen_at: datetime
|
||||||
|
status: str
|
||||||
|
explanation: str
|
||||||
|
|
||||||
|
|
||||||
class CatchOut(BaseModel):
|
class CatchOut(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
fish: str
|
fish: str
|
||||||
@@ -81,6 +133,18 @@ class CatchOut(BaseModel):
|
|||||||
retrieve_speed: int | None
|
retrieve_speed: int | None
|
||||||
source_system: str
|
source_system: str
|
||||||
source_url: str | None
|
source_url: str | None
|
||||||
|
tackle_components: list["CatchTackleComponentOut"]
|
||||||
|
|
||||||
|
|
||||||
|
class CatchTackleComponentOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
role: str
|
||||||
|
position: int
|
||||||
|
raw_value: str
|
||||||
|
tackle_item_id: UUID | None
|
||||||
|
rig_id: UUID | None
|
||||||
|
source_system: str | None
|
||||||
|
source_url: str | None
|
||||||
|
|
||||||
|
|
||||||
class SpotOut(BaseModel):
|
class SpotOut(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
||||||
|
from app.routers.analytics import tackle_combinations
|
||||||
|
|
||||||
|
|
||||||
|
def test_tackle_recommendation_requires_samples_and_independent_players() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
waterbody = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1)
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||||
|
spot = Spot(waterbody=waterbody, x=10, y=20)
|
||||||
|
db.add_all([waterbody, fish, spot])
|
||||||
|
db.flush()
|
||||||
|
for index, player in enumerate(("One", "One", "Two")):
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, waterbody=waterbody, spot=spot, weight_g=1000,
|
||||||
|
caught_at=now - timedelta(hours=1), reported_at=now - timedelta(hours=1),
|
||||||
|
player_name=player, source_type=SourceType.user, source_confidence=80,
|
||||||
|
moderation_status=ModerationStatus.approved,
|
||||||
|
)
|
||||||
|
report.tackle_components.append(CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"))
|
||||||
|
db.add(report)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=2)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert (rows[0].status, rows[0].catches, rows[0].unique_players) == ("recommendation", 3, 2)
|
||||||
|
|
||||||
|
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=3)
|
||||||
|
assert rows[0].status == "insufficient_data"
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import Rig, RigComponent, TackleItem
|
||||||
|
from app.routers.catalog import rig_detail, tackle_item, tackle_items
|
||||||
|
|
||||||
|
|
||||||
|
def test_tackle_catalog_filters_details_and_missing_fields() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
item = TackleItem(
|
||||||
|
name="API тестовая блесна", normalized_name="api тестовая блесна",
|
||||||
|
category="lure", subcategory="spinner", brand="RF4", family=None,
|
||||||
|
unlock_level=0, source_system="fixture", source_external_id="api-lure-1",
|
||||||
|
source_url="https://example.test/lure/api-lure-1",
|
||||||
|
)
|
||||||
|
rig = Rig(name="API тестовый монтаж", normalized_name="api тестовый монтаж", source_system="fixture")
|
||||||
|
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value=item.name))
|
||||||
|
db.add(rig)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
page = tackle_items(db, category="lure", brand="RF4", family=None, unlock_level=None, limit=10, offset=0)
|
||||||
|
assert page.total == 1
|
||||||
|
assert page.items[0].missing_fields == ["family", "source_checked_at"]
|
||||||
|
assert tackle_item(item.id, db).name == item.name
|
||||||
|
details = rig_detail(rig.id, db)
|
||||||
|
assert details.components[0].raw_value == item.name
|
||||||
|
assert details.missing_fields == ["source_url", "source_checked_at"]
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
@@ -17,6 +17,8 @@ export type PaginatedActivity = {
|
|||||||
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[] };
|
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[] };
|
||||||
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null };
|
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null };
|
||||||
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
|
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
|
||||||
|
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[] };
|
||||||
|
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
|
||||||
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
|
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string };
|
||||||
export type PaginatedOfficialRecord = {
|
export type PaginatedOfficialRecord = {
|
||||||
items: OfficialRecord[];
|
items: OfficialRecord[];
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import PageHero from "../../components/PageHero.astro";
|
||||||
|
import StatePanel from "../../components/StatePanel.astro";
|
||||||
|
import Pagination from "../../components/Pagination.astro";
|
||||||
|
import TackleGlyph from "../../components/TackleGlyph.astro";
|
||||||
|
import { api, type PaginatedTackleItems } from "../../lib/api";
|
||||||
|
|
||||||
|
const params = Astro.url.searchParams;
|
||||||
|
const category = params.get("category") ?? "";
|
||||||
|
const brand = params.get("brand") ?? "";
|
||||||
|
const family = params.get("family") ?? "";
|
||||||
|
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||||
|
const limit = 48;
|
||||||
|
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
|
const query = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||||
|
if (category) query.set("category", category);
|
||||||
|
if (brand) query.set("brand", brand);
|
||||||
|
if (family) query.set("family", family);
|
||||||
|
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, unavailable = false;
|
||||||
|
try { result = await api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`); } catch { unavailable = true; }
|
||||||
|
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
|
||||||
|
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" };
|
||||||
|
const filterParams = new URLSearchParams();
|
||||||
|
if (category) filterParams.set("category", category);
|
||||||
|
if (brand) filterParams.set("brand", brand);
|
||||||
|
if (family) filterParams.set("family", family);
|
||||||
|
---
|
||||||
|
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и монтажей Russian Fishing 4 с источниками и отметками неполноты.">
|
||||||
|
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
|
||||||
|
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
|
||||||
|
<label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
|
||||||
|
<label>Бренд<input name="brand" value={brand} maxlength="100" placeholder="Например, RF4" /></label>
|
||||||
|
<label>Семейство<input name="family" value={family} maxlength="100" placeholder="Название семейства" /></label>
|
||||||
|
<button data-action="primary">Фильтровать</button>
|
||||||
|
{(category || brand || family) && <a data-action="quiet" href="/tackle">Сбросить</a>}
|
||||||
|
</form>
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
|
||||||
|
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
|
||||||
|
</Layout>
|
||||||
+2
-2
@@ -71,8 +71,8 @@
|
|||||||
- [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk.
|
- [ ] **G03 · Модель и provenance.** Добавлены отдельные `tackle_item`, `rig` и `rig_component` с миграцией `0021`; legacy `bait` и `catch_report.bait_id` не изменялись. `tackle_item` хранит `category`, `subcategory`, `brand`, `family`, `unlock_level`, source identity, timestamp и `raw_payload`; `rig_component` хранит роль, порядок, исходное значение и optional canonical item. Связи с catch-потоком добавлены в G05; безопасный backfill остаётся только после реального crosswalk.
|
||||||
- [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты.
|
- [ ] **G04 · Crosswalk и нормализация.** Добавлен offline `gear_crosswalk`: нормализация регистра/пробелов/`е/ё`, точное имя или alias плюс совместимая категория, консервативная проверка brand/family. Неоднозначные, несовместимые, брендовые и unmatched-строки получают review-статус без canonical key; исходное значение сохраняется. Остаётся подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты.
|
||||||
- [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk.
|
- [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk.
|
||||||
- [ ] **G06 · API и публичный каталог.** Добавить пагинированные каталоги и detail endpoints с фильтрами по категории, бренду, семейству и уровню, а также безопасные ссылки из улова/точки на использованную приманку, снасть и монтаж. Показывать только подтверждённые характеристики, источник, свежесть и неполноту; не выдавать рейтинг эффективности, если его нельзя объяснить числом наблюдений, игроками, периодом и качеством источников.
|
- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Остаётся подключить публичные Astro-карточки и ссылки из всех нужных представлений улова.
|
||||||
- [ ] **G07 · Аналитика сочетаний и рекомендации.** После появления достаточных данных считать отдельно «водоём + рыба + предмет», «точка + рыба + предмет» и «способ ловли + монтаж». Зафиксировать минимальный объём выборки, защиту от одного игрока/дубликатов и decay по свежести; разделить факт использования, частоту и рекомендацию. Пустая или малая выборка должна показывать «данных мало», а не советовать конкретную снасть.
|
- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Пустая или малая выборка получает `insufficient_data`; decay по свежести и отдельные UI-состояния остаются следующим шагом.
|
||||||
- [ ] **G08 · Медиа и качество.** Разнести media roles для `tackle_item`, `bait`, `rig` и общего reference; связать варианты через `entity_key`, `duplicate_of`, `supersedes`/`replaced_by`. Проверять dimensions, MIME, SHA-256, прозрачность, aspect ratio, подпись и категорию; не переключать approved-файл автоматически, не считать userguide-скриншот карточкой предмета и не публиковать media-кандидатов без review и разрешённого provenance.
|
- [ ] **G08 · Медиа и качество.** Разнести media roles для `tackle_item`, `bait`, `rig` и общего reference; связать варианты через `entity_key`, `duplicate_of`, `supersedes`/`replaced_by`. Проверять dimensions, MIME, SHA-256, прозрачность, aspect ratio, подпись и категорию; не переключать approved-файл автоматически, не считать userguide-скриншот карточкой предмета и не публиковать media-кандидатов без review и разрешённого provenance.
|
||||||
- [ ] **G09 · Приёмка и эксплуатация.** Добавить fixture/regression tests, offline catalog/crosswalk/media audits, проверку идемпотентности и сохранения старых данных при сбое, API/UI acceptance для пустых, неоднозначных и многокомпонентных комплектов, browser QA desktop/mobile и query-plan gate для фильтров/сочетаний. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`.
|
- [ ] **G09 · Приёмка и эксплуатация.** Добавить fixture/regression tests, offline catalog/crosswalk/media audits, проверку идемпотентности и сохранения старых данных при сбое, API/UI acceptance для пустых, неоднозначных и многокомпонентных комплектов, browser QA desktop/mobile и query-plan gate для фильтров/сочетаний. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs, { access } from "node:fs/promises";
|
||||||
import readline from "node:readline/promises";
|
import readline from "node:readline/promises";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -22,6 +22,7 @@ Options:
|
|||||||
--html-output PATH retain browser DOM HTML (default: temporary .cache file)
|
--html-output PATH retain browser DOM HTML (default: temporary .cache file)
|
||||||
--state-file PATH shared cooldown state file
|
--state-file PATH shared cooldown state file
|
||||||
--profile PATH persistent Chromium profile directory
|
--profile PATH persistent Chromium profile directory
|
||||||
|
--executable PATH Chromium/Chrome executable (default: RF4_CHROMIUM_EXECUTABLE or system Chrome)
|
||||||
--headless run Chromium headless; cannot handle manual challenges
|
--headless run Chromium headless; cannot handle manual challenges
|
||||||
--wait-seconds N headed wait after load (default: 30)
|
--wait-seconds N headed wait after load (default: 30)
|
||||||
`);
|
`);
|
||||||
@@ -37,6 +38,7 @@ function parseArgs(argv) {
|
|||||||
const args = {
|
const args = {
|
||||||
mode, url: mode === "catalog" ? "https://rf4db.com/ru/maps" : null,
|
mode, url: mode === "catalog" ? "https://rf4db.com/ru/maps" : null,
|
||||||
output: null, htmlOutput: null, stateFile: DEFAULT_STATE, profile: DEFAULT_PROFILE,
|
output: null, htmlOutput: null, stateFile: DEFAULT_STATE, profile: DEFAULT_PROFILE,
|
||||||
|
executable: process.env.RF4_CHROMIUM_EXECUTABLE || null,
|
||||||
headless: false, waitSeconds: 30,
|
headless: false, waitSeconds: 30,
|
||||||
};
|
};
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
@@ -46,6 +48,7 @@ function parseArgs(argv) {
|
|||||||
else if (arg === "--html-output") args.htmlOutput = argv[++index];
|
else if (arg === "--html-output") args.htmlOutput = argv[++index];
|
||||||
else if (arg === "--state-file") args.stateFile = argv[++index];
|
else if (arg === "--state-file") args.stateFile = argv[++index];
|
||||||
else if (arg === "--profile") args.profile = argv[++index];
|
else if (arg === "--profile") args.profile = argv[++index];
|
||||||
|
else if (arg === "--executable") args.executable = argv[++index];
|
||||||
else if (arg === "--headless") args.headless = true;
|
else if (arg === "--headless") args.headless = true;
|
||||||
else if (arg === "--wait-seconds") args.waitSeconds = Number(argv[++index]);
|
else if (arg === "--wait-seconds") args.waitSeconds = Number(argv[++index]);
|
||||||
else throw new Error(`unknown argument: ${arg}`);
|
else throw new Error(`unknown argument: ${arg}`);
|
||||||
@@ -61,6 +64,22 @@ function parseArgs(argv) {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveExecutable(requested) {
|
||||||
|
if (requested) {
|
||||||
|
await access(requested);
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
for (const candidate of ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium"]) {
|
||||||
|
try {
|
||||||
|
await access(candidate);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// Try the next locally installed browser.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function reserveCooldown(args) {
|
function reserveCooldown(args) {
|
||||||
const source = args.mode === "catalog" ? "rf4db-waterbodies" : "rf4db-waterbody";
|
const source = args.mode === "catalog" ? "rf4db-waterbodies" : "rf4db-waterbody";
|
||||||
const command = ["-m", "rf4_research.community_cli", source, "--url", args.url, "--state-file", args.stateFile, "--reserve-only"];
|
const command = ["-m", "rf4_research.community_cli", source, "--url", args.url, "--state-file", args.stateFile, "--reserve-only"];
|
||||||
@@ -87,7 +106,12 @@ async function main() {
|
|||||||
await fs.mkdir(path.dirname(htmlOutput), { recursive: true });
|
await fs.mkdir(path.dirname(htmlOutput), { recursive: true });
|
||||||
await fs.mkdir(path.dirname(output), { recursive: true });
|
await fs.mkdir(path.dirname(output), { recursive: true });
|
||||||
|
|
||||||
const context = await chromium.launchPersistentContext(path.resolve(args.profile), { headless: args.headless });
|
const executablePath = await resolveExecutable(args.executable);
|
||||||
|
if (executablePath) console.log(`Using browser executable: ${executablePath}`);
|
||||||
|
const context = await chromium.launchPersistentContext(path.resolve(args.profile), {
|
||||||
|
headless: args.headless,
|
||||||
|
...(executablePath ? { executablePath } : {}),
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const page = context.pages()[0] || await context.newPage();
|
const page = context.pages()[0] || await context.newPage();
|
||||||
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||||
|
|||||||
Reference in New Issue
Block a user