feat(seo): public HTML SEO router + templates; add CLAUDE.md; ignore .nimbalyst
- app/api/seo.py (+ app/templates/seo/*): publiczny HTML SEO router (programmatic entity long-tail: performer/studio/scene/landing/2257), bez api-key. Importowany przez main.py — wymagany do uruchomienia, dotąd untracked. Opsec-clean (brak VPS IP/sekretów). - CLAUDE.md: instrukcje projektu (dotąd untracked). - .gitignore: .nimbalyst/ (lokalne tracker-tooling, nie dla OSS repo). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b942565a6e
commit
9c49a69a66
11 changed files with 835 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -82,3 +82,6 @@ launch/
|
|||
# should NOT contain SSH commands, systemd units, or smoke-test playbooks
|
||||
# referencing concrete hosts.
|
||||
deploy/
|
||||
|
||||
# Local tracker tooling (nimbalyst MCP) — not for OSS repo
|
||||
.nimbalyst/
|
||||
|
|
|
|||
31
CLAUDE.md
Normal file
31
CLAUDE.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Goon
|
||||
|
||||
Self-hosted aggregator of adult-content scene metadata. Multi-source ingest (TPDB, StashDB, 30+ tubes) → dedup → on-demand stream resolution (yt-dlp + JWPlayer unpacker) → API + Expo mobile client.
|
||||
|
||||
Stack: Python 3.12 (FastAPI + SQLAlchemy + Alembic), Postgres 16, React Native (Expo), Docker Compose.
|
||||
Git: github.com/goon-foss/goon (remote: `goonfoss`). Public OSS repo. 18+ — see `DISCLAIMER.md`.
|
||||
|
||||
---
|
||||
|
||||
## Kanban (auto-aggregation)
|
||||
|
||||
Ten workspace ma własny Kanban (custom tracker type `kanban` w `.nimbalyst/trackers/kanban.yaml`) z kolumnami:
|
||||
|
||||
**Backlog → Następne → W trakcie → Czeka → Zrobione**
|
||||
|
||||
### Reguła auto-agregacji — WAŻNE
|
||||
|
||||
Kiedy podczas sesji pojawi się nowy temat / bug / pomysł / blocker, na który **nie ma teraz czasu** lub jest poboczny względem aktualnego zadania — **utwórz tracker item** zamiast tylko wspomnieć o nim w czacie:
|
||||
|
||||
```
|
||||
mcp__nimbalyst-mcp__tracker_create(
|
||||
type: "kanban",
|
||||
title: "<krótki tytuł>",
|
||||
status: "backlog",
|
||||
description: "<1-2 zdania kontekstu + ścieżka pliku jeśli dotyczy>"
|
||||
)
|
||||
```
|
||||
|
||||
Powód: Jan często skupia się na pierwszej rzeczy z planu, a pozostałe znikają. Kanban jest gwarancją, że nic nie wypadnie. Jeśli wspomnisz coś w czacie i nie założysz ticketu — to się zgubi.
|
||||
|
||||
Status `next` = "wybrane na najbliższe dni" (3-5 max). `waiting` = zablokowane na zewnętrzne. `done` ustawia użytkownik, nigdy AI.
|
||||
513
app/api/seo.py
Normal file
513
app/api/seo.py
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
"""Publiczna, crawlowalna powierzchnia SEO — programmatic entity pages.
|
||||
|
||||
Jedyny publiczny router HTML poza /static i healthchecks — NIE wymaga api key,
|
||||
bo Googlebot/użytkownik musi dotrzeć bez tokenu. Cel: łapać nawigacyjny long-tail
|
||||
(nazwy performerów / studiów / tytuły scen), którego mainstream-SEO nie indeksuje,
|
||||
a który realnie generuje organiczny ruch → pobranie apki.
|
||||
|
||||
Zasady (świadome, nie przypadkowe):
|
||||
|
||||
* **LINK-OUT only.** Strony renderują metadane i odsyłają do źródeł (`page_url`).
|
||||
NIE eksponujemy `stream_url`/`embed_url` — to zostaje value-add apki i trzyma
|
||||
legalny profil "agregatora/wyszukiwarki" (dowozimy ruch tubom, nie re-streamujemy).
|
||||
* **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc
|
||||
crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+.
|
||||
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
|
||||
* **Blacklist respektowany** — te same wykluczenia performer/studio/tag co w /scenes.
|
||||
Nie publikujemy SEO-stron dla zblacklistowanej treści.
|
||||
* **Anti-thin-page** — strona encji powstaje tylko gdy ma realną zawartość
|
||||
(performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404,
|
||||
nie pusty doorway (Google karze masowe cienkie strony).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.scenes import _needs_proxy, _wrap_image_proxy
|
||||
from app.db import get_session
|
||||
from app.models.blacklist import (
|
||||
BlacklistedPerformer,
|
||||
BlacklistedStudio,
|
||||
BlacklistedTag,
|
||||
)
|
||||
from app.models.performer import Performer, PerformerAlias
|
||||
from app.models.playback_source import PlaybackSource
|
||||
from app.models.scene import Scene, ScenePerformer, SceneTag
|
||||
from app.models.studio import Studio
|
||||
from app.models.tag import Tag
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||
|
||||
# Analityka — wstrzykiwana do każdej strony SEO tylko gdy odpowiedni env jest ustawiony.
|
||||
# Puste = tag się nie renderuje (zero third-party requestów, zachowanie bez zmian).
|
||||
# Włączenie = ustaw zmienną w .env na VPS + restart, bez zmian w kodzie.
|
||||
templates.env.globals["gtm_id"] = os.environ.get("GOON_GTM_ID", "")
|
||||
templates.env.globals["ga4_id"] = os.environ.get("GOON_GA4_ID", "")
|
||||
templates.env.globals["gsc_verify"] = os.environ.get("GOON_GSC_VERIFY", "")
|
||||
|
||||
# Limit URL-i na pojedynczy plik sitemap (spec: max 50k). Trzymamy z zapasem.
|
||||
_SITEMAP_PAGE = 25_000
|
||||
# Ile scen renderujemy na stronie encji (performer/studio) — pełna lista 1000+ scen
|
||||
# to thin/slow; bierzemy najświeższe N, reszta i tak wpada przez sitemap scen.
|
||||
_SCENES_PER_ENTITY = 120
|
||||
|
||||
router = APIRouter(tags=["seo"])
|
||||
|
||||
|
||||
def base_url() -> str:
|
||||
"""Publiczny origin pod którym serwowane są te strony (do canonical/sitemap/OG)."""
|
||||
return os.environ.get("BACKEND_PUBLIC_URL", "https://goon-foss.org").rstrip("/")
|
||||
|
||||
|
||||
# --- reużywalne fragmenty zapytań -------------------------------------------------
|
||||
|
||||
|
||||
def _live_playback_exists():
|
||||
"""EXISTS: scena ma ≥1 żywy (dead_at IS NULL) playback_source."""
|
||||
return exists(
|
||||
select(1).where(
|
||||
PlaybackSource.scene_id == Scene.id,
|
||||
PlaybackSource.dead_at.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _not_blacklisted():
|
||||
"""Lista warunków WHERE wykluczających zblacklistowaną treść (performer/studio/tag).
|
||||
|
||||
Te same reguły co w GET /scenes — żeby SEO nie publikowało tego, co katalog ukrywa.
|
||||
"""
|
||||
return [
|
||||
~exists(
|
||||
select(1)
|
||||
.select_from(ScenePerformer)
|
||||
.join(
|
||||
BlacklistedPerformer,
|
||||
BlacklistedPerformer.performer_id == ScenePerformer.performer_id,
|
||||
)
|
||||
.where(ScenePerformer.scene_id == Scene.id)
|
||||
),
|
||||
~Scene.studio_id.in_(select(BlacklistedStudio.studio_id)),
|
||||
~exists(
|
||||
select(1)
|
||||
.select_from(SceneTag)
|
||||
.join(BlacklistedTag, BlacklistedTag.tag_id == SceneTag.tag_id)
|
||||
.where(SceneTag.scene_id == Scene.id)
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _indexable_scenes():
|
||||
"""SELECT Scene żywych, nie-zblacklistowanych scen — baza pod listy i sitemap."""
|
||||
stmt = select(Scene).where(_live_playback_exists())
|
||||
for cond in _not_blacklisted():
|
||||
stmt = stmt.where(cond)
|
||||
return stmt
|
||||
|
||||
|
||||
def _performer_indexable_exists():
|
||||
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
|
||||
|
||||
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
|
||||
`performer_page` — inaczej sitemap zgłaszałby URL-e dające 404.
|
||||
"""
|
||||
sub = (
|
||||
select(1)
|
||||
.select_from(ScenePerformer)
|
||||
.join(Scene, Scene.id == ScenePerformer.scene_id)
|
||||
.where(ScenePerformer.performer_id == Performer.id)
|
||||
.where(_live_playback_exists())
|
||||
)
|
||||
for cond in _not_blacklisted():
|
||||
sub = sub.where(cond)
|
||||
return exists(sub)
|
||||
|
||||
|
||||
def _scene_card_rows(session: Session, scene_ids: list[uuid.UUID]) -> dict[uuid.UUID, dict]:
|
||||
"""Batch: dla listy scen zbierz dane do karty (studio name/slug, #źródeł, thumb)."""
|
||||
if not scene_ids:
|
||||
return {}
|
||||
out: dict[uuid.UUID, dict] = {sid: {"sources": 0, "thumb": None} for sid in scene_ids}
|
||||
|
||||
# liczba żywych źródeł + pierwszy thumbnail (z page_url do proxy referer)
|
||||
pb_rows = session.execute(
|
||||
select(PlaybackSource.scene_id, PlaybackSource.thumbnail_url, PlaybackSource.page_url)
|
||||
.where(
|
||||
PlaybackSource.scene_id.in_(scene_ids),
|
||||
PlaybackSource.dead_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
for sid, thumb, page_url in pb_rows:
|
||||
out[sid]["sources"] += 1
|
||||
if out[sid]["thumb"] is None and thumb:
|
||||
if _needs_proxy(thumb):
|
||||
thumb = _wrap_image_proxy(thumb, page_url)
|
||||
out[sid]["thumb"] = thumb
|
||||
return out
|
||||
|
||||
|
||||
def _iso_duration(seconds: int | None) -> str | None:
|
||||
"""sekundy → ISO-8601 (PT#M#S) dla schema.org VideoObject.duration."""
|
||||
if not seconds or seconds <= 0:
|
||||
return None
|
||||
m, s = divmod(int(seconds), 60)
|
||||
return f"PT{m}M{s}S"
|
||||
|
||||
|
||||
# --- strony encji -----------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
def performer_page(
|
||||
slug: str,
|
||||
request: Request,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> HTMLResponse:
|
||||
performer = session.execute(
|
||||
select(Performer).where(Performer.slug == slug)
|
||||
).scalar_one_or_none()
|
||||
if performer is None:
|
||||
raise HTTPException(status_code=404, detail="performer not found")
|
||||
|
||||
# Sceny tej osoby — żywe, nie-blacklisted, najświeższe pierwsze.
|
||||
scenes = (
|
||||
session.execute(
|
||||
_indexable_scenes()
|
||||
.where(
|
||||
exists(
|
||||
select(1).where(
|
||||
ScenePerformer.scene_id == Scene.id,
|
||||
ScenePerformer.performer_id == performer.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(Scene.release_date.desc().nullslast(), Scene.created_at.desc())
|
||||
.limit(_SCENES_PER_ENTITY)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not scenes:
|
||||
# Pusta encja → 404 zamiast thin doorway.
|
||||
raise HTTPException(status_code=404, detail="no indexable scenes for performer")
|
||||
|
||||
cards = _scene_card_rows(session, [s.id for s in scenes])
|
||||
studios = {
|
||||
st.id: st
|
||||
for st in session.execute(
|
||||
select(Studio).where(
|
||||
Studio.id.in_({s.studio_id for s in scenes if s.studio_id})
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
aliases = [
|
||||
a.alias
|
||||
for a in session.execute(
|
||||
select(PerformerAlias).where(PerformerAlias.performer_id == performer.id)
|
||||
).scalars()
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"seo/performer.html",
|
||||
{
|
||||
"base_url": base_url(),
|
||||
"performer": performer,
|
||||
"aliases": sorted({a for a in aliases if a.lower() != performer.canonical_name.lower()}),
|
||||
"scenes": scenes,
|
||||
"cards": cards,
|
||||
"studios": studios,
|
||||
"canonical": f"{base_url()}/p/{performer.slug}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/studio/{slug}", response_class=HTMLResponse)
|
||||
def studio_page(
|
||||
slug: str,
|
||||
request: Request,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> HTMLResponse:
|
||||
studio = session.execute(
|
||||
select(Studio).where(Studio.slug == slug)
|
||||
).scalar_one_or_none()
|
||||
if studio is None:
|
||||
raise HTTPException(status_code=404, detail="studio not found")
|
||||
|
||||
scenes = (
|
||||
session.execute(
|
||||
_indexable_scenes()
|
||||
.where(Scene.studio_id == studio.id)
|
||||
.order_by(Scene.release_date.desc().nullslast(), Scene.created_at.desc())
|
||||
.limit(_SCENES_PER_ENTITY)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not scenes:
|
||||
raise HTTPException(status_code=404, detail="no indexable scenes for studio")
|
||||
|
||||
cards = _scene_card_rows(session, [s.id for s in scenes])
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"seo/studio.html",
|
||||
{
|
||||
"base_url": base_url(),
|
||||
"studio": studio,
|
||||
"scenes": scenes,
|
||||
"cards": cards,
|
||||
"canonical": f"{base_url()}/studio/{studio.slug}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scene/{scene_id}", response_class=HTMLResponse)
|
||||
def scene_page(
|
||||
scene_id: uuid.UUID,
|
||||
request: Request,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> HTMLResponse:
|
||||
scene = session.get(Scene, scene_id)
|
||||
if scene is None:
|
||||
raise HTTPException(status_code=404, detail="scene not found")
|
||||
|
||||
# Źródła — żywe, deduplikowane po origin (pokazujemy 1 link per tube).
|
||||
sources_raw = (
|
||||
session.execute(
|
||||
select(PlaybackSource)
|
||||
.where(
|
||||
PlaybackSource.scene_id == scene.id,
|
||||
PlaybackSource.dead_at.is_(None),
|
||||
)
|
||||
.order_by(PlaybackSource.origin.asc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not sources_raw:
|
||||
raise HTTPException(status_code=404, detail="scene has no live sources")
|
||||
|
||||
seen_origins: set[str] = set()
|
||||
sources = []
|
||||
thumb: str | None = None
|
||||
for s in sources_raw:
|
||||
if thumb is None and s.thumbnail_url:
|
||||
thumb = _wrap_image_proxy(s.thumbnail_url, s.page_url) if _needs_proxy(s.thumbnail_url) else s.thumbnail_url
|
||||
if s.origin in seen_origins:
|
||||
continue
|
||||
seen_origins.add(s.origin)
|
||||
label = s.origin.split(":", 1)[1] if ":" in s.origin else s.origin
|
||||
sources.append({"label": label, "page_url": s.page_url, "quality": s.quality})
|
||||
|
||||
studio = session.get(Studio, scene.studio_id) if scene.studio_id else None
|
||||
performers = (
|
||||
session.execute(
|
||||
select(Performer)
|
||||
.join(ScenePerformer, ScenePerformer.performer_id == Performer.id)
|
||||
.where(ScenePerformer.scene_id == scene.id)
|
||||
.order_by(ScenePerformer.position.asc().nullslast())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
tags = (
|
||||
session.execute(
|
||||
select(Tag)
|
||||
.join(SceneTag, SceneTag.tag_id == Tag.id)
|
||||
.where(SceneTag.scene_id == scene.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"seo/scene.html",
|
||||
{
|
||||
"base_url": base_url(),
|
||||
"scene": scene,
|
||||
"studio": studio,
|
||||
"performers": performers,
|
||||
"tags": tags,
|
||||
"sources": sources,
|
||||
"thumb": thumb,
|
||||
"iso_duration": _iso_duration(scene.duration_sec),
|
||||
"canonical": f"{base_url()}/scene/{scene.id}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def landing(
|
||||
request: Request,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> HTMLResponse:
|
||||
"""Strona główna — crawl-entry. Najświeższe indeksowalne sceny + CTA."""
|
||||
scenes = (
|
||||
session.execute(
|
||||
_indexable_scenes()
|
||||
.order_by(Scene.created_at.desc())
|
||||
.limit(48)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
cards = _scene_card_rows(session, [s.id for s in scenes])
|
||||
studios = {
|
||||
st.id: st
|
||||
for st in session.execute(
|
||||
select(Studio).where(Studio.id.in_({s.studio_id for s in scenes if s.studio_id}))
|
||||
).scalars()
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"seo/landing.html",
|
||||
{
|
||||
"base_url": base_url(),
|
||||
"scenes": scenes,
|
||||
"cards": cards,
|
||||
"studios": studios,
|
||||
"canonical": f"{base_url()}/",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/get", response_class=HTMLResponse)
|
||||
def get_app(request: Request) -> HTMLResponse:
|
||||
"""Paid-traffic landing page — odchudzona pod konwersję instalacji APK.
|
||||
noindex (patrz get.html) — nie ma konkurować w SERP z entity-stronami."""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"seo/get.html",
|
||||
{"base_url": base_url(), "canonical": f"{base_url()}/get"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/2257", response_class=HTMLResponse)
|
||||
def page_2257(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"seo/page_2257.html",
|
||||
{"base_url": base_url(), "canonical": f"{base_url()}/2257"},
|
||||
)
|
||||
|
||||
|
||||
# --- robots + sitemap -------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{fname}.html", response_class=PlainTextResponse, include_in_schema=False)
|
||||
def gsc_site_verification(fname: str) -> PlainTextResponse:
|
||||
"""Plik weryfikacyjny Google Search Console (metoda 'Plik HTML').
|
||||
|
||||
Token z env GOON_GSC_FILE (np. 'google67b49088b5416adc' — bez '.html'). Google
|
||||
pobiera /<token>.html i oczekuje body 'google-site-verification: <token>.html'.
|
||||
Każda inna nazwa .html → 404. Nie koliduje z innymi trasami (żadna inna nie jest
|
||||
jednosegmentowym *.html w rootcie).
|
||||
"""
|
||||
expected = os.environ.get("GOON_GSC_FILE", "")
|
||||
if expected and fname == expected:
|
||||
return PlainTextResponse(f"google-site-verification: {fname}.html")
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
|
||||
@router.get("/robots.txt", response_class=PlainTextResponse)
|
||||
def robots() -> PlainTextResponse:
|
||||
body = (
|
||||
"User-agent: *\n"
|
||||
"Allow: /\n"
|
||||
"Disallow: /proxy/\n"
|
||||
"Disallow: /ui/\n"
|
||||
f"Sitemap: {base_url()}/sitemap.xml\n"
|
||||
)
|
||||
return PlainTextResponse(body)
|
||||
|
||||
|
||||
def _count(session: Session, stmt) -> int:
|
||||
return session.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
|
||||
|
||||
|
||||
@router.get("/sitemap.xml")
|
||||
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
|
||||
"""Sitemap index — listuje paginowane pod-sitemapy per typ encji."""
|
||||
n_perf = _count(
|
||||
session,
|
||||
select(Performer.id).where(_performer_indexable_exists()),
|
||||
)
|
||||
n_studio = _count(
|
||||
session,
|
||||
select(Studio.id).where(
|
||||
exists(select(1).where(Scene.studio_id == Studio.id).where(_live_playback_exists()))
|
||||
),
|
||||
)
|
||||
n_scene = _count(session, _indexable_scenes().with_only_columns(Scene.id))
|
||||
|
||||
bu = base_url()
|
||||
parts = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
||||
for kind, total in (("performers", n_perf), ("studios", n_studio), ("scenes", n_scene)):
|
||||
pages = max(1, -(-total // _SITEMAP_PAGE)) # ceil
|
||||
for p in range(pages):
|
||||
parts.append(f"<sitemap><loc>{bu}/sitemap/{kind}-{p}.xml</loc></sitemap>")
|
||||
parts.append("</sitemapindex>")
|
||||
return Response("\n".join(parts), media_type="application/xml")
|
||||
|
||||
|
||||
@router.get("/sitemap/{kind}-{page}.xml")
|
||||
def sitemap_page(
|
||||
kind: str,
|
||||
page: int,
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
) -> Response:
|
||||
bu = base_url()
|
||||
off = page * _SITEMAP_PAGE
|
||||
urls: list[str] = []
|
||||
|
||||
if kind == "performers":
|
||||
rows = session.execute(
|
||||
select(Performer.slug)
|
||||
.where(_performer_indexable_exists())
|
||||
.order_by(Performer.created_at.asc())
|
||||
.offset(off)
|
||||
.limit(_SITEMAP_PAGE)
|
||||
).scalars()
|
||||
urls = [f"{bu}/p/{slug}" for slug in rows]
|
||||
elif kind == "studios":
|
||||
rows = session.execute(
|
||||
select(Studio.slug)
|
||||
.where(
|
||||
exists(select(1).where(Scene.studio_id == Studio.id).where(_live_playback_exists()))
|
||||
)
|
||||
.order_by(Studio.created_at.asc())
|
||||
.offset(off)
|
||||
.limit(_SITEMAP_PAGE)
|
||||
).scalars()
|
||||
urls = [f"{bu}/studio/{slug}" for slug in rows]
|
||||
elif kind == "scenes":
|
||||
rows = session.execute(
|
||||
_indexable_scenes()
|
||||
.with_only_columns(Scene.id)
|
||||
.order_by(Scene.created_at.asc())
|
||||
.offset(off)
|
||||
.limit(_SITEMAP_PAGE)
|
||||
).scalars()
|
||||
urls = [f"{bu}/scene/{sid}" for sid in rows]
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="unknown sitemap kind")
|
||||
|
||||
parts = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
||||
parts += [f"<url><loc>{u}</loc></url>" for u in urls]
|
||||
parts.append("</urlset>")
|
||||
return Response("\n".join(parts), media_type="application/xml")
|
||||
16
app/templates/seo/_cards.html
Normal file
16
app/templates/seo/_cards.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{# Siatka kart scen. Wymaga: scenes, cards; opcjonalnie studios (mapa studio_id→Studio). #}
|
||||
<div class="grid">
|
||||
{% for s in scenes %}
|
||||
<a class="card" href="{{ base_url }}/scene/{{ s.id }}">
|
||||
{% if cards[s.id].thumb %}
|
||||
<img src="{{ cards[s.id].thumb }}" alt="{{ s.title }}" loading="lazy">
|
||||
{% endif %}
|
||||
<div class="t">{{ s.title }}
|
||||
<div class="s">
|
||||
{% if studios is defined and s.studio_id and studios.get(s.studio_id) %}{{ studios[s.studio_id].name }} · {% endif %}
|
||||
{% if s.release_date %}{{ s.release_date.year }} · {% endif %}{{ cards[s.id].sources }} source{{ 's' if cards[s.id].sources != 1 else '' }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
105
app/templates/seo/base.html
Normal file
105
app/templates/seo/base.html
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{# --- Analityka (gated na env, patrz seo.py) --- #}
|
||||
{% if gsc_verify %}<meta name="google-site-verification" content="{{ gsc_verify }}">{% endif %}
|
||||
{% if gtm_id %}<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','{{ gtm_id }}');</script>{% endif %}
|
||||
{# GA4 bezpośrednio przez gtag. Jeśli wpniesz GA4 wewnątrz GTM, odustaw GOON_GA4_ID
|
||||
żeby nie liczyć podwójnie. #}
|
||||
{% if ga4_id %}<script async src="https://www.googletagmanager.com/gtag/js?id={{ ga4_id }}"></script>
|
||||
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js',new Date());gtag('config','{{ ga4_id }}');</script>{% endif %}
|
||||
{# RTA label — sygnał dla filtrów rodzicielskich/SafeSearch że to adult. #}
|
||||
<meta name="rating" content="RTA-5042-1996-1400-1577-RTA">
|
||||
<meta name="rating" content="adult">
|
||||
<title>{% block title %}Goon{% endblock %}</title>
|
||||
<meta name="description" content="{% block description %}{% endblock %}">
|
||||
<link rel="canonical" href="{{ canonical }}">
|
||||
{# Indeksuj treść, ale nie follow-uj wychodzących linków do tubów jako endorsement. #}
|
||||
<meta name="robots" content="{% block robots %}index, follow, max-image-preview:large{% endblock %}">
|
||||
<meta property="og:site_name" content="Goon">
|
||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
||||
<meta property="og:url" content="{{ canonical }}">
|
||||
<meta property="og:title" content="{% block og_title %}{{ self.title() }}{% endblock %}">
|
||||
{% block og_image %}{% endblock %}
|
||||
<style>
|
||||
:root { --bg:#1a1012; --fg:#ece0e2; --muted:#a98b90; --ox:#7b2230; --card:#241619; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,sans-serif; }
|
||||
a { color:#e88; text-decoration:none; } a:hover { text-decoration:underline; }
|
||||
header, main, footer { max-width:1100px; margin:0 auto; padding:16px; }
|
||||
header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.wordmark { font-weight:800; letter-spacing:-.02em; font-size:22px; color:var(--fg); }
|
||||
.cta { background:var(--ox); color:#fff; padding:9px 16px; border-radius:8px; font-weight:700; }
|
||||
h1 { font-size:26px; margin:.2em 0; } h2 { font-size:18px; color:var(--muted); font-weight:600; }
|
||||
.meta { color:var(--muted); font-size:14px; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; margin-top:18px; }
|
||||
.card { background:var(--card); border-radius:10px; overflow:hidden; }
|
||||
.card img { width:100%; aspect-ratio:16/9; object-fit:cover; background:#000; }
|
||||
.card .t { padding:8px 10px; font-size:13px; line-height:1.35; }
|
||||
.card .s { color:var(--muted); font-size:12px; }
|
||||
.sources a { display:inline-block; background:var(--card); padding:7px 12px; border-radius:7px; margin:4px 6px 0 0; }
|
||||
.tags a { color:var(--muted); font-size:13px; margin-right:10px; }
|
||||
footer { color:var(--muted); font-size:13px; border-top:1px solid #3a262b; margin-top:40px; }
|
||||
#agegate { position:fixed; inset:0; background:#0c0709; display:flex; align-items:center;
|
||||
justify-content:center; z-index:9999; text-align:center; padding:24px; }
|
||||
/* `hidden` attribute must beat `#agegate{display:flex}` (id specificity),
|
||||
otherwise the overlay can't be dismissed — see bug 2026-05-30. */
|
||||
#agegate[hidden] { display:none; }
|
||||
#agegate .box { max-width:440px; } #agegate h2 { color:var(--fg); }
|
||||
#agegate button { background:var(--ox); color:#fff; border:0; padding:12px 22px; border-radius:8px;
|
||||
font-size:16px; font-weight:700; cursor:pointer; margin:8px; }
|
||||
#agegate .leave { background:#333; }
|
||||
</style>
|
||||
{% block jsonld %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% if gtm_id %}<noscript><iframe src="https://www.googletagmanager.com/ns.html?id={{ gtm_id }}"
|
||||
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>{% endif %}
|
||||
<header>
|
||||
<a class="wordmark" href="{{ base_url }}/">GOON</a>
|
||||
<a class="cta" href="/static/app-release.apk" rel="nofollow" onclick="goonTrackDownload('header')">Get the app</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Goon is a scene metadata aggregator — we link out to the original sources and do
|
||||
not host or stream any content. <a href="{{ base_url }}/2257">18 U.S.C. 2257</a> ·
|
||||
<a href="https://github.com/goon-foss/goon">open source</a></p>
|
||||
<p>All models were 18 or older at the time of depiction. Content links point to
|
||||
third-party sites; Goon stores no media.</p>
|
||||
</footer>
|
||||
|
||||
{# Age-gate: overlay tylko dla ludzi. Treść jest w DOM, więc crawler ją widzi. #}
|
||||
<div id="agegate" hidden>
|
||||
<div class="box">
|
||||
<div class="wordmark" style="font-size:28px">GOON</div>
|
||||
<h2>This site contains adult content</h2>
|
||||
<p>You must be 18 years or older (21 where applicable) to enter.</p>
|
||||
<button onclick="goonAgeOk()">I am 18 or older — Enter</button>
|
||||
<button class="leave" onclick="location.href='https://www.google.com'">Leave</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Conversion: APK download click → event to GA4 (gtag, direct) and to GTM
|
||||
// (dataLayer, for ad-network pixels). Safe when analytics is disabled.
|
||||
function goonTrackDownload(src){
|
||||
try{ if(window.gtag) gtag('event','download_click',{source:src}); }catch(e){}
|
||||
try{ if(window.dataLayer) window.dataLayer.push({event:'download_click',source:src}); }catch(e){}
|
||||
}
|
||||
function goonAgeOk(){ document.cookie="age_ok=1;path=/;max-age=2592000;samesite=lax";
|
||||
document.getElementById('agegate').hidden=true; document.body.style.overflow=''; }
|
||||
(function(){ if(!/(^|;\s*)age_ok=1/.test(document.cookie)){
|
||||
document.getElementById('agegate').hidden=false; document.body.style.overflow='hidden'; } })();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
28
app/templates/seo/get.html
Normal file
28
app/templates/seo/get.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% extends "seo/base.html" %}
|
||||
{# Paid-traffic LP — noindex (cienka, pod konwersję, nie ma konkurować w SERP). #}
|
||||
{% block robots %}noindex, nofollow{% endblock %}
|
||||
{% block title %}Get Goon — free adult scene finder (Android){% endblock %}
|
||||
{% block description %}Download Goon — search adult scenes across 30+ sites in one app. Free, no account, no signup.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section style="text-align:center; padding:20px 0 8px;">
|
||||
<h1 style="font-size:32px; margin-bottom:6px;">One search. Every source.</h1>
|
||||
<p class="meta" style="font-size:17px; max-width:600px; margin:10px auto 26px;">
|
||||
Goon indexes adult scenes from 30+ sites and takes you straight to the source.
|
||||
Free, no account, no signup.
|
||||
</p>
|
||||
|
||||
<a class="cta" style="font-size:18px; padding:14px 30px; display:inline-block;"
|
||||
href="/static/app-release.apk" rel="nofollow" onclick="goonTrackDownload('lp')">
|
||||
⬇ Download the app — Android
|
||||
</a>
|
||||
<p class="meta" style="margin-top:10px; font-size:13px;">Android APK · no Play Store needed</p>
|
||||
|
||||
<ul style="list-style:none; padding:0; max-width:480px; margin:34px auto 0; text-align:left;">
|
||||
<li style="margin:10px 0;">🔎 Search by performer, studio or title</li>
|
||||
<li style="margin:10px 0;">🔗 Links to the original source — nothing hosted here</li>
|
||||
<li style="margin:10px 0;">🎚 Filter by tags, quality and source</li>
|
||||
<li style="margin:10px 0;">🆓 Free & open-source — no account</li>
|
||||
</ul>
|
||||
</section>
|
||||
{% endblock %}
|
||||
13
app/templates/seo/landing.html
Normal file
13
app/templates/seo/landing.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "seo/base.html" %}
|
||||
{% block title %}Goon — adult scene metadata aggregator{% endblock %}
|
||||
{% block description %}Goon is an open-source aggregator indexing adult scenes across 30+ sources. Search by performer, studio or title and find where to watch — free, no account.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Find any scene, across every source</h1>
|
||||
<p class="meta">Goon indexes adult scene metadata from 30+ sites and links you to the
|
||||
source. Open-source, free, no account. Browse by
|
||||
<a href="{{ base_url }}/sitemap.xml">performer, studio or title</a>, or get the app for
|
||||
the full search + filters.</p>
|
||||
<h2>Latest indexed</h2>
|
||||
{% include "seo/_cards.html" %}
|
||||
{% endblock %}
|
||||
20
app/templates/seo/page_2257.html
Normal file
20
app/templates/seo/page_2257.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{% extends "seo/base.html" %}
|
||||
{% block title %}18 U.S.C. 2257 Statement | Goon{% endblock %}
|
||||
{% block description %}18 U.S.C. 2257 record-keeping compliance statement for Goon.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>18 U.S.C. § 2257 Compliance Statement</h1>
|
||||
<p>Goon is a metadata aggregator and search index. <strong>Goon does not produce,
|
||||
host, store, or stream any visual depictions of actual or simulated sexually
|
||||
explicit conduct.</strong> All media linked from this site is hosted by third-party
|
||||
websites that are the actual producers of the content.</p>
|
||||
<p>As Goon stores no media and produces no content, the record-keeping requirements
|
||||
of 18 U.S.C. § 2257 and 28 C.F.R. § 75 are not applicable to Goon. Records required
|
||||
by those provisions are maintained by the respective producers of the content, whose
|
||||
sites are linked from each scene page.</p>
|
||||
<p>All persons depicted in content reachable through the links on this site were
|
||||
18 years of age or older at the time of production, as represented and recorded by
|
||||
the originating producers.</p>
|
||||
<p>To report content or request removal of a link, open an issue at
|
||||
<a href="https://github.com/goon-foss/goon">github.com/goon-foss/goon</a>.</p>
|
||||
{% endblock %}
|
||||
29
app/templates/seo/performer.html
Normal file
29
app/templates/seo/performer.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{% extends "seo/base.html" %}
|
||||
{% block title %}{{ performer.canonical_name }} — scenes & videos | Goon{% endblock %}
|
||||
{% block description %}Browse {{ scenes|length }} {{ performer.canonical_name }} scenes aggregated across multiple sources. Find where to watch — free metadata index by Goon.{% endblock %}
|
||||
{% block og_type %}profile{% endblock %}
|
||||
|
||||
{% block jsonld %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context":"https://schema.org",
|
||||
"@type":"Person",
|
||||
"name": {{ performer.canonical_name|tojson }},
|
||||
{% if aliases %}"alternateName": {{ aliases|tojson }},{% endif %}
|
||||
{% if performer.gender %}"gender": {{ performer.gender.value|tojson }},{% endif %}
|
||||
{% if performer.country %}"nationality": {{ performer.country|tojson }},{% endif %}
|
||||
"url": {{ canonical|tojson }}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ performer.canonical_name }}</h1>
|
||||
<p class="meta">
|
||||
{% if aliases %}aka {{ aliases|join(", ") }}<br>{% endif %}
|
||||
{% if performer.gender %}{{ performer.gender.value|replace("_"," ") }}{% endif %}
|
||||
{% if performer.country %} · {{ performer.country }}{% endif %}
|
||||
· {{ scenes|length }} scenes indexed
|
||||
</p>
|
||||
{% include "seo/_cards.html" %}
|
||||
{% endblock %}
|
||||
54
app/templates/seo/scene.html
Normal file
54
app/templates/seo/scene.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{% extends "seo/base.html" %}
|
||||
{% set perf_names = performers|map(attribute="canonical_name")|list %}
|
||||
{% block title %}{{ scene.title }}{% if studio %} – {{ studio.name }}{% endif %} | Goon{% endblock %}
|
||||
{% block description %}{% if scene.description %}{{ scene.description|truncate(155) }}{% else %}Watch {{ scene.title }}{% if perf_names %} with {{ perf_names|join(", ") }}{% endif %}{% if studio %} ({{ studio.name }}){% endif %}. Available on {{ sources|length }} source{{ 's' if sources|length != 1 else '' }} — indexed by Goon.{% endif %}{% endblock %}
|
||||
{% block og_type %}video.other{% endblock %}
|
||||
{% block og_image %}{% if thumb %}<meta property="og:image" content="{{ thumb if thumb.startswith('http') else base_url ~ thumb }}">{% endif %}{% endblock %}
|
||||
|
||||
{% block jsonld %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context":"https://schema.org",
|
||||
"@type":"VideoObject",
|
||||
"name": {{ scene.title|tojson }},
|
||||
"description": {{ (scene.description or scene.title)|tojson }},
|
||||
{% if thumb %}"thumbnailUrl": {{ (thumb if thumb.startswith('http') else base_url ~ thumb)|tojson }},{% endif %}
|
||||
{% if scene.release_date %}"uploadDate": {{ scene.release_date.isoformat()|tojson }},{% endif %}
|
||||
{% if iso_duration %}"duration": {{ iso_duration|tojson }},{% endif %}
|
||||
{% if perf_names %}"actor": {{ perf_names|map("string")|list|tojson }},{% endif %}
|
||||
{% if studio %}"productionCompany": {{ {"@type":"Organization","name": studio.name}|tojson }},{% endif %}
|
||||
"url": {{ canonical|tojson }}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ scene.title }}</h1>
|
||||
<p class="meta">
|
||||
{% if studio %}<a href="{{ base_url }}/studio/{{ studio.slug }}">{{ studio.name }}</a>{% endif %}
|
||||
{% if scene.release_date %} · {{ scene.release_date }}{% endif %}
|
||||
{% if scene.duration_sec %} · {{ (scene.duration_sec // 60) }} min{% endif %}
|
||||
</p>
|
||||
|
||||
{% if performers %}
|
||||
<p>Featuring:
|
||||
{% for p in performers %}<a href="{{ base_url }}/p/{{ p.slug }}">{{ p.canonical_name }}</a>{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if scene.description %}<p>{{ scene.description }}</p>{% endif %}
|
||||
|
||||
<h2>Watch on {{ sources|length }} source{{ 's' if sources|length != 1 else '' }}</h2>
|
||||
<div class="sources">
|
||||
{% for src in sources %}
|
||||
<a href="{{ src.page_url }}" rel="nofollow sponsored noopener" target="_blank">
|
||||
{{ src.label }}{% if src.quality %} · {{ src.quality }}{% endif %} ↗</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if tags %}
|
||||
<p class="tags" style="margin-top:20px">
|
||||
{% for t in tags %}<span>#{{ t.name }}</span>{% if not loop.last %} {% endif %}{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
23
app/templates/seo/studio.html
Normal file
23
app/templates/seo/studio.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{% extends "seo/base.html" %}
|
||||
{% block title %}{{ studio.name }} — scenes & videos | Goon{% endblock %}
|
||||
{% block description %}{{ studio.name }} scenes aggregated across multiple sources{% if studio.network %} ({{ studio.network }}){% endif %}. Browse the latest {{ scenes|length }} releases — metadata index by Goon.{% endblock %}
|
||||
|
||||
{% block jsonld %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context":"https://schema.org",
|
||||
"@type":"Organization",
|
||||
"name": {{ studio.name|tojson }},
|
||||
{% if studio.homepage_url %}"sameAs": {{ [studio.homepage_url]|tojson }},{% endif %}
|
||||
"url": {{ canonical|tojson }}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ studio.name }}</h1>
|
||||
<p class="meta">
|
||||
{% if studio.network %}Network: {{ studio.network }} · {% endif %}{{ scenes|length }} scenes indexed
|
||||
</p>
|
||||
{% include "seo/_cards.html" %}
|
||||
{% endblock %}
|
||||
Loading…
Add table
Reference in a new issue