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:
jtrzupek 2026-05-31 16:29:59 +02:00
parent b942565a6e
commit 9c49a69a66
11 changed files with 835 additions and 0 deletions

3
.gitignore vendored
View file

@ -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
View 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
View 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 indeksuje; overlay zasłania 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")

View 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
View 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>

View 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 &amp; open-source — no account</li>
</ul>
</section>
{% endblock %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}