goon/app/api/seo.py
goon-foss 7accd4692f fix(seo): bez prywatnych blacklist i z cache indeksu sitemapy
Blacklisty sa per urzadzenie od 2026-06-10, a SEO dalej wycinalo sume blacklist
wszystkich urzadzen: 465 osob, 4477 tagow, z publicznych stron znikalo ~86% scen.
To samo zapytanie przy liczeniu do /sitemap.xml pisalo 3,7 GB plikow tymczasowych
i przy kilku wejsciach robota naraz zapychalo dysk (GOON-22). Teraz strony SEO
pokazuja to, co katalog nowemu urzadzeniu, a indeks sitemapy liczy sie raz na 6 h
(blokada na rownolegle zadania). Liczenie 135 MB plikow tymczasowych, indeks 13 s,
potem 0,2 s. Wykonawcy 20 tys. -> 81 tys., sceny 3,5 mln.

Na VPS dodatkowo temp_file_limit=2GB dla API w override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:11 +02:00

500 lines
17 KiB
Python

"""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.
* **Bez blacklist.** Blacklisty sa prywatne, per urzadzenie (od 2026-06-10), a strony
SEO sa anonimowe, wiec pokazuja to, co katalog pokazuje nowemu urzadzeniu. Wczesniej
wycinalismy sume blacklist WSZYSTKICH urzadzen: 465 osob ukrylo 4477 tagow i z SEO
znikalo ~86% scen, a liczenie do /sitemap.xml pisalo 3,7 GB plikow tymczasowych
i przy kilku naraz zapychalo dysk (Sentry GOON-22, 2026-09-22).
* **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 threading
import time
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.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 _indexable_scenes():
"""SELECT Scene z żywym źródłem, baza pod listy i sitemap."""
return select(Scene).where(_live_playback_exists())
def _performer_indexable_exists():
"""EXISTS: performer ma ≥1 indeksowalną (żywą) scenę.
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
`performer_page`, inaczej sitemap zgłaszałby URL-e dające 404.
"""
return exists(
select(1)
.select_from(ScenePerformer)
.join(Scene, Scene.id == ScenePerformer.scene_id)
.where(ScenePerformer.performer_id == Performer.id)
.where(_live_playback_exists())
)
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()
# Indeks sitemapy liczy trzy agregaty po calym katalogu, a roboty potrafia zapytac kilka
# razy w tej samej sekundzie (2026-09-04: 5 naraz). Liczymy raz na _SITEMAP_INDEX_TTL
# w procesie; blokada sprawia, ze rownolegle zadania czekaja na jeden wynik.
_SITEMAP_INDEX_TTL = 6 * 3600.0
_sitemap_index_cache: tuple[float, str] | None = None
_sitemap_index_lock = threading.Lock()
@router.get("/sitemap.xml")
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
"""Sitemap index, listuje paginowane pod-sitemapy per typ encji."""
global _sitemap_index_cache
with _sitemap_index_lock:
cached = _sitemap_index_cache
if cached is None or time.monotonic() - cached[0] > _SITEMAP_INDEX_TTL:
cached = (time.monotonic(), _build_sitemap_index(session))
_sitemap_index_cache = cached
return Response(cached[1], media_type="application/xml")
def _build_sitemap_index(session: Session) -> str:
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 "\n".join(parts)
@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")