Compare commits

..

No commits in common. "cf9741b3192f571cce40bd72b6bce36865bbbc44" and "c849c4c8be39ef01084eb4f2fbc36682b6bce727" have entirely different histories.

388 changed files with 3437 additions and 19014 deletions

2
.gitattributes vendored
View file

@ -1,2 +0,0 @@
# Latki patch-package musza miec LF, inaczej nie naloza sie na pliki z npm (autocrlf na Windowsie).
mobile/patches/*.patch text eol=lf

View file

@ -1,47 +0,0 @@
"""phash_backfill_queue: kolejka scen do policzenia phasha z zapisanej miniaturki
Uzasadnienie i liczby: `app/models/phash_backfill_queue.py`.
Revision ID: 0028_phash_backfill_queue
Revises: 0027_scenes_title_trgm
Create Date: 2026-08-13
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0028_phash_backfill_queue"
down_revision: str | None = "0027_scenes_title_trgm"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"phash_backfill_queue",
sa.Column("scene_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("thumbnail_url", sa.String(length=1024), nullable=False),
sa.Column("referer", sa.String(length=255), nullable=True),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("done_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.ForeignKeyConstraint(["scene_id"], ["scenes.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("scene_id"),
)
# Jedyne zapytanie ścieżki gorącej: „weź N nieprzetworzonych". Indeks częściowy,
# bo przetworzone wiersze zostają w tabeli jako ślad, ale nigdy ich nie czytamy.
op.create_index(
"ix_phash_backfill_pending",
"phash_backfill_queue",
["attempts"],
postgresql_where=sa.text("done_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("ix_phash_backfill_pending", table_name="phash_backfill_queue")
op.drop_table("phash_backfill_queue")

View file

@ -1,50 +0,0 @@
"""scenes.max_quality_p: zdenormalizowana najwyzsza znana jakosc zrodel sceny
Po co, skoro filtr dalo sie napisac zapytaniem: NIE dalo sie go napisac szybko.
Zmierzone na produkcji 2026-08-18 (EXPLAIN ANALYZE, prog 1080p):
EXISTS na playback_sources 32 s (3 556 612 odwiedzonych scen)
+ indeks funkcyjny per-scena 24 s
JOIN z DISTINCT po jakosci 1,1 s W IZOLACJI, ale
ten sam JOIN + reszta filtrow 52 s (planer wraca do przechodzenia scen)
Ostatni wiersz jest sednem: w polaczeniu z bramka wertykalow, heurystyka szkieletow
i progiem dlugosci planer i tak wybiera przejscie `scenes` po `created_at` i testowanie
warunku scena po scenie. Kaztaltem zapytania sie tego nie ominie, bo koszt to LICZBA
odwiedzonych scen, a nie pojedynczy test. Skalarna kolumna zdejmuje problem: staje sie
zwyklym filtrem, takim jak `duration_sec`, ktory jest szybki.
NULL = nie znamy jakosci zadnego zywego zrodla (93,5% katalogu: `quality` zapisujemy
oportunistycznie, tylko gdy listing zrodla ja podaje). Filtr celowo takie sceny ODSIEWA,
patrz komentarz przy QUALITY_OPTIONS w mobile/src/screens/ScenesFilterModal.tsx.
Revision ID: 0029_scenes_max_quality
Revises: 0028_phash_backfill_queue
Create Date: 2026-08-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "0029_scenes_max_quality"
down_revision: str | None = "0028_phash_backfill_queue"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("scenes", sa.Column("max_quality_p", sa.SmallInteger(), nullable=True))
# (max_quality_p, created_at DESC): filtr to zakres po pierwszej kolumnie, a sort
# feedu idzie po drugiej. Partial, bo 93,5% scen ma NULL i nie ma po co ich indeksowac.
op.create_index(
"ix_scenes_max_quality_created",
"scenes",
["max_quality_p", sa.text("created_at DESC")],
postgresql_where=sa.text("max_quality_p IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ix_scenes_max_quality_created", table_name="scenes")
op.drop_column("scenes", "max_quality_p")

View file

@ -1,50 +0,0 @@
"""Indeksy czesciowe pod progi filtra jakosci (720/1080/2160)
Dlaczego osobne indeksy na prog, a nie jeden na (max_quality_p, created_at):
tamten nie da sie uzyc do `ORDER BY created_at DESC LIMIT n`, bo wynik nie jest
posortowany po created_at w obrebie ZAKRESU wartosci max_quality_p. Planer go
ignorowal i wracal do przejscia `scenes` po dacie z filtrem.
Indeks czesciowy z predykatem `max_quality_p >= N`, posortowany po created_at DESC,
jest dla progu N dokladnie tym, czego zapytanie potrzebuje: Postgres uzywa go, gdy
warunek zapytania implikuje predykat indeksu. Zmierzone na produkcji 2026-08-18:
0029 (sama kolumna) 2,0-2,6 s planer szedl ix_scenes_created_at_desc
0030 (te indeksy) 0,09-0,11 s Index Scan using ix_scenes_created_q1080
Progi sa ZWIAZANE Z UI: `QUALITY_OPTIONS` w mobile/src/screens/ScenesFilterModal.tsx
oferuje dokladnie 720/1080/2160. Inna wartosc w `?min_quality_p=` nadal dziala, tylko
wolniej (fallback do skanu po dacie). Dodajac opcje w UI, dodaj tu indeks.
Rozmiary sa pomijalne (4,3 MB / 2,7 MB / 0,3 MB), bo jakosc zna tylko 6,5% katalogu.
Revision ID: 0030_scenes_quality_idx
Revises: 0029_scenes_max_quality
Create Date: 2026-08-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "0030_scenes_quality_idx"
down_revision: str | None = "0029_scenes_max_quality"
branch_labels = None
depends_on = None
_THRESHOLDS = (720, 1080, 2160)
def upgrade() -> None:
for q in _THRESHOLDS:
op.create_index(
f"ix_scenes_created_q{q}",
"scenes",
[sa.text("created_at DESC")],
postgresql_where=sa.text(f"max_quality_p >= {q}"),
)
def downgrade() -> None:
for q in _THRESHOLDS:
op.drop_index(f"ix_scenes_created_q{q}", table_name="scenes")

View file

@ -1,55 +0,0 @@
"""duration_backfill_queue: kolejka scen do doliczenia dlugosci ze strony tuba
Uzasadnienie i liczby: `app/models/duration_backfill_queue.py`.
Revision ID: 0031_duration_backfill
Revises: 0030_scenes_quality_idx
Create Date: 2026-08-20
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# Uwaga: `alembic_version.version_num` to varchar(32), dluzsze id nie wejdzie.
revision: str = "0031_duration_backfill"
down_revision: str | None = "0030_scenes_quality_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"duration_backfill_queue",
sa.Column("scene_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("page_url", sa.String(length=1024), nullable=False),
sa.Column("origin", sa.String(length=64), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("done_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["scene_id"], ["scenes.id"], ondelete="CASCADE",
name="fk_duration_backfill_queue_scene_id_scenes",
),
sa.PrimaryKeyConstraint("scene_id", name="pk_duration_backfill_queue"),
)
# Paczka bierze wiersze niezrobione, po originie (zeby nie zawalic jednego hosta).
op.create_index(
"ix_duration_backfill_queue_pending",
"duration_backfill_queue",
["origin", "attempts"],
postgresql_where=sa.text("done_at IS NULL"),
)
def downgrade() -> None:
op.drop_index(
"ix_duration_backfill_queue_pending", table_name="duration_backfill_queue"
)
op.drop_table("duration_backfill_queue")

View file

@ -1,51 +0,0 @@
"""indeksy pod listowanie scen jednego serwisu sortowane po dacie premiery
Zgłoszenie 4cccc70f: „po kliknięciu sxyprn na liście sites sceny ładują się po ok
18 sekundach". Odtworzone co do sekundy: `/scenes?origin=tube:sxyprncom&
sort=release_date` schodziło 19,23 s, a to samo z `sort=created_at` 0,08 s.
Powód: ekran serwisu sortuje po dacie premiery, czyli
`release_date DESC NULLS LAST, created_at DESC, id DESC`. Istniejący
`ix_scenes_release_date` jest ROSNĄCY, więc czytany od tyłu daje NULL-e na
początku, a nie na końcu, i nie obsługuje tego układu ani dwóch kluczy
rozstrzygających. Bez pasującego indeksu planner leciał pełnym skanem `scenes`
(3,9 mln) i `playback_sources` (3,7 mln), sortował całość i dopiero brał 25 wierszy.
Z pasującym indeksem strumieniuje sceny już w docelowej kolejności i zatrzymuje się
po 25 trafieniach. Po założeniu na produkcji: sxyprn 19,23 s → 0,11 s,
hqporner 7,99 s → 0,87 s.
Drugi indeks (`origin`, `scene_id`) obsługuje wyszukanie źródeł jednego serwisu.
Na `playback_sources` nie było ŻADNEGO indeksu po originie.
`IF NOT EXISTS`, bo na produkcji oba założono od razu (CONCURRENTLY, żeby nie
blokować ruchu). Tu chodzi o to, by świeża instalacja miała to samo.
Revision ID: 0032_origin_release_idx
Revises: 0031_duration_backfill
Create Date: 2026-08-20
"""
from __future__ import annotations
from alembic import op
revision: str = "0032_origin_release_idx"
down_revision: str | None = "0031_duration_backfill"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS ix_scenes_release_created_id_desc "
"ON scenes (release_date DESC NULLS LAST, created_at DESC, id DESC)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_playback_sources_origin_live "
"ON playback_sources (origin, scene_id) WHERE dead_at IS NULL"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_playback_sources_origin_live")
op.execute("DROP INDEX IF EXISTS ix_scenes_release_created_id_desc")

View file

@ -1,48 +0,0 @@
"""indeks pod sortowanie po dacie premiery z fallbackiem na date dodania
Zgłoszenie 1df50de3 („hqporner: brak nowych scen"). Sceny były i ingest działał, tylko
tuby daty premiery zwykle NIE podają: xhamster 99,2% scen bez daty, xnxx 97,7%,
porntrex 95,8%, hqporner 93,5%, sxyprn 85,7%. Przy `release_date DESC NULLS LAST`
wszystkie bezdatowe lądowały ZA datowanymi, więc 82 z 91 scen dodanych w hqporner
przez tydzień siedziały za 3,4 tys. starszych pozycji i użytkownik widział pustkę.
Sortujemy więc po dacie premiery, a gdy jej nie ma, po dniu trafienia sceny do
katalogu. Sceny z prawdziwą datą (kanon z TPDB/StashDB) zachowują dotychczasową
kolejność.
Wyrażenie MUSI być immutable, żeby weszło do indeksu. `created_at::date` zależy od
strefy czasowej sesji (stable, nie immutable), stąd jawne `AT TIME ZONE 'UTC'`.
Musi też być IDENTYCZNE z `_effective_date_desc()` w `app/api/scenes.py`, inaczej
planner indeksu nie użyje i wróci do pełnego skanu.
Pomiar po założeniu: hqporner Index Scan bez sortowania, 0,49 s; sxyprn, xhamster
i porntrex po 0,02 s, wszystkie z dzisiejszymi scenami na górze.
`IF NOT EXISTS`, bo na produkcji założony od razu (CONCURRENTLY, bez blokowania ruchu).
Revision ID: 0033_effective_date_idx
Revises: 0032_origin_release_idx
Create Date: 2026-08-20
"""
from __future__ import annotations
from alembic import op
revision: str = "0033_effective_date_idx"
down_revision: str | None = "0032_origin_release_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS ix_scenes_effective_date_desc ON scenes ("
" (COALESCE(release_date, (created_at AT TIME ZONE 'UTC')::date)) DESC,"
" created_at DESC,"
" id DESC"
")"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_scenes_effective_date_desc")

View file

@ -1,34 +0,0 @@
"""ulubione strony: przypinanie zrodel na gorze zakladki Sites
Prosba z apki 2026-09-02. Tabela jak favorite_studios, kluczowana po device_id, wiec
kod synchronizacji (= device_id) przenosi ja na inne urzadzenie razem z reszta.
Origin bez klucza obcego: zrodla nie maja tabeli kluczowanej po origin.
Revision ID: 0034_favorite_sites
Revises: 0033_effective_date_idx
Create Date: 2026-09-02
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0034_favorite_sites"
down_revision = "0033_effective_date_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"favorite_sites",
sa.Column("device_id", sa.String(64), primary_key=True),
sa.Column("origin", sa.String(64), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
def downgrade() -> None:
op.drop_table("favorite_sites")

View file

@ -1,35 +0,0 @@
"""Indeks pod sortowanie scen po dlugosci (najdluzsze / najkrotsze).
Jeden indeks (duration_sec, created_at, id) WHERE duration_sec IS NOT NULL obsluguje
oba kierunki: skan w przod daje najkrotsze, w tyl najdluzsze. Sceny bez dlugosci sa
odfiltrowane w zapytaniu, wiec kolejnosc NULL-i nie gra roli i wystarczy jeden indeks.
CONCURRENTLY w autocommit_block, jak w 0027: zwykly CREATE INDEX blokowalby zapisy
ingestu na czas budowy (4,1 mln wierszy). Nieudany CONCURRENTLY zostawia indeks
INVALID; wtedy DROP INDEX i ponownie.
Revision ID: 0035_scenes_duration_idx
Revises: 0034_favorite_sites
Create Date: 2026-09-14
"""
from __future__ import annotations
from alembic import op
revision = "0035_scenes_duration_idx"
down_revision: str | None = "0034_favorite_sites"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scenes_duration "
"ON scenes (duration_sec, created_at, id) WHERE duration_sec IS NOT NULL"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scenes_duration")

View file

@ -1,70 +0,0 @@
"""Klipy do feedu Swipe: tabele `clips` i `clip_tags`.
Osobny wertykał (krótkie pionowe wideo twórców z RedGifs / Pornhub Shorties), nie sceny.
Uzasadnienie w docstringu `app/models/clip.py`.
Revision ID: 0036_clips
Revises: 0035_scenes_duration_idx
Create Date: 2026-09-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0036_clips"
down_revision: str | None = "0035_scenes_duration_idx"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"clips",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("source", sa.String(16), nullable=False),
sa.Column("source_id", sa.String(64), nullable=False),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("creator", sa.String(128), nullable=True),
sa.Column("creator_verified", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("duration_sec", sa.Float(), nullable=True),
sa.Column("width", sa.Integer(), nullable=True),
sa.Column("height", sa.Integer(), nullable=True),
sa.Column("has_audio", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("content_type", sa.String(32), nullable=True),
sa.Column("niches", postgresql.ARRAY(sa.String(64)), nullable=False, server_default="{}"),
sa.Column("hd_url", sa.Text(), nullable=True),
sa.Column("sd_url", sa.Text(), nullable=True),
sa.Column("poster_url", sa.Text(), nullable=True),
sa.Column("thumb_url", sa.Text(), nullable=True),
sa.Column("hls", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("likes", sa.Integer(), nullable=False, server_default="0"),
sa.Column("views", sa.Integer(), nullable=False, server_default="0"),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("dead_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("dead_reason", sa.String(128), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_clips"),
sa.UniqueConstraint("source", "source_id", name="uq_clips_source_source_id"),
)
op.create_index("ix_clips_published_at", "clips", ["published_at"])
op.create_index("ix_clips_creator", "clips", ["creator"])
op.create_table(
"clip_tags",
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("tag", sa.String(64), nullable=False),
sa.ForeignKeyConstraint(["clip_id"], ["clips.id"], name="fk_clip_tags_clip_id_clips", ondelete="CASCADE"),
sa.PrimaryKeyConstraint("clip_id", "tag", name="pk_clip_tags"),
)
op.create_index("ix_clip_tags_tag_clip", "clip_tags", ["tag", "clip_id"])
def downgrade() -> None:
op.drop_index("ix_clip_tags_tag_clip", table_name="clip_tags")
op.drop_table("clip_tags")
op.drop_index("ix_clips_creator", table_name="clips")
op.drop_index("ix_clips_published_at", table_name="clips")
op.drop_table("clips")

View file

@ -1,86 +0,0 @@
"""Feed Swipe, stan per urządzenie: clip_seen, clip_feed_events, clip_affinity,
clip_favorites, clip_blacklist. Plus GIN na clips.niches pod skan po niszy.
Uzasadnienie w docstringu `app/models/clip_feed.py`.
Revision ID: 0037_clip_feed
Revises: 0036_clips
Create Date: 2026-09-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0037_clip_feed"
down_revision: str | None = "0036_clips"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"clip_seen",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("outcome", sa.String(16), nullable=False, server_default="served"),
sa.Column("seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["clip_id"], ["clips.id"], name="fk_clip_seen_clip_id_clips", ondelete="CASCADE"),
sa.PrimaryKeyConstraint("device_id", "clip_id", name="pk_clip_seen"),
)
op.create_table(
"clip_feed_events",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("session_id", sa.String(32), nullable=True),
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("kind", sa.String(16), nullable=False),
sa.Column("dwell_ms", sa.Integer(), nullable=True),
sa.Column("reason", sa.String(160), nullable=True),
sa.Column("slot", sa.String(12), nullable=True),
sa.Column("position", sa.SmallInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id", name="pk_clip_feed_events"),
)
op.create_index("ix_clip_feed_events_device_created", "clip_feed_events", ["device_id", "created_at"])
op.create_index("ix_clip_feed_events_created", "clip_feed_events", ["created_at"])
op.create_table(
"clip_affinity",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("tag_weights", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("niche_weights", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("creator_weights", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("events_seen", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("device_id", name="pk_clip_affinity"),
)
op.create_table(
"clip_favorites",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("clip_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["clip_id"], ["clips.id"], name="fk_clip_favorites_clip_id_clips", ondelete="CASCADE"),
sa.PrimaryKeyConstraint("device_id", "clip_id", name="pk_clip_favorites"),
)
op.create_table(
"clip_blacklist",
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("kind", sa.String(8), nullable=False),
sa.Column("value", sa.String(128), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("device_id", "kind", "value", name="pk_clip_blacklist"),
)
# Skan kandydatów po niszy (`niches && ARRAY[...]`) bez pełnego przebiegu tabeli.
op.execute("CREATE INDEX IF NOT EXISTS ix_clips_niches_gin ON clips USING gin (niches)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_clips_niches_gin")
op.drop_table("clip_blacklist")
op.drop_table("clip_favorites")
op.drop_table("clip_affinity")
op.drop_index("ix_clip_feed_events_created", table_name="clip_feed_events")
op.drop_index("ix_clip_feed_events_device_created", table_name="clip_feed_events")
op.drop_table("clip_feed_events")
op.drop_table("clip_seen")

View file

@ -1,37 +0,0 @@
"""Indeks pod liste scen studia i licznik "+N" ulubionych studiow.
Oba sortuja po dacie premiery albo, gdy jej nie ma, dniu trafienia do katalogu (to samo
wyrazenie co ix_scenes_effective_date_desc z 0033), w obrebie jednego studia. Bez tego
Postgres czytal WSZYSTKIE sceny studia (Brazzers ma ich dziesiatki tysiecy), sprawdzal
kazdej playback i sortowal, a licznik dla 209 ulubionych studiow trwal 10-25 s. Z tym
indeksem idzie po kolei i konczy po 200 widocznych scenach na studio.
CONCURRENTLY w autocommit_block, jak w 0035. Nieudany CONCURRENTLY zostawia indeks
INVALID; wtedy DROP INDEX i ponownie.
Revision ID: 0038_scenes_studio_effdate_idx
Revises: 0037_clip_feed
Create Date: 2026-09-15
"""
from __future__ import annotations
from alembic import op
revision = "0038_scenes_studio_effdate_idx"
down_revision: str | None = "0037_clip_feed"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scenes_studio_effective_date_desc "
"ON scenes (studio_id, COALESCE(release_date, (created_at AT TIME ZONE 'UTC')::date) DESC, "
"created_at DESC, id DESC) WHERE studio_id IS NOT NULL"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scenes_studio_effective_date_desc")

View file

@ -30,7 +30,7 @@ router = APIRouter(
def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
"""Wyciąga thumbnail URL z external_records.raw dla danego źródła.
TPDB ma `image`/`poster`/`background.large`. StashDB raw nie zawiera image
(osobny query do StashDB potrzebny, tu zwracamy None)."""
(osobny query do StashDB potrzebny — tu zwracamy None)."""
if kind == SourceKind.tpdb:
for k in ("image", "poster"):
v = raw.get(k)
@ -42,7 +42,7 @@ def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
if isinstance(v, str) and v.startswith("http"):
return v
elif kind == SourceKind.stashdb:
# StashDB scene response includes images via separate query, nie trzymamy
# StashDB scene response includes images via separate query — nie trzymamy
# tego w raw obecnie. TODO: dorzucić mirror do `paths.screenshot` przy ingest.
paths = raw.get("paths")
if isinstance(paths, dict):
@ -142,7 +142,7 @@ def list_candidates(
# Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue
# używa statycznego do listy + animowanego po hold-to-preview). Wybieramy najpierw
# napotkany niepusty URL, kolejność rzędów playback_sources nie jest gwarantowana,
# napotkany niepusty URL — kolejność rzędów playback_sources nie jest gwarantowana,
# ale dla triage to wystarcza.
thumbs: dict[uuid.UUID, str] = {}
animated_thumbs: dict[uuid.UUID, str] = {}
@ -292,7 +292,7 @@ def _fmt_bytes(b: int) -> str:
def bandwidth_stats() -> BandwidthStats:
"""Per-CDN VPS proxy bytes-out + Hetzner traffic stats.
Critical dla public release, pokazuje gdzie VPS bandwidth wycieka. Pozwala
Critical dla public release — pokazuje gdzie VPS bandwidth wycieka. Pozwala
spotted Mixdrop / bandwidth-heavy CDN-y przed Hetzner overage charge.
"""
from app.api.stream_proxy import get_bandwidth_stats
@ -308,7 +308,7 @@ def bandwidth_stats() -> BandwidthStats:
s_24h = get_bandwidth_stats(24)
s_7d = get_bandwidth_stats(168)
# Hetzner stats, load from cache file (written by check_hetzner_traffic.py cron).
# Hetzner stats — load from cache file (written by check_hetzner_traffic.py cron).
hetzner_data = None
settings = get_settings()
if settings.hetzner_api_token and settings.hetzner_server_id:

View file

@ -1,9 +1,9 @@
"""htmx + Jinja2 admin UI dla MergeCandidate triage.
Endpointy:
GET /ui/ lista pending (filter status)
GET /ui/candidate/{id} side-by-side scen
POST /ui/candidate/{id}/resolve, htmx form submit (action=merge_keep_left|merge_keep_right|reject)
GET /ui/ — lista pending (filter status)
GET /ui/candidate/{id} — side-by-side scen
POST /ui/candidate/{id}/resolve — htmx form submit (action=merge_keep_left|merge_keep_right|reject)
zwraca fragment HTML z potwierdzeniem
"""
from __future__ import annotations
@ -197,9 +197,9 @@ def resolve_form(
def mount_static(app) -> None: # pragma: no cover - dev convenience
# APK MIME type, bez tego Android Browser nie traktuje pliku jako instalable APK
# APK MIME type — bez tego Android Browser nie traktuje pliku jako instalable APK
# (text/plain → "Plik został pobrany" zamiast prompta install). Rejestracja jest
# idempotentna na poziomie procesu, bezpiecznie wywoływać przy każdym startup.
# idempotentna na poziomie procesu — bezpiecznie wywoływać przy każdym startup.
import mimetypes
mimetypes.add_type("application/vnd.android.package-archive", ".apk")
if _STATIC_DIR.exists():

View file

@ -1,12 +1,12 @@
"""Blacklists, globalnie ukryte performerki/studia/tagi.
"""Blacklists — globalnie ukryte performerki/studia/tagi.
Sceny które MAJĄ blacklisted entity wypadają z każdego /scenes (pełna lista, search,
performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`.
Endpointy:
GET /blacklist wszystkie 3 listy w jednym response
POST /blacklist/{kind}/{entity_id} dodaj (idempotent)
DELETE /blacklist/{kind}/{entity_id} usuń
GET /blacklist — wszystkie 3 listy w jednym response
POST /blacklist/{kind}/{entity_id} — dodaj (idempotent)
DELETE /blacklist/{kind}/{entity_id} — usuń
`kind` ∈ {performer, studio, tag}.
"""

View file

@ -1,10 +1,10 @@
"""Bug reports, mobile FAB → POST /bug-reports → admin lista przez admin_html.
"""Bug reports — mobile FAB → POST /bug-reports → admin lista przez admin_html.
POST nie wymaga obecnego scene_id (user może raportować z FavoritesScreen,
SearchScreen itp.). Screenshot opcjonalny, niektóre ekrany nie warto kapturować.
SearchScreen itp.). Screenshot opcjonalny — niektóre ekrany nie warto kapturować.
Limit body 1.5MB (FastAPI default jest hojny, ale dla rozsądku ograniczamy).
Screenshot to PNG/JPEG z react-native-view-shot, base64, typowe rozmiary:
Screenshot to PNG/JPEG z react-native-view-shot, base64 — typowe rozmiary:
- mały ekran scene-list: ~200-400KB
- duży scene-detail z thumbnail: ~600KB-1MB
"""
@ -92,7 +92,7 @@ def create_bug_report(
scene_id = payload.scene_id
elif session.get(Movie, payload.scene_id) is not None:
movie_id = payload.scene_id
# else: ID nie istnieje już nigdzie (deleted), drop oba na null
# else: ID nie istnieje już nigdzie (deleted) — drop oba na null
br = BugReport(
id=uuid.uuid4(),
@ -211,7 +211,7 @@ def get_bug_report_screenshot(
bug_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> dict[str, str | None]:
"""Zwraca base64-encoded screenshot (jeśli jest), admin UI go renderuje."""
"""Zwraca base64-encoded screenshot (jeśli jest) — admin UI go renderuje."""
br = session.get(BugReport, bug_id)
if br is None:
raise HTTPException(status_code=404, detail="not found")

View file

@ -1,664 +0,0 @@
"""Klipy (feed Swipe): feed z afinicznością, pamięć widzianych, telemetria, ulubione,
blacklista, onboarding. Logika bez bazy w `app/clips_feed.py`.
**Feed** `GET /clips/feed`: strona = 80 % z puli afiniczności (tagi, nisze, twórcy z wag
urządzenia) + 20 % explore (okno po kluczu głównym od kursora, losowanie ważone lajkami).
Bez wag (nowe urządzenie, brak onboardingu) 100 % explore. Każdy podany klip ląduje
w `clip_seen` od razu, więc nigdy nie wraca. Widoczność: żywy, do `clips_feed_max_duration_sec`,
z plikiem, nie widziany, nie na blackliście (tag / nisza / twórca).
**Zdarzenia** `POST /clips/events`: paczka z apki (co ~5 s). Zapis do `clip_feed_events`,
podniesienie `clip_seen.outcome`, ulubione przy like/unlike, delty na wagi afiniczności
(`clips_feed.apply_event`).
Adresy plików i posterów są publiczne (`media.redgifs.com`), więc jadą do apki wprost,
bez image proxy: RedGifs nie sprawdza Referera, a proxy tylko dodałoby VPS do toru.
"""
from __future__ import annotations
import logging
import time
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, Field
from sqlalchemy import String, cast, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app import clips_feed as cf
from app.api.device import get_device_id
from app.auth import require_api_key
from app.config import get_settings
from app.db import get_session
from app.models.clip import Clip, ClipTag
from app.models.clip_feed import ClipAffinity, ClipBlacklist, ClipFavorite, ClipFeedEvent, ClipSeen
log = logging.getLogger(__name__)
router = APIRouter(prefix="/clips", tags=["clips"], dependencies=[Depends(require_api_key)])
_EXPLORE_WINDOW = 2000
_AFFINITY_TAGS, _AFFINITY_NICHES, _AFFINITY_CREATORS = 6, 3, 3
_PER_TAG, _PER_NICHE, _PER_CREATOR = 60, 60, 40
_UUID_ZERO = uuid.UUID(int=0)
_BLACKLIST_KINDS = frozenset({"tag", "niche", "creator"})
# ---- schematy ---------------------------------------------------------------------------
class ClipOut(BaseModel):
id: uuid.UUID
source: str
source_id: str
title: str | None
creator: str | None
creator_verified: bool
duration_sec: float | None
width: int | None
height: int | None
has_audio: bool
content_type: str | None
niches: list[str]
tags: list[str]
hd_url: str | None
sd_url: str | None
poster_url: str | None
thumb_url: str | None
likes: int
views: int
published_at: datetime | None
is_favorite: bool = False
class ClipListOut(BaseModel):
items: list[ClipOut]
limit: int
offset: int
has_more: bool
class ClipStatsOut(BaseModel):
total: int
alive: int
dead: int
with_audio: int
verified: int
by_source: dict[str, int]
newest_published_at: datetime | None
tags: int
class FeedItemOut(BaseModel):
clip: ClipOut
#: `affinity` | `explore`, do telemetrii i do pomiaru, czy personalizacja coś daje.
slot: str
is_fresh: bool
class FeedPageOut(BaseModel):
items: list[FeedItemOut]
next_cursor: str
seed: str
#: True gdy okno explore doszło do końca katalogu i zawinęło się na początek.
wrapped: bool
#: Ile klipów to urządzenie już widziało (pigułka „N nowych").
served_total: int
personalized: bool
class FeedEventIn(BaseModel):
clip_id: uuid.UUID
kind: str = Field(min_length=1, max_length=16)
dwell_ms: int | None = Field(default=None, ge=0, le=3_600_000)
reason: str | None = Field(default=None, max_length=160)
slot: str | None = Field(default=None, max_length=12)
position: int | None = Field(default=None, ge=0, le=32767)
class FeedEventsIn(BaseModel):
session_id: str | None = Field(default=None, max_length=32)
events: list[FeedEventIn] = Field(max_length=100)
class OnboardingIn(BaseModel):
tags: list[str] = Field(default_factory=list, max_length=20)
niches: list[str] = Field(default_factory=list, max_length=20)
class OnboardingOptionOut(BaseModel):
value: str
count: int
class OnboardingOptionsOut(BaseModel):
tags: list[OnboardingOptionOut]
niches: list[OnboardingOptionOut]
class AffinityOut(BaseModel):
tags: dict[str, float]
niches: dict[str, float]
creators: dict[str, float]
events_seen: int
class BlacklistItemOut(BaseModel):
kind: str
value: str
# ---- pomocnicze -------------------------------------------------------------------------
def _tags_for(session: Session, clip_ids: list[uuid.UUID]) -> dict[uuid.UUID, list[str]]:
if not clip_ids:
return {}
out: dict[uuid.UUID, list[str]] = {}
for cid, tag in session.execute(
select(ClipTag.clip_id, ClipTag.tag).where(ClipTag.clip_id.in_(clip_ids)).order_by(ClipTag.tag)
):
out.setdefault(cid, []).append(tag)
return out
def _favorites_for(session: Session, device_id: str, clip_ids: list[uuid.UUID]) -> set[uuid.UUID]:
if not clip_ids:
return set()
return {
r[0]
for r in session.execute(
select(ClipFavorite.clip_id).where(ClipFavorite.device_id == device_id, ClipFavorite.clip_id.in_(clip_ids))
)
}
def _clip_out(r: Clip, tags: list[str], fav: bool = False) -> ClipOut:
return ClipOut(
id=r.id,
source=r.source,
source_id=r.source_id,
title=r.title,
creator=r.creator,
creator_verified=r.creator_verified,
duration_sec=r.duration_sec,
width=r.width,
height=r.height,
has_audio=r.has_audio,
content_type=r.content_type,
niches=list(r.niches or []),
tags=tags,
hd_url=r.hd_url,
sd_url=r.sd_url,
poster_url=r.poster_url,
thumb_url=r.thumb_url,
likes=r.likes,
views=r.views,
published_at=r.published_at,
is_favorite=fav,
)
def _load_affinity(session: Session, device_id: str) -> tuple[ClipAffinity | None, cf.Affinity]:
row = session.get(ClipAffinity, device_id)
if row is None:
return None, cf.Affinity()
return row, cf.Affinity(
tags=dict(row.tag_weights or {}), niches=dict(row.niche_weights or {}), creators=dict(row.creator_weights or {})
)
def _save_affinity(session: Session, device_id: str, row: ClipAffinity | None, aff: cf.Affinity, events: int) -> None:
if row is None:
row = ClipAffinity(device_id=device_id)
session.add(row)
row.tag_weights = aff.tags
row.niche_weights = aff.niches
row.creator_weights = aff.creators
row.events_seen = (row.events_seen or 0) + events
def _blacklist(session: Session, device_id: str) -> dict[str, set[str]]:
out: dict[str, set[str]] = {"tag": set(), "niche": set(), "creator": set()}
for kind, value in session.execute(
select(ClipBlacklist.kind, ClipBlacklist.value).where(ClipBlacklist.device_id == device_id)
):
out.setdefault(kind, set()).add(value)
return out
def _visible_clauses(device_id: str, bl: dict[str, set[str]]) -> list:
clauses = [
Clip.dead_at.is_(None),
Clip.duration_sec.is_not(None),
Clip.duration_sec <= get_settings().clips_feed_max_duration_sec,
(Clip.hd_url.is_not(None)) | (Clip.sd_url.is_not(None)),
~select(ClipSeen.clip_id)
.where(ClipSeen.device_id == device_id, ClipSeen.clip_id == Clip.id)
.exists(),
]
if bl["creator"]:
clauses.append((Clip.creator.is_(None)) | (~Clip.creator.in_(list(bl["creator"]))))
if bl["niche"]:
clauses.append(~Clip.niches.overlap(list(bl["niche"])))
if bl["tag"]:
clauses.append(
~select(ClipTag.clip_id)
.where(ClipTag.clip_id == Clip.id, ClipTag.tag.in_(list(bl["tag"])))
.exists()
)
return clauses
_CAND_COLS = (Clip.id, Clip.creator, Clip.likes, Clip.published_at, Clip.niches)
def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.Candidate]:
"""Wiersze `_CAND_COLS` → Candidate. Tagi tylko na życzenie: pula explore punktuje
wyłącznie lajkami, więc ciągnięcie tagów dla 300 kandydatów było czystym kosztem."""
tags = _tags_for(session, [r[0] for r in rows]) if with_tags else {}
return [
cf.Candidate(id=r[0], creator=r[1], likes=r[2], published_at=r[3], tags=tags.get(r[0], []), niches=list(r[4] or []))
for r in rows
]
def _candidates(session: Session, ids: list[uuid.UUID], clauses: list) -> list[cf.Candidate]:
if not ids:
return []
rows = session.execute(select(*_CAND_COLS).where(Clip.id.in_(ids), *clauses)).all()
return _rows_to_candidates(session, rows, with_tags=True)
def _explore_candidates(
session: Session, pk: uuid.UUID, clauses: list, limit: int
) -> tuple[list[cf.Candidate], uuid.UUID, bool]:
"""Widoczni kandydaci z okna `_EXPLORE_WINDOW` id po `pk` (podzapytanie, bez
przenoszenia 2000 uuid przez SQL). Zwraca (kandydaci, koniec okna, czy okno było
ostatnie). Ostatnie okno = katalog wyczerpany od `pk`, następna strona zawija do zera."""
window = (
select(Clip.id).where(Clip.id > pk, Clip.dead_at.is_(None)).order_by(Clip.id).limit(_EXPLORE_WINDOW)
).subquery()
# Postgres nie ma max(uuid); tekstowo porządek jest ten sam (małe hex, stały format).
end_txt, n = session.execute(
select(func.max(cast(window.c.id, String)), func.count()).select_from(window)
).one()
if not n:
return [], _UUID_ZERO, True
end_id = uuid.UUID(end_txt)
rows = session.execute(
select(*_CAND_COLS).where(Clip.id.in_(select(window.c.id)), *clauses).order_by(Clip.id).limit(limit)
).all()
last = n < _EXPLORE_WINDOW
return _rows_to_candidates(session, rows, with_tags=False), (end_id if not last else _UUID_ZERO), last
def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]:
ids: set[uuid.UUID] = set()
for tag in aff.top("tags", _AFFINITY_TAGS):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(ClipTag.clip_id).where(ClipTag.tag == tag, ClipTag.clip_id > start).order_by(ClipTag.clip_id).limit(_PER_TAG)
).all()
if len(rows) < _PER_TAG:
rows += session.execute(
select(ClipTag.clip_id).where(ClipTag.tag == tag).order_by(ClipTag.clip_id).limit(_PER_TAG - len(rows))
).all()
ids.update(r[0] for r in rows)
for niche in aff.top("niches", _AFFINITY_NICHES):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(Clip.id).where(Clip.niches.overlap([niche]), Clip.id > start).order_by(Clip.id).limit(_PER_NICHE)
).all()
ids.update(r[0] for r in rows)
for creator in aff.top("creators", _AFFINITY_CREATORS):
start = uuid.UUID(int=rng.getrandbits(128))
rows = session.execute(
select(Clip.id).where(Clip.creator == creator, Clip.id > start).order_by(Clip.id).limit(_PER_CREATOR)
).all()
if len(rows) < _PER_CREATOR:
rows += session.execute(
select(Clip.id).where(Clip.creator == creator).order_by(Clip.id).limit(_PER_CREATOR - len(rows))
).all()
ids.update(r[0] for r in rows)
return ids
# ---- feed -------------------------------------------------------------------------------
@router.get("/feed", response_model=FeedPageOut)
def clip_feed(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
seed: str | None = Query(default=None, max_length=16),
cursor: str | None = Query(default=None, max_length=200),
limit: int = Query(default=10, ge=1, le=30),
) -> FeedPageOut:
t0 = time.monotonic()
decoded = cf.decode_cursor(cursor) if cursor else None
if decoded:
seed, pk, page = decoded
else:
seed = (seed or cf.new_seed())[:16]
pk, page = cf.seed_start_uuid(seed), 0
rng = cf.page_rng(seed, page)
_row, aff = _load_affinity(session, device_id)
personalized = not aff.is_empty()
bl = _blacklist(session, device_id)
clauses = _visible_clauses(device_id, bl)
n_aff, n_explore = cf.split_counts(limit, personalized)
picked_aff: list[cf.Candidate] = []
if n_aff:
cand_ids = _affinity_candidate_ids(session, aff, rng)
picked_aff = cf.pick_affinity(_candidates(session, list(cand_ids), clauses), aff, n_aff)
taken = {c.id for c in picked_aff}
# Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna).
# Braki z afinicznosci (za malo kandydatow po filtrach) dopelnia explore.
picked_explore: list[cf.Candidate] = []
wrapped = False
next_pk = pk
want = n_explore + max(0, n_aff - len(picked_aff))
for _ in range(3):
pool, next_pk, last = _explore_candidates(session, next_pk, clauses, limit=max(60, want * 15))
pool = [c for c in pool if c.id not in taken]
picked_explore += cf.pick_explore(pool, want - len(picked_explore), rng)
taken.update(c.id for c in picked_explore)
if last:
wrapped = True
if len(picked_explore) >= want or last:
break
page_items = cf.compose_page(picked_aff, picked_explore, rng)
clip_ids = [c.id for c, _ in page_items]
if clip_ids:
session.execute(
pg_insert(ClipSeen)
.values([{"device_id": device_id, "clip_id": cid, "outcome": "served"} for cid in clip_ids])
.on_conflict_do_nothing()
)
rows = {r.id: r for r in session.execute(select(Clip).where(Clip.id.in_(clip_ids))).scalars()} if clip_ids else {}
tags = _tags_for(session, clip_ids)
favs = _favorites_for(session, device_id, clip_ids)
served_total = session.execute(
select(func.count()).select_from(ClipSeen).where(ClipSeen.device_id == device_id)
).scalar_one()
session.commit()
items = [
FeedItemOut(
clip=_clip_out(rows[c.id], tags.get(c.id, []), c.id in favs),
slot=slot,
is_fresh=cf.is_fresh(rows[c.id].published_at),
)
for c, slot in page_items
if c.id in rows
]
log.info(
"clips/feed: device=%s page=%d served=%d aff=%d explore=%d wrapped=%s ms=%d",
device_id[:8], page, len(items), len(picked_aff), len(picked_explore), wrapped,
int((time.monotonic() - t0) * 1000),
)
return FeedPageOut(
items=items,
next_cursor=cf.encode_cursor(seed, next_pk, page + 1),
seed=seed,
wrapped=wrapped,
served_total=int(served_total),
personalized=personalized,
)
# ---- zdarzenia --------------------------------------------------------------------------
@router.post("/events", status_code=204)
def clip_events(
body: FeedEventsIn,
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
) -> Response:
events = [e for e in body.events if e.kind in cf.ALLOWED_KINDS]
if not events:
return Response(status_code=204)
clip_ids = list({e.clip_id for e in events})
clips = {r.id: r for r in session.execute(select(Clip).where(Clip.id.in_(clip_ids))).scalars()}
tags = _tags_for(session, clip_ids)
seen = {
r.clip_id: r
for r in session.execute(
select(ClipSeen).where(ClipSeen.device_id == device_id, ClipSeen.clip_id.in_(clip_ids))
).scalars()
}
row, aff = _load_affinity(session, device_id)
changed = 0
for e in events:
session.add(
ClipFeedEvent(
device_id=device_id,
session_id=body.session_id,
clip_id=e.clip_id,
kind=e.kind,
dwell_ms=e.dwell_ms,
reason=e.reason,
slot=e.slot,
position=e.position,
)
)
clip = clips.get(e.clip_id)
if clip is None:
continue
# clip_seen: podnies wynik, nigdy nie cofaj.
outcome = e.kind if e.kind in cf.OUTCOME_RANK else None
if outcome:
s = seen.get(e.clip_id)
if s is None:
s = ClipSeen(device_id=device_id, clip_id=e.clip_id, outcome=outcome)
session.add(s)
seen[e.clip_id] = s
elif cf.OUTCOME_RANK[outcome] > cf.OUTCOME_RANK.get(s.outcome, 0):
s.outcome = outcome
if e.kind == "like":
session.execute(
pg_insert(ClipFavorite).values(device_id=device_id, clip_id=e.clip_id).on_conflict_do_nothing()
)
elif e.kind == "unlike":
session.execute(
text("DELETE FROM clip_favorites WHERE device_id = :d AND clip_id = :c"),
{"d": device_id, "c": e.clip_id},
)
if cf.apply_event(
aff,
kind=e.kind,
dwell_ms=e.dwell_ms,
reason=e.reason,
tags=tags.get(e.clip_id, []),
niches=list(clip.niches or []),
creator=clip.creator,
):
changed += 1
if changed:
_save_affinity(session, device_id, row, aff, changed)
session.commit()
return Response(status_code=204)
# ---- onboarding, afinicznosc ------------------------------------------------------------
_OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {}
@router.get("/onboarding-options", response_model=OnboardingOptionsOut)
def onboarding_options(session: Session = Depends(get_session)) -> OnboardingOptionsOut:
"""Najpopularniejsze tagi i nisze do wyboru „3 tapów" przy pierwszym wejsciu.
Liczone z bazy, cache 10 min w procesie."""
hit = _OPTIONS_CACHE.get("v1")
if hit and time.monotonic() - hit[0] < 600:
return hit[1]
tags = [
OnboardingOptionOut(value=t, count=n)
for t, n in session.execute(
select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(40)
)
]
niches = [
OnboardingOptionOut(value=v, count=n)
for v, n in session.execute(
text("SELECT n, count(*) FROM clips, unnest(niches) n WHERE dead_at IS NULL GROUP BY n ORDER BY 2 DESC LIMIT 30")
)
]
out = OnboardingOptionsOut(tags=tags, niches=niches)
_OPTIONS_CACHE["v1"] = (time.monotonic(), out)
return out
@router.post("/onboarding", response_model=AffinityOut)
def onboarding(
body: OnboardingIn,
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
) -> AffinityOut:
row, aff = _load_affinity(session, device_id)
for t in body.tags:
t = t.strip().lower()[:64]
if t:
aff.tags[t] = round(aff.tags.get(t, 0.0) + 5.0, 3)
for n in body.niches:
n = n.strip()[:64]
if n:
aff.niches[n] = round(aff.niches.get(n, 0.0) + 5.0, 3)
_save_affinity(session, device_id, row, aff, 0)
session.commit()
row2 = session.get(ClipAffinity, device_id)
return AffinityOut(tags=aff.tags, niches=aff.niches, creators=aff.creators, events_seen=row2.events_seen if row2 else 0)
@router.get("/affinity", response_model=AffinityOut)
def get_affinity(session: Session = Depends(get_session), device_id: str = Depends(get_device_id)) -> AffinityOut:
row, aff = _load_affinity(session, device_id)
return AffinityOut(tags=aff.tags, niches=aff.niches, creators=aff.creators, events_seen=row.events_seen if row else 0)
@router.delete("/affinity", status_code=204)
def reset_affinity(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
seen: bool = Query(default=False, description="True = zapomnij tez widziane klipy (feed od zera)"),
) -> Response:
"""„Resetuj moj feed": kasuje wagi; z `seen=true` takze pamiec widzianych."""
session.execute(text("DELETE FROM clip_affinity WHERE device_id = :d"), {"d": device_id})
if seen:
session.execute(text("DELETE FROM clip_seen WHERE device_id = :d"), {"d": device_id})
session.commit()
return Response(status_code=204)
# ---- ulubione, blacklista ----------------------------------------------------------------
@router.get("/favorites", response_model=ClipListOut)
def clip_favorites(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ClipListOut:
rows = session.execute(
select(Clip)
.join(ClipFavorite, ClipFavorite.clip_id == Clip.id)
.where(ClipFavorite.device_id == device_id)
.order_by(ClipFavorite.created_at.desc())
.offset(offset)
.limit(limit + 1)
).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
tags = _tags_for(session, [r.id for r in rows])
return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), True) for r in rows], limit=limit, offset=offset, has_more=has_more
)
@router.get("/blacklist", response_model=list[BlacklistItemOut])
def clip_blacklist(session: Session = Depends(get_session), device_id: str = Depends(get_device_id)) -> list[BlacklistItemOut]:
return [
BlacklistItemOut(kind=k, value=v)
for k, v in session.execute(
select(ClipBlacklist.kind, ClipBlacklist.value).where(ClipBlacklist.device_id == device_id).order_by(ClipBlacklist.kind, ClipBlacklist.value)
)
]
@router.post("/blacklist/{kind}/{value}", status_code=204)
def clip_blacklist_add(
kind: str, value: str, session: Session = Depends(get_session), device_id: str = Depends(get_device_id)
) -> Response:
if kind not in _BLACKLIST_KINDS or not value.strip():
raise HTTPException(status_code=400, detail="kind must be tag|niche|creator")
session.execute(
pg_insert(ClipBlacklist).values(device_id=device_id, kind=kind, value=value.strip()[:128]).on_conflict_do_nothing()
)
session.commit()
return Response(status_code=204)
@router.delete("/blacklist/{kind}/{value}", status_code=204)
def clip_blacklist_remove(
kind: str, value: str, session: Session = Depends(get_session), device_id: str = Depends(get_device_id)
) -> Response:
session.execute(
text("DELETE FROM clip_blacklist WHERE device_id = :d AND kind = :k AND value = :v"),
{"d": device_id, "k": kind, "v": value.strip()[:128]},
)
session.commit()
return Response(status_code=204)
# ---- listing i statystyki (pilot) --------------------------------------------------------
@router.get("", response_model=ClipListOut)
def list_clips(
session: Session = Depends(get_session),
device_id: str = Depends(get_device_id),
tag: str | None = Query(default=None, max_length=64),
source: str | None = Query(default=None, max_length=16),
creator: str | None = Query(default=None, max_length=128),
has_audio: bool | None = None,
max_duration_sec: float | None = Query(default=None, gt=0),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ClipListOut:
q = select(Clip).where(Clip.dead_at.is_(None))
if tag:
q = q.where(Clip.id.in_(select(ClipTag.clip_id).where(ClipTag.tag == tag)))
if source:
q = q.where(Clip.source == source)
if creator:
q = q.where(Clip.creator == creator)
if has_audio is not None:
q = q.where(Clip.has_audio.is_(has_audio))
if max_duration_sec is not None:
q = q.where(Clip.duration_sec <= max_duration_sec)
rows = session.execute(
q.order_by(Clip.published_at.desc().nullslast(), Clip.id).offset(offset).limit(limit + 1)
).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
ids = [r.id for r in rows]
tags = _tags_for(session, ids)
favs = _favorites_for(session, device_id, ids)
return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), r.id in favs) for r in rows], limit=limit, offset=offset, has_more=has_more
)
@router.get("/stats", response_model=ClipStatsOut)
def clip_stats(session: Session = Depends(get_session)) -> ClipStatsOut:
row = session.execute(
select(
func.count(),
func.count().filter(Clip.dead_at.is_(None)),
func.count().filter(Clip.dead_at.is_not(None)),
func.count().filter(Clip.has_audio.is_(True), Clip.dead_at.is_(None)),
func.count().filter(Clip.creator_verified.is_(True), Clip.dead_at.is_(None)),
func.max(Clip.published_at),
)
).one()
by_source = {s: n for s, n in session.execute(select(Clip.source, func.count()).group_by(Clip.source))}
tags = session.execute(select(func.count(func.distinct(ClipTag.tag)))).scalar_one()
return ClipStatsOut(
total=row[0], alive=row[1], dead=row[2], with_audio=row[3], verified=row[4],
by_source=by_source, newest_published_at=row[5], tags=tags,
)

View file

@ -6,7 +6,7 @@ nowi użytkownicy widzieli/nadpisywali ulubione, watched-badge i blacklisty Jana
nagłówku `X-Device-Id`; backend scope'uje wszystkie tabele stanu po `device_id`.
Stare wiersze (sprzed migracji) mają `device_id = LEGACY_DEVICE`. Klient bez nagłówka
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE, czyli dostaje dawną
(stara wersja apki przed OTA) trafia również na LEGACY_DEVICE — czyli dostaje dawną
współdzieloną pulę, dopóki nie zaktualizuje bundla. Endpoint `/me/adopt-legacy`
przepina LEGACY rows na konkretne device (Jan robi to raz po update).
"""

View file

@ -8,9 +8,9 @@ Każdy update wgrany przez `scripts/publish_update.py` ląduje w
`app/static/expo-updates/<runtime>/<update_id>/`. Plik
`app/static/expo-updates/<runtime>/current.json` wskazuje aktywny update_id.
Endpointy SĄ PUBLICZNE (no auth), Expo Updates SDK nie wstrzykuje X-API-Key.
Endpointy SĄ PUBLICZNE (no auth) — Expo Updates SDK nie wstrzykuje X-API-Key.
Bezpieczeństwo opiera się na TLS pinningu (mobile ufa tylko naszej self-signed
cert SPKI z network_security_config), ktoś bez tego pinu nie podstawi MITM
cert SPKI z network_security_config) — ktoś bez tego pinu nie podstawi MITM
manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key.
"""
from __future__ import annotations
@ -35,7 +35,7 @@ def get_manifest(
expo_platform: str | None = Header(default=None, alias="expo-platform"),
) -> Response:
"""Zwraca aktualny manifest dla podanego `expo-runtime-version` (default 1.0)
+ platform (default android, i tak tylko Android wspieramy).
+ platform (default android — i tak tylko Android wspieramy).
204 No Content gdy nie ma update'u dla tego runtime'u → klient nadal odpala
embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest
@ -87,7 +87,7 @@ def get_asset(
) -> Response:
"""Serwuje pojedynczy asset (JS bundle, image, font) z update directory.
`asset` to relative path względem `static/expo-updates/<runtime>/`,
`asset` to relative path względem `static/expo-updates/<runtime>/` —
zwykle `<update_id>/_expo/static/js/android/<hash>.js` lub
`<update_id>/assets/<hash>`. Path traversal blocked przez resolve+is_relative.
"""
@ -103,7 +103,7 @@ def get_asset(
raise HTTPException(status_code=400, detail="invalid asset path")
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="asset not found")
# Content type, bundle to text/javascript, reszta autodetect przez FileResponse.
# Content type — bundle to text/javascript, reszta autodetect przez FileResponse.
media_type = None
if target.suffix in (".js", ".bundle"):
media_type = "application/javascript"

View file

@ -1,19 +1,19 @@
"""Favorites, ulubione performerki + studia + liczenie nowych scen.
"""Favorites — ulubione performerki + studia + liczenie nowych scen.
Single-user (brak users), więc API zwraca/operuje na global zbiorze. Multi-user
można dodać dorzuceniem `user_id` query/header bez breaking change.
Endpointy (performers, `/favorites/...` zostawione żeby nie łamać starego mobile):
GET /favorites lista ulubionych performerek
POST /favorites/{performer_id} dodaj (idempotent)
DELETE /favorites/{performer_id} usuń
POST /favorites/{performer_id}/seen mark-as-seen (zeruje badge)
Endpointy (performers — `/favorites/...` zostawione żeby nie łamać starego mobile):
GET /favorites — lista ulubionych performerek
POST /favorites/{performer_id} — dodaj (idempotent)
DELETE /favorites/{performer_id} — usuń
POST /favorites/{performer_id}/seen — mark-as-seen (zeruje badge)
Endpointy (studios):
GET /favorites/studios lista ulubionych studiów
POST /favorites/studios/{studio_id} dodaj
DELETE /favorites/studios/{studio_id} usuń
POST /favorites/studios/{studio_id}/seen, mark-as-seen
GET /favorites/studios — lista ulubionych studiów
POST /favorites/studios/{studio_id} — dodaj
DELETE /favorites/studios/{studio_id} — usuń
POST /favorites/studios/{studio_id}/seen — mark-as-seen
"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at:
- dla performerki: ScenePerformer.performer_id = X
@ -21,32 +21,26 @@ Endpointy (studios):
"""
from __future__ import annotations
import logging
import threading
import time
import uuid
from datetime import UTC, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import exists, func, select
from sqlalchemy import exists, select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.auth import require_api_key
from app.db import get_session, session_scope
from app.db import get_session
from app.models.favorite_movie import FavoriteMovie
from app.models.favorite_performer import FavoritePerformer
from app.models.favorite_site import FavoriteSite
from app.models.favorite_studio import FavoriteStudio
from app.models.movie import Movie
from app.models.performer import Performer
from app.models.scene import Scene, ScenePerformer
from app.models.studio import Studio
log = logging.getLogger(__name__)
router = APIRouter(
prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)]
)
@ -66,71 +60,55 @@ def _new_counts(session: Session, device_id: str, *, kind: str) -> dict:
"""Policz per-favorite "+N nowych" = sceny created_at > last_seen_at, ale TYLKO wśród
tych które user zobaczy na liście: te same filtry widoczności co list_scenes (żywy
playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem
`release_date` z list_scenes, czyli data premiery albo dzień dodania), z pominięciem
backfillu. Liczy w SQL (count(*) FILTER), zwraca {group_id: n}.
release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca
{group_id: n} — bez streamowania N*200 wierszy do Pythona.
kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers.
kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują
odsiewu stub (mogą nie mieć performera); performerki nie (mają).
"""
from sqlalchemy import and_, func, true
from sqlalchemy import and_, func
from app.api.scenes import (
_effective_date_desc,
blacklist_clauses,
live_playback_exists,
stub_exclusion_clause,
)
from app.api.scenes import blacklist_clauses, live_playback_exists, stub_exclusion_clause
clauses = [live_playback_exists(), *blacklist_clauses(session, device_id)]
list_order = (_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc())
if kind == "studio":
# Po kolei indeksem ix_scenes_studio_effective_date_desc (migracja 0038) i stop po
# 200 widocznych scenach na studio. Okno row_number() czytało wszystkie sceny
# wszystkich ulubionych studiów (324 tys. u najcięższego urządzenia, 10-25 s).
clauses.append(stub_exclusion_clause())
top = (
select(Scene.created_at.label("created_at"), Scene.backfill.label("backfill"))
.where(Scene.studio_id == FavoriteStudio.studio_id, *clauses)
.order_by(*list_order)
.limit(_FAVORITES_PAGE_CAP)
.lateral("top")
)
rows = session.execute(
select(
FavoriteStudio.studio_id,
func.count().filter(
and_(
top.c.backfill.is_(False),
top.c.created_at > FavoriteStudio.last_seen_at,
)
),
)
.select_from(FavoriteStudio)
.join(top, true())
.where(FavoriteStudio.device_id == device_id)
.group_by(FavoriteStudio.studio_id)
).all()
return {gid_val: int(n) for gid_val, n in rows}
# Performerki: sceny idą przez scene_performers, więc indeks po dacie nie pomoże i
# trzeba przeczytać całą filmografię. To jest drogie (6-15 s przy ~1000 ulubionych),
# dlatego wynik siedzi w cache, patrz _cached_new_counts.
if kind == "performer":
gid = ScenePerformer.performer_id
last_seen = FavoritePerformer.last_seen_at
base = (
select(
ScenePerformer.performer_id.label("gid"),
gid.label("gid"),
Scene.created_at.label("created_at"),
Scene.backfill.label("backfill"),
FavoritePerformer.last_seen_at.label("last_seen"),
last_seen.label("last_seen"),
)
.select_from(FavoritePerformer)
.join(ScenePerformer, ScenePerformer.performer_id == FavoritePerformer.performer_id)
.join(Scene, Scene.id == ScenePerformer.scene_id)
.where(FavoritePerformer.device_id == device_id)
)
partition = ScenePerformer.performer_id
else:
clauses.append(stub_exclusion_clause())
gid = Scene.studio_id
last_seen = FavoriteStudio.last_seen_at
base = (
select(
gid.label("gid"),
Scene.created_at.label("created_at"),
Scene.backfill.label("backfill"),
last_seen.label("last_seen"),
)
.select_from(FavoriteStudio)
.join(Scene, Scene.studio_id == FavoriteStudio.studio_id)
.where(FavoriteStudio.device_id == device_id)
)
partition = Scene.studio_id
rn = func.row_number().over(
partition_by=ScenePerformer.performer_id, order_by=list_order
partition_by=partition,
order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()),
).label("rn")
inner = base.add_columns(rn).where(*clauses).subquery()
rows = session.execute(
@ -156,7 +134,7 @@ def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest
o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna
top-N ani odsiewu stub/backfill, te pojęcia dotyczą scen.
top-N ani odsiewu stub/backfill — te pojęcia dotyczą scen.
"""
from sqlalchemy import func
@ -207,94 +185,6 @@ def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
return counts
# Cache liczników "+N" w pamięci procesu API (uvicorn chodzi jednym procesem). Liczenie
# dla urządzeń z setkami ulubionych trwa sekundy, a apka woła /favorites przy wejściu na
# listę scen, ulubione i każdą performerkę. Liczniki zmieniają się wolno (ingest), więc:
# świeży wynik oddajemy od razu, starszy niż _COUNTS_FRESH_SEC też od razu i liczymy
# nowy w tle, a dopiero starszy niż _COUNTS_MAX_AGE_SEC liczymy na miejscu.
# mark_seen zeruje licznik w cache, żeby badge zgasł natychmiast.
_COUNTS_FRESH_SEC = 300.0
_COUNTS_MAX_AGE_SEC = 6 * 3600.0
_counts_cache: dict[tuple[str, str], tuple[float, dict]] = {}
_counts_seen: dict[tuple[str, str], dict] = {} # gid -> kiedy oznaczono jako obejrzane
_counts_locks: dict[tuple[str, str], threading.Lock] = {}
_counts_guard = threading.Lock()
def _counts_lock(key: tuple[str, str]) -> threading.Lock:
with _counts_guard:
lock = _counts_locks.get(key)
if lock is None:
lock = _counts_locks[key] = threading.Lock()
return lock
def _store_counts(key: tuple[str, str], started: float, counts: dict) -> None:
with _counts_guard:
# mark_seen w trakcie liczenia: wynik policzono ze starym last_seen_at
for gid, at in _counts_seen.get(key, {}).items():
if at >= started:
counts[gid] = 0
_counts_seen.pop(key, None)
_counts_cache[key] = (time.monotonic(), counts)
stale = [k for k, (at, _) in _counts_cache.items() if time.monotonic() - at > _COUNTS_MAX_AGE_SEC]
for k in stale:
_counts_cache.pop(k, None)
_counts_locks.pop(k, None)
def _compute_counts(key: tuple[str, str]) -> None:
device_id, kind = key
started = time.monotonic()
with session_scope() as session:
counts = _merged_new_counts(session, device_id, kind=kind)
_store_counts(key, started, counts)
def _refresh_in_background(key: tuple[str, str]) -> None:
lock = _counts_lock(key)
if not lock.acquire(blocking=False):
return # już ktoś liczy
def run() -> None:
try:
_compute_counts(key)
except Exception: # noqa: BLE001
log.exception("favorites: odświeżenie liczników w tle nie wyszło %s", key)
finally:
lock.release()
threading.Thread(target=run, name="fav-counts", daemon=True).start()
def _cached_new_counts(device_id: str, *, kind: str) -> dict:
key = (device_id, kind)
hit = _counts_cache.get(key)
if hit is not None:
age = time.monotonic() - hit[0]
if age < _COUNTS_MAX_AGE_SEC:
if age > _COUNTS_FRESH_SEC:
_refresh_in_background(key)
return dict(hit[1])
# Brak w cache: liczymy teraz. Lock, żeby równoległe zapytania z kilku ekranów
# nie liczyły tego samego naraz, drugie poczeka i weźmie gotowy wynik.
with _counts_lock(key):
hit = _counts_cache.get(key)
if hit is not None and time.monotonic() - hit[0] < _COUNTS_FRESH_SEC:
return dict(hit[1])
_compute_counts(key)
return dict(_counts_cache[key][1])
def _mark_counts_seen(device_id: str, kind: str, gid: uuid.UUID) -> None:
key = (device_id, kind)
with _counts_guard:
_counts_seen.setdefault(key, {})[gid] = time.monotonic()
hit = _counts_cache.get(key)
if hit is not None and hit[1].get(gid):
hit[1][gid] = 0
class FavoriteOut(BaseModel):
performer_id: uuid.UUID
canonical_name: str
@ -308,17 +198,13 @@ class FavoriteOut(BaseModel):
class FavoriteListOut(BaseModel):
items: list[FavoriteOut]
total: int
new_total: int # suma new_count po wszystkich, dla badge w toolbar
new_total: int # suma new_count po wszystkich — dla badge w toolbar
@router.get("", response_model=FavoriteListOut)
def list_favorites(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
counts: bool = Query(
default=True,
description="false: bez liczników +N (ekrany, które chcą tylko wiedzieć, czy zapalić gwiazdkę)",
),
) -> FavoriteListOut:
rows = session.execute(
select(FavoritePerformer, Performer)
@ -330,10 +216,10 @@ def list_favorites(
return FavoriteListOut(items=[], total=0, new_total=0)
# scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez
# _job_refresh_taxonomy_counts), ta sama definicja co przed (sceny z żywym
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
new_counts = _cached_new_counts(device_id, kind="performer") if counts else {}
new_counts = _merged_new_counts(session, device_id, kind="performer")
items: list[FavoriteOut] = []
new_total = 0
@ -388,7 +274,7 @@ def remove_favorite(
) -> None:
fav = session.get(FavoritePerformer, (device_id, performer_id))
if fav is None:
# idempotent, brak ulubionego = nie ma nic do usunięcia, success
# idempotent — brak ulubionego = nie ma nic do usunięcia, success
return
session.delete(fav)
session.commit()
@ -410,7 +296,6 @@ def mark_seen(
raise HTTPException(status_code=404, detail="not in favorites")
fav.last_seen_at = datetime.now(UTC)
session.commit()
_mark_counts_seen(device_id, "performer", performer_id)
return SeenOut(performer_id=performer_id, last_seen_at=fav.last_seen_at)
@ -422,10 +307,6 @@ class FavoriteStudioOut(BaseModel):
slug: str
network: str | None = None
scene_count: int
#: Filmów z żywym playbackiem. Studio może mieć ZERO scen i setki filmów
#: (Jonathan Jordan XXX 0/654, Kink Clips 0/616, EAGLE 0/523), a lista ulubionych
#: pokazywała wtedy samo „0" i wyglądała na pustą, mimo że „+N" liczyło już filmy.
movie_count: int = 0
new_count: int
last_seen_at: datetime
created_at: datetime
@ -441,10 +322,6 @@ class FavoriteStudioListOut(BaseModel):
def list_favorite_studios(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
counts: bool = Query(
default=True,
description="false: bez liczników +N i filmów (sam stan gwiazdki)",
),
) -> FavoriteStudioListOut:
rows = session.execute(
select(FavoriteStudio, Studio)
@ -454,51 +331,10 @@ def list_favorite_studios(
).all()
if not rows:
return FavoriteStudioListOut(items=[], total=0, new_total=0)
if not counts:
return FavoriteStudioListOut(
items=[
FavoriteStudioOut(
studio_id=st.id,
name=st.name,
slug=st.slug,
network=st.network,
scene_count=st.scene_count,
new_count=0,
last_seen_at=fav.last_seen_at,
created_at=fav.created_at,
)
for fav, st in rows
],
total=len(rows),
new_total=0,
)
# scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
scene_counts: dict = {st.id: st.scene_count for _, st in rows}
# movie_count liczymy NA ŻYWO: ulubionych studiów jest kilkaset na urządzenie,
# a filmów o rzędy wielkości mniej niż scen, więc agregat jest tani i nie trzeba
# kolejnej zdenormalizowanej kolumny do utrzymania.
from app.models.movie import Movie
from app.models.movie_playback_source import MoviePlaybackSource
studio_ids = [st.id for _, st in rows]
movie_counts: dict = {
sid: n
for sid, n in session.execute(
select(Movie.studio_id, func.count())
.where(
Movie.studio_id.in_(studio_ids),
exists(
select(1).where(
MoviePlaybackSource.movie_id == Movie.id,
MoviePlaybackSource.dead_at.is_(None),
)
),
)
.group_by(Movie.studio_id)
).all()
}
new_counts = _cached_new_counts(device_id, kind="studio")
new_counts = _merged_new_counts(session, device_id, kind="studio")
items: list[FavoriteStudioOut] = []
new_total = 0
@ -512,7 +348,6 @@ def list_favorite_studios(
slug=st.slug,
network=st.network,
scene_count=scene_counts.get(st.id, 0),
movie_count=movie_counts.get(st.id, 0),
new_count=nc,
last_seen_at=fav.last_seen_at,
created_at=fav.created_at,
@ -576,77 +411,12 @@ def mark_studio_seen(
raise HTTPException(status_code=404, detail="not in favorites")
fav.last_seen_at = datetime.now(UTC)
session.commit()
_mark_counts_seen(device_id, "studio", studio_id)
return SeenStudioOut(studio_id=studio_id, last_seen_at=fav.last_seen_at)
# ---------- Sites (origins) ----------
# Przypiete strony w zakladce Sites (prosba z apki 2026-09-02). Bez licznikow "+N": lista
# zrodel i tak pokazuje swiezosc per strona, a badge liczy sie tylko dla performerow,
# studiow i filmow.
_ORIGIN_RE = r"^[a-z0-9][a-z0-9:._-]{1,63}$"
class FavoriteSiteListOut(BaseModel):
items: list[str] # origins, w kolejnosci dodania
total: int
class FavoriteSiteAddOut(BaseModel):
origin: str
created: bool
@router.get("/sites", response_model=FavoriteSiteListOut)
def list_favorite_sites(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteSiteListOut:
rows = session.execute(
select(FavoriteSite.origin)
.where(FavoriteSite.device_id == device_id)
.order_by(FavoriteSite.created_at)
).scalars().all()
return FavoriteSiteListOut(items=list(rows), total=len(rows))
@router.post("/sites/{origin}", response_model=FavoriteSiteAddOut)
def add_favorite_site(
origin: Annotated[str, Path(pattern=_ORIGIN_RE)],
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> FavoriteSiteAddOut:
from app.models.playback_source import PlaybackSource
znany = session.execute(
select(1).where(PlaybackSource.origin == origin).limit(1)
).scalar_one_or_none()
if znany is None:
raise HTTPException(status_code=404, detail="unknown origin")
if session.get(FavoriteSite, (device_id, origin)) is not None:
return FavoriteSiteAddOut(origin=origin, created=False)
session.add(FavoriteSite(device_id=device_id, origin=origin))
session.commit()
return FavoriteSiteAddOut(origin=origin, created=True)
@router.delete("/sites/{origin}", status_code=status.HTTP_204_NO_CONTENT)
def remove_favorite_site(
origin: Annotated[str, Path(pattern=_ORIGIN_RE)],
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> None:
fav = session.get(FavoriteSite, (device_id, origin))
if fav is None:
return
session.delete(fav)
session.commit()
# ── Favorite movies ────────────────────────────────────────────────────────
# Movies nie mają child scenes per-favorite (jak performerki/studia), więc
# `last_seen_at` nie jest tu używany do NEW count, tylko jako tracking ostatniego
# `last_seen_at` nie jest tu używany do NEW count — tylko jako tracking ostatniego
# wglądu przez usera. Mobile używa NEW badge w liście /movies przez OSOBNY
# globalny last_seen z AsyncStorage (client-side, brak backendowego state).

View file

@ -2,7 +2,7 @@
Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`.
`POST /me/adopt-legacy` przepina WSZYSTKIE legacy wiersze (favorites/progress/blacklisty)
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki, kolejne
na device wołającego. Robi to JEDEN raz właściciel instancji po update apki — kolejne
wywołania nic nie znajdą (legacy już puste). Jeśli na keep-device istnieje już wiersz
dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING → potem
kasujemy resztki legacy).
@ -22,7 +22,7 @@ from app.db import get_session
router = APIRouter(prefix="/me", tags=["me"], dependencies=[Depends(require_api_key)])
# (tabela, kolumna-encji), wszystkie tabele device-scoped (migracja 0022).
# (tabela, kolumna-encji) — wszystkie tabele device-scoped (migracja 0022).
_TABLES: list[tuple[str, str]] = [
("favorite_performers", "performer_id"),
("favorite_studios", "studio_id"),
@ -33,11 +33,6 @@ _TABLES: list[tuple[str, str]] = [
("blacklisted_performers", "performer_id"),
("blacklisted_studios", "studio_id"),
("blacklisted_tags", "tag_id"),
# Feed Swipe: widziane klipy i ulubione klipy jadą z użytkownikiem na nowy telefon.
# Afiniczność (clip_affinity) celowo NIE: ma jeden wiersz na urządzenie bez kolumny
# encji, a i tak odbuduje się z pierwszych zdarzeń.
("clip_seen", "clip_id"),
("clip_favorites", "clip_id"),
]
@ -53,10 +48,7 @@ class ImportOut(BaseModel):
# Tabele do przeniesienia przy transferze między urządzeniami + kolumna-encji do dedupu.
# saved_searches ma własne `id` PK (nie (device_id,encja)), więc dedup po `query`.
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [
("saved_searches", "query"),
("clip_blacklist", "value"),
]
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [("saved_searches", "query")]
@router.post("/import/{source_device}", response_model=ImportOut)
@ -67,7 +59,7 @@ def import_from_device(
) -> ImportOut:
"""Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego
urządzenia na wołające. `source_device` to device_id starego telefonu (pełni rolę kodu
transferu, jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
transferu — jest losowym UUID, więc jego znajomość == autoryzacja; instancja bez kont).
KOPIUJE (nie przenosi): stary telefon zachowuje swoje. Dedup: pomija encje które
urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid()."""
@ -122,7 +114,7 @@ def adopt_legacy(
).bindparams(dev=device_id, legacy=LEGACY_DEVICE)
)
moved[table] = res.rowcount or 0
# Resztki legacy (encje które device już miał), skasuj, żeby nie wisiały.
# Resztki legacy (encje które device już miał) — skasuj, żeby nie wisiały.
session.execute(
text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE)
)

View file

@ -1,8 +1,7 @@
"""GET /movies, lista i szczegóły filmów."""
"""GET /movies — lista i szczegóły filmów."""
from __future__ import annotations
import uuid
from collections import defaultdict
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
@ -133,14 +132,14 @@ def list_movies(
base = base.limit(per_page).offset((page - 1) * per_page)
movies = list(session.execute(base).scalars().all())
items = _movies_to_out(session, movies, device_id=device_id)
movies = session.execute(base).scalars().all()
items = [_movie_to_out(session, m, device_id=device_id) for m in movies]
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
# Movie playback origin policy, module-level (kiedyś było inline per-request
# definition, code-review #19, perf hit + dorosły kod).
# Movie playback origin policy — module-level (kiedyś było inline per-request
# definition, code-review #19 — perf hit + dorosły kod).
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
# random per origin).
_MOVIE_PREFERRED_ORIGINS = (
@ -151,9 +150,9 @@ _MOVIE_PREFERRED_ORIGINS = (
"streamporn",
"pandamovies",
)
# File hosters które NIGDY nie dadzą się stream-extract bez premium account,
# File hosters które NIGDY nie dadzą się stream-extract bez premium account —
# odfiltrowywane całkowicie (zaśmiecały listę watch options, bug-report
# 2026-05-15). Streamtape przywrócony 2026-05-15, ma dedicated extractor,
# 2026-05-15). Streamtape przywrócony 2026-05-15 — ma dedicated extractor,
# ~5% URLów żyje.
_MOVIE_DROP_ORIGINS = frozenset({
"mangoporn:rapidgator",
@ -164,49 +163,7 @@ _MOVIE_DROP_ORIGINS = frozenset({
_MOVIE_LANDING_HIDE = frozenset({"mangoporn", "pandamovies", "streamporn"})
# Rodzina seekplayer (embed `#<hash>`, wspólny silnik) na sam koniec listy.
# Pomiar 2026-08-24 na 250 losowych źródłach: 3 miały jeszcze strumień, 88 oddało
# HTTP 404, a 79 odpowiedź bez pola `source`, po której ICH WŁASNY player pisze
# „Video is not ready yet" (sprawdzone w przeglądarce z łącza domowego, z VPS i przez
# brytyjskie proxy, więc to nie blokada po IP). Zgłoszenia `d59fc396`, `ccbc75da`,
# `7bfc2e8d` i sąsiednie z tego dnia to dokładnie te trafienia.
#
# Nie wyrzucamy ich z listy (te ~2% działa i bywa jedynym źródłem), ale nie mogą stać
# przed hosterami, które grają. Filmów zależnych WYŁĄCZNIE od tej rodziny jest w bazie
# jeden, więc de-ranking nikomu nie zabiera dostępu.
_MOVIE_DEPRIORITIZED_HOSTERS = frozenset({
"easyvidplayer", "embedseek", "seekplayer", "upns", "player4me", "rpmplay",
"4meplayer", "seekplays", "ezplayer", "seeks",
})
# Rodzina DoodStream (playmogo, doply, myvidplay i klony) wymaga KLIKANEGO Cloudflare
# Turnstile: widżet pojawia sie dopiero po tapie w play i przeladowaniu, wiec
# `pass_md5` nie ma w HTML i telefon nie rozwiaze tego sam (memory 2026-08-26, INJECTED_JS
# omija te hosty). Skutek w telemetrii: ZERO zdarzen odtwarzania w 30 dniach, bo droga
# WebView niczego nie raportuje, podczas gdy mixdrop/voe/luluvid/vidara graja w ~99%.
#
# Dotad te originy mialy priorytet neutralny (500) i sortowaly sie z dzialajacymi po dacie
# dodania, a doply/playmogo bywaja dopisane jako OSTATNIE, wiec ladowaly na GORZE listy
# "Watch on". Zgloszenia "Playmogo nie dziala tutaj" (1278eb49, 2026-09-04) i seria
# "Myvidplay nie dziala" (3b75cb54, d3af0813, 11d7b17b) to dokladnie ten mechanizm:
# zgloszony film mial obok zywy mixdrop, ktory gra.
#
# Pomiar 2026-09-05: rodzina siedzi w 54 446 filmach, z czego 54 320 (99,8%) ma dzialajaca
# alternatywe. Tylko 126 filmow zalezy od niej wylacznie, dla nich zostaje jako ostatnia
# deska ratunku, wiec de-ranking nikomu nie zabiera dostepu. Lista hostow = doodstream.ts.
_MOVIE_INTERACTIVE_HOSTERS = frozenset({
"playmogo", "myvidplay", "doply", "doodstream", "dood", "d0000d", "dooood",
"do0od", "do7go", "ds2play", "vikingfile", "vide0",
})
def _movie_origin_priority(origin: str) -> int:
host = origin.rpartition(":")[2]
if host in _MOVIE_DEPRIORITIZED_HOSTERS:
return 900 # za wszystkim, co nieklasyfikowane
if host in _MOVIE_INTERACTIVE_HOSTERS:
return 800 # gra, ale tylko po recznym kliku w Turnstile; za wszystkim, co gra samo
try:
return _MOVIE_PREFERRED_ORIGINS.index(origin)
except ValueError:
@ -226,108 +183,63 @@ def get_movie(
def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEVICE) -> MovieOut:
return _movies_to_out(session, [movie], device_id=device_id)[0]
studio_out: StudioOut | None = None
if movie.studio_id:
studio = session.get(Studio, movie.studio_id)
if studio is not None:
studio_out = StudioOut.model_validate(studio)
def _movies_to_out(
session: Session, movies: list[Movie], *, device_id: str = LEGACY_DEVICE
) -> list[MovieOut]:
"""Serializacja wsadowa: po jednym zapytaniu na relacje dla calej strony listy.
Wczesniej kazdy film robil 7 zapytan (studio, obsada, tagi, rozdzialy, refy,
zrodla, ulubione + postep), czyli ~210 na strone 30 filmow i 350 ms na
GET /movies (Sentry: N+1 Query list_movies, pomiar 2026-09-08). Logika per film
jest ta sama co w starym `_movie_to_out`, tylko dane przychodza z map.
"""
if not movies:
return []
ids = [m.id for m in movies]
studio_ids = {m.studio_id for m in movies if m.studio_id}
studios: dict = {}
if studio_ids:
studios = {
st.id: st for st in session.execute(select(Studio).where(Studio.id.in_(studio_ids))).scalars()
}
perf_by: dict = defaultdict(list)
for mid, perf, alias in session.execute(
select(MoviePerformer.movie_id, Performer, MoviePerformer.as_alias)
.join(Performer, MoviePerformer.performer_id == Performer.id)
.where(MoviePerformer.movie_id.in_(ids))
performer_rows = session.execute(
select(Performer, MoviePerformer.as_alias)
.join(MoviePerformer, MoviePerformer.performer_id == Performer.id)
.where(MoviePerformer.movie_id == movie.id)
.order_by(MoviePerformer.position.asc().nulls_last())
).all():
perf_by[mid].append((perf, alias))
tags_by: dict = defaultdict(list)
for mid, tag in session.execute(
select(MovieTag.movie_id, Tag)
.join(Tag, MovieTag.tag_id == Tag.id)
.where(MovieTag.movie_id.in_(ids))
.order_by(Tag.name.asc())
).all():
tags_by[mid].append(tag)
chapters_by: dict = defaultdict(list)
for ch in session.execute(
select(MovieChapter).where(MovieChapter.movie_id.in_(ids)).order_by(MovieChapter.chapter_index.asc())
).scalars():
chapters_by[ch.movie_id].append(ch)
refs_by: dict = defaultdict(list)
for ref, name in session.execute(
select(MovieExternalRef, Source.name)
.join(Source, Source.id == MovieExternalRef.source_id)
.where(MovieExternalRef.movie_id.in_(ids))
).all():
refs_by[ref.movie_id].append((ref, name))
pb_by: dict = defaultdict(list)
for pb in session.execute(
select(MoviePlaybackSource)
.where(MoviePlaybackSource.movie_id.in_(ids))
.where(MoviePlaybackSource.dead_at.is_(None))
.order_by(MoviePlaybackSource.created_at.desc())
).scalars():
pb_by[pb.movie_id].append(pb)
favs = set(
session.execute(
select(FavoriteMovie.movie_id)
.where(FavoriteMovie.device_id == device_id)
.where(FavoriteMovie.movie_id.in_(ids))
).scalars()
)
progress_by = {
pr.movie_id: pr
for pr in session.execute(
select(MoviePlayProgress)
.where(MoviePlayProgress.device_id == device_id)
.where(MoviePlayProgress.movie_id.in_(ids))
).scalars()
}
out: list[MovieOut] = []
for movie in movies:
studio = studios.get(movie.studio_id) if movie.studio_id else None
studio_out = StudioOut.model_validate(studio) if studio is not None else None
).all()
performers = [
PerformerOut(
id=perf.id,
canonical_name=perf.canonical_name,
slug=perf.slug,
gender=perf.gender.value if perf.gender else None,
id=p.id,
canonical_name=p.canonical_name,
slug=p.slug,
gender=p.gender.value if p.gender else None,
as_alias=alias,
)
for perf, alias in perf_by.get(movie.id, [])
for p, alias in performer_rows
]
tags = [TagOut.model_validate(t) for t in tags_by.get(movie.id, [])]
chapters = [MovieChapterOut.model_validate(c) for c in chapters_by.get(movie.id, [])]
tag_rows = session.execute(
select(Tag).join(MovieTag, MovieTag.tag_id == Tag.id)
.where(MovieTag.movie_id == movie.id)
.order_by(Tag.name.asc())
).scalars().all()
tags = [TagOut.model_validate(t) for t in tag_rows]
chapter_rows = session.execute(
select(MovieChapter).where(MovieChapter.movie_id == movie.id)
.order_by(MovieChapter.chapter_index.asc())
).scalars().all()
chapters = [MovieChapterOut.model_validate(c) for c in chapter_rows]
ref_rows = session.execute(
select(MovieExternalRef, Source.name)
.join(Source, Source.id == MovieExternalRef.source_id)
.where(MovieExternalRef.movie_id == movie.id)
).all()
external_refs = [
ExternalRefOut(source=name, external_id=ref.external_id, url=ref.url, last_seen=ref.last_seen)
for ref, name in refs_by.get(movie.id, [])
ExternalRefOut(
source=name,
external_id=ref.external_id,
url=ref.url,
last_seen=ref.last_seen,
)
for ref, name in ref_rows
]
pb_rows = list(pb_by.get(movie.id, []))
pb_rows = session.execute(
select(MoviePlaybackSource)
.where(MoviePlaybackSource.movie_id == movie.id)
.where(MoviePlaybackSource.dead_at.is_(None))
.order_by(MoviePlaybackSource.created_at.desc())
).scalars().all()
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_DROP_ORIGINS]
# Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/
# `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi
@ -355,9 +267,10 @@ def _movies_to_out(
pb_rows = deduped
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
progress = progress_by.get(movie.id)
out.append(
MovieOut(
is_fav = session.get(FavoriteMovie, (device_id, movie.id)) is not None
progress = session.get(MoviePlayProgress, (device_id, movie.id))
return MovieOut(
id=movie.id,
title=movie.title,
slug=movie.slug,
@ -377,10 +290,8 @@ def _movies_to_out(
external_refs=external_refs,
playback_sources=playback_sources,
created_at=movie.created_at,
is_favorite=movie.id in favs,
is_favorite=is_fav,
last_played_at=progress.last_played_at if progress else None,
finished=progress.finished if progress else False,
position_sec=progress.position_sec if progress else 0,
)
)
return out

View file

@ -1,14 +1,14 @@
"""POST /scenes/{scene_id}/playback/{playback_id}/resolve, rozwiązuje stream URL.
"""POST /scenes/{scene_id}/playback/{playback_id}/resolve — rozwiązuje stream URL.
Mobile apka woła ten endpoint na klik "Watch", backend ekstraktuje świeży
Mobile apka woła ten endpoint na klik "Watch" — backend ekstraktuje świeży
stream URL (m3u8/mp4) z page tube'a i zwraca go. Mobile otwiera URL przez
Linking.openURL → Android player chooser (MX Player / VLC / browser).
Stream URLs są podpisane/expire (zwykle ~kilka godzin), nie cache'ujemy ich
Stream URLs są podpisane/expire (zwykle ~kilka godzin) — nie cache'ujemy ich
w DB, tylko resolve on-demand. Logika ekstrakcji per-tube w `app.extractors`.
**Dead-link detection**: gdy hoster embed page mówi "Video deleted/not found",
oznaczamy `PlaybackSource.dead_at = now()`, API dalej go nie listuje, mobile
oznaczamy `PlaybackSource.dead_at = now()` — API dalej go nie listuje, mobile
nie pokaże martwego buttonu.
"""
from __future__ import annotations
@ -39,23 +39,23 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/scenes", tags=["playback"], dependencies=[Depends(require_api_key)])
# CDN-domain allowlist dla mobile direct fetch, token IS time-bound (nie IP-bound),
# CDN-domain allowlist dla mobile direct fetch — token IS time-bound (nie IP-bound),
# zweryfikowane cross-IP curl test 2026-05-18. Mobile ExoPlayer pobiera manifest+segments
# bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc).
#
# Verified time-bound:
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding), signed token w path + exp_time
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube), validfrom+validto+hash
# - privatehost.com (pornhat CDN), sign + exp_time, brak Referer requirement
# - sxyprn.com, signed path
# - eporner.com CDN, IP literal w path ale CDN go ignoruje
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding) — signed token w path + exp_time
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube) — validfrom+validto+hash
# - privatehost.com (pornhat CDN) — sign + exp_time, brak Referer requirement
# - sxyprn.com — signed path
# - eporner.com CDN — IP literal w path ale CDN go ignoruje
#
# NIE w allowlist (IP-bound, wymagają proxy):
# - premilkyway.com (latestpornvideo), 403 cross-IP
# - tnmr.org (mypornerleak), 403 cross-IP
# - porntrex.com/get_file, single-use token (410 po reuse)
# - freshporno.org/get_file, cv= signed token IP-bound
# - sn.porn-xp.com, porn00.org, force_proxy explicit
# - premilkyway.com (latestpornvideo) — 403 cross-IP
# - tnmr.org (mypornerleak) — 403 cross-IP
# - porntrex.com/get_file — single-use token (410 po reuse)
# - freshporno.org/get_file — cv= signed token IP-bound
# - sn.porn-xp.com, porn00.org — force_proxy explicit
_TIME_BOUND_CDN_RE = re.compile(
r"\b(?:"
r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream
@ -65,42 +65,10 @@ _TIME_BOUND_CDN_RE = re.compile(
re.IGNORECASE,
)
# Hostery znane z przekierowywania całej domeny na następcę (rodzina DoodStream
# zmienia szyld co kilka miesięcy). TYLKO dla nich rozwiązujemy przekierowanie przed
# oddaniem embedu, bo to kosztuje jeden fetch; zwykłe embedy idą bez dotykania sieci.
_REDIRECTING_EMBED_RE = re.compile(
r"//(?:[a-z0-9-]+\.)?(?:myvidplay|doply|doodstream|dood|d0+d|dooood|do0od|do7go"
r"|ds2play)\.[a-z]{2,6}/",
re.IGNORECASE,
)
def _resolve_embed_redirect(url: str) -> str:
"""Zwraca końcowy adres po przekierowaniach albo oryginał, gdy nic się nie zmienia.
Wynik trafia do `embed_url` w bazie, więc każdy adres jest rozwiązywany najwyżej
raz; po utrwaleniu regex przestaje pasować i ta funkcja robi się darmowa.
"""
if not _REDIRECTING_EMBED_RE.search(url):
return url
from app.extractors import browser_get
try:
res = browser_get(url, timeout=15.0, follow_redirects=True)
final = str(getattr(res, "url", "") or "")
# Utrwalamy tylko realny skok na inny adres http(s); błąd hostera (5xx itp.)
# zostawia oryginał, żeby nie zabetonować chwilowej awarii jako "nowego" adresu.
if final.startswith("http") and final != url and res.status_code < 400:
return final
except Exception as e:
log.info("embed redirect resolve padl dla %s: %s", url, e)
return url
# IP-BOUND CDN signature, token bind do requester IP, cross-IP fetch = 403.
# IP-BOUND CDN signature — token bind do requester IP, cross-IP fetch = 403.
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
# Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak,
# latestpornvideo), wszystkie używają tego samego CDN pool.
# latestpornvideo) — wszystkie używają tego samego CDN pool.
_IP_BOUND_CDN_RE = re.compile(
r"\b(?:"
r"premilkyway\.com" # latestpornvideo
@ -114,17 +82,17 @@ _IP_BOUND_CDN_RE = re.compile(
class StreamLink(BaseModel):
"""Pojedynczy variant stream URL (różne quality / kontener).
`stream_url` = URL do video file (mp4/m3u8/webm), proxy-wrapped URL przez backend
`stream_url` = URL do video file (mp4/m3u8/webm) — proxy-wrapped URL przez backend
VPS (`/proxy/{token}/play.ext`). Bezpieczny fallback gdy CDN binduje URL do IP
extractora (np. fpo.xxx z kt_remote_ips cookie). Bandwidth idzie przez VPS.
`direct_url` + `headers` = surowy CDN URL z headers do bezpośredniego fetchu z
urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
poprawnie content gdy mobile player wysyła `Referer` + `User-Agent` z `headers`.
Mobile próbuje direct PIERWSZY, gdy CDN zwróci 403/410 (IP-bound), spada na
Mobile próbuje direct PIERWSZY — gdy CDN zwróci 403/410 (IP-bound), spada na
`stream_url` (proxy). Daje 0 bandwidth na VPS-ie dla większości scen.
`embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn), mobile
`embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn) — mobile
otwiera w WebView. Type: 'hoster'.
"""
@ -154,7 +122,7 @@ def resolve_movie_playback(
playback_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> ResolveOut:
"""Movies playback resolve, analog do `/scenes/{id}/playback/{pb}/resolve`.
"""Movies playback resolve — analog do `/scenes/{id}/playback/{pb}/resolve`.
Origin patterns:
- 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc
@ -182,7 +150,7 @@ def resolve_movie_playback(
if pb.origin == "paradisehill":
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
# Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane
# bez auth, 200 OK z plain User-Agent + Referer.
# bez auth — 200 OK z plain User-Agent + Referer.
# Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N").
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
# nie ma sposobu przejść do następnego.
@ -194,7 +162,7 @@ def resolve_movie_playback(
parts = []
if parts:
for url, label in parts:
# NIE proxifikujemy tutaj, outer `_proxify_link` poniżej (linia 247) opakuje
# NIE proxifikujemy tutaj — outer `_proxify_link` poniżej (linia 247) opakuje
# wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL).
links.append(
StreamLink(
@ -206,7 +174,7 @@ def resolve_movie_playback(
)
)
else:
# Fallback: brak videoList (np. login-only movie), WebView na całość.
# Fallback: brak videoList (np. login-only movie) — WebView na całość.
links = [
StreamLink(
stream_url=None,
@ -223,7 +191,7 @@ def resolve_movie_playback(
try:
stream = extract_stream_from_hoster(target, referer=referer)
except HosterDead as e:
# Hoster wprost mówi "video deleted", oznacz dead, NIE proponuj
# Hoster wprost mówi "video deleted" — oznacz dead, NIE proponuj
# embed fallback (mobile ExoPlayer dostałby 404 HTML page i
# próbowałby zapisać jako .bin file; bug-report 2026-05-16
# "streamtape ściąga hurtowo pliki .bin").
@ -235,7 +203,7 @@ def resolve_movie_playback(
except Exception as e:
log.warning("movie hoster extract failed for %s: %s", target, e)
if stream and _IP_BOUND_CDN_RE.search(stream):
# IP-bound CDN (luluvid → cdn-tnmr.org, etc.), token bind do VPS IP,
# IP-bound CDN (luluvid → cdn-tnmr.org, etc.) — token bind do VPS IP,
# mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView).
log.info(
"movie playback %s: stream URL IP-bound CDN — skip, WebView fallback",
@ -257,11 +225,11 @@ def resolve_movie_playback(
type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4"
raw_meta: dict = {"origin": pb.origin, "host": target}
# seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/
# embedseek/upns, ~322k sources) zwraca master.m3u8 na raw-IP CDN
# embedseek/upns — ~322k sources) zwraca master.m3u8 na raw-IP CDN
# (185.237.x/203.188.x/45.156.x `/v4/<tok>/<exp>/pp/<hash>/master.m3u8`).
# Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
# manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest
# VALID (verify=True OK, IP-SAN, nie self-signed jak głosił stary docstring).
# VALID (verify=True OK — IP-SAN, nie self-signed jak głosił stary docstring).
# Token jest TIME-bound (`<exp>` unix ts), NIE IP-bound. → mobile ExoPlayer
# gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink).
# Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź
@ -278,23 +246,8 @@ def resolve_movie_playback(
raw=raw_meta,
)
)
# Zawsze dorzucamy embed jako fallback, mobile WebView może wyłapać URL z JS-a
# Zawsze dorzucamy embed jako fallback — mobile WebView może wyłapać URL z JS-a
if pb.embed_url:
# Hoster potrafi zostać atrapą przekierowującą na następcę (myvidplay i
# doply odpowiadają 30x na playmogo). WebView w apce blokuje top-frame
# skok na inną domenę (ochrona przed popunderami), więc surowy adres
# kończy się czarnym ekranem: zgłoszenia 3b75cb54/11d7b17b/d3af0813,
# wcześniej identyczny mechanizm w galaxyporn (vikingfile → vik1ngfile).
# Rozwiązujemy przekierowanie TUTAJ i utrwalamy wynik, żeby telefon
# dostał adres, na którym wolno mu zostać.
final_embed = _resolve_embed_redirect(pb.embed_url)
if final_embed != pb.embed_url:
log.info(
"movie playback %s: embed %s przekierowuje na %s, utrwalam",
pb.id, pb.embed_url, final_embed,
)
pb.embed_url = final_embed[:2048]
session.commit()
links.append(
StreamLink(
stream_url=None,
@ -344,7 +297,7 @@ def mark_playback_dead(
request: Request,
session: Annotated[Session, Depends(get_session)],
) -> None:
"""User-triggered mark dead, long-press na playback chip w mobile.
"""User-triggered mark dead — long-press na playback chip w mobile.
Bug-report 2026-05-12 (dd17c709): "Eporner to nie temporary. Więc długie
przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead
@ -409,7 +362,7 @@ def resolve_playback(
sitetag: str | None = None
if pb.origin.startswith("pornapp:"):
# Legacy origin format, pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
# Legacy origin format — pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
sitetag = pb.origin.split(":", 1)[1]
elif pb.origin.startswith("tube:"):
sitetag = pb.origin.split(":", 1)[1]
@ -429,7 +382,7 @@ def resolve_playback(
log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e)
raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e
except TubePageError as e:
# Tube page is gone (404/410), mark dead, propagate as 410. Inne 5xx → 502.
# Tube page is gone (404/410) — mark dead, propagate as 410. Inne 5xx → 502.
if e.status_code in (404, 410):
reason = f"tube page {e.status_code} {pb.page_url}"
pb.dead_at = datetime.now(UTC)
@ -444,7 +397,7 @@ def resolve_playback(
) from e
if not sources:
# Extractor None, TRANSIENT failure (network glitch, tube chwilowy 503,
# Extractor None — TRANSIENT failure (network glitch, tube chwilowy 503,
# ad-network response zmieniony, race condition). NIE oznaczamy `dead_at`
# bo wcześniej powodowało false-positive permanent dead dla freshporno scen
# które działały przy następnym attempt (bug-report 2026-05-12).
@ -457,9 +410,9 @@ def resolve_playback(
"extractor None for playback %s (origin=%s) — transient, not marking dead",
pb.id, pb.origin,
)
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film", ten kod
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film" — ten kod
# jest dla permanent removal. 503 = transient, user może retry.
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py),
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py) —
# bez tego GOON-3 spam-floodował issue list (16 events/5h dla expected case).
raise HTTPException(
status_code=503,
@ -493,7 +446,7 @@ DEFAULT_PLAYER_UA = (
def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
"""Wzbogaca StreamLink o:
- `stream_url`: proxy URL (fallback gdy direct fails)
- `direct_url`: surowy CDN URL (preferowany, 0 VPS bandwidth)
- `direct_url`: surowy CDN URL (preferowany — 0 VPS bandwidth)
- `headers`: Referer + User-Agent dla direct fetch
Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410.
"""
@ -509,21 +462,21 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate"))
# force_proxy=True (extractor flag) → direct_url=proxied od razu. Dla CDN-ów
# gdzie token IS bound do VPS IP (porn00 v-acctoken, pornxp sv.porn-xp.com
# signed path), mobile direct ZAWSZE 403, więc nie ma sensu próbować.
# signed path) — mobile direct ZAWSZE 403, więc nie ma sensu próbować.
# Bez tego: każdy playback "mrugnie" (direct fail → fallback na proxy).
force_proxy = bool(link.raw and link.raw.get("force_proxy"))
# mobile_direct_ok=True (extractor flag) → m3u8 może iść direct do mobile bo
# CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera
# manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
mobile_direct_ok = bool(link.raw and link.raw.get("mobile_direct_ok"))
# Auto-detect time-bound CDN po domain, bez per-extractor flag setting.
# Auto-detect time-bound CDN po domain — bez per-extractor flag setting.
# Critical dla public release: wszystkie mainstream tubes (xvideos/xnxx/pornhub/
# youporn/redtube + pornhat) zwracają time-bound URLs które działają cross-IP.
if not mobile_direct_ok and raw_url and _TIME_BOUND_CDN_RE.search(raw_url):
mobile_direct_ok = True
refetch_url = (link.raw or {}).get("refetch_url")
refetch_hoster = (link.raw or {}).get("refetch_hoster")
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL, `/proxy/hls`
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL — `/proxy/hls`
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
manifest_producer = (link.raw or {}).get("manifest_producer")
token = make_token(
@ -545,15 +498,15 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
else:
ext = "mp4"
proxied = f"/proxy/{token}/play.{ext}"
# `direct_url`: surowy CDN URL, mobile próbuje go PIERWSZY (0 VPS bandwidth).
# `direct_url`: surowy CDN URL — mobile próbuje go PIERWSZY (0 VPS bandwidth).
# ALE: dla type=m3u8/hls/mpd manifest URL musi być rewritowany żeby segmenty/keys
# też leciały przez proxy (inne IP może też mieć rate limit/token issues), plus
# ExoPlayer wybiera extractor po URL extension, `.mp4` w direct URL pornhat
# ExoPlayer wybiera extractor po URL extension — `.mp4` w direct URL pornhat
# → Mp4Extractor → fail bo content to HLS. Dla m3u8/mpd zwracamy proxied JAKO
# direct (mobile używa go bezpośrednio, 1 hop przez VPS ale to jedyny sposób
# żeby manifest+segments były spójne i ExoPlayer wybrał HlsMediaSource).
# Dla CDNs które wymagają chrome JA3 (mxcontent) direct_url też zawsze przez
# proxy, bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
# proxy — bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
# → extra round-trip + ExoPlayer "no extractors" przed retry.
# mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token
# (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy.

View file

@ -1,8 +1,8 @@
"""Playback telemetry, POST /playback-events (fire-and-forget z apki).
"""Playback telemetry — POST /playback-events (fire-and-forget z apki).
Apka po każdej próbie odtwarzania woła to z origin sceny + wynikiem (success/error).
Sygnał zasila health/szybkość w rankingu źródeł (run_source_stats agreguje okno 7d).
Best-effort: apka NIE czeka i ignoruje błąd, telemetria nie może psuć playbacku.
Best-effort: apka NIE czeka i ignoruje błąd — telemetria nie może psuć playbacku.
"""
from __future__ import annotations
@ -22,12 +22,12 @@ router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[
class PlaybackEventIn(BaseModel):
# 'tube:<sitetag>', zgodne z playback_sources.origin. Bez tego ping bezużyteczny.
# 'tube:<sitetag>' — zgodne z playback_sources.origin. Bez tego ping bezużyteczny.
origin: str = Field(min_length=1, max_length=64)
status: Literal["success", "error"]
scene_id: uuid.UUID | None = None
error_kind: str | None = Field(default=None, max_length=64)
# Surowa treść błędu z native playera (ExoPlayer message), diagnostyka realnych
# Surowa treść błędu z native playera (ExoPlayer message) — diagnostyka realnych
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
error_detail: str | None = Field(default=None, max_length=512)
ttff_ms: int | None = Field(default=None, ge=0, le=600_000)

View file

@ -1,12 +1,12 @@
"""Saved searches, zapisane słowa kluczowe per urządzenie.
"""Saved searches — zapisane słowa kluczowe per urządzenie.
User-report mobilism: sceny słabo opisane → user chce zapisywać często używane
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
Endpointy:
GET /saved-searches lista zapisów tego urządzenia (najnowsze pierwsze)
POST /saved-searches dodaj {query} (idempotent na device+query)
DELETE /saved-searches/{id} usuń
GET /saved-searches — lista zapisów tego urządzenia (najnowsze pierwsze)
POST /saved-searches — dodaj {query} (idempotent na device+query)
DELETE /saved-searches/{id} — usuń
"""
from __future__ import annotations
@ -17,7 +17,6 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.device import get_device_id
@ -31,7 +30,7 @@ router = APIRouter(
dependencies=[Depends(require_api_key)],
)
# Limit zapisów per urządzenie, to wygodny skrót, nie nieograniczona kolekcja.
# Limit zapisów per urządzenie — to wygodny skrót, nie nieograniczona kolekcja.
MAX_PER_DEVICE = 50
@ -72,7 +71,7 @@ def add_saved_search(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SavedSearch:
# Idempotent, ten sam (device, query) zwraca istniejący wiersz.
# Idempotent — ten sam (device, query) zwraca istniejący wiersz.
existing = session.execute(
select(SavedSearch).where(
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
@ -90,23 +89,7 @@ def add_saved_search(
)
row = SavedSearch(device_id=device_id, query=payload.query)
session.add(row)
try:
session.commit()
except IntegrityError:
# Wyscig na `uq_saved_searches_device_query`: sprawdzenie wyzej i INSERT to dwie
# osobne operacje, wiec dwa rownolegle zapisy tego samego zapytania (podwojne
# tapniecie, ponowienie po timeoucie) oba widza pusto i oba wstawiaja. Drugi
# dostawal 500 zamiast idempotentnej odpowiedzi (GOON-4Y). Skoro wiersz juz
# istnieje, to dokladnie ten, ktory user chcial zapisac, wiec go oddajemy.
session.rollback()
existing = session.execute(
select(SavedSearch).where(
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
)
).scalar_one_or_none()
if existing is None:
raise
return existing
session.refresh(row)
return row

View file

@ -1,9 +1,9 @@
"""Scene favorites, ulubione sceny (single-user, równolegle do /favorites/performers).
"""Scene favorites — ulubione sceny (single-user, równolegle do /favorites/performers).
Endpointy:
GET /scene-favorites lista ulubionych scen (pełen SceneOut)
POST /scene-favorites/{scene_id} dodaj (idempotent)
DELETE /scene-favorites/{scene_id} usuń
GET /scene-favorites — lista ulubionych scen (pełen SceneOut)
POST /scene-favorites/{scene_id} — dodaj (idempotent)
DELETE /scene-favorites/{scene_id} — usuń
"""
from __future__ import annotations
@ -16,7 +16,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.api.scenes import _build_scenes_out_batch
from app.api.scenes import _build_scene_out
from app.api.schemas import SceneOut
from app.auth import require_api_key
from app.db import get_session
@ -54,11 +54,7 @@ def list_scene_favorites(
)
.all()
)
# Wsadowo jak listy scen: ekran ulubionych rysuje ten sam kafelek, a pojedyncze
# _build_scene_out robilo 7 zapytan na scene (setki ulubionych = sekundy).
items = _build_scenes_out_batch(
session, [scene for scene, _ in rows], light=True, device_id=device_id
)
items = [_build_scene_out(session, scene, device_id=device_id) for scene, _ in rows]
return SceneFavoriteListOut(items=items, total=len(items))

View file

@ -1,15 +1,14 @@
"""GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
"""GET /scenes — lista i szczegóły scen z bazy kanonicznej."""
from __future__ import annotations
import logging
import re
import hashlib
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import Date, and_, distinct, exists, false, func, literal_column, select, union
from sqlalchemy import distinct, exists, false, func, literal_column, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@ -30,7 +29,6 @@ from app.models.favorite_scene import FavoriteScene
from app.models.performer import Performer, PerformerExternalRef
from app.models.play_progress import ScenePlayProgress
from app.models.playback_source import PlaybackSource
from app import verticals
from app.models.scene import Scene, SceneExternalRef, ScenePerformer, SceneTag
from app.models.source import Source, SourceKind
from app.models.studio import Studio
@ -41,29 +39,7 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)])
_VALID_SORTS = {
"created_at",
"release_date",
"title",
"studio",
"random",
"duration_desc",
"duration_asc",
}
def _effective_date_desc():
"""`release_date`, a gdy go brak, dzien trafienia sceny do katalogu, malejaco.
MUSI byc identyczne z wyrazeniem w `ix_scenes_effective_date_desc`, inaczej planner
indeksu nie uzyje i wroci do pelnego skanu `scenes`. `created_at::date` NIE nadaje
sie do indeksu, bo zalezy od strefy czasowej sesji (stable, nie immutable), stad
jawne AT TIME ZONE 'UTC'.
"""
return func.coalesce(
Scene.release_date,
func.cast(func.timezone("UTC", Scene.created_at), Date),
).desc()
_VALID_SORTS = {"created_at", "release_date", "title", "studio"}
# TTL-cache dla count'u scen-z-żywym-playback (default lista bez filtra). Full-scan
# 1.69M scen + EXISTS ~950ms; liczba zmienia się wolno i jest przybliżona (header
@ -84,8 +60,8 @@ def _default_scene_count(session: Session) -> int:
PlaybackSource.dead_at.is_(None),
)
),
# Domyślny feed wyklucza wertykały (JAV, animacja) → licznik też.
~_vertical_source_exists(),
# Domyślny feed wyklucza JAV (osobna sekcja) → licznik też.
~_jav_source_exists(),
).subquery()
)
total = session.execute(count_query).scalar_one()
@ -94,36 +70,30 @@ def _default_scene_count(session: Session) -> int:
return total
# Wertykały (JAV, animacja) mają własne sekcje w apce i NIE wchodzą do głównego feedu.
# Definicje i uzasadnienie: `app/verticals.py`. Tu zostaje tylko budowa predykatu.
# `JAV_ORIGINS` re-eksportowane, bo importują je inne moduły i skrypty.
JAV_ORIGINS = verticals.JAV_ORIGINS
# JAV vertical — osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace
# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem,
# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna.
JAV_ORIGINS = ("tube:javflix", "tube:javguru", "tube:vjav", "tube:supjav")
def _vertical_source_exists(vertical: str | None = None):
"""EXISTS żywego źródła z originem wertykału. `None` → DOWOLNY wertykał, czyli
predykat, którym główny feed odcina je wszystkie naraz."""
def _jav_source_exists():
return exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.in_(verticals.origins_for(vertical)),
PlaybackSource.origin.in_(JAV_ORIGINS),
)
)
def _jav_source_exists():
"""Zachowane pod starą nazwą, używane przez `/scenes/{id}/similar` i testy."""
return _vertical_source_exists("jav")
# Blacklisty (performer/studio/tag) są zwykle PUSTE (self-hosted, single-user). Mimo to
# 3 NOT EXISTS klauzule doklejały się do KAŻDEJ filtrowanej listy scen i były ewaluowane
# per-row, przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
# per-row — przy filtrze typu duży-tag/has_playback planer chodzi po ~176k scen, więc te
# puste-zawsze klauzule kosztowały ~3.4s (mega-tag „anal": 6.7s→3.3s po pominięciu).
# Cache'ujemy emptiness (TTL 5 min); gdy ktoś doda blacklist-wpis, w ciągu 5 min klauzule
# wracają. Patrz reference_scenes_list_perf / task #22.
# Cache per device_id (blacklisty są teraz device-scoped, bug 2026-06-08).
# Cache per device_id (blacklisty są teraz device-scoped — bug 2026-06-08).
_BLACKLIST_EMPTY_CACHE: dict[str, tuple[float, bool]] = {}
_BLACKLIST_EMPTY_TTL = 300.0
@ -271,14 +241,6 @@ def list_scenes(
"z jedynym playback z hqporner (~7-min Brazzers trailer clipy zalewają katalog)."
),
),
vertical: str | None = Query(
default=None,
description=(
"Wertykał do pokazania: 'jav' albo 'animation'. Brak → główny feed, "
"czyli WSZYSTKIE wertykały wykluczone. Zastępuje starsze `jav=true`, "
"które zostaje dla instalacji sprzed sierpnia 2026."
),
),
jav: bool = Query(
default=False,
description=(
@ -286,15 +248,7 @@ def list_scenes(
"JAV to osobna sekcja w apce, nie zalewa głównego feedu (origin javflix/javguru/vjav/supjav)."
),
),
sort: str = Query(
default="created_at",
description="created_at|release_date|title|studio|random|duration_desc|duration_asc",
),
seed: str | None = Query(
default=None,
max_length=64,
description="Dla sort=random: ten sam seed daje ten sam porzadek na kolejnych stronach.",
),
sort: str = Query(default="created_at", description="created_at|release_date|title|studio"),
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=200),
) -> SceneListOut:
@ -329,7 +283,7 @@ def list_scenes(
if len(tag_slug_list) > _MAX_TAGS:
raise HTTPException(status_code=422, detail=f"too many tag filters (max {_MAX_TAGS})")
# AND między tagami: scena musi mieć WSZYSTKIE zaznaczone tagi. Każdy slug → osobny
# exists(), zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika.
# exists() — zaznaczanie kolejnych filtrów zawęża wyniki, jak intuicja użytkownika.
#
# PERF (2026-06-07): resolvujemy slug→tag_id w aplikacji i filtrujemy po LITERALNYM
# tag_id (NIE JOIN po Tag.slug). Z literałem planner zna kardynalność tagu ze
@ -383,49 +337,32 @@ def list_scenes(
base = base.where(~live_playback_exists())
if origin:
# Pelny origin z ekranu strony ('tube:hdporngg') porownujemy wprost: rownosc idzie
# po ix_playback_sources_origin_live, a ILIKE '%...%' przeszukiwal kazde zrodlo
# kazdej kandydujacej sceny (wyszukiwanie w obrebie strony 4,6 s, Sentry Slow DB
# Query 2026-09-15). Krotka forma z filtra ('hqporner') zostaje substringiem.
origin_clause = (
PlaybackSource.origin == origin
if ":" in origin
else PlaybackSource.origin.ilike(f"%{origin}%")
)
# Substring match na origin — 'hqporner' złapie 'tube:hqpornercom'.
base = base.where(
exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
origin_clause,
PlaybackSource.origin.ilike(f"%{origin}%"),
)
)
)
# Brama wertykałów: domyślnie WSZYSTKIE wykluczone z głównego feedu, `?vertical=X`
# → TYLKO ten wertykał. ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/
# vjav/hentaigasm → SiteScenes), bramy nie stosujemy, bo jawnie chce ten origin
# (regresja "No videos" na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko
# głównego feedu (origin=None).
wanted = vertical if vertical in verticals.VERTICALS else ("jav" if jav else None)
if wanted:
base = base.where(_vertical_source_exists(wanted))
# JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV.
# ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes),
# bramy nie stosujemy — jawnie chce ten origin, więc pokazujemy (regresja "No videos"
# na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None).
if jav:
base = base.where(_jav_source_exists())
elif origin is None:
base = base.where(~_vertical_source_exists())
base = base.where(~_jav_source_exists())
# Blacklisty device (performer/studio/tag), globalne wykluczenia, współdzielone z
# Blacklisty device (performer/studio/tag) — globalne wykluczenia, współdzielone z
# licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu).
for _bl_clause in blacklist_clauses(session, device_id):
base = base.where(_bl_clause)
# `min_duration_sec=0` znaczy "bez progu", a NIE `duration_sec >= 0`. To rozroznienie
# jest tu istotne, bo w SQL NULL nie przechodzi ZADNEGO porownania: przy `>= 0` scena
# o NIEZNANEJ dlugosci wypadala tak samo jak dziesieciosekundowy smiec. Dla serwisow,
# ktore dlugosci nie podaja w ogole (hentaimama, javflix, supjav, hentaigasm,
# hentaiplay, xanimeporn maja 100% NULL), lista wychodzila PUSTA, mimo ze wejscie w
# konkretny serwis jest jawna prosba usera o jego zawartosc. Prog > 0 dalej odcina
# NULL-e swiadomie, bo tam filtrujemy smieci w glownym feedzie.
if min_duration_sec:
if min_duration_sec is not None:
base = base.where(Scene.duration_sec >= min_duration_sec)
if max_duration_sec is not None:
base = base.where(Scene.duration_sec <= max_duration_sec)
@ -436,14 +373,28 @@ def list_scenes(
base = base.where(Scene.release_date >= cutoff)
if min_quality_p is not None:
# Skalarny filtr po zdenormalizowanej kolumnie. Wcześniejsze podejścia (EXISTS
# na playback_sources, potem JOIN z DISTINCT) dawały 32-52 s na produkcji, bo
# planer i tak przechodził `scenes` po `created_at` scena po scenie. Pomiary
# i uzasadnienie: migracja 0029_scenes_max_quality.
#
# NULL (93,5% katalogu) NIE przechodzi, i tak ma być: filtr obiecuje jakość
# potwierdzoną, a nie zgadywaną. UI mówi o tym wprost pod przełącznikiem.
base = base.where(Scene.max_quality_p >= min_quality_p)
# PlaybackSource.quality to wolny string — szukamy liczb w prefixie ('1080p',
# '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy
# playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160.
from sqlalchemy import Integer, cast, or_
numeric_q = cast(
func.coalesce(func.substring(PlaybackSource.quality, r"\d+"), "0"),
Integer,
)
conds = [numeric_q >= min_quality_p]
if min_quality_p <= 2160:
conds.append(PlaybackSource.quality.ilike("%4k%"))
conds.append(PlaybackSource.quality.ilike("%uhd%"))
base = base.where(
exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
PlaybackSource.quality.isnot(None),
or_(*conds),
)
)
)
if not include_stubs:
# Stub scene heuristic (współdzielona z licznikiem +N ulubionych): tube-only scena
@ -455,9 +406,9 @@ def list_scenes(
_is_pure_default = (
not include_stubs and not q and not studio_slug_list and not tag_slug_list
and not perf_id_strings and origin is None and has_playback is None
and not min_duration_sec
and min_duration_sec is None
and max_duration_sec is None and released_within_days is None
and min_quality_p is None and not jav and vertical is None
and min_quality_p is None and not jav
)
# Count strategy:
# - PURE default: cached pełny licznik katalogu (TTL 10 min).
@ -465,88 +416,45 @@ def list_scenes(
# dominującym kosztem (~4s na has_playback / min_duration / duży tag) i plan
# był NIESTABILNY (literal LIMIT + count-nad-PK pomogły w części przypadków,
# ale planer i tak czasem skanuje cały zbiór zamiast urwać). Mobile paginuje
# po `has_more` (per_page+1 fetch), NIE po `total`, `total` to tylko licznik
# po `has_more` (per_page+1 fetch), NIE po `total` — `total` to tylko licznik
# "N+" w UI. Wyprowadzamy go z has_more PO fetchu (patrz niżej): dolna granica
# + flaga "jest więcej". Eliminuje cały koszt count z każdej filtrowanej listy.
total_capped = False
total: int | None = _default_scene_count(session) if _is_pure_default else None
# Sort: KAŻDY tryb kończy się `Scene.id.desc()`, unikalny PK jako ostateczny
# Sort: KAŻDY tryb kończy się `Scene.id.desc()` — unikalny PK jako ostateczny
# tie-break. Bez tego przy OFFSET-paginacji sceny z IDENTYCZNYM created_at (masowy
# ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek,
# więc kolejne strony nakładają się na siebie → feed "powtarza się" przy scrollu
# (report 304c9258). created_at desc dalej pierwszy, więc index-walk zostaje.
if sort == "release_date":
# Data premiery LUB, gdy jej nie ma, data trafienia do katalogu. Czyste
# `release_date DESC NULLS LAST` chowalo swiezosc, bo tuby daty premiery zwykle
# NIE podaja: xhamster 99,2% scen bez daty, xnxx 97,7%, porntrex 95,8%, hqporner
# 93,5%, sxyprn 85,7%. Wszystkie NULL-e ladowaly za datowanymi, wiec 82 z 91 scen
# dodanych w hqporner przez tydzien siedzialo za 3,4 tys. starszych pozycji i
# user widzial "brak nowych scen" (zgloszenie 1df50de3), mimo ze ingest dzialal.
# Sceny z prawdziwa data (kanon z TPDB/StashDB) sortuja sie jak dotad.
# Wyrazenie musi byc IMMUTABLE, zeby weszlo do indeksu: `created_at::date` zalezy
# od strefy sesji, stad jawne AT TIME ZONE 'UTC'. Indeks:
# ix_scenes_effective_date_desc (migracja 0033).
ordered = base.order_by(
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
Scene.release_date.desc().nullslast(), Scene.created_at.desc(), Scene.id.desc()
)
elif sort == "title":
ordered = base.order_by(
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
)
elif sort == "studio":
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze.
# Sceny bez studio na końcu; w obrębie studio — najświeższe pierwsze.
ordered = (
base.outerjoin(Studio, Studio.id == Scene.studio_id)
.order_by(
Studio.name_normalized.asc().nullslast(),
_effective_date_desc(),
Scene.release_date.desc().nullslast(),
Scene.created_at.desc(),
Scene.id.desc(),
)
)
elif sort in ("duration_desc", "duration_asc"):
# Najdluzsze / najkrotsze (zgloszenie ae6a045b). Sceny bez dlugosci odpadaja,
# dzieki czemu jeden indeks (duration_sec, created_at, id) WHERE duration_sec
# IS NOT NULL obsluguje oba kierunki skanem w przod i w tyl (migracja 0035).
dur = Scene.duration_sec.desc() if sort == "duration_desc" else Scene.duration_sec.asc()
created = Scene.created_at.desc() if sort == "duration_desc" else Scene.created_at.asc()
ident = Scene.id.desc() if sort == "duration_desc" else Scene.id.asc()
# Gorny limit 10 h: tuby raportuja czasem absurdy (rekord 130 h), ktore
# zasmiecalyby szczyt listy „najdluzsze".
ordered = (
base.where(Scene.duration_sec.isnot(None), Scene.duration_sec <= 36000)
.order_by(dur, created, ident)
)
elif sort == "random":
# Losowo bez sortowania milionow wierszy: id to losowy UUID, wiec porzadek po id
# od losowego punktu startu JEST losowy i idzie po indeksie PK. Punkt startu
# wynika z seeda (md5 -> uuid), wiec paginacja jest stabilna, a nowy seed
# (pull-to-refresh w apce) daje nowe rozdanie. Za koncem zakresu lista sie konczy.
start = uuid.UUID(hashlib.md5((seed or uuid.uuid4().hex).encode("utf-8")).hexdigest())
# Okno kandydatow po PK z LIMIT, a filtry (EXISTS/NOT EXISTS) dopiero na nim.
# Bez okna planer robil Merge Anti Join z indeksem playback_sources po scene_id
# i przechodzil 2,3 mln wierszy (7,6 s), bo ORDER BY id pasowal do obu indeksow.
# Z oknem 2000 id to nested loop po kilkudziesieciu wierszach (pomiar 2026-09-14).
# Okno rosnie ze strona, zeby glebsze strony mialy z czego wybierac. Sekcje
# wertykalne (JAV, Anime) to ~2% katalogu, wiec dostaja 4x szersze okno; z 2000 id
# JAV wyciagal 27 scen i druga strona bylaby pusta. Szersze okno dla wszystkich
# nie wchodzi w gre: z filtrem tagu 6000 id to 2,7 s (probe scene_tags per scena).
base_window = 8000 if (vertical or jav) else 2000
window = max(base_window, page * per_page * 4)
cand = (
select(Scene.id).where(Scene.id > start).order_by(Scene.id.asc()).limit(window).subquery()
)
ordered = base.where(Scene.id.in_(select(cand.c.id))).order_by(Scene.id.asc())
else: # created_at
ordered = base.order_by(
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()
)
# Fetch per_page+1, obecność (per_page+1)-szego wiersza = jest kolejna strona.
# Fetch per_page+1 — obecność (per_page+1)-szego wiersza = jest kolejna strona.
# To źródło prawdy dla paginacji (mobile getNextPageParam), niezależne od bounded
# `total`. Nadmiarowy wiersz odcinamy przed serializacją.
# LIMIT/OFFSET literalne (NIE bound-param), patrz wyżej: sparametryzowany LIMIT
# LIMIT/OFFSET literalne (NIE bound-param) — patrz wyżej: sparametryzowany LIMIT
# psuje early-termination i przy filtrach EXISTS planer robi gather-all+sort (sekundy)
# zamiast limit-aware index-walk po `ix_scenes_created_at_desc`. page/per_page to
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
@ -567,9 +475,7 @@ def list_scenes(
total = (page - 1) * per_page + len(rows)
total_capped = has_more
items = _build_scenes_out_batch(
session, list(rows), light=True, device_id=device_id, prefer_origin=origin
)
items = _build_scenes_out_batch(session, list(rows), light=True, device_id=device_id)
return SceneListOut(
items=items,
@ -600,14 +506,14 @@ def similar_scenes(
device_id: Annotated[str, Depends(get_device_id)],
limit: int = Query(default=12, ge=1, le=30),
) -> SceneListOut:
"""Sceny podobne do danej, ranking po WSPÓLNYCH performerach (mocny sygnał) i
"""Sceny podobne do danej — ranking po WSPÓLNYCH performerach (mocny sygnał) i
tagach (słabszy). Zwraca tylko grywalne sceny (żywy playback), zgodne z bramą JAV
(JAV↔JAV, nie-JAV↔nie-JAV), z pominięciem blacklist/stubów/samej siebie.
Wydajność: performerzy są selektywni, więc pula kandydatów z nich jest mała (cap
_CAND_CAP). Tagi liczymy TYLKO w obrębie tej puli (tanio). Fill z tagów (bounded
_TAG_FILL_CAP, HAVING >=2 wspólne) odpala się gdy scena ma mało/zero performerów
(anonimowe tube sceny), inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy
(anonimowe tube sceny) — inaczej szeroki tag typu 'blowjob' skanowałby setki tysięcy
wierszy. Cały ranking i sort robimy w Pythonie nad OGRANICZONYMI zbiorami id."""
scene = session.get(Scene, scene_id)
if scene is None:
@ -621,7 +527,7 @@ def similar_scenes(
select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id)
).scalars().all()
# Odsiej ŚMIECIOWYCH performerów: kategorie/studia podszywające się pod performera
# (over-attribution z luźnego tube-searcha, np. "Big Tits", "Brazzers", "Ball Licking").
# (over-attribution z luźnego tube-searcha — np. "Big Tits", "Brazzers", "Ball Licking").
# Sygnał: slug pokrywa się z tagiem I performer NIE ma kanonicznego refa (tpdb/stashdb).
# Prawdziwi performerzy z tym samym tagiem (tuby tagują sceny nazwiskiem) mają kanoniczny
# ref → zostają. Bez tego similar klastrował się po "Ball Licking" zamiast po treści.
@ -678,38 +584,15 @@ def similar_scenes(
.group_by(SceneTag.scene_id)
).all()
tag_shared = {r[0]: r[1] for r in rows}
# Fill z tagów gdy performerów mało/brak (anonimowe tube sceny), inaczej pomijamy,
# Fill z tagów gdy performerów mało/brak (anonimowe tube sceny) — inaczej pomijamy,
# bo szeroki tag skanuje setki tysięcy scen.
if tag_ids and len(cand_ids) < limit * 4:
# Pula z NAJRZADSZYCH tagów (najwięcej mówią o scenie), po _TAG_SEED_ROWS scen na
# tag, a wspólne tagi liczymy tylko dla tej puli. Grupowanie po wszystkich tagach
# sceny naraz czytało całe 'amateur' + 'teen-18' + 'handjob' (600 tys. wierszy,
# do 5 s). Sceny, które łączą z celem wyłącznie szerokie tagi, i tak są słabo
# podobne, więc ich pominięcie nic nie psuje.
_TAG_SEED_TAGS, _TAG_SEED_ROWS = 4, 1500
tag_sizes = dict(
session.execute(select(Tag.id, Tag.scene_count).where(Tag.id.in_(tag_ids))).all()
)
live_tags = [t for t in tag_ids if (tag_sizes.get(t) or 0) > 0] or list(tag_ids)
seed_tags = sorted(live_tags, key=lambda t: tag_sizes.get(t) or 0)[:_TAG_SEED_TAGS]
pool = union(
*[
select(SceneTag.scene_id)
.where(SceneTag.tag_id == t, SceneTag.scene_id != scene_id)
.limit(_TAG_SEED_ROWS)
for t in seed_tags
]
).subquery("pool")
shared = func.count(distinct(SceneTag.tag_id))
rows = session.execute(
select(pool.c.scene_id, shared.label("c"))
.join(
SceneTag,
and_(SceneTag.scene_id == pool.c.scene_id, SceneTag.tag_id.in_(tag_ids)),
)
.group_by(pool.c.scene_id)
.having(shared >= 2)
.order_by(shared.desc())
select(SceneTag.scene_id, func.count(distinct(SceneTag.tag_id)).label("c"))
.where(SceneTag.tag_id.in_(tag_ids), SceneTag.scene_id != scene_id)
.group_by(SceneTag.scene_id)
.having(func.count(distinct(SceneTag.tag_id)) >= 2)
.order_by(func.count(distinct(SceneTag.tag_id)).desc())
.limit(_TAG_FILL_CAP)
).all()
for sid, c in rows:
@ -719,24 +602,19 @@ def similar_scenes(
if not cand_ids:
return _empty
# Filtr widoczności (ta sama definicja co lista): grywalne + ten sam wertykał +
# Filtr widoczności (ta sama definicja co lista): grywalne + zgodne z bramą JAV +
# blacklist + nie-stub + nie-self.
# Podobne muszą zostać w tym samym wertykale co scena-cel: dla sceny animowanej
# nie proponujemy zachodnich i odwrotnie (inne namespace'y, zero dedupu).
target_vertical: str | None = None
for _name in verticals.VERTICALS:
if session.execute(
select(_vertical_source_exists(_name)).select_from(Scene).where(Scene.id == scene_id)
).scalar():
target_vertical = _name
break
target_is_jav = bool(
session.execute(
select(_jav_source_exists()).select_from(Scene).where(Scene.id == scene_id)
).scalar()
)
filt = select(Scene.id).where(
Scene.id.in_(cand_ids),
Scene.id != scene_id,
live_playback_exists(),
stub_exclusion_clause(),
_vertical_source_exists(target_vertical) if target_vertical
else ~_vertical_source_exists(),
_jav_source_exists() if target_is_jav else ~_jav_source_exists(),
)
for _bl in blacklist_clauses(session, device_id):
filt = filt.where(_bl)
@ -764,7 +642,7 @@ _SXYPRN_POST_RE = re.compile(r"sxyprn\.com/post/([0-9a-f]{6,40})", re.IGNORECASE
def _sxyprn_thumb_url(page_url: str | None) -> str | None:
"""Dla źródła sxyprn zwraca STABILNY endpoint on-demand resolvera
(`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL, token żyje ~1h,
(`/proxy/sxyprn-thumb/<id>`) zamiast martwego trafficdeposit URL — token żyje ~1h,
więc poster resolvujemy przy serwowaniu (bug 2026-06-10)."""
if not page_url:
return None
@ -777,7 +655,7 @@ _YOUPORN_WATCH_RE = re.compile(r"youporn\.com/watch/(\d+)", re.IGNORECASE)
def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
"""Dla youporn z PODPISANYM posterem (imgproxy `?hash=...&validto=`) zwraca stabilny
on-demand endpoint `/proxy/youporn-thumb/<video_id>`, zapisany podpis wygasa i CDN
on-demand endpoint `/proxy/youporn-thumb/<video_id>` — zapisany podpis wygasa i CDN
oddaje 410 (bug-report bebf8c92; 8666 z 62930 źródeł). Statycznych `.../original/N.jpg`
NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy)."""
if not page_url or not thumb or "hash=" not in thumb:
@ -789,94 +667,40 @@ def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
def _is_rotting_thumb(url: str) -> bool:
"""sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po
~tygodniach, nie odświeżalne server-side; bug 2026-06-10). De-prioritize je w wyborze
slim-thumbnaila, używamy tylko gdy scena nie ma żadnej innej miniaturki."""
slim-thumbnaila — używamy tylko gdy scena nie ma żadnej innej miniaturki."""
if "trafficdeposit.com" in url:
return True
# imgs.xiaoshenke.net (fullporner) było tu wpisane 2026-08-02, bo host oddawał 200
# z ~153-bajtową atrapą zamiast obrazu (zgłoszenia a01dc891 / 2fbba822 / b437d5f7).
# To był stan PRZEJŚCIOWY: pomiar 2026-08-11 na 300 losowych URL-ach z bazy dał
# 298 prawdziwych JPEG-ów (5-25 kB), tak samo z VPS jak i z łącza domowego.
# Blokada zdjęta, bo szkodziła bardziej niż pomagała: w SceneDetail (linia ~879)
# rotting-thumb jest ZEROWANY, więc sceny mające tylko fullporner traciły miniaturę
# całkowicie („Jakiś problem z miniaturkami z fullporner"). Dotyczyło 53 tys. źródeł.
# Gdyby atrapy wróciły, filtruj po ROZMIARZE odpowiedzi w image-proxy, nie po hoście.
# tn.sextu.com (fullvideosporn), CDN odrzuca ruch z IP datacenter (429), więc nasze
# imgs.xiaoshenke.net (fullporner) oddaje 200 z ~153-bajtową ATRAPĄ zamiast obrazu.
# Formalnie „ma miniaturę", więc przechodziło warunek `find(s => s.thumbnail_url)`
# w apce i WYGRYWAŁO jako pierwsze źródło — a działająca miniatura (np. hqporner,
# 22 kB) leżała niżej i nigdy nie była użyta. Objawiało się dopiero po scaleniu scen
# („3 źródła live, a miniaturki nie ma", zgłoszenia a01dc891 / 2fbba822 / b437d5f7):
# przy jednym źródle brało się to dobre, po merge na czoło wchodziła atrapa.
if "imgs.xiaoshenke.net" in url:
return True
# tn.sextu.com (fullvideosporn) — CDN odrzuca ruch z IP datacenter (429), więc nasze
# image-proxy NIGDY tej miniatury nie pobierze (znany problem tego źródła). Traktujemy
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
if "tn.sextu.com" in url:
return True
# fastpic.org (galaxyporn i inne re-uploadery), hosting zrywa połączenie zamiast
# fastpic.org (galaxyporn i inne re-uploadery) — hosting zrywa połączenie zamiast
# oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk:
# scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony
# tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn.
return "fastpic.org" in url
def _is_flaky_thumb(url: str) -> bool:
"""Host, który działa BYWA, ale zbyt często pada, żeby wygrywać wybór miniaturki.
W odróżnieniu od `_is_rotting_thumb` NIE zerujemy takiej miniatury w SceneDetail:
to nie jest martwy URL, tylko niepewny. Zerowanie powtórzyłoby wpadkę z fullpornerem
z sierpnia 2026, gdzie sceny mające tylko to źródło traciły obrazek całkowicie.
Problem był w kolejności wyboru: miniaturkę wygrywał pierwszy origin alfabetycznie,
a `tube:fullmoviesxxx` sortuje się PRZED `tube:fullporner`, więc chwiejny host
przykrywał zdrowy. 51 799 z 52 758 takich scen (98%) ma alternatywę, więc degradacja
niemal zawsze ma czym zastąpić.
Pomiar 2026-08-13, po 20-25 losowych URL-ach na host:
img.fullmovies.xxx 12/20 ok (5× ReadTimeout, 3× 502) 54 019 źródeł
img.hdporn.gg 16/20 ok (3× 502, 1× ReadTimeout) 54 267 źródeł
imgs.xiaoshenke.net 20/20 ok (fullporner) 68 406 źródeł
freshporno.org 20/20 ok
`img.hdporn.gg` dopisany po sprawdzeniu efektu pierwszej wersji: degradacja samego
fullmovies przesunęła wybór na hdporn (44 z 60 scen), czyli przeniosłaby problem
zamiast go rozwiązać. Po dopisaniu obu wygrywają hosty ze 100% skutecznością.
"""
return "img.fullmovies.xxx" in url or "img.hdporn.gg" in url
#: Im niżej, tym gorzej. Używane TYLKO wewnątrz tieru fallback, czyli gdy scena nie ma
#: żadnej zdrowej miniatury i trzeba wybrać mniejsze zło. Bez tego decydowała kolejność
#: alfabetyczna originu, przez co scena mająca fullmovies + hdporn dostawała fullmovies,
#: czyli host o 40% awaryjności zamiast tego o 20%.
_FLAKY_RANK = (
("img.hdporn.gg", 1), # 16/20 ok
("img.fullmovies.xxx", 2), # 12/20 ok
)
def _flaky_rank(url: str) -> int:
for host, rank in _FLAKY_RANK:
if host in url:
return rank
return 0 # nie-chwiejny (martwy rotting-thumb) — bez preferencji
def _is_preferred_origin(origin: str | None, prefer_origin: str | None) -> bool:
"""Czy to origin, ktory user wlasnie przeglada (`/scenes?origin=`).
Porownanie substringiem, bo parametr API jest substringowy: `hqporner` ma zlapac
`tube:hqpornercom`.
"""
if not prefer_origin or not origin:
return False
return prefer_origin.lower() in origin.lower()
def _needs_proxy(url: str) -> bool:
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend.
Większość CDN-ów porn-tube'ów wymaga Refera (hqporner, mypornerleak/58img,
inne sxyprn/eporner CDN-y), expo-image nie wysyła Referera.
inne sxyprn/eporner CDN-y) — expo-image nie wysyła Referera.
Self-hosted lub backend-internal URL-e (zaczynające się od `/`) skipujemy."""
return url.startswith("http") and not url.startswith("/proxy/")
def _wrap_image_proxy(url: str, referer: str) -> str:
"""Wraps a thumbnail URL through /proxy/img/{token}/img.jpg. Klient nie musi
znać sekretu Referer, backend wstawi sam. Long TTL (30d) bo thumby
znać sekretu Referer — backend wstawi sam. Long TTL (30d) bo thumby
są stabilne, krótkie ttl by tylko niepotrzebnie zaśmiecało cache."""
from app.api.stream_proxy import make_token
# stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image
@ -889,16 +713,11 @@ def _wrap_image_proxy(url: str, referer: str) -> str:
def _build_scenes_out_batch(
session: Session,
scenes: list[Scene],
*,
light: bool = False,
device_id: str = LEGACY_DEVICE,
prefer_origin: str | None = None,
session: Session, scenes: list[Scene], *, light: bool = False, device_id: str = LEGACY_DEVICE
) -> list[SceneOut]:
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N).
Eliminuje N+1 z `_build_scene_out` w listach scen, `/scenes?per_page=24` szło
Eliminuje N+1 z `_build_scene_out` w listach scen — `/scenes?per_page=24` szło
z ~9.6s do <500ms. Pojedyncza scena (`/scenes/{id}`) nadal używa `_build_scene_out`
bo overhead na batch nie ma sensu dla N=1.
@ -941,7 +760,7 @@ def _build_scenes_out_batch(
)
)
# 3) Tags + 4) External refs, kafelek listy ich nie używa; w light mode pomijamy
# 3) Tags + 4) External refs — kafelek listy ich nie używa; w light mode pomijamy
# (SceneDetail re-fetchuje pełną scenę przez /scenes/{id}).
tags_by_scene: dict = defaultdict(list)
refs_by_scene: dict = defaultdict(list)
@ -969,11 +788,9 @@ def _build_scenes_out_batch(
)
)
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę), kafelek
# 5) Playback sources. Light mode: tylko miniaturka (jedna na scenę) — kafelek
# czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url.
pb_by_scene: dict = defaultdict(list)
# origin per scena, wyłącznie do policzenia `vertical` (obie ścieżki go wypełniają).
origins_by_scene: dict = {}
if light:
pb_light = session.execute(
select(
@ -981,10 +798,6 @@ def _build_scenes_out_batch(
PlaybackSource.thumbnail_url,
PlaybackSource.animated_thumbnail_url,
PlaybackSource.page_url,
# origin dociągamy wyłącznie po to, żeby policzyć `vertical` (tania kolumna
# z tego samego wiersza, zero dodatkowych zapytań). Do samego kafelka
# nieużywany, slim wpis niżej dostaje origin="".
PlaybackSource.origin,
)
.where(
PlaybackSource.scene_id.in_(scene_ids),
@ -993,46 +806,31 @@ def _build_scenes_out_batch(
.order_by(PlaybackSource.origin.asc())
).all()
# Pierwsza miniaturka + pierwszy animated per scena (1 slim wpis). De-prioritize
# sxyprn/trafficdeposit thumbnaile, są podpisane czasowo i ROTUJĄ (asset 404 po
# sxyprn/trafficdeposit thumbnaile — są podpisane czasowo i ROTUJĄ (asset 404 po
# ~tygodniach, nie da się re-signować; bug 2026-06-10). Wolimy miniaturkę z innego
# źródła gdy istnieje; sxyprn bierzemy tylko gdy nic innego nie ma (świeże jeszcze
# działają, martwe → mobile pokazuje placeholder zamiast broken-image).
thumb_by_scene: dict = {}
thumb_fallback: dict = {}
anim_by_scene: dict = {}
for sid, thumb, anim, page_url, origin in pb_light:
origins_by_scene.setdefault(sid, []).append(origin)
for sid, thumb, anim, page_url in pb_light:
sxy = _sxyprn_thumb_url(page_url)
yp = _youporn_thumb_url(page_url, thumb)
if sxy:
# sxyprn → żywy on-demand resolver (martwy stored URL ignorujemy),
# tier fallback: użyty tylko gdy scena nie ma stabilniejszej miniatury.
thumb_fallback.setdefault(sid, (sxy, page_url, origin))
thumb_fallback.setdefault(sid, (sxy, page_url))
elif yp:
# youporn z podpisanym (wygasającym) posterem → on-demand resolver.
# Też tier fallback: statyczna miniatura z innego źródła jest tańsza.
thumb_fallback.setdefault(sid, (yp, page_url, origin))
thumb_fallback.setdefault(sid, (yp, page_url))
elif thumb:
if _is_rotting_thumb(thumb) or _is_flaky_thumb(thumb):
# Martwe ORAZ chwiejne lądują w tierze fallback: użyte tylko wtedy,
# gdy scena nie ma stabilniejszej miniatury z innego źródła. W obrębie
# samego fallbacku wybieramy mniejsze zło wg `_FLAKY_RANK`, bo inaczej
# decydowałaby kolejność alfabetyczna originu i scena z fullmovies +
# hdporn dostawała gorszy z dwóch (pomiar: 38 z 60 takich scen).
prev = thumb_fallback.get(sid)
if prev is None or _flaky_rank(thumb) < _flaky_rank(prev[0]):
thumb_fallback[sid] = (thumb, page_url, origin)
elif sid not in thumb_by_scene or _is_preferred_origin(origin, prefer_origin):
# Gdy user przeglada KONKRETNY serwis, jego miniaturka wygrywa.
# Wczesniej decydowala kolejnosc alfabetyczna originu, wiec na liscie
# hqporner kafelki scen wspoldzielonych z fullpornerem bralo
# `tube:fullporner` (< `tube:hqpornercom`), a czesc tych miniaturek to
# 153-bajtowa atrapa z imgs.xiaoshenke.net oddawana z kodem 200. Efekt:
# puste kafelki podpisane FULLPORNER na liscie hqporner, mimo ze ta
# sama scena miala obok zdrowa miniaturke hqporner (zgloszenie 1df50de3).
thumb_by_scene[sid] = (thumb, page_url, origin)
if anim and (sid not in anim_by_scene or _is_preferred_origin(origin, prefer_origin)):
anim_by_scene[sid] = (anim, page_url, origin)
if _is_rotting_thumb(thumb):
thumb_fallback.setdefault(sid, (thumb, page_url))
elif sid not in thumb_by_scene:
thumb_by_scene[sid] = (thumb, page_url)
if sid not in anim_by_scene and anim:
anim_by_scene[sid] = (anim, page_url)
# Uzupełnij scenami które mają TYLKO rotting thumbnail (sxyprn-only).
for sid, val in thumb_fallback.items():
thumb_by_scene.setdefault(sid, val)
@ -1048,12 +846,11 @@ def _build_scenes_out_batch(
t_url = _wrap_image_proxy(t_url, ref)
if a_url and _needs_proxy(a_url):
a_url = _wrap_image_proxy(a_url, ref)
# `origin` niesiemy PRAWDZIWY (źródła wybranej miniatury): kafelek pokazuje
# nazwę serwisu (element z makiety C, 2026-08-18). `id`/`page_url` zostają
# sentinelem, bo SceneDetail i tak re-fetchuje pełne źródła.
# id/origin/page_url wymagane przez schemat ale nieużywane przez kafelek
# (SceneDetail re-fetchuje pełne źródła) — dummy sentinel.
pb_by_scene[sid].append(
PlaybackSourceOut(
id=uuid.UUID(int=0), origin=(t or a)[2] or "", page_url="",
id=uuid.UUID(int=0), origin="", page_url="",
thumbnail_url=t_url, animated_thumbnail_url=a_url,
)
)
@ -1067,7 +864,6 @@ def _build_scenes_out_batch(
.order_by(PlaybackSource.origin.asc())
).scalars().all()
for p in pb_rows:
origins_by_scene.setdefault(p.scene_id, []).append(p.origin)
out = PlaybackSourceOut.model_validate(p)
# sxyprn/trafficdeposit stored thumb rotuje (404 po ~tygodniach). SceneDetail
# bierze PIERWSZE źródło z thumbem (origin ASC → sxyprncom często pierwsze), więc
@ -1129,8 +925,6 @@ def _build_scenes_out_batch(
tags=tags_by_scene.get(scene.id, []),
external_refs=refs_by_scene.get(scene.id, []),
playback_sources=pb_by_scene.get(scene.id, []),
vertical=verticals.vertical_for_origins(origins_by_scene.get(scene.id, ())),
max_quality_p=scene.max_quality_p,
created_at=scene.created_at,
backfill=scene.backfill,
last_played_at=progress.last_played_at if progress else None,
@ -1205,7 +999,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
)
# Collapse źródła dzielące ten sam origin (hoster). Zmergowana scena często agreguje
# kilka uploadów z JEDNEGO tube'a (re-enkody / wersje 4K: bug-report aa79a995 "2 linki,
# oba do porntrex" = ta sama scena std+4K), w UI to nierozróżnialne linki do tego
# oba do porntrex" = ta sama scena std+4K) — w UI to nierozróżnialne linki do tego
# samego hostera (resolvują tym samym extractorem). Zostawiamy jeden najlepszy per
# origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość →
# pierwszy (stabilnie, query jest origin-asc). Martwe już odfiltrowane (dead_at).
@ -1228,7 +1022,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
for p in playback_rows:
out = PlaybackSourceOut.model_validate(p)
# sxyprn: podmień martwy trafficdeposit URL na żywy on-demand resolver
# (/proxy/sxyprn-thumb/<id>), TAK JAK lista (_build_scenes_out_batch). Bez tego
# (/proxy/sxyprn-thumb/<id>) — TAK JAK lista (_build_scenes_out_batch). Bez tego
# detal sceny pokazywał martwą miniaturę sxyprn mimo że lista działała, i "refresh
# thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22.
sxy = _sxyprn_thumb_url(p.page_url)
@ -1240,7 +1034,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url):
out.thumbnail_url = None
# Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera
# (hqporner, fastporndelivery zwraca 403 bez Referer headera, expo-image
# (hqporner — fastporndelivery zwraca 403 bez Referer headera, expo-image
# nie wysyła go domyślnie). Token ma 30-dniowy TTL bo thumby są stabilne.
if out.thumbnail_url and _needs_proxy(out.thumbnail_url):
out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url)
@ -1279,8 +1073,6 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
tags=tags_out,
external_refs=refs_out,
playback_sources=playback_out,
vertical=verticals.vertical_for_origins(p.origin for p in playback_rows),
max_quality_p=scene.max_quality_p,
created_at=scene.created_at,
backfill=scene.backfill,
last_played_at=progress.last_played_at if progress else None,
@ -1298,7 +1090,7 @@ def remove_tag_from_scene(
) -> None:
"""Usuwa relację scene↔tag (np. user uznał że tag jest błędny dla tej sceny).
Idempotent: brak relacji = success. Nie kasuje samego Tag-a, inne sceny mogą
Idempotent: brak relacji = success. Nie kasuje samego Tag-a — inne sceny mogą
z niego korzystać. Sam tag zostaje w słowniku tagów.
"""
rel = session.execute(
@ -1350,7 +1142,7 @@ def hide_scene(
) -> SceneHideOut:
"""Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources
jako dead → scena wypada z list (has_playback=false). Odwracalne w DB (dead_at).
Nie kasujemy wiersza sceny, zachowujemy refs/dedup, tylko znika z UI."""
Nie kasujemy wiersza sceny — zachowujemy refs/dedup, tylko znika z UI."""
from datetime import UTC, datetime
from app.models.playback_source import PlaybackSource
@ -1481,45 +1273,27 @@ def enrich_tags_from_tube(
# Upsert: dla każdego taga utwórz/znajdź Tag, dorzuć SceneTag idempotentnie.
# Używamy PostgreSQL INSERT ... ON CONFLICT DO NOTHING zamiast ORM session.add()
# bo `resolve_tag` robi session.flush() w pętli, emitując pending SceneTag INSERT
# z poprzednich iteracji, gdy 2 concurrent enrich-tags collide na tym samym
# z poprzednich iteracji — gdy 2 concurrent enrich-tags collide na tym samym
# (scene_id, tag_id), drugi flush dostaje UniqueViolation (GOON-H, 4 events
# w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently.
from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.normalize.tag_stoplist import is_junk_tag
# Istniejące tagi jednym zapytaniem po slugach. resolve_tag robi 1-4 zapytania na
# tag (Sentry N+1 przy 20-40 tagach ze strony), więc woła się go już tylko dla
# nowych nazw i dla slugów z końcówką litera+"2", które kanonizuje po swojemu.
slugs = {slugify(n) for n in tag_names}
direct = [
s for s in slugs
if s and len(s) <= 120 and not is_junk_tag(s)
and not (len(s) > 1 and s[-1] == "2" and s[-2].isalpha())
]
by_slug = (
{t.slug: t for t in session.execute(select(Tag).where(Tag.slug.in_(direct))).scalars()}
if direct else {}
)
tag_ids: list = []
for name in tag_names:
slug = slugify(name)
tag = by_slug.get(slug)
if tag is None:
tag = resolve_tag(session, norm=NormalizedTag(name=name, slug=slug, external_id=None))
if tag is not None and tag.id not in tag_ids:
tag_ids.append(tag.id)
added = 0
if tag_ids:
# Jeden INSERT; sortowane id, żeby dwa równoległe wywołania dla tej samej sceny
# zakładały blokady w tej samej kolejności.
result = session.execute(
seen_tag_ids: set = set()
for name in tag_names:
norm = NormalizedTag(name=name, slug=slugify(name), external_id=None)
tag = resolve_tag(session, norm=norm)
if tag is None or tag.id in seen_tag_ids:
continue
seen_tag_ids.add(tag.id)
stmt = (
pg_insert(SceneTag.__table__)
.values([{"scene_id": scene_id, "tag_id": t, "source_id": None} for t in sorted(tag_ids)])
.values(scene_id=scene_id, tag_id=tag.id, source_id=None)
.on_conflict_do_nothing(index_elements=["scene_id", "tag_id"])
.returning(SceneTag.__table__.c.tag_id)
)
added = len(result.fetchall())
result = session.execute(stmt)
# rowcount == 1 gdy faktycznie wstawiony, 0 gdy ON CONFLICT skip
if result.rowcount and result.rowcount > 0:
added += 1
session.commit()
return EnrichTagsOut(scene_id=scene_id, added=added, tube_used=sitetag, tags=tag_names)
@ -1535,11 +1309,11 @@ def enrich_duration_from_tube(
scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> EnrichDurationOut:
"""Wyciąga duration z dowolnego tube playback_source, wszystkie znane tube'y
"""Wyciąga duration z dowolnego tube playback_source — wszystkie znane tube'y
udostępniają duration na detail page (og:video:duration lub LD-JSON ISO 8601).
Mobile wywołuje to przy otwarciu SceneDetail gdy scene.duration_sec jest null
AND ma tube source. Dla dedupu duration to najsilniejszy single signal, bez
AND ma tube source. Dla dedupu duration to najsilniejszy single signal — bez
niego sceny z weak title-only score są capowane na 0.85 (review queue).
Idempotent: zwraca aktualne duration_sec jeśli już ustawione.
@ -1605,7 +1379,7 @@ def enrich_studio_from_tube(
"""Wyciąga studio (DVD/series) z pornhat scene page'a.
Pornhat ma `class="info-video js-ajax-dvd" data-setup='{"title": "Adult Time", ...}'`
dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern, na razie
dla studio. Inne tube'y obsługiwane będą gdy znajdziemy ich pattern — na razie
tylko pornhat (najczystsze studio metadata wśród free tubes).
"""
import json as _json
@ -1702,7 +1476,7 @@ def enrich_thumbnail_from_tube(
Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration).
`force=true` (przycisk "Refresh thumbnail" na SceneDetail, zgłoszenie d3376a71):
NADPISUJE istniejącą miniaturę świeżą ze strony tube'a, dla zepsutych/stałych
NADPISUJE istniejącą miniaturę świeżą ze strony tube'a — dla zepsutych/stałych
(rotting sxyprn/trafficdeposit, błędna grafika)."""
from app.extractors._fetch import browser_get
from app.extractors._models import TubePageError
@ -1723,7 +1497,7 @@ def enrich_thumbnail_from_tube(
sources_with_thumb = [s for s in sources if s.thumbnail_url]
if sources_with_thumb and not force:
# już mamy, idempotent return (force=true pomija, żeby odświeżyć).
# już mamy — idempotent return (force=true pomija, żeby odświeżyć).
return EnrichThumbOut(
scene_id=scene_id,
thumbnail_url=sources_with_thumb[0].thumbnail_url,

View file

@ -66,17 +66,8 @@ class SceneOut(BaseModel):
tags: list[TagOut] = []
external_refs: list[ExternalRefOut] = []
playback_sources: list[PlaybackSourceOut] = []
# Wertykał sceny ('jav' / 'animation') albo None dla głównego katalogu. Liczony
# z originów źródeł, patrz `app/verticals.py`. Mobile MUSI go przekazywać dalej przy
# nawigacji do tagu/studia/performera: bez `?vertical=` backend wyklucza wszystkie
# wertykały, więc tag z anime pokazywał sceny spoza anime, a studio animacyjne
# wyglądało na martwe (zgłoszenia 195ae42e / e9de414c, 2026-08-14).
vertical: str | None = None
# Najwyższa ZNANA jakość źródeł (720/1080/2160), NULL gdy żadne jej nie podało.
# Kafelek pokazuje ją jako badge; patrz migracja 0029_scenes_max_quality.
max_quality_p: int | None = None
# Kiedy scena trafiła do bazy (ingest). Używane przez mobile do oznaczenia
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen, gdy
# "NEW" na karcie scen w PerformerScenesScreen / StudioScenesScreen — gdy
# `created_at > last_seen_at` (favorite) → badge.
created_at: datetime | None = None
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
@ -96,7 +87,7 @@ class SceneListOut(BaseModel):
page: int
per_page: int
# has_more: czy istnieje kolejna strona. Liczone z fetcha per_page+1 (≈darmowe),
# NIE z `total`, bo dla filtrowanych list `total` jest bounded ("1000+") żeby
# NIE z `total` — bo dla filtrowanych list `total` jest bounded ("1000+") żeby
# uniknąć ~5s exhaustive count. Mobile paginuje po has_more, nie po total.
has_more: bool = False
# total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+".

View file

@ -1,6 +1,6 @@
"""Publiczna, crawlowalna powierzchnia SEO, programmatic entity pages.
"""Publiczna, crawlowalna powierzchnia SEO — programmatic entity pages.
Jedyny publiczny router HTML poza /static i healthchecks, NIE wymaga api key,
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.
@ -8,14 +8,14 @@ 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
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.
* **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ść
* **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).
"""
@ -49,7 +49,7 @@ 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.
# 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", "")
@ -58,7 +58,7 @@ 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
# 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
@ -86,7 +86,7 @@ def _live_playback_exists():
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.
Te same reguły co w GET /scenes — żeby SEO nie publikowało tego, co katalog ukrywa.
"""
return [
~exists(
@ -109,7 +109,7 @@ def _not_blacklisted():
def _indexable_scenes():
"""SELECT Scene żywych, nie-zblacklistowanych scen, baza pod listy i sitemap."""
"""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)
@ -120,7 +120,7 @@ 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.
`performer_page` — inaczej sitemap zgłaszałby URL-e dające 404.
"""
sub = (
select(1)
@ -180,7 +180,7 @@ def performer_page(
if performer is None:
raise HTTPException(status_code=404, detail="performer not found")
# Sceny tej osoby, żywe, nie-blacklisted, najświeższe pierwsze.
# Sceny tej osoby — żywe, nie-blacklisted, najświeższe pierwsze.
scenes = (
session.execute(
_indexable_scenes()
@ -283,7 +283,7 @@ def scene_page(
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).
# Źródła — żywe, deduplikowane po origin (pokazujemy 1 link per tube).
sources_raw = (
session.execute(
select(PlaybackSource)
@ -354,7 +354,7 @@ def landing(
request: Request,
session: Annotated[Session, Depends(get_session)],
) -> HTMLResponse:
"""Strona główna, crawl-entry. Najświeższe indeksowalne sceny + CTA."""
"""Strona główna — crawl-entry. Najświeższe indeksowalne sceny + CTA."""
scenes = (
session.execute(
_indexable_scenes()
@ -386,8 +386,8 @@ def landing(
@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."""
"""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",
@ -411,7 +411,7 @@ def page_2257(request: Request) -> HTMLResponse:
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
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).
@ -440,7 +440,7 @@ def _count(session: Session, stmt) -> int:
@router.get("/sitemap.xml")
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
"""Sitemap index, listuje paginowane pod-sitemapy per typ encji."""
"""Sitemap index — listuje paginowane pod-sitemapy per typ encji."""
n_perf = _count(
session,
select(Performer.id).where(_performer_indexable_exists()),

View file

@ -1,12 +1,12 @@
"""GET /sources, lista tube źródeł dla feature "Sites" (mobile top-level tab).
"""GET /sources — lista tube źródeł dla feature "Sites" (mobile top-level tab).
Bug-report 2026-05-24 (ea6f05f9, Scenes screen): user chce wybrać "pages"
obok Scenes i Movies, widzieć liście tube'ów i wchodzić w nie żeby zobaczyć
obok Scenes i Movies — widzieć liście tube'ów i wchodzić w nie żeby zobaczyć
najnowsze sceny z konkretnego źródła.
Endpoint enumeruje distinct `playback_sources.origin` z ŻYWYCH playback_sources
(`dead_at IS NULL`), tylko origins zaczynające się od 'tube:' (kanoniczne źródła
typu `canonical:tpdb_trailer` są pomijane, to nie są "scrapowane strony" w sensie
typu `canonical:tpdb_trailer` są pomijane — to nie są "scrapowane strony" w sensie
intencji feature'a).
Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
@ -14,11 +14,8 @@ Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
from __future__ import annotations
import logging
import os
import re
import time
from datetime import datetime
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends
@ -45,7 +42,7 @@ class SourceRating(BaseModel):
richness: int
"""0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość)."""
health: int | None
"""0-5: czy realnie gra (telemetria odtwarzania), 0=offline. None gdy brak danych."""
"""0-5: czy realnie gra (telemetria odtwarzania) — 0=offline. None gdy brak danych."""
health_basis: str | None = None
"""'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve)."""
components: dict | None = None
@ -54,48 +51,30 @@ class SourceRating(BaseModel):
class SourceOut(BaseModel):
origin: str
"""Raw origin string z DB, np. 'tube:hqpornercom'. Używany jako parametr
"""Raw origin string z DB — np. 'tube:hqpornercom'. Używany jako parametr
`origin=` filtra w GET /scenes (substring match)."""
sitetag: str
"""Origin bez prefiksu 'tube:', np. 'hqpornercom'. Stabilne ID tube'a (zgodne
"""Origin bez prefiksu 'tube:' — np. 'hqpornercom'. Stabilne ID tube'a (zgodne
z `BaseDirectTubeScraper.sitetag`)."""
display_name: str
"""Czytelna nazwa do UI, np. 'hqporner.com'. Wyprowadzona z sitetag przez
"""Czytelna nazwa do UI — np. 'hqporner.com'. Wyprowadzona z sitetag przez
`_sitetag_to_display`. Tylko presentation; logikę trzymamy na sitetag/origin."""
scene_count: int
"""Liczba ŻYWYCH playback_sources (dead_at IS NULL) per origin. Approx scenes
coverage, scena może mieć wiele sources tego samego origin (różne page_url),
coverage — scena może mieć wiele sources tego samego origin (różne page_url),
więc trochę zawyża rzeczywistą scene-distinct count, ale dla orientacji OK."""
last_scraped_at: datetime | None
"""MAX(last_seen_at), najświeższy scrape dla tego origin. Pozwala mobile pokazać
"""MAX(last_seen_at) — najświeższy scrape dla tego origin. Pozwala mobile pokazać
'scrapowane Xh temu' i sortować świeżość."""
rating: SourceRating | None = None
"""Ocena 0-5★ (freshness/richness/health) z source_stats, None gdy jeszcze
"""Ocena 0-5★ (freshness/richness/health) z source_stats — None gdy jeszcze
nie policzona (job source-stats leci co kilka h)."""
icon_url: str | None = None
"""Logo strony (favicon 64x64 PNG) z app/static/site-icons, pobierane przez
scripts/site_icons.py (job raz na dobe). None, gdy jeszcze nie ma pliku. Adres
absolutny przez BACKEND_PUBLIC_URL, inaczej wzgledny '/static/...' (apka dokleja
baseUrl)."""
_ICON_DIR = Path(__file__).resolve().parent.parent / "static" / "site-icons"
_SOURCES_TTL = 300.0
_SOURCES_CACHE: dict = {"at": 0.0, "rows": None}
def _icon_url(sitetag: str) -> str | None:
if not (_ICON_DIR / f"{sitetag}.png").is_file():
return None
base = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
return f"{base}/static/site-icons/{sitetag}.png"
class SourceListOut(BaseModel):
items: list[SourceOut]
@ -140,14 +119,9 @@ def list_sources(
) -> SourceListOut:
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources.
Filter: `origin LIKE 'tube:%'` (drop canonical:* TPDB trailery to inna semantyka).
Filter: `origin LIKE 'tube:%'` (drop canonical:* — TPDB trailery to inna semantyka).
"""
# Agregat idzie po 4,1 mln zywych zrodel (600 ms na kazde wejscie w Sites, Sentry:
# Slow DB Query list_sources). Liczby zmieniaja sie o promile na godzine, wiec
# wynik zyje w pamieci procesu przez _SOURCES_TTL sekund.
now = time.monotonic()
if _SOURCES_CACHE["rows"] is None or now - _SOURCES_CACHE["at"] > _SOURCES_TTL:
_SOURCES_CACHE["rows"] = session.execute(
rows = session.execute(
select(
PlaybackSource.origin,
func.count(PlaybackSource.id).label("scene_count"),
@ -157,8 +131,6 @@ def list_sources(
.where(PlaybackSource.origin.like("tube:%"))
.group_by(PlaybackSource.origin)
).all()
_SOURCES_CACHE["at"] = now
rows = _SOURCES_CACHE["rows"]
# Oceny z source_stats (policzone offline przez run_source_stats). Origin → row.
stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()}
@ -187,7 +159,6 @@ def list_sources(
scene_count=scene_count,
last_scraped_at=last_scraped_at,
rating=rating,
icon_url=_icon_url(sitetag),
)
)

View file

@ -1,8 +1,8 @@
"""Stream proxy, pomost VPS↔phone dla podpisanych URL-i CDN-ów.
"""Stream proxy — pomost VPS↔phone dla podpisanych URL-i CDN-ów.
Wiele hosterów (luluvids/medixiru/cdnvids/bigcdn) bindą podpisany URL do IP klienta
który fetchował embed page. Gdy backend ekstraktuje URL z VPS-a, signature
weryfikuje VPS IP, telefon dostaje 403. Player na phonie kieruje requesty
weryfikuje VPS IP — telefon dostaje 403. Player na phonie kieruje requesty
*przez backend* (tym samym IP co podczas extracji) → CDN sprawdza signature
poprawnie i serwuje content.
@ -31,7 +31,6 @@ import time
from typing import Annotated
from urllib.parse import urljoin, urlparse
import asyncio
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response, StreamingResponse
@ -42,8 +41,8 @@ router = APIRouter(prefix="/proxy", tags=["proxy"])
log = logging.getLogger(__name__)
# In-memory bandwidth counter, bytes-out per CDN domain per hour bucket.
# Restart api resetuje counter (akceptowalne, to operational metric, nie billing).
# In-memory bandwidth counter — bytes-out per CDN domain per hour bucket.
# Restart api resetuje counter (akceptowalne — to operational metric, nie billing).
# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage.
from collections import defaultdict
from threading import Lock
@ -91,7 +90,7 @@ TOKEN_TTL_SEC = 4 * 60 * 60 # 4h
# URL-level redirect cache: target_url -> (final_resolved_url, expires_ts).
# Mobile ExoPlayer robi range-requesty per seek/preload, każdy hituje proxy z tym
# Mobile ExoPlayer robi range-requesty per seek/preload — każdy hituje proxy z tym
# samym tokenem, proxy GET-uje target_url. Dla `porntrex.com/get_file/...` (a także
# fpoxxx, freshporno) URL jest **single-use**: pierwszy GET → 302 → CDN URL (time-bound),
# drugi GET → 410. Bez cache: drugi range = 410 → ExoPlayer fail → mobile fallback do
@ -148,36 +147,6 @@ HOP_BY_HOP = {
"content-length",
}
# Nagłówki CORS hostera są bezużyteczne dalej i wręcz szkodliwe: mixdrop oddaje
# `Access-Control-Allow-Origin: https://mixdrop.my`, więc odbiornik Chromecasta (który
# ma zupełnie inny origin) odrzuciłby manifest. Wycinamy je i podstawiamy własne.
_UPSTREAM_CORS_HEADERS = {
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
}
def _with_cast_cors(headers: dict[str, str]) -> dict[str, str]:
"""Pozwól odtwarzać ten strumień z dowolnego origin.
Po co: Chromecast pobiera plik SAM, ze swojego adresu, a przy HLS robi to przez
Media Source Extensions, które wymagają zgody CORS. Bez tego telewizor dostaje
manifest i go wyrzuca. Progresywne MP4 leci przez zwykły element `video` i o CORS
nie pyta, ale ustawiamy jednakowo, żeby nie zależeć od tego, co akurat gra.
Bezpieczne, bo dostęp do treści i tak reguluje podpisany token w ścieżce, a nie
origin pytającego.
"""
out = {k: v for k, v in headers.items() if k.lower() not in _UPSTREAM_CORS_HEADERS}
out["Access-Control-Allow-Origin"] = "*"
out["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"
out["Access-Control-Allow-Headers"] = "Range"
out["Access-Control-Expose-Headers"] = "Content-Length, Content-Range, Accept-Ranges"
return out
def _secret() -> bytes:
s = os.environ.get("STREAM_PROXY_SECRET") or os.environ.get("API_KEYS", "")
@ -201,10 +170,10 @@ def make_token(
`refresh`: URL embed page do refetch gdy `url` zwraca 4xx. Proxy odbierze
fresh stream URL z embed (np. mixdrop MDCore.wurl) gdy oryginalny token expired.
`refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.), proxy
`refresh_hoster`: hoster name dla refresh logic (mixdrop / etc.) — proxy
dispatch do dedicated re-extract logic.
`impersonate`: użyć curl_cffi chrome120 zamiast httpx (dla hosterów z JA3 bot
detection, mxcontent, cloudflare-protected).
detection — mxcontent, cloudflare-protected).
`stable_bucket_sec`: kwantyzuje czas bazowy expiry do okna N sekund, więc token
(→ proxied URL) jest IDENTYCZNY dla wszystkich requestów w oknie. Bez tego `e`
zawiera bieżący timestamp → URL inny przy każdym fetchu listy → expo-image cache
@ -221,7 +190,7 @@ def make_token(
if impersonate:
payload["i"] = 1
if producer:
# Hoster oddający TREŚĆ manifestu (nie URL), `/proxy/hls` zawoła producenta
# Hoster oddający TREŚĆ manifestu (nie URL) — `/proxy/hls` zawoła producenta
# zamiast GET-a. Patrz app/extractors/hosters/loadvid.py.
payload["mp"] = producer
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
@ -251,7 +220,7 @@ def parse_token(token: str) -> dict:
def _ascii_safe_url(url: str) -> str:
"""Encode non-ASCII chars w URL path/query, zachowując reserved chars dla URI.
httpx wymaga ASCII headers, Referer z polskim/cyrillic/unicode (np. hqporner
httpx wymaga ASCII headers — Referer z polskim/cyrillic/unicode (np. hqporner
`Honies_№2.html`) wcześniej throw'ował UnicodeEncodeError (GOON-A). `quote`
z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko
enkoduje znaki spoza ASCII."""
@ -286,8 +255,8 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
"""Rewrite m3u8 manifest tak, że wszystkie sub-resourcey idą przez proxy.
HLS manifest ma:
- linie URI (segmenty .ts / sub-playlisty .m3u8), relatywne lub absolute
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"`, też potrzebują rewrite
- linie URI (segmenty .ts / sub-playlisty .m3u8) — relatywne lub absolute
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"` — też potrzebują rewrite
Każdy URL → token + /proxy/{token}/<basename>.<ext>.
"""
out: list[str] = []
@ -315,7 +284,7 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
def _absolutize_m3u8(content: str, base_url: str) -> str:
"""Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN,
"""Rewrite m3u8 tak, że child-URL-e (variant/segmenty/key) są ABSOLUTNE na CDN —
NIE przez proxy. Dla direct-HLS passthrough (`/proxy/hls/...`): telefon ciągnie je
bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin),
absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje)."""
@ -341,34 +310,21 @@ def sign_url(
referer: str | None = Query(default=None),
) -> dict:
"""Pomocniczy endpoint dla mobile do uzyskania świeżego tokena (np. po expiry).
Normalnie /resolve zwraca już proxy URL, to fallback."""
Normalnie /resolve zwraca już proxy URL — to fallback."""
return {"token": make_token(url, referer), "expires_in": TOKEN_TTL_SEC}
@functools.lru_cache(maxsize=1)
def _fallback_proxy_url() -> str | None:
"""Residential proxy, uzywany TYLKO gdy CDN odrzucil nasz adres IP.
NIE wstawiaj niczego miedzy dekorator trasy a `proxy_image`: przy pierwszym podejsciu
ta funkcja wladowala sie wlasnie tam i `@router.get` udekorowal JA, wiec /proxy/img/
oddawalo URL proxy razem z loginem i haslem jako JSON.
"""
from app.config import get_settings
return getattr(get_settings(), "brightdata_proxy_url", None) or None
@router.get("/img/{token}/{_basename:path}")
async def proxy_image(
token: str,
_basename: str,
request: Request,
) -> Response:
"""Image proxy, używany dla thumbnaili z CDN-ów wymagających Referera
"""Image proxy — używany dla thumbnaili z CDN-ów wymagających Referera
(hqporner i inne porn-app sourcy). Mobile expo-image nie wysyła Referera
domyślnie, CDN zwraca 403. Backend dodaje Referer i streamuje obrazek.
Cache-Control: public,max-age=86400, thumby są stabilne, klient może cachować."""
Cache-Control: public,max-age=86400 — thumby są stabilne, klient może cachować."""
payload = parse_token(token)
target = payload["u"]
referer = payload["r"] or None
@ -378,59 +334,18 @@ async def proxy_image(
try:
r = await client.get(target, headers=headers)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
# CDN connect/timeout, transient (np. Cloudflare 523 origin unreachable
# CDN connect/timeout — transient (np. Cloudflare 523 origin unreachable
# gdy upstream host jest off). Log INFO + 503, mobile renderuje placeholder.
# Bez tego Sentry dostawał setki ERROR-ów (GOON-D/6) z każdym broken
# tube'em, spam-szumiło real-issues.
# tube'em — spam-szumiło real-issues.
log.info("img proxy connect/timeout for %s: %s", target, e)
return Response(content=b"", status_code=503, media_type="image/jpeg")
except Exception as e:
log.warning("img proxy fetch failed for %s: %s", target, e)
raise HTTPException(status_code=502, detail=f"img fetch failed: {e}") from e
if r.status_code in (401, 403):
# Referer moze byc z INNEGO serwisu niz host obrazka. Zdarza sie, gdy tube
# zaciaga miniaturki cudzego CDN-a: fullporner zapisuje pliki z
# fastporndelivery.hqporner.com, a my dokladalismy Referer fullporner.com,
# ktorego CDN nie akceptuje. Pomiar 2026-08-20 na tym samym pliku:
# Referer fullporner.com 403 (153 B), brak Referera 403, Referer hqporner.com
# 200 (30 kB), Referer rowny wlasnemu hostowi obrazka 200. Skutkiem byly
# puste kafelki podpisane FULLPORNER na liscie hqporner (zgloszenie 1df50de3).
# Ponawiamy raz z wlasnym originem obrazka, bo to dziala dla calej tej klasy
# przypadkow i nie zmienia zachowania tam, gdzie pierwsza proba sie udaje.
from urllib.parse import urlsplit
parts = urlsplit(str(r.url) or target)
own = f"{parts.scheme}://{parts.netloc}/"
if own != (referer or ""):
try:
r2 = await client.get(target, headers=_build_headers(own))
if r2.status_code < 400:
r = r2
except Exception as e:
log.info("img proxy retry z wlasnym originem padl %s: %s", target, e)
if r.status_code in (401, 403, 429):
# Ostatnia deska ratunku: CDN blokuje NASZ adres IP, nie zapytanie. Miniaturki
# fullvideosporn (tn.sextu.com) oddaja z serwerowni 429 przy kazdej probie
# (3/3, tez po przerwie), a przez residential proxy 200 i 26 kB. Stad puste
# kafelki mimo poprawnych adresow w bazie (zgloszenie f4995691). Obrazek ma
# kilkadziesiat kB i klient cachuje go na dobe, wiec to nie jest ten sam
# przypadek co proxowanie wideo. Sciagamy tylko wtedy, gdy host nas odrzucil.
proxy_url = _fallback_proxy_url()
if proxy_url:
try:
async with httpx.AsyncClient(
timeout=timeout, follow_redirects=True, proxy=proxy_url
) as pclient:
r3 = await pclient.get(target, headers=headers)
if r3.status_code < 400:
r = r3
else:
log.info("img proxy: proxy tez oddal %d dla %s", r3.status_code, target)
except Exception as e:
log.info("img proxy: proba przez proxy padla %s: %s", target, e)
if r.status_code >= 400:
# Upstream 4xx/5xx dla thumba, degraded zamiast raise (placeholder w mobile).
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
# Upstream 4xx/5xx dla thumba — degraded zamiast raise (placeholder w mobile).
# GOON-5 (Cloudflare 523) i GOON-D — bezsensowny noise w Sentry, lepiej
# info log + 502 pass-through bez exception.
log.info("img proxy upstream %d for %s", r.status_code, target)
return Response(
@ -447,7 +362,7 @@ async def proxy_image(
# sxyprn on-demand thumbnail resolve (bug 2026-06-10). trafficdeposit poster token
# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować, resolvujemy bieżący
# żyje ~1h (bucket godzinowy), więc URL-i NIE da się przechować — resolvujemy bieżący
# og:image ze strony /post/<id> przy serwowaniu. Cache resolved poster URL ~40min
# (< 1h TTL). Klient dostaje STABILNY /proxy/sxyprn-thumb/<id> → cache'uje bajty na
# stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post.
@ -461,10 +376,10 @@ _SXYPRN_PID_RE = re.compile(r"^[0-9a-f]{6,40}$")
# youporn on-demand thumbnail (bug-report bebf8c92 2026-07-25 "miniaturki z youporn
# nie działają"). youporn przeszedł dla części katalogu na imgproxy z PODPISANYM URL-em
# (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`), taki URL
# (`.../original_<id>.mov/plain/rs:fit:1280:720/vts:N?hash=...&validto=<ts>`) — taki URL
# wygasa, więc zapisany w bazie po czasie zwraca 410 (audit 2026-07-26: 8666 z 62930
# źródeł youporn). Reszta katalogu ma stare statyczne `.../original/N.jpg`, które działają
# bezterminowo, tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy
# bezterminowo — tych NIE ruszamy (żaden dodatkowy fetch). Tu, jak w sxyprn, resolvujemy
# świeży URL przy serwowaniu; klient dostaje stabilny `/proxy/youporn-thumb/<video_id>`.
_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {}
_YOUPORN_POSTER_TTL = 1800
@ -502,7 +417,7 @@ async def youporn_thumb(video_id: str) -> Response:
)
if not m:
return Response(content=b"", status_code=404, media_type="image/jpeg")
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&amp;`), bez unescape
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&amp;`) — bez unescape
# podpis w query jest zepsuty i CDN oddaje 403.
poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/"))
if poster.startswith("//"):
@ -577,7 +492,7 @@ async def sxyprn_thumb(post_id: str) -> Response:
async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None:
"""Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl.
Cookies persist w session, użytkowane potem do mp4 GET (same-session bind).
UA + Accept wymagane, bez tego mixdrop zwraca minimalny body (bez packera).
UA + Accept wymagane — bez tego mixdrop zwraca minimalny body (bez packera).
"""
import re
from yt_dlp.utils import decode_packed_codes
@ -648,9 +563,9 @@ async def _curl_cffi_stream(
await session.close()
return _upstream_error_response(upstream.status_code, dict(upstream.headers), target)
out_headers = _with_cast_cors(
{k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
)
out_headers = {
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
}
async def streamer():
bytes_out = 0
@ -695,28 +610,6 @@ async def _produce_manifest(producer: str, embed_url: str) -> str | None:
return None
_CHALLENGE_COOKIE_RE = re.compile(r"document\.cookie\s*=\s*'([^=]+)=([^;']+)")
def _challenge_cookie(r: httpx.Response) -> str | None:
"""Ciasteczko z odpowiedzi-wyzwania sieci TXXX (vjav i rodzina), albo None.
Serwis dlawi ruch z serwerowni: zamiast tresci oddaje 429 i mikroskopijna strone,
ktora w JS ustawia jedno ciasteczko i przeladowuje sie. Wartosc stoi WPROST w tej
odpowiedzi, wiec wystarczy ja odczytac i powtorzyc zapytanie, bez wykonywania JS.
Ekstraktor vjav robil to od 2026-08-21 przy `videofile.php`, ale adres manifestu
oddawany odtwarzaczowi tej sztuczki nie dostawal. Skutek: `try_extract` konczyl sie
sukcesem, wiec zrodlo wygladalo na zywe, a `/proxy/hls` przy kazdym odtworzeniu
dostawal 429 i oddawal blad. Stad 587 bledow i ZERO udanych odtworzen na vjav w
ciagu 14 dni, przy 58 779 zrodel.
"""
if r.status_code != 429 or len(r.content) > 4096:
return None
m = _CHALLENGE_COOKIE_RE.search(r.text)
return f"{m.group(1)}={m.group(2)}" if m else None
@router.get("/hls/{token}/{_basename:path}")
async def proxy_hls_manifest(token: str, _basename: str) -> Response:
"""Direct-HLS manifest passthrough dla time-bound (mobile_direct_ok) m3u8 hosterów.
@ -729,7 +622,7 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
Tu serwujemy SAM manifest pod `play.m3u8` (ExoPlayer → HlsMediaSource), a child-URL-e
(variant/segmenty) zostają ABSOLUTNE na CDN → telefon ciągnie je DIRECT (phone IP,
token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty,
token time-bound działa cross-IP; Referer propaguje Media3 na child-requesty —
zweryfikowane cross-IP 2026-06-11). Przez VPS leci tylko ~1KB manifestu, nie segmenty.
Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound
hostery dalej idą pełnym `/proxy/`."""
@ -754,32 +647,16 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
try:
r = await client.get(target, headers=headers)
if (ck := _challenge_cookie(r)) is not None:
r = await client.get(target, headers=dict(headers, Cookie=ck))
except httpx.HTTPError as e:
log.info("proxy-hls fetch failed %s: %s", target, e)
raise HTTPException(status_code=502, detail="manifest fetch failed") from e
if r.status_code >= 400:
return _upstream_error_response(r.status_code, dict(r.headers), target)
# Hoster potrafi oddac 200 z trescia, ktora manifestem NIE jest (strona bledu albo
# reklama zamiast playlisty). Bez tej kontroli przepuszczalismy takie cos dalej jako
# `application/vnd.apple.mpegurl` ze statusem 200, a odtwarzacz konczyl komunikatem
# "Input does not start with the #EXTM3U header" (perverzija, 203 zdarzenia/14 dni).
# Gorsze od samego bledu bylo to, ze przy statusie 200 apka uznawala odpowiedz za
# dobra i nie probowala odswiezyc zrodla. Oddajemy 502, zeby wlaczyla sie normalna
# sciezka ponowienia.
if not r.text.lstrip(" \t\r\n").startswith("#EXTM3U"):
log.info(
"proxy-hls: odpowiedz 200 nie jest manifestem (%dB) dla %s", len(r.content), target
)
raise HTTPException(status_code=502, detail="upstream returned non-manifest body")
rewritten = _absolutize_m3u8(r.text, base_url=str(r.url))
return Response(
content=rewritten,
media_type="application/vnd.apple.mpegurl",
headers=_with_cast_cors({"Cache-Control": "no-store"}),
headers={"Cache-Control": "no-store"},
)
@ -797,7 +674,7 @@ async def proxy_stream(
refetch_hoster = payload.get("rh")
# Jeśli ten target był już wcześniej follow-redirect-ed, użyj cached final URL.
# Powód: porntrex `get_file/` 410 po reuse, patrz `_REDIRECT_CACHE` docstring.
# Powód: porntrex `get_file/` 410 po reuse — patrz `_REDIRECT_CACHE` docstring.
cached_target = _redirect_cache_get(original_target)
target = cached_target or original_target
@ -807,10 +684,10 @@ async def proxy_stream(
if range_h:
headers["Range"] = range_h
method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny, proxy zwraca pełne odpowiedzi
method = "GET" # ExoPlayer głównie GET; HEAD nie potrzebny — proxy zwraca pełne odpowiedzi
# Hostery które wymagają Chrome JA3 fingerprint (mxcontent / cloudflare-protected
# CDNs), od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip.
# CDNs) — od razu używamy curl_cffi zamiast httpx żeby uniknąć 403→retry round-trip.
# Token `i=1` flag ustawiana przez extractor dla tych hostów (mixdrop.py).
if use_impersonate:
return await _curl_cffi_stream(
@ -830,41 +707,19 @@ async def proxy_stream(
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
try:
# Sprobój streaming send PIERWSZY, sprawdź content-type po headers,
# Sprobój streaming send PIERWSZY — sprawdź content-type po headers,
# potem decyzja: rewrite manifest vs stream binary.
upstream = await client.send(
client.build_request(method, target, headers=headers),
stream=True,
follow_redirects=True,
)
# Przejściowy błąd SERWERA upstreamu → jedno ponowienie, zanim oddamy błąd
# do playera. Te CDN-y stoją na wielu węzłach (galaxyporn: 203.188.166.163,
# 185.237.106.182, 185.237.107.133), więc druga próba zwykle trafia w zdrowy.
#
# Telemetria 30 dni: 502/503 to JEDYNA klasa błędów playbacku przy kilku
# niezależnych źródłach naraz (porndoe 65, 8kporner 37, galaxyporn 22), przy
# 1310 udanych odtworzeniach samego galaxyporn, czyli 1,9%. Zgłoszenie
# 7f2234eb („samo wideo działa a aplikacja zgłasza problem z hostem") to
# dokładnie ten przypadek: dla usera wygląda na zepsute źródło, a to mignięcie.
#
# Ponawiamy TYLKO 502/503/504 i tylko dla GET/HEAD (idempotentne, segment HLS
# to zwykły GET). 4xx zostawiamy: 403/410 to wygasły token, a nie chwilowa
# awaria, i mają własną ścieżkę z invalidacją cache niżej.
if upstream.status_code in (502, 503, 504) and method.upper() in ("GET", "HEAD"):
await upstream.aclose()
await asyncio.sleep(0.25)
log.info("proxy: upstream %d, ponawiam %s", upstream.status_code, target[:70])
upstream = await client.send(
client.build_request(method, target, headers=headers),
stream=True,
follow_redirects=True,
)
if upstream.status_code >= 400:
status = upstream.status_code
ups_headers = dict(upstream.headers)
await upstream.aclose()
await client.aclose()
# Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410),
# Cached final URL zwrócił error (np. CDN signed-URL expired, 403/410) —
# invaliduj cache i daj mobile retry przez fresh /resolve. Bez tego stale
# cache trzymałby martwy CDN URL przez 30 min (TTL).
if cached_target is not None and status in (401, 403, 404, 410):
@ -873,7 +728,7 @@ async def proxy_stream(
# Pierwszy successful pass dla single-use targets (np. porntrex get_file):
# cache resolved final URL (po follow_redirects). Następne range-requesty
# pójdą direct w CDN URL, get_file nie dostaje drugiego hita.
# pójdą direct w CDN URL — get_file nie dostaje drugiego hita.
if cached_target is None:
final_url = str(upstream.url)
if final_url != original_target:
@ -886,7 +741,7 @@ async def proxy_stream(
or "application/x-mpegurl" in ct
)
if is_m3u8:
# Manifest content, buffer fully, rewrite, return as m3u8.
# Manifest content — buffer fully, rewrite, return as m3u8.
body = await upstream.aread()
await upstream.aclose()
await client.aclose()
@ -902,9 +757,9 @@ async def proxy_stream(
headers={"Cache-Control": "no-store"},
)
out_headers = _with_cast_cors(
{k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
)
out_headers = {
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
}
async def streamer():
bytes_out = 0
@ -914,7 +769,7 @@ async def proxy_stream(
yield chunk
except httpx.TransportError as e:
# Upstream CDN zerwał połączenie w trakcie streamu (peer closed before
# full body, RemoteProtocolError, ReadError, timeout). Klient dostaje
# full body — RemoteProtocolError, ReadError, timeout). Klient dostaje
# uciętą odpowiedź i ponawia Range-requestem; to NIE jest bug aplikacji.
# Bez tego wyjątek leci z generatora PO zwróceniu StreamingResponse, omija
# outer try/except i ląduje w Sentry jako unhandled (GOON-Y 2026-06-11).
@ -937,7 +792,7 @@ async def proxy_stream(
await client.aclose()
raise
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
# CDN connect failure / timeout, transient, log na INFO (nie ERROR do Sentry).
# CDN connect failure / timeout — transient, log na INFO (nie ERROR do Sentry).
# Zwracamy 503 zamiast 502 + Retry-After, mobile może retry-ować bez panic.
await client.aclose()
log.info("proxy connect/timeout for %s: %s", target, e)
@ -963,10 +818,10 @@ def _upstream_error_response(
Rationale per status:
- **429 Too Many Requests**: CDN rate-limit (np. fpo.xxx gdy proxy hammeruje
get_file/). Pass-through 429 + Retry-After żeby mobile zrobiło backoff.
Log INFO (nie ERROR), to expected behavior CDN-a, nie nasz bug.
Log INFO (nie ERROR) — to expected behavior CDN-a, nie nasz bug.
- **404/410**: video deleted/expired token. Pass-through żeby player wiedział.
- **5xx upstream**: pochodzi z CDN-a, nie z naszego kodu. Log INFO.
- **inne 4xx**: 502 (i Sentry warn), może być nasza wina (bad referer itp.).
- **inne 4xx**: 502 (i Sentry warn) — może być nasza wina (bad referer itp.).
"""
retry_after = upstream_headers.get("retry-after") or upstream_headers.get("Retry-After")
if status == 429:
@ -990,7 +845,7 @@ def _upstream_error_response(
media_type="text/plain",
)
if 500 <= status < 600:
# CDN-side error (np. Cloudflare 523, origin unreachable). Pass-through
# CDN-side error (np. Cloudflare 523 — origin unreachable). Pass-through
# 502 ale log INFO bo to nie nasza wina.
log.info("proxy upstream %d for %s", status, target)
return Response(
@ -999,28 +854,5 @@ def _upstream_error_response(
headers={"Retry-After": "5"},
media_type="text/plain",
)
if status in (401, 403):
# WYGASŁY/NIEWAŻNY PODPIS, nie awaria hosta. Przepuszczamy prawdziwy kod zamiast
# mapować na 502, z dwóch powodów:
#
# 1. Diagnostyka. `playback_events.error_detail` bierze się z komunikatu
# ExoPlayera („Response code: 502"), więc dopóki mapowaliśmy wszystko na 502,
# w telemetrii nie dało się odróżnić „CDN padł" od „token wygasł". Zgłoszenie
# 7f2234eb („samo wideo działa a aplikacja zgłasza problem z hostem") to
# dokładnie ta dwuznaczność: dla usera źródło wygląda na zepsute, a wystarczy
# świeży resolve.
# 2. Sentry. Wygaśnięcie podpisanego URL-a jest NORMALNE (te CDN-y dają tokeny
# na minuty), a raise robił z tego wyjątek „może nasz bug" i zaśmiecał alerty.
#
# Cache i tak jest tu invalidowany wyżej (401/403/404/410), więc kolejny resolve
# dostanie świeży URL.
log.info("proxy upstream %d (wygasly podpis?) for %s", status, target)
return Response(
content=f"upstream {status}",
status_code=status,
media_type="text/plain",
headers={"Cache-Control": "no-store"},
)
# Pozostałe 4xx (400/405/451…) nadal jako 502 + wyjątek: tego NIE spodziewamy się
# od poprawnie zbudowanego requestu, więc chcemy o tym wiedzieć w Sentry.
# 4xx other (403 itp.) — raise żeby Sentry zarejestrował (może bug naszego kodu)
raise HTTPException(status_code=502, detail=f"upstream {status}")

View file

@ -1,4 +1,4 @@
"""GET /tags, /performers, /studios, listy taxonomies do filtrów na mobile.
"""GET /tags, /performers, /studios — listy taxonomies do filtrów na mobile.
Każdy endpoint wspiera:
- q: substring search po name_normalized (trgm fallback ilike)
@ -9,7 +9,6 @@ Zwraca też scene_count żeby UI pokazywał "(123)" przy każdym tagu/performerz
"""
from __future__ import annotations
import logging
import uuid
from typing import Annotated
@ -27,9 +26,6 @@ from app.models.playback_source import PlaybackSource
from app.models.scene import ScenePerformer, SceneTag
from app.models.studio import Studio
from app.models.tag import Tag
from app.verticals import VERTICALS
log = logging.getLogger(__name__)
router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)])
@ -106,19 +102,12 @@ def list_tags(
" Filtruje krótkie listy filtrów żeby nie pokazywać tagów-sierot."
),
),
vertical: str | None = Query(
default=None,
description=(
"Wertykał ('jav'/'animation'): licz i pokazuj TYLKO tagi jego scen. "
"Brak → główny feed, bez tagów wertykałowych."
),
),
) -> TagListOut:
if order not in ("popular", "scene_count", "name"):
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
if for_movies:
# Movie tag count, zliczamy tylko Movies z ≥1 live MoviePlaybackSource.
# Movie tag count — zliczamy tylko Movies z ≥1 live MoviePlaybackSource.
# Tag-bez-żadnego-movie zwraca 0 (LEFT OUTER JOIN przez coalesce). Movies są
# małe (~41k) więc live-aggregat OK; scenes path niżej używa denormalizacji.
_movie_live = exists().where(
@ -157,55 +146,7 @@ def list_tags(
]
return TagListOut(items=items, total=total, page=page, per_page=per_page)
if vertical in VERTICALS:
# Wertykał liczymy NA ŻYWO, bez denormalizacji: to małe zbiory (animacja ~9,5 tys.
# scen wobec 3,8 mln całości), więc agregat jest tani, a dokładanie kolumny
# `scene_count_<wertykał>` do `tags` kosztowałoby migrację przy każdym nowym
# wertykale. Zdenormalizowany `Tag.scene_count` opisuje WYŁĄCZNIE główny feed.
v_scenes = (
select(PlaybackSource.scene_id)
.where(
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.in_(VERTICALS[vertical]),
)
.distinct()
.subquery()
)
v_count = (
select(SceneTag.tag_id, func.count().label("c"))
.join(v_scenes, v_scenes.c.scene_id == SceneTag.scene_id)
.group_by(SceneTag.tag_id)
.subquery()
)
base = (
select(Tag, func.coalesce(v_count.c.c, 0).label("scene_count"))
.outerjoin(v_count, v_count.c.tag_id == Tag.id)
)
if q:
base = base.where(Tag.name.ilike(f"%{q}%"))
# Tag spoza wertykału ma tu 0, więc `only_with_content` sam odsiewa zachodnie
# słownictwo. Dla wertykału trzymamy to WŁĄCZONE zawsze: lista bez tego byłaby
# w 99% tagami, których w tej sekcji nie ma czym wypełnić.
base = base.where(v_count.c.tag_id.is_not(None))
total = session.execute(
select(func.count()).select_from(base.subquery())
).scalar_one()
if order in ("popular", "scene_count"):
ordered = base.order_by(func.coalesce(v_count.c.c, 0).desc(), Tag.name.asc())
else:
ordered = base.order_by(Tag.name.asc())
rows = session.execute(
ordered.offset((page - 1) * per_page).limit(per_page)
).all()
return TagListOut(
items=[
TagCount(id=t.id, name=t.name, slug=t.slug, scene_count=int(c))
for t, c in rows
],
total=total, page=page, per_page=per_page,
)
# Scenes path, czyta zdenormalizowany Tag.scene_count (refresh w tle przez
# Scenes path — czyta zdenormalizowany Tag.scene_count (refresh w tle przez
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
base = select(Tag)
@ -241,72 +182,10 @@ def list_performers(
order: str = Query(default="scene_count", description="scene_count|name"),
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=500),
vertical: str | None = Query(
default=None,
description=(
"Wertykał ('jav'): licz i pokazuj TYLKO obsadę jego scen. Brak → główny feed. "
"Dla 'animation' zwraca pusto, bo animacja z definicji nie ma obsady."
),
),
) -> PerformerListOut:
if order not in ("scene_count", "popular", "name"):
raise HTTPException(status_code=400, detail="order must be 'scene_count' or 'name'")
if vertical in VERTICALS:
# Jak przy tagach: wertykał liczymy NA ŻYWO, bo `Performer.scene_count` z założenia
# opisuje wyłącznie główny feed (job taxonomy_counts wyklucza originy wertykałowe).
# Bez tego zakładka JAV pokazywała zachodnią obsadę, a jej własne 1392 nazwiska
# miały licznik 0 i lądowały na końcu listy.
#
# Animacja NIE MA obsady i to nie jest brak danych, tylko wlasciwosc: dla postaci
# rysowanych nie istnieje kanon (TPDB/StashDB ich nie maja), patrz app/verticals.py.
# Zmierzone 2026-08-14: 12 698 scen animowanych, 0 performerów. Zapytanie odda
# wiec pusto i tak ma byc; apka chowa tam ten przycisk.
v_scenes = (
select(PlaybackSource.scene_id)
.where(
PlaybackSource.dead_at.is_(None),
PlaybackSource.origin.in_(VERTICALS[vertical]),
)
.distinct()
.subquery()
)
v_count = (
select(ScenePerformer.performer_id, func.count().label("c"))
.join(v_scenes, v_scenes.c.scene_id == ScenePerformer.scene_id)
.group_by(ScenePerformer.performer_id)
.subquery()
)
base = (
select(Performer, func.coalesce(v_count.c.c, 0).label("scene_count"))
.join(v_count, v_count.c.performer_id == Performer.id)
)
if q:
base = base.where(Performer.name_normalized.ilike(f"%{q.lower()}%"))
total = session.execute(
select(func.count()).select_from(base.subquery())
).scalar_one()
if order in ("scene_count", "popular"):
ordered = base.order_by(v_count.c.c.desc(), Performer.canonical_name.asc())
else:
ordered = base.order_by(Performer.canonical_name.asc())
rows = session.execute(
ordered.offset((page - 1) * per_page).limit(per_page)
).all()
return PerformerListOut(
items=[
PerformerCount(
id=p.id,
canonical_name=p.canonical_name,
slug=p.slug,
gender=p.gender.value if p.gender else None,
scene_count=int(c),
)
for p, c in rows
],
total=total, page=page, per_page=per_page,
)
# Czyta zdenormalizowany Performer.scene_count (refresh w tle). Wcześniej agregat
# 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019.
base = select(Performer)
@ -357,13 +236,13 @@ def list_studios(
description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.",
),
) -> StudioListOut:
from app.models.scene import Scene # lokalny import, Scene FK do Studio
from app.models.scene import Scene # lokalny import — Scene FK do Studio
if order not in ("name", "scene_count", "popular"):
raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'")
if for_movies:
# Movies małe (~41k), live aggregat OK. Scenes path niżej = denormalizacja.
# Movies małe (~41k) — live aggregat OK. Scenes path niżej = denormalizacja.
_movie_live = exists().where(
and_(
MoviePlaybackSource.movie_id == Movie.id,
@ -401,7 +280,7 @@ def list_studios(
]
return StudioListOut(items=items, total=total, page=page, per_page=per_page)
# Scenes path, czyta zdenormalizowany Studio.scene_count (refresh w tle).
# Scenes path — czyta zdenormalizowany Studio.scene_count (refresh w tle).
# Wcześniej agregat 1.69M scenes + EXISTS playback per-request. Patrz migracja 0019.
base = select(Studio)
if q:
@ -443,10 +322,6 @@ class PerformerRefreshOut(BaseModel):
counters: dict[str, dict[str, int]]
new_scenes: int
last_searched_at: str | None
# "started" = wyszukiwanie ruszyło w tle i sceny dopiszą się za chwilę. Pole doszło
# przy przejściu na async (zgłoszenie 23c64a28); klient sprzed tej zmiany go nie
# czyta i pokaże "0 new scenes", co jest znośne przez okno do publikacji OTA.
status: str = "done"
class PerformerRescrapeOut(BaseModel):
@ -465,13 +340,13 @@ class PerformerRescrapeOut(BaseModel):
# przy partial commits. 45s wall-clock + 50 scen max = ~12 fetches × 3s budgetowo.
# Większe rescrape'y user może odpalać wielokrotnie (idempotent dzięki has_thumb/
# tag_count check).
_RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s, 5s margin na response build
_RESCRAPE_WALL_SEC = 55.0 # nginx read timeout 60s — 5s margin na response build
_RESCRAPE_MAX_SCENES = 50
# Re-fetch tagów dla scen z < N tagami. Niektórzy performerzy mają legit 1-2 tagi
# (niche), no harm w sprawdzeniu pierwszy raz; powtarzane wywołania są idempotent
# bo INSERT ... ON CONFLICT DO NOTHING.
_TAG_RESCRAPE_THRESHOLD = 3
# Mainstream tubes priority dla tagów, bogate metadane.
# Mainstream tubes priority dla tagów — bogate metadane.
_TAG_PRIORITY = [
"xhamstercom", "porntrexcom", "epornercom", "youporncom",
"xvideoscom", "xnxxcom",
@ -485,7 +360,7 @@ def performer_top_tags(
limit: int = Query(default=20, ge=1, le=50),
) -> TagListOut:
"""Top tagi/kategorie agregowane ze scen performera (PerformerScenes UI chips,
bug-report 1a4bf258, zastępują dev-only przycisk Re-scrape). Liczymy tylko po
bug-report 1a4bf258 — zastępują dev-only przycisk Re-scrape). Liczymy tylko po
scenach z żywym playbackiem (to co user realnie widzi na liście). scene_id index
pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request."""
live = exists().where(
@ -543,7 +418,7 @@ def rescrape_performer_scenes(
if perf is None:
raise HTTPException(status_code=404, detail="performer not found")
# 1) ID-only query, sceny ze ≥1 alive tube playback.
# 1) ID-only query — sceny ze ≥1 alive tube playback.
scene_ids = session.execute(
select(Scene.id)
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
@ -593,7 +468,7 @@ def rescrape_performer_scenes(
capped = False
cap_reason: str | None = None
started = _time.monotonic()
# Narrow exception set, łapiemy TYLKO oczekiwane network/parse failures.
# Narrow exception set — łapiemy TYLKO oczekiwane network/parse failures.
# `Exception` catch-all blokował KeyboardInterrupt + maskował pool exhaustion.
NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError)
@ -611,7 +486,7 @@ def rescrape_performer_scenes(
has_thumb = any(s.thumbnail_url for s in sources)
existing_tag_count = tag_counts.get(scene_id, 0)
# SAVEPOINT, fail isolation. Pojedyncza scena z FK violation w SceneTag
# SAVEPOINT — fail isolation. Pojedyncza scena z FK violation w SceneTag
# insert nie odpaliłby outer transaction; bez nested rollback całe N scen
# po niej miałoby PendingRollbackError.
sp = session.begin_nested()
@ -629,7 +504,7 @@ def rescrape_performer_scenes(
thumb = extract_thumbnail_url(r.text)
if thumb:
# Update tylko źródła z którego pochodzi thumb (single playback).
# Wcześniej apply'owalismy do wszystkich siblings, wrong-CDN
# Wcześniej apply'owalismy do wszystkich siblings — wrong-CDN
# cross-attribution (np. xhamster thumb na porntrex entry).
# `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile
# find()), więc 1 wystarczy.
@ -711,18 +586,11 @@ def refresh_performer(
performer_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> PerformerRefreshOut:
"""On-demand search across all tubes dla pojedynczego performera. Startuje w TLE
i odpowiada od razu (`status="started"`), a sceny dopisują się w miarę mielenia.
"""On-demand search across all tubes dla pojedynczego performera. Synchronous —
blokujemy aż search skończy. Mobile pokazuje spinner.
Dlaczego nie synchronicznie, jak do 2026-08-26: search po ~25 tubach trwa dłużej,
niż klient jest gotów czekać, i przy rozbudowanych performerach telefon zrywał
połączenie. W logu zostawały starty `performer_driven` bez ANI JEDNEJ domkniętej
odpowiedzi (Cassie Del Isla, 871 scen, 4 zerwane requesty), użytkownik widział
"Refresh failed", klikał znowu i mieliliśmy to samo kilka razy równolegle
(zgłoszenie 23c64a28 "Search more scenes button not working").
Rate-guard bez zmian: refresh < 60s temu → 429. `last_searched_at` stawiamy PRZED
startem wątku, więc guard chroni też przed dublowaniem samego startu.
Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style
detail). Continuous worker w tle też robi swoje, więc cache jest częsty.
"""
from datetime import UTC as _UTC, datetime as _dt, timedelta as _td
@ -738,37 +606,27 @@ def refresh_performer(
detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit",
)
perf.last_searched_at = _dt.now(_UTC)
perf.search_run_count = (perf.search_run_count or 0) + 1
session.commit()
name = perf.canonical_name
searched_at = perf.last_searched_at.isoformat()
def _run_in_background() -> None:
# Własny import i własna obsługa błędów: wątek nie ma requestu, któremu mógłby
# oddać wyjątek, a `run_performer_driven` otwiera sesje DB samodzielnie.
try:
# Lazy import — performer_driven ma ciężki connector tree
from app.scheduler.performer_driven import run_performer_driven
# NOTE: ten request blokuje request thread API na 30-90s (search across ~25 tubes).
# Akceptowalne dla self-hosted single-user. W razie potrzeby dorobić task queue.
counters_obj = run_performer_driven(
performer_ids=[performer_id],
top_n=0,
per_performer_limit=200,
)
# Update last_searched_at + counter (tak samo jak continuous worker)
perf.last_searched_at = _dt.now(_UTC)
perf.search_run_count = (perf.search_run_count or 0) + 1
session.commit()
new_total = sum(s.get("new", 0) for s in counters_obj.per_source.values())
log.info("refresh-performer %s (%s): tlo skonczone, new=%d", performer_id, name, new_total)
except Exception:
log.exception("refresh-performer %s (%s): tlo padlo", performer_id, name)
import threading
threading.Thread(target=_run_in_background, name=f"perf-refresh-{performer_id}", daemon=True).start()
return PerformerRefreshOut(
performer_id=performer_id,
canonical_name=name,
counters={},
new_scenes=0,
last_searched_at=searched_at,
status="started",
canonical_name=perf.canonical_name,
counters=counters_obj.per_source,
new_scenes=new_total,
last_searched_at=perf.last_searched_at.isoformat() if perf.last_searched_at else None,
)

View file

@ -1,8 +1,8 @@
"""Watch history + continue watching.
Single-user. Mobile pingu POST /scenes/{id}/progress przy:
- Klik Watch (position_sec=0), wciąga scenę do recent watch
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT), z faktyczną pozycją
- Klik Watch (position_sec=0) — wciąga scenę do recent watch
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT) — z faktyczną pozycją
Continue watching rail na home: GET /watch/recent?limit=10 → top scen po last_played_at,
filtruje dead-finished (>=95% lub flag finished). Mobile pokazuje progress bar
@ -20,7 +20,7 @@ from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
from app.api.scenes import _build_scenes_out_batch
from app.api.scenes import _build_scene_out
from app.api.schemas import SceneOut
from app.auth import require_api_key
from app.db import get_session
@ -55,7 +55,7 @@ def upsert_progress(
if session.get(Scene, scene_id) is None:
raise HTTPException(status_code=404, detail="scene not found")
# PG upsert, eliminuje race condition gdy mobile wysyła progress równolegle
# PG upsert — eliminuje race condition gdy mobile wysyła progress równolegle
# (np. 2 instancje playera lub auto-save + manual save). Wcześniej `get → add →
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -139,7 +139,7 @@ def upsert_movie_progress(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> MovieProgressOut:
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26,
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26 —
"przydałoby się oznaczenie filmów już obejrzanych")."""
if session.get(Movie, movie_id) is None:
raise HTTPException(status_code=404, detail="movie not found")
@ -237,20 +237,11 @@ def list_recent(
if not include_finished:
stmt = stmt.where(ScenePlayProgress.finished.is_(False))
# Serializacja wsadowa jak w liscie scen: rail „Continue watching" leci przy kazdym
# starcie apki, a pelny `_build_scene_out` robil 5-7 zapytan na scene (N+1 w Sentry,
# 2026-09-14). Rail uzywa tytulu, miniatury i dlugosci, wiec wersja lekka wystarcza.
rows = session.execute(stmt).all()
outs = _build_scenes_out_batch(session, [scene for _, scene in rows], light=True, device_id=device_id)
out_by_id = {o.id: o for o in outs}
items: list[WatchEntry] = []
for prog, scene in rows:
scene_out = out_by_id.get(scene.id)
if scene_out is None:
continue
for prog, scene in session.execute(stmt).all():
items.append(
WatchEntry(
scene=scene_out,
scene=_build_scene_out(session, scene, device_id=device_id),
position_sec=prog.position_sec,
duration_sec=prog.duration_sec,
finished=prog.finished,

View file

@ -1,7 +1,7 @@
"""API key authentication.
Klucz przyjmowany z header `X-API-Key` lub `Authorization: Bearer <key>`.
Gdy `settings.api_keys` jest puste, auth jest wyłączony (dev mode).
Gdy `settings.api_keys` jest puste — auth jest wyłączony (dev mode).
Dodatkowo (anti-tamper): gdy `ALLOWED_APP_SIG_HASH` jest ustawione, każdy request
musi zawierać `X-App-Signature` z SHA256 (hex) signing certu APK. Mismatch → 403.
@ -30,7 +30,7 @@ def require_api_key(
)
if not settings.auth_enabled:
return # local/dev, wszystko otwarte
return # local/dev — wszystko otwarte
candidate: str | None = None
if x_api_key:

View file

@ -1,243 +0,0 @@
"""Logika feedu Swipe bez bazy: kursor, wagi afiniczności, punktacja, składanie strony.
Wyniesione z `app/api/clips.py`, żeby dało się testować bez Postgresa (`tests/test_clips_feed.py`).
**Kursor** = base64 JSON `{"s": seed, "pk": <uuid>, "p": <nr strony>}`. Nie offset: pula
„explore" idzie oknem po kluczu głównym od `pk` (ten sam trik co `sort=random` w
`list_scenes`), pula afiniczności losuje punkty startu z `Random(seed, p)`. Dzięki temu
dwa telefony z tym samym seedem dostają inne strony tylko przez różne `clip_seen`,
a pull-to-refresh to po prostu nowy seed.
**Wagi**: trzy słowniki (tag, nisza, twórca) → liczba. Zdarzenia dodają delty
(`EVENT_DELTAS`), powód „nie interesuje" dokłada karę na wskazany cel, nocny decay ×0,97
zapomina stare gusta, cap trzyma słowniki małe (60/40/40). Wszystko celowo proste i
przejrzyste: da się wytłumaczyć użytkownikowi, dlaczego coś widzi, i skasować jednym
wywołaniem.
**Punktacja kandydata** = Σ wag tagów + 2·waga niszy + 3·waga twórcy + 0,3·ln(1+lajki)
+ 0,5 gdy klip z ostatnich 7 dni. Strona: 80 % z afiniczności (max 2 klipy jednego twórcy),
20 % explore, przemieszane deterministycznie z seeda.
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import random
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
FRESH_DAYS = 7
EXPLORE_SHARE = 0.2
MAX_PER_CREATOR = 2
CAP_TAGS, CAP_NICHES, CAP_CREATORS = 60, 40, 40
DECAY_FACTOR = 0.97
DECAY_FLOOR = 0.2
#: Poniżej tego dwell „impression" nic nie znaczy, powyżej `DWELL_POSITIVE_MS` to plus.
SKIP_MS = 2000
DWELL_POSITIVE_MS = 10000
#: kind → (delta tag, delta nisza, delta twórca)
EVENT_DELTAS: dict[str, tuple[float, float, float]] = {
"complete": (3.0, 3.0, 2.0),
"like": (5.0, 5.0, 4.0),
"unlike": (-5.0, -5.0, -4.0),
"dwell": (1.0, 1.0, 0.5),
"skip": (-1.0, -1.0, -0.5),
"not_interested": (-4.0, -4.0, -3.0),
}
REASON_PENALTY = -8.0
ALLOWED_KINDS = frozenset(
{"impression", "skip", "complete", "like", "unlike", "not_interested", "autoswipe", "open"}
)
#: Kolejność „siły" wyniku w `clip_seen`: nie cofamy do słabszego.
OUTCOME_RANK = {"served": 0, "skip": 1, "complete": 2, "like": 3, "not_interested": 4}
# ---- kursor -----------------------------------------------------------------------------
def new_seed() -> str:
return uuid.uuid4().hex[:12]
def seed_start_uuid(seed: str, salt: str = "") -> uuid.UUID:
return uuid.UUID(hashlib.md5(f"{seed}:{salt}".encode()).hexdigest())
def encode_cursor(seed: str, pk: uuid.UUID, page: int) -> str:
raw = json.dumps({"s": seed, "pk": str(pk), "p": page}, separators=(",", ":"))
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def decode_cursor(cursor: str) -> tuple[str, uuid.UUID, int] | None:
"""None gdy kursor jest zepsuty (klient zaczyna wtedy od nowa, bez 400)."""
try:
pad = "=" * (-len(cursor) % 4)
d = json.loads(base64.urlsafe_b64decode(cursor + pad).decode())
seed = str(d["s"])[:16]
pk = uuid.UUID(str(d["pk"]))
page = int(d["p"])
if not seed or page < 0 or page > 100000:
return None
return seed, pk, page
except Exception:
return None
def page_rng(seed: str, page: int) -> random.Random:
return random.Random(f"{seed}:{page}")
# ---- wagi -------------------------------------------------------------------------------
@dataclass
class Affinity:
tags: dict[str, float] = field(default_factory=dict)
niches: dict[str, float] = field(default_factory=dict)
creators: dict[str, float] = field(default_factory=dict)
def is_empty(self) -> bool:
return not (
any(w > 0 for w in self.tags.values())
or any(w > 0 for w in self.niches.values())
or any(w > 0 for w in self.creators.values())
)
def top(self, which: str, n: int) -> list[str]:
d = getattr(self, which)
return [k for k, w in sorted(d.items(), key=lambda kv: -kv[1]) if w > 0][:n]
def _bump(d: dict[str, float], key: str | None, delta: float) -> None:
if not key or not delta:
return
d[key] = round(d.get(key, 0.0) + delta, 3)
def cap_weights(d: dict[str, float], n: int) -> dict[str, float]:
"""Zostaw `n` pozycji o największej wartości bezwzględnej (kary też są informacją)."""
if len(d) <= n:
return d
keep = sorted(d.items(), key=lambda kv: -abs(kv[1]))[:n]
return dict(keep)
def decay_weights(d: dict[str, float], factor: float = DECAY_FACTOR, floor: float = DECAY_FLOOR) -> dict[str, float]:
out = {k: round(v * factor, 3) for k, v in d.items()}
return {k: v for k, v in out.items() if abs(v) >= floor}
def event_kind_for_weights(kind: str, dwell_ms: int | None) -> str | None:
"""Zdarzenie z apki → klucz w `EVENT_DELTAS`. `impression` liczy się tylko przy długim
dwell, `skip` tylko przy krótkim; `autoswipe`/`open` nie ruszają wag."""
if kind == "impression":
return "dwell" if (dwell_ms or 0) >= DWELL_POSITIVE_MS else None
if kind == "skip":
return "skip" if (dwell_ms or 0) < SKIP_MS else None
return kind if kind in EVENT_DELTAS else None
def apply_event(
aff: Affinity,
*,
kind: str,
dwell_ms: int | None,
reason: str | None,
tags: list[str],
niches: list[str],
creator: str | None,
) -> bool:
"""Nakłada delty zdarzenia na wagi. True gdy coś się zmieniło."""
wkind = event_kind_for_weights(kind, dwell_ms)
changed = False
if wkind:
dt, dn, dc = EVENT_DELTAS[wkind]
for t in tags:
_bump(aff.tags, t, dt)
for n in niches:
_bump(aff.niches, n, dn)
_bump(aff.creators, creator, dc)
changed = True
if kind == "not_interested" and reason and ":" in reason:
target_kind, _, target = reason.partition(":")
target = target.strip()
if target_kind == "tag":
_bump(aff.tags, target, REASON_PENALTY)
elif target_kind == "niche":
_bump(aff.niches, target, REASON_PENALTY)
elif target_kind == "creator":
_bump(aff.creators, target, REASON_PENALTY)
changed = True
if changed:
aff.tags = cap_weights(aff.tags, CAP_TAGS)
aff.niches = cap_weights(aff.niches, CAP_NICHES)
aff.creators = cap_weights(aff.creators, CAP_CREATORS)
return changed
# ---- punktacja i składanie strony -----------------------------------------------------
@dataclass
class Candidate:
id: uuid.UUID
creator: str | None
likes: int
published_at: datetime | None
tags: list[str]
niches: list[str]
def score_candidate(c: Candidate, aff: Affinity, now: datetime | None = None) -> float:
now = now or datetime.now(UTC)
s = sum(aff.tags.get(t, 0.0) for t in c.tags)
s += 2.0 * sum(aff.niches.get(n, 0.0) for n in c.niches)
s += 3.0 * aff.creators.get(c.creator or "", 0.0)
s += 0.3 * math.log1p(max(c.likes, 0))
if c.published_at and c.published_at >= now - timedelta(days=FRESH_DAYS):
s += 0.5
return s
def pick_affinity(cands: list[Candidate], aff: Affinity, n: int, *, max_per_creator: int = MAX_PER_CREATOR) -> list[Candidate]:
"""Najlepsze `n` po punktach, max `max_per_creator` klipów jednego twórcy na stronę."""
ranked = sorted(cands, key=lambda c: -score_candidate(c, aff))
out: list[Candidate] = []
per: dict[str, int] = {}
for c in ranked:
key = c.creator or ""
if per.get(key, 0) >= max_per_creator:
continue
per[key] = per.get(key, 0) + 1
out.append(c)
if len(out) >= n:
break
return out
def pick_explore(cands: list[Candidate], n: int, rng: random.Random) -> list[Candidate]:
"""Losowanie ważone popularnością: ln(1+lajki)+1, żeby zero lajków też miało szansę."""
pool = list(cands)
out: list[Candidate] = []
while pool and len(out) < n:
weights = [math.log1p(max(c.likes, 0)) + 1.0 for c in pool]
pick = rng.choices(range(len(pool)), weights=weights, k=1)[0]
out.append(pool.pop(pick))
return out
def split_counts(limit: int, has_affinity: bool) -> tuple[int, int]:
"""(z afiniczności, explore). Bez wag wszystko idzie z explore."""
if not has_affinity:
return 0, limit
n_explore = max(1, round(limit * EXPLORE_SHARE))
return limit - n_explore, n_explore
def compose_page(aff_items: list[Candidate], explore_items: list[Candidate], rng: random.Random) -> list[tuple[Candidate, str]]:
page = [(c, "affinity") for c in aff_items] + [(c, "explore") for c in explore_items]
rng.shuffle(page)
return page
def is_fresh(published_at: datetime | None, now: datetime | None = None) -> bool:
now = now or datetime.now(UTC)
return bool(published_at and published_at >= now - timedelta(days=FRESH_DAYS))

View file

@ -25,7 +25,7 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
# Sentry observability, pusty DSN = init no-op (devel/local). Cloud free tier
# Sentry observability — pusty DSN = init no-op (devel/local). Cloud free tier
# 5k errors/mies wystarczy dla 1-user app.
sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT")
@ -48,12 +48,12 @@ class Settings(BaseSettings):
title_token_set_min: int = 88
date_window_days: int = 7
# Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source,
# Skip ingestu clip-store (ManyVids/IWantClips/Clips4Sale/...) z canonical source —
# to permanentne orphany (free tubes nie hostują), ~56% ingestu TPDB/StashDB.
# False = wciągaj jak dawniej. Tube'y z clip-store studiem NIE są skipowane (mają playback).
skip_clip_store: bool = Field(default=True, validation_alias="GOON_SKIP_CLIP_STORE")
# Minimalny duration sceny z tube/scraper przy ingescie, <N s = trailer/teaser/preview.
# Minimalny duration sceny z tube/scraper przy ingescie — <N s = trailer/teaser/preview.
# 0 = wyłączony. Nieznany duration nie jest wycinany. NIE dotyczy canonical (TPDB/StashDB).
min_ingest_duration_sec: int = Field(default=180, validation_alias="GOON_MIN_INGEST_DURATION_SEC")
@ -74,7 +74,7 @@ class Settings(BaseSettings):
sched_performer_continuous_refresh_days: int = Field(
default=30, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_REFRESH_DAYS"
)
# Movie ingest, paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/
# Movie ingest — paradisehill (primary) + dooplay mirrory (mangoporn/streamporn/
# pandamovies). Każdy connector zapisuje swój `Source` i robi delta od ostatniego
# successful run. Set to 0 to disable. Domyślnie 24h: movie sites rosną wolniej
# niż tube'y (~5-30 nowych dziennie), nie ma sensu wymiatać częściej.
@ -92,80 +92,50 @@ class Settings(BaseSettings):
sched_browse_latest_max_pages: int = Field(
default=5, validation_alias="GOON_SCHED_BROWSE_LATEST_MAX_PAGES"
)
# Deep-crawl (Faza 2a), pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko
# Deep-crawl (Faza 2a) — pełne katalogi browse-tube'ów (porndoe ~62k itd.), nie tylko
# top-N. Round-robin po tube'ach, wznawialny kursor (app/_state/deepcrawl_state.json).
# 0 = wyłączony. 60 stron/run × ~31 scen ≈ 1860 scen/run (~22 min, hard-timeout 1h).
sched_deep_crawl_hours: int = Field(default=1, validation_alias="GOON_SCHED_DEEP_CRAWL_HOURS")
deep_crawl_pages_per_run: int = Field(default=60, validation_alias="GOON_DEEP_CRAWL_PAGES_PER_RUN")
deepcrawl_state_path: str = Field(default="", validation_alias="GOON_DEEPCRAWL_STATE_PATH")
# Bulk-dedup performers safety net, auto-merge duplikatów które resolver-time
# Bulk-dedup performers safety net — auto-merge duplikatów które resolver-time
# scoring pominął. 12h cadence: leci 2x dziennie (po porannym browse-latest run).
sched_bulk_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_BULK_DEDUP_HOURS"
)
# Thumb-asset dedup, scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły,
# Thumb-asset dedup — scala dupy hdporn.gg/fullmovies.xxx (ten sam film, różne tytuły,
# ten sam asset-id miniatury + długość). bulk_dedup tego nie łapie (brak phash/tytuł).
# Re-ingesty pod nowymi tytułami → dupy odrastają, stąd cykliczny job. 12h. 0 = off.
sched_thumb_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_THUMB_DEDUP_HOURS"
)
# Title+duration dedup, scala missing-merge dupy (ten sam performer + identyczny
# Title+duration dedup — scala missing-merge dupy (ten sam performer + identyczny
# znormalizowany tytuł + długość co do sekundy), których bulk_dedup nie łapie (tube
# re-scrape / cross-tube np. porn00 vs xnxx, reports 28fe8181/32df33b1). Odrastają
# przy re-ingeście, stąd cyklicznie. 12h, playback-only (to co user widzi). 0 = off.
sched_title_dedup_hours: int = Field(
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
)
# Blacklista zdegenerowanych phashy, wartości dzielonych przez ≥10 scen (zaślepka,
# Blacklista zdegenerowanych phashy — wartości dzielonych przez ≥10 scen (zaślepka,
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
sched_phash_blacklist_hours: int = Field(
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
)
# Doliczanie phasha scenom, które mają zapisaną miniaturkę, ale nie mają odcisku.
# Audyt 2026-08-13: phash miało tylko 37,8% grywalnych scen, bo liczą go WYŁĄCZNIE
# niektóre konektory przy ingeście (xvideos 1,8%, xnxx 1,4%, eporner 1,0%, przy
# ~100% pokryciu miniaturką). Bez odcisku dedup nie ma czym skleić duplikatów, patrz
# zgłoszenie 9d272101. Job leci często i małymi paczkami, bo to pobrania z cudzych
# CDN-ów, patrz throttling w `app/scheduler/phash_backfill.py`. 0 = off.
# 800 co 15 min = ~3200/h, czyli ~3 dni na nadrobienie 239 tys. zaległości. Paczka
# 400 zajmuje ~22 s (4 wątki), więc realnie worker śpi prawie cały ten czas, a
# pobrania rozkładają się na kilkanaście różnych CDN-ów, nie na jeden host.
sched_phash_backfill_minutes: int = Field(
default=15, validation_alias="GOON_SCHED_PHASH_BACKFILL_MINUTES"
)
sched_phash_backfill_batch: int = Field(
default=800, validation_alias="GOON_SCHED_PHASH_BACKFILL_BATCH"
)
# Doliczanie brakujacej dlugosci scen (patrz app/models/duration_backfill_queue.py).
# 500 stron co 5 min to ~144 tys. dziennie, wiec kolejka 846 tys. schodzi w ok. 6 dni.
# To ~1,7 zapytania na sekunde rozlozone na trzy duze tuby, czyli ruch, ktorego one
# nie zauwaza. Job ma 30-minutowy limit, a paczka schodzi w ok. 3 min, wiec z zapasem.
sched_duration_backfill_minutes: int = Field(
default=5, validation_alias="GOON_SCHED_DURATION_BACKFILL_MINUTES"
)
sched_duration_backfill_batch: int = Field(
default=500, validation_alias="GOON_SCHED_DURATION_BACKFILL_BATCH"
)
# Próg ciszy dla konektorów FILMOWYCH. Wcześniej filmy nie były pokryte watchdogiem
# w ogóle, streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# w ogóle — streamporn.vip stał 23 dni i nic nie krzyknęło. Filmy ingestujemy
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
ingest_watchdog_movie_max_age_hours: int = Field(
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
)
# Śmieciowi performerzy, kategorie i studia podszywające się pod osoby („Creampie",
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie",
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
sched_junk_performers_hours: int = Field(
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
)
# Przycinanie ogona tagow rule34video (tagowanie booru: nazwy postaci/serii).
# 356 scen wnosilo 1112 tagow do WSPOLNEJ listy, 602 z nich uzyte raz. 24h. 0 = off.
sched_tag_tail_trim_hours: int = Field(
default=24, validation_alias="GOON_SCHED_TAG_TAIL_TRIM_HOURS"
)
# Ingest freshness watchdog, alert do Sentry gdy aktywny tube (origin
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
# widzi (np. freshporno browse z rotującego roota, report 14f3a655). 6h cadence
@ -173,19 +143,19 @@ class Settings(BaseSettings):
sched_ingest_watchdog_hours: int = Field(
default=6, validation_alias="GOON_SCHED_INGEST_WATCHDOG_HOURS"
)
# Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS), crawlowane raz dziennie z
# Próg dla browse-scraperów (ALL_BROWSE_SCRAPERS) — crawlowane raz dziennie z
# listingu, więc 48h ciszy = anomalia.
ingest_watchdog_max_age_hours: int = Field(
default=48, validation_alias="GOON_INGEST_WATCHDOG_MAX_AGE_HOURS"
)
# Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS), kadencja jest
# Próg dla performer-driven search-scraperów (ALL_DIRECT_SCRAPERS) — kadencja jest
# nierówna (continuous queue ~30d refresh per performer, ingest orphan-heavy), więc
# 48h dawałoby false-positivy. 7d (168h): healthy search-tuby obserwowane <6h świeżości
# (continuous tick hituje wszystkie tuby per performer), zamrożone ≥73h → ~28× margines.
ingest_watchdog_search_max_age_hours: int = Field(
default=168, validation_alias="GOON_INGEST_WATCHDOG_SEARCH_MAX_AGE_HOURS"
)
# TPDB movie enrichment, wzbogaca filmy obsadą + kategoriami (tagi) + studiem +
# TPDB movie enrichment — wzbogaca filmy obsadą + kategoriami (tagi) + studiem +
# reżyserem, plus kanoniczny TPDB UUID do dedupu mirrorów. paradisehill (primary)
# prawie nie ma obsady, więc to domyka największą lukę zakładki movies. Batch
# najświeższych GRYWALNYCH filmów bez obsady/studia per run; 6h cadence. 0 = off.
@ -195,20 +165,20 @@ class Settings(BaseSettings):
tpdb_movie_enrich_batch: int = Field(
default=200, validation_alias="GOON_TPDB_MOVIE_ENRICH_BATCH"
)
# Taxonomy scene_count refresh, przelicza denormalizowane liczniki scen na
# Taxonomy scene_count refresh — przelicza denormalizowane liczniki scen na
# tags/performers/studios (hot-path /tags|/performers|/studios|/favorites czyta
# gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence,
# gotową kolumnę zamiast agregować 6.3M scene_tags per-request). 3h cadence —
# counts do tego stale, dla sortu "popular" + badge "(N)" bez znaczenia. 0 = off.
sched_taxonomy_counts_hours: int = Field(
default=3, validation_alias="GOON_SCHED_TAXONOMY_COUNTS_HOURS"
)
# Hetzner Cloud bandwidth monitor, read-only API token (Security → API Tokens
# Hetzner Cloud bandwidth monitor — read-only API token (Security → API Tokens
# w panelu Hetzner Cloud). Bez tokenu monitor wyłączony (warning w log).
# Free traffic per server: CX22=20TB, CPX21=20TB itd. Overage = €1/TB.
hetzner_api_token: str | None = Field(default=None, validation_alias="HETZNER_API_TOKEN")
hetzner_server_id: int | None = Field(default=None, validation_alias="HETZNER_SERVER_ID")
# Alert thresholds (% of included_traffic), Sentry severity levels.
# Alert thresholds (% of included_traffic) — Sentry severity levels.
hetzner_alert_info_pct: int = Field(default=50, validation_alias="HETZNER_ALERT_INFO_PCT")
hetzner_alert_warning_pct: int = Field(default=80, validation_alias="HETZNER_ALERT_WARNING_PCT")
hetzner_alert_error_pct: int = Field(default=95, validation_alias="HETZNER_ALERT_ERROR_PCT")
@ -218,27 +188,14 @@ class Settings(BaseSettings):
default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS"
)
# Source ranking (Sites screen), przelicz source_stats (freshness/richness/health
# Source ranking (Sites screen) — przelicz source_stats (freshness/richness/health
# per origin). 0/None = wyłączone. Domyślnie 6h (richness to ciężki agregat po
# ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli).
sched_source_stats_hours: int = Field(
default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS"
)
# Klipy do feedu Swipe (RedGifs): godzinny run globalnego „najnowsze", N stron po
# 100 (RedGifs publikuje kilkaset klipów na godzinę). 0 = wyłączony. Backfill po
# niszach ręcznie: `python -m scripts.clips_backfill_redgifs`.
sched_clips_hours: int = Field(default=1, validation_alias="GOON_SCHED_CLIPS_HOURS")
clips_latest_pages: int = Field(default=5, validation_alias="GOON_CLIPS_LATEST_PAGES")
# Konserwacja feedu Swipe raz na dobę: decay wag afiniczności (×0,97), retencja
# zdarzeń (90 dni), reaper martwych plików (HEAD na 2000 najdawniej sprawdzanych).
sched_clips_maintenance_hours: int = Field(
default=24, validation_alias="GOON_SCHED_CLIPS_MAINTENANCE_HOURS"
)
# Feed podaje tylko klipy do tej długości (RedGifs p90 = 60 s; dłuższe to nie „swipe").
clips_feed_max_duration_sec: int = Field(default=120, validation_alias="GOON_CLIPS_FEED_MAX_DURATION_SEC")
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB),
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB) —
# używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare
# 403 nawet z browser-TLS (superporn). Streamu i tak nie ruszamy proxy (tokeny CDN
# IP-bound). Format env: `host:port:user:pass` (panel Bright Data). Pusty = brak.

View file

@ -1,14 +1,14 @@
"""Connector registry helpers.
Lazy factories, importy connectorów wykonują się dopiero w `get_movie_connectors()`
Lazy factories — importy connectorów wykonują się dopiero w `get_movie_connectors()`
żeby uniknąć circular imports (modeles/db). Każdy entry: `(name, class)` w porządku
ingestu (primary FIRST, mirrory potem, `resolve_movie` wtedy ma do czego dokleić
ingestu (primary FIRST, mirrory potem — `resolve_movie` wtedy ma do czego dokleić
mirror playback sources).
## Jak dodać nowe movie site
1. Napisz subclass `DooplayConnector` w `app/connectors/dooplay.py` (jeśli site używa
dooplay/PsyPlay WP theme), wystarczy `name` + `base_url`. Jeśli inny theme,
dooplay/PsyPlay WP theme) — wystarczy `name` + `base_url`. Jeśli inny theme,
napisz osobny connector implementujący `BaseMovieConnector.fetch_movies()`.
2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej.
3. Backend job `_job_movie_ingest` w `app/scheduler/jobs.py` automatycznie weźmie
@ -31,10 +31,11 @@ from __future__ import annotations
def get_movie_connectors() -> list[tuple[str, type]]:
"""Zwraca listę (name, ConnectorCls) tuples w kolejności ingestu.
Lazy import, uniknięcie circular import bo connectory zaczepiają db/models.
Lazy import — uniknięcie circular import bo connectory zaczepiają db/models.
"""
from app.connectors.dooplay import (
MangopornConnector,
PandamoviesConnector,
StreampornVipConnector,
)
from app.connectors.freeomovie import FreeoMovieConnector
@ -42,10 +43,10 @@ def get_movie_connectors() -> list[tuple[str, type]]:
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content,
# niego przyklejają), potem mangoporn (jedyny mirror z realnym new-content —
# 72 nowych 2026-05-28; streamporn/pandamovies zwracają stale 0 new), na końcu
# streamporn + pandamovies. Powód reorderu (2026-05-30): gdy streamporn wiesza
# się intermittentnie, mangoporn musi zdążyć przed nim, patrz per-connector
# się intermittentnie, mangoporn musi zdążyć przed nim — patrz per-connector
# timeout w _job_movie_ingest.
# streamporn.nl padł (0 new od 2026-05-23) → zastąpiony żywym streamporn.vip
# (StreampornVipConnector, ocena 2026-07-02). Stara klasa .nl zostaje w dooplay.py
@ -54,26 +55,16 @@ def get_movie_connectors() -> list[tuple[str, type]]:
("paradisehill", ParadisehillConnector),
("mangoporn", MangopornConnector),
("streampornvip", StreampornVipConnector),
# pandamovies WYLACZONY 2026-08-20: serwis padl, zostal im tylko zapamietany
# fronton. Strona glowna oddaje 291 kB i wypisuje 56 filmow, ale KAZDA inna
# sciezka to 404: listing /movies/ (ten, ktory czytal konektor), /movies/page/2/
# linkowana przez ich wlasny szablon, /genre/* i /release-year/* z ich menu,
# sitemapa, a przede wszystkim same strony filmow (0 na 6 dzialajacych).
# To nie blokada na nasze IP: przez residential proxy wychodzi identyczne 404
# (te same 796 bajtow). Konektor konczyl runy jako success z zerem rekordow,
# co falszowalo obraz w monitoringu. Playback tego, co juz mamy, dziala dalej,
# bo zrodla wskazuja na hostery (doply, luluvid, seekplayer), nie na ten serwis.
# Klasa zostaje w dooplay.py dla backrefu origins `pandamovies:*`.
# ("pandamovies", PandamoviesConnector),
# freeomovie.to (bestia theme, nie dooplay), mirror: świeże filmy DVD,
("pandamovies", PandamoviesConnector),
# freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD,
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
("freeomovie", FreeoMovieConnector),
# yourdailypornmovies.ws (własny motyw WP, nie dooplay), ocena 2026-07-28.
# yourdailypornmovies.ws (własny motyw WP, nie dooplay) — ocena 2026-07-28.
# SPRAWDZONY NA DUPLIKAT: 18/40 filmów ze strony głównej było nowych, bo to inna
# pula treści (okładki DVD z adultempire, nie re-hosty z pandanetwork.club, na
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
# gotowe moduły hosterów), vidara.to nieznana, leci jako zwykły embed.
# gotowe moduły hosterów), vidara.to nieznana — leci jako zwykły embed.
("yourdailypornmovies", YourDailyPornMoviesConnector),
]

View file

@ -93,7 +93,7 @@ class RawScene(BaseModel):
(np. 'tpdb', 'stashdb')."""
raw: dict[str, Any] = Field(default_factory=dict)
"""Oryginalny payload z API, leci do external_records.raw."""
"""Oryginalny payload z API — leci do external_records.raw."""
class BaseConnector(abc.ABC):
@ -114,15 +114,15 @@ class BaseConnector(abc.ABC):
# ---------------------------------------------------------------------------
# Movies, odrębny encja od scen, ale ten sam wzorzec connectorów
# Movies — odrębny encja od scen, ale ten sam wzorzec connectorów
# ---------------------------------------------------------------------------
class RawMovieChapter(BaseModel):
"""Pojedynczy rozdział filmu (movies czasem dzielą się na "Part 1/2/3" itp.).
Identyfikatory chaptera nie są kanonizowane między źródłami, są lokalne dla movie,
Identyfikatory chaptera nie są kanonizowane między źródłami — są lokalne dla movie,
indeksowane przez `chapter_index`. Może linkować do separate scene (jeśli ta scena
znana z TPDB/StashDB), tym zajmuje się normalizator wyżej."""
znana z TPDB/StashDB) — tym zajmuje się normalizator wyżej."""
model_config = ConfigDict(extra="allow")
@ -133,7 +133,7 @@ class RawMovieChapter(BaseModel):
class RawMovie(BaseModel):
"""Surowy film z connectora, odpowiednik RawScene dla movies.
"""Surowy film z connectora — odpowiednik RawScene dla movies.
Performers / studio / tags reusable z RawPerformer / RawStudio / RawTag (te same
typy w obu pipelinach). Playback sources to lista mirrorów odtwarzania (paradisehill
@ -170,7 +170,7 @@ class BaseMovieConnector(abc.ABC):
"""Connector dla source'a movies (paradisehill, psyplay, wp_movies).
Symetrycznie do BaseConnector ale yielduje RawMovie. Każde źródło zna własną
paginację i format ID, konwerter wyżej (resolver) dba o dedup między źródłami.
paginację i format ID — konwerter wyżej (resolver) dba o dedup między źródłami.
"""
kind: SourceKind

View file

@ -1,2 +0,0 @@
"""Konektory klipów (feed Swipe). Osobne od `direct_scrapers`: inny model (`Clip`),
inny potok (upsert bez dedupu i bez matchera performerów), własne joby."""

View file

@ -1,223 +0,0 @@
"""RedGifs, oficjalne publiczne API v2. Główne źródło klipów do feedu Swipe. Dodane 2026-09-14.
**Dlaczego RedGifs, a nie nsfwswipe.** Sprawdzone tego samego dnia: nsfwswipe to lustro
wideo natywnych Reddita (v.redd.it) bez audio, z ostatnim klipem z 19.08 (stoi od
czterech tygodni) i z losowym, nieoficjalnym API oddającym HTML. RedGifs daje JSON,
klipy z tej minuty, 1080x1920, audio w ok. 1/3 klipów, twórcę z odznaką weryfikacji,
lajki i widoki. Pliki `media.redgifs.com/<Id>.mp4` są publiczne, bez podpisu, grają bez
Referera z VPS-a i z telefonu (206), więc do bazy wchodzi gotowy adres, bez ekstraktora.
**Token.** `GET /v2/auth/temporary` oddaje token na kilka godzin, związany z IP i User-Agentem
wołającego: kolejne zapytania MUSZĄ iść z tymi samymi nagłówkami, inaczej 401 `WrongSender`
(zjadło pół godziny przy pierwszym podejściu, bo curl i node miały inne UA). Na 401
odnawiamy token raz i powtarzamy.
**Listing.** `GET /v2/gifs/search?order=latest&count=100&page=N` bez `search_text` to
globalne „najnowsze" (98-100 na stronę). `order` ∈ latest|top|top7|top28|trending|score,
NIE `new` (400 BadOrder). Każde zapytanie widzi maks. 10 000 wyników (`pages` ≤ 100,
strona 101 → 500), więc pełny katalog chodzi się po niszach (`/v2/niches`, 1812
społeczności, `/v2/niches/<id>/gifs`) i po twórcach (`/v2/users/<name>/search`).
Limit: per IP i per endpoint (`subject: "<ip>|niche-gifs|<n>"` w ciele 429), bez nagłówka
Retry-After, za to w ciele `error.delay` w sekundach (widziane 1167 s po ~65 zapytaniach
w 20 s). Trzymamy 1 zapytanie/s; przy 429 czekamy `delay`, a gdy jest dłuższy niż 5 min,
przerywamy przebieg (`RateLimited`), bo dalsze zapytania tylko przedłużają blokadę.
**Rekord gifa** (pola, które bierzemy): `id`, `type` (1 = wideo, 2 = obraz, pomijamy),
`urls{hd,sd,poster,thumbnail}`, `duration`, `width/height`, `hasAudio`, `hls`, `tags[]`,
`niches[]`, `contentType`, `sexuality[]`, `likes`, `views`, `createDate` (unix s),
`userName`, `verified`, `cta`/`promoted` (reklama, informacyjnie).
"""
from __future__ import annotations
import logging
import re
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
import httpx
log = logging.getLogger(__name__)
_BASE = "https://api.redgifs.com/v2"
#: Stałe UA i Origin: token jest do nich przywiązany (patrz docstring).
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
),
"Accept": "application/json",
"Origin": "https://www.redgifs.com",
"Referer": "https://www.redgifs.com/",
}
_PAGE_MAX = 100
#: Pomiar 2026-09-14: przy ~3 zapytaniach/s po ~65 zapytaniach z rzedu RedGifs oddaje 429
#: z `delay` ~20 min. 1 zapytanie/s ma sie trzymac w limicie (do weryfikacji na backfillu).
_MIN_INTERVAL_S = 1.0
_RATE_LIMIT_BACKOFF_S = (30, 90, 180)
#: Dluzsze `delay` z ciala 429 nie czekamy w procesie, tylko przerywamy przebieg.
_RATE_LIMIT_MAX_WAIT_S = 300
_TAG_SLUG_RE = re.compile(r"[^a-z0-9]+")
@dataclass
class RawClip:
source: str
source_id: str
title: str | None
creator: str | None
creator_verified: bool
duration_sec: float | None
width: int | None
height: int | None
has_audio: bool
content_type: str | None
niches: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
hd_url: str | None = None
sd_url: str | None = None
poster_url: str | None = None
thumb_url: str | None = None
hls: bool = False
likes: int = 0
views: int = 0
published_at: datetime | None = None
def tag_slug(name: str) -> str:
""""Big Tits" → "big-tits". Ta sama reguła dla RedGifs i (później) Pornhuba, żeby
afiniczność liczyła się na wspólnych slugach."""
return _TAG_SLUG_RE.sub("-", (name or "").strip().lower()).strip("-")[:64]
def map_gif(g: dict) -> RawClip | None:
"""Rekord API → RawClip. None dla obrazów, rekordów bez pliku i bez id."""
if not isinstance(g, dict) or g.get("type") not in (1, None):
return None
gid = str(g.get("id") or "").strip()
urls = g.get("urls") or {}
hd, sd = urls.get("hd"), urls.get("sd")
if not gid or not (hd or sd):
return None
created = g.get("createDate")
published = (
datetime.fromtimestamp(int(created), tz=UTC) if isinstance(created, (int, float)) and created > 0 else None
)
tags: list[str] = []
seen: set[str] = set()
for t in g.get("tags") or []:
s = tag_slug(str(t))
if s and s not in seen:
seen.add(s)
tags.append(s)
niches = [str(n)[:64] for n in (g.get("niches") or []) if n]
dur = g.get("duration")
return RawClip(
source="redgifs",
source_id=gid,
title=(g.get("description") or None),
creator=(g.get("userName") or None),
creator_verified=bool(g.get("verified")),
duration_sec=float(dur) if isinstance(dur, (int, float)) and dur > 0 else None,
width=g.get("width") if isinstance(g.get("width"), int) else None,
height=g.get("height") if isinstance(g.get("height"), int) else None,
has_audio=bool(g.get("hasAudio")),
content_type=(str(g["contentType"])[:32] if g.get("contentType") else None),
niches=niches,
tags=tags,
hd_url=hd,
sd_url=sd,
poster_url=urls.get("poster"),
thumb_url=urls.get("thumbnail"),
hls=bool(g.get("hls")),
likes=int(g.get("likes") or 0),
views=int(g.get("views") or 0),
published_at=published,
)
def _rate_limit_wait(r: httpx.Response, attempt: int) -> float:
"""Odstęp po 429: `Retry-After`, potem `error.delay` z ciała, potem stała drabinka."""
retry_after = r.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
return float(retry_after)
try:
delay = (r.json() or {}).get("error", {}).get("delay")
if isinstance(delay, (int, float)) and delay > 0:
return float(delay)
except ValueError:
pass
return float(_RATE_LIMIT_BACKOFF_S[min(attempt, len(_RATE_LIMIT_BACKOFF_S) - 1)])
class RateLimited(RuntimeError):
"""429 mimo odczekania: wołający ma przerwać przebieg, nie iść do następnej niszy
(każde kolejne zapytanie tylko przedłuża blokadę)."""
class RedgifsClient:
"""Cienki klient z tokenem tymczasowym, odnowieniem na 401 i odczekiwaniem 429."""
def __init__(self, *, timeout: float = 30.0) -> None:
self._http = httpx.Client(headers=_HEADERS, timeout=timeout)
self._token: str | None = None
self._last_call = 0.0
def close(self) -> None:
self._http.close()
def _auth(self) -> str:
r = self._http.get(f"{_BASE}/auth/temporary")
r.raise_for_status()
tok = (r.json() or {}).get("token")
if not tok:
raise RuntimeError("redgifs: brak tokenu w /auth/temporary")
self._token = tok
return tok
def _get(self, path: str, params: dict | None = None) -> dict:
wait = _MIN_INTERVAL_S - (time.monotonic() - self._last_call)
if wait > 0:
time.sleep(wait)
tok = self._token or self._auth()
for attempt in range(1 + len(_RATE_LIMIT_BACKOFF_S)):
r = self._http.get(f"{_BASE}{path}", params=params, headers={"Authorization": f"Bearer {tok}"})
self._last_call = time.monotonic()
if r.status_code == 401 and attempt == 0:
tok = self._auth()
continue
if r.status_code == 429:
if attempt >= len(_RATE_LIMIT_BACKOFF_S):
raise RateLimited(f"redgifs: 429 na {path} po {attempt} odczekaniach")
wait = _rate_limit_wait(r, attempt)
if wait > _RATE_LIMIT_MAX_WAIT_S:
raise RateLimited(f"redgifs: 429 na {path}, zadany odstep {wait:.0f} s")
log.info("redgifs: 429 na %s, czekam %.0f s", path, wait)
time.sleep(wait)
continue
r.raise_for_status()
return r.json()
raise RuntimeError(f"redgifs: {path} nie odpowiedzial poprawnie")
# ---- listingi ---------------------------------------------------------------------
def latest(self, page: int = 1, count: int = _PAGE_MAX) -> list[dict]:
"""Globalne „najnowsze" (bez search_text)."""
return self._get("/gifs/search", {"order": "latest", "count": count, "page": page}).get("gifs") or []
def search(self, text: str, *, order: str = "latest", page: int = 1, count: int = _PAGE_MAX) -> list[dict]:
return self._get(
"/gifs/search", {"search_text": text, "order": order, "count": count, "page": page}
).get("gifs") or []
def niches(self, page: int = 1, count: int = _PAGE_MAX) -> dict:
"""`{"page","pages","total","niches":[{"id","name","gifs","subscribers","tags":[...]}]}`"""
return self._get("/niches", {"count": count, "page": page})
def niche_gifs(self, niche_id: str, *, order: str = "latest", page: int = 1, count: int = _PAGE_MAX) -> list[dict]:
return self._get(f"/niches/{niche_id}/gifs", {"order": order, "count": count, "page": page}).get("gifs") or []
def user_gifs(self, user: str, *, order: str = "latest", page: int = 1, count: int = _PAGE_MAX) -> list[dict]:
return self._get(f"/users/{user}/search", {"order": order, "count": count, "page": page}).get("gifs") or []
def gif(self, gif_id: str) -> dict | None:
return (self._get(f"/gifs/{gif_id}") or {}).get("gif")

View file

@ -1,25 +1,25 @@
"""Direct tube scrapers.
Każdy scraper hit'uje tube bezpośrednio HTTPm, różne tube'y to różne rate limit
Każdy scraper hit'uje tube bezpośrednio HTTPm — różne tube'y to różne rate limit
budgets, więc mogą iść równolegle. Wszystkie feedują sceny do tej samej
`Source(name=SCRAPER_SOURCE_NAME)` z external_id `f"{sitetag}:{url}"`. Resolver
mergeuje idempotentnie po tym kluczu.
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"`, myląca pozostałość po usuniętym
zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA,
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"` — myląca pozostałość po usuniętym
zewnętrznym porn-app API (sugerowała zależność od obcego serwisu, której NIE MA —
to nasze własne direct-scrapery tubów). Przemianowana na `"tube-scraper"`; wiersz
`sources` zaktualizowany w DB (UPDATE name) więc cała historia ingest_runs została.
Search-based ścieżka (per performer name); category browse'ng przez `categoriesUrl`.
UWAGA, speculative scrapers: większość aggregator + special tubes (xmoviesforyou,
UWAGA — speculative scrapers: większość aggregator + special tubes (xmoviesforyou,
watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak,
porndittcom, perverzija, fpoxxx, ...) ma URL templates + regex'y oparte na typowych
WordPress conventions. Wymagają post-deploy verification, gdy któryś nie zwraca
WordPress conventions. Wymagają post-deploy verification — gdy któryś nie zwraca
wyników, sprawdź real search HTML + popraw template/regex w odpowiednim pliku.
"""
# Umbrella Source.name dla wszystkich direct-scraperów (deep-crawl, browse-latest,
# performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące, nie ma zależności
# performer-driven). Rename z legacy "pornapp" 2026-06-07 (mylące — nie ma zależności
# od zewnętrznego porn-app API).
SCRAPER_SOURCE_NAME = "tube-scraper"
@ -27,7 +27,7 @@ from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
from app.connectors.direct_scrapers.eporner import EpornerScraper
from app.connectors.direct_scrapers.fpoxxx import FpoxxxScraper
from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401, kept for backref; disabled
from app.connectors.direct_scrapers.hdporn92 import HDPorn92Scraper # noqa: F401 — kept for backref; disabled
from app.connectors.direct_scrapers.hqporner import HQPornerScraper
from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper
from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper
@ -48,12 +48,6 @@ from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper
from app.connectors.direct_scrapers.hentaimama import HentaiMamaScraper
from app.connectors.direct_scrapers.hentaigasm import HentaiGasmScraper
from app.connectors.direct_scrapers.hentaiplay import HentaiPlayScraper
from app.connectors.direct_scrapers.naughtymachinima import NaughtyMachinimaScraper
from app.connectors.direct_scrapers.rule34video import Rule34VideoScraper
from app.connectors.direct_scrapers.xanimeporn import XAnimePornScraper
from app.connectors.direct_scrapers.fullporner import FullPornerScraper
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
from app.connectors.direct_scrapers.youperv import YoupervScraper
@ -61,21 +55,21 @@ from app.connectors.direct_scrapers.xhamster import XHamsterScraper
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
from app.connectors.direct_scrapers.xnxx import XnxxScraper
from app.connectors.direct_scrapers.xvideos import XVideosScraper
from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401, kept for backref; delisted
from app.connectors.direct_scrapers.xxxfreewatch import XxxFreeWatchScraper # noqa: F401 — kept for backref; delisted
from app.connectors.direct_scrapers.youporn import YouPornScraper
ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
# Existing 4 (verified, in production)
HQPornerScraper,
# HDPorn92Scraper, wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe
# HDPorn92Scraper — wyłączony 2026-05-18. Scene pages to SEO shell: ZERO player iframe
# (tylko happyleafmotion ads), JS hijackuje wszystkie kliki → `go.rmishe.com/smartpop/...`
# popunder redirect. Mobile WebView page-as-hoster pokazuje ad redirect zamiast video.
# 33,598 playback_sources mass-marked dead, 27,374 solo-orphan scenes deleted.
SxyLandScraper,
# ZeroDayXXScraper (0dayxx), USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan
# ZeroDayXXScraper (0dayxx) — USUNIĘTY CAŁKOWICIE 2026-06-22 (user request). Orphan
# factory (0.1% canonical), zastępujemy lepszymi źródłami. Dane/pliki/extractor skasowane.
# Mainstream (URL templates well-known)
# PornHub + RedTube, USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od
# PornHub + RedTube — USUNIĘTE CAŁKOWICIE 2026-06-22 (user request). Disabled od
# 2026-05-12 (0.4% canonical match), zamrożone dane skasowane z DB, pliki scraperów
# i ekstraktory usunięte. Powód: skrócone amatorskie clipy, nigdy nie matchują studio.
XVideosScraper,
@ -84,60 +78,60 @@ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
YouPornScraper,
PornTrexScraper,
EpornerScraper,
# Aggregators (WordPress-like ?s= search; speculative, verify post-deploy)
# XMoviesForYouScraper, wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
# streamtape (DEAD_HOSTER_RE, malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
# Aggregators (WordPress-like ?s= search; speculative — verify post-deploy)
# XMoviesForYouScraper — wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
# streamtape (DEAD_HOSTER_RE — malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
# Mixdrop zrebrandował na m1xdrop.bz, yt-dlp out-of-date, packer/JS extract = fail.
# Playmogo = DoodStream CAPTCHA. Porn-app sam olewa xmoviesforyou (brak handlera w
# jadx). 1,321 solo-orphan scen.
# WatchPornScraper, RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS.
# WatchPornScraper — RE-ENABLE 2026-07-02, przeniesiony do ALL_BROWSE_SCRAPERS.
# Site przebudowany na KVS (DoodStream-CAPTCHA z 2026-05-12 zniknął); browse
# /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py.
# SiskaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
# bo search siski zepsuty site-side, `?s=` ignoruje query). Patrz siska.py.
# Porn4DaysScraper, wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
# SiskaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
# bo search siski zepsuty site-side — `?s=` ignoruje query). Patrz siska.py.
# Porn4DaysScraper — wyłączony 2026-05-12 (post audit fix). 100% scen na streamtape
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
# streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346
# solo-orphan scen.
# PornDishScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24,
# PornDishScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-24,
# watchdog GOON-16: search `?s=` zamarzł 2026-05-07). WordPress → browse przez WP REST
# API (/wp-json/wp/v2/posts) jak perverzija: tytuł/data/thumb/studio(category)/tagi.
# XxxFreeWatchScraper, wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan.
# XxxFreeWatchScraper — wyłączony 2026-05-18. 790 scen, 0% canonical match, 100% solo-orphan.
# Cloudflare 403 z VPS IP, mobile WebView teoretycznie działa ale 0/790 scen miało jakikolwiek
# match do TPDB/StashDB. Pure orphan factory. Solo scenes deleted, scraper disabled.
# LatestPornVideoScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# LatestPornVideoScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# user 1da0375e: search-driven nie brał feedu "latest" → stary zestaw w apce).
# LatestLeaksScraper, wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0%
# LatestLeaksScraper — wyłączony 2026-05-12 (source quality report): 16,438 scen, 0.0%
# canonical match. Slug-concat tytuły, brak studio/duration/date signali. Solo orphany
# usunięte (~15k scen).
MyPornerLeakScraper,
# Added 2026-05-12 (theporndude survey): jeden z 14 free tubes na liście który
# zwraca consistent search results. KVS engine, slug-aware scene URLs. Mostly
# orphan ingest (auto-screenshots, no canonical phash match, sprawdzone), ale
# orphan ingest (auto-screenshots, no canonical phash match — sprawdzone), ale
# może łapać sceny popularnych performerów których jeszcze nie mamy w TPDB.
# PornHat (pornhatcom) + PornDitt (porndittcom), USUNIĘTE CAŁKOWICIE 2026-06-22
# PornHat (pornhatcom) + PornDitt (porndittcom) — USUNIĘTE CAŁKOWICIE 2026-06-22
# (user request). Orphan factories (0.2% / weak-signal canonical match), zastępujemy
# lepszymi źródłami. Dane/pliki scraperów/extractory skasowane.
# Special
SxyPrnScraper,
# PerverzijaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# PerverzijaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# user request). Search `?s=` → 429, homepage JS-renderowane; browse przez WP REST API
# (/wp-json/wp/v2/posts) daje tytuł/datę/thumb/studio(category)/tagi. Playback embed-iframe.
# FpoxxxScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# FpoxxxScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-22,
# user request). fpo.xxx to KVS, nie WordPress → search `?s=` zwracał 0; browse z
# `/new-<n>/` daje listing tile (tytuł/thumb/duration). Playback i tak phone-side (KVS).
]
# Browse-mode scrapers, iterują `latest-vids` listing zamiast search-by-performer.
# Browse-mode scrapers — iterują `latest-vids` listing zamiast search-by-performer.
# Phash thumbnail fingerprint (waga 0.40 w composite scoring) auto-mergeuje do
# canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane
# raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` +
# `app/scheduler/browse_latest.py`.
#
# **Pilot results (2026-05-12):**
# - ShyfapScraper: 0/23 match (0%), robi własne thumbnails ≠ canonical
# - ShyfapScraper: 0/23 match (0%) — robi własne thumbnails ≠ canonical
# (phash Hamming 12-16). Plus rebranduje tytuły. **Wyłączony.**
# - FreshpornoScraper: 39/59 match (66%), hot-linkuje studio thumbnaile
# - FreshpornoScraper: 39/59 match (66%) — hot-linkuje studio thumbnaile
# (phash Hamming 0). Oryginalne tytuły + channels=studio 1:1. **Aktywny.**
from app.connectors.direct_scrapers.freshporno import FreshpornoScraper # noqa: E402
from app.connectors.direct_scrapers.porn00 import Porn00Scraper # noqa: E402
@ -153,8 +147,6 @@ from app.connectors.direct_scrapers.javflix import JavflixScraper # noqa: E402
from app.connectors.direct_scrapers.vjav import VjavScraper # noqa: E402
from app.connectors.direct_scrapers.supjav import SupjavScraper # noqa: E402
from app.connectors.direct_scrapers.neporn import NepornScraper # noqa: E402
from app.connectors.direct_scrapers.porndupe import PorndupeScraper # noqa: E402
from app.connectors.direct_scrapers.tubepornclassic import TubePornClassicScraper # noqa: E402
from app.connectors.direct_scrapers.superporn import SuperpornScraper # noqa: E402
from app.connectors.direct_scrapers.eporner_api import EpornerApiScraper # noqa: E402
from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper # noqa: E402
@ -162,93 +154,62 @@ from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper
ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
PerverzijaScraper,
PornDishScraper,
# WatchPornScraper, re-enabled 2026-07-02 jako browse (site przebudowany na KVS,
# WatchPornScraper — re-enabled 2026-07-02 jako browse (site przebudowany na KVS,
# DoodStream-CAPTCHA zniknął). Browse /latest-updates/: og:title, JSON-LD duration/
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
WatchPornScraper,
# YoupervScraper, dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
# YoupervScraper — dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
# hqporner). Ripy paysite, tytuły `Studio - Performer - Title` (71% próbki), performerzy
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
YoupervScraper,
# GalaxyPornScraper, dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
# GalaxyPornScraper — dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
# wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu
# tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go
# w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
GalaxyPornScraper,
# PornBusyScraper, dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
# PornBusyScraper — dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
# uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
PornBusyScraper,
# PornMikeScraper, dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
# PornMikeScraper — dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
# VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z
# refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23%
# katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar).
# UWAGA: to klipy 5-12 min, nie pełne sceny, ~80% katalogu to jednak studia bez
# UWAGA: to klipy 5-12 min, nie pełne sceny — ~80% katalogu to jednak studia bez
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
PornMikeScraper,
# KPorner8Scraper, dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
# KPorner8Scraper — dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
# tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
# tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
# Pilot: seen 62, new 25, updated 37, 60% dopina się do scen, które już mamy.
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy.
KPorner8Scraper,
# HentaiMamaScraper — dodany 2026-08-04, PIERWSZE źródło wertykału hentai.
# UWAGA na kryterium: dla animacji NIE ISTNIEJE kanon (TPDB/StashDB nie mają
# rysowanych postaci), więc każda scena stąd jest orphanem i to jest POPRAWNE.
# Nie stosujemy tu ani bramki na obsadę (nie ma obsady), ani miary canonical match.
# Jakość mierzymy kompletem seria + odcinek + gatunki. Jednostka = ODCINEK.
# Motyw z rodziny Dooplay, ale DooplayConnector NIE pasuje (brak data-fl-source).
# Playback: extractor `hentaimama`, POST admin-ajax za Cloudflare → Bright Data.
HentaiMamaScraper,
# XAnimePornScraper — dodany 2026-08-04, drugie źródło wertykału hentai.
# NIE re-host hentaimamy: wspólny odcinek (Shoujo Ramune 7) leży na obu, ale to inne
# pliki — tu jest jawny wybór 1080p (222 MB) / 720p (84 MB), tam jedna jakość bez
# etykiety. Paginacja strony głównej ZEPSUTA (str. 2-3 = str. 1, jak przy 8kporner),
# więc listing z dwóch archiwów: /uncensored/ i /censored/, które stronicują poprawnie.
# Playback bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy.
XAnimePornScraper,
# HentaiGasmScraper — dodany 2026-08-04, trzecie zrodlo wertykalu i TRZECI
# niezalezny host (hgasm1.com). Metadane w CALOSCI z RSS-a, bo strona odcinka nie ma
# daty w zadnym standardowym miejscu, a /genre/ to 38 linkow chmury z sidebara.
# Playback najprostszy z calej trojki: JWPlayer z gotowym file: wprost w HTML.
HentaiGasmScraper,
# HentaiPlayScraper — dodany 2026-08-04, CZWARTY niezalezny host wertykalu
# (hentaiplanet.info). Metadane z RSS-a, bo /genre/ w HTML to 2084 linki chmury,
# najwiecej w calym wertykale. Playback: mp4 wprost w HTML, bez CF i bez proxy.
HentaiPlayScraper,
# NaughtyMachinimaScraper — dodany 2026-08-13, drugie zrodlo 3D i najzdrowsza
# jednostka tresci wertykalu (mediana 289s, zero ponizej minuty).
NaughtyMachinimaScraper,
# Rule34VideoScraper — dodany 2026-08-13, pierwsze zrodlo 3D (SFM/Blender).
# Bramka >=60s odrzuca ~47% katalogu (mediana 68s), bo apka i tak filtruje
# ponizej minuty. Tagi TYLKO z class=tag_item, `class=button` to chmura.
Rule34VideoScraper,
# FullPornerScraper, dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
# FullPornerScraper — dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO, za to
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO — za to
# data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120
# scenach: 31% dopina się do tego, co mamy (composite waży performera + długość),
# przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny
# bez performera scaliły się 0/23. phash liczony celowo, to jedyny sygnał dla
# bez performera scaliły się 0/23. phash liczony celowo — to jedyny sygnał dla
# bulk_dedup na te duplikaty, po tytule nie pójdzie.
FullPornerScraper,
# SexuScraper (`sexu.py`), ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
# SexuScraper (`sexu.py`) — ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
# minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min
# w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i
# tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie
# to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie
# performera. Scraper i extractor zostają w repo, gdyby serwis kiedyś zaczął wrzucać
# performera. Scraper i extractor zostają w repo — gdyby serwis kiedyś zaczął wrzucać
# pełne sceny, wystarczy dopisać klasę z powrotem tutaj.
# FullVideosPornScraper, dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
@ -263,114 +224,105 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
XnxxBrowseScraper,
FreshpornoScraper,
FpoxxxScraper,
# LatestPornVideoScraper, browse od 2026-06-22 (user 1da0375e: search-driven
# LatestPornVideoScraper — browse od 2026-06-22 (user 1da0375e: search-driven
# nie brał feedu "latest"). Listing card: tytuł (z embedded "<Studio> YY MM DD"),
# thumb (studio+date w nazwie), category-* jako tag. Performerów listing nie ma
# czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid
# iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje.
LatestPornVideoScraper,
# SiskaScraper, re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski
# SiskaScraper — re-enabled 2026-06-20 jako browse (user fa4083a2). Search siski
# zepsuty site-side (`?s=` ignoruje query), więc latest-browse z `/page/<n>/`.
# Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/
# kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side.
SiskaScraper,
# PornXPScraper, pilot 2026-05-17 (20 scen): studio 100%, performer 95%,
# PornXPScraper — pilot 2026-05-17 (20 scen): studio 100%, performer 95%,
# release_date 100%, duration 100%, stream_url 100%, phash 100%. Najlepsze
# sygnały spośród browse-mode scraperów. Stream direct mp4 (sv.porn-xp.com)
# 360/720 quality. Release year z `Released: <year>` na detail.
PornXPScraper,
# Porn00Scraper, pilot 2026-05-17 (16 scen): brak studio (0%) + brak release
# Porn00Scraper — pilot 2026-05-17 (16 scen): brak studio (0%) + brak release
# date (0%) ALE performer 100%, duration 100%, stream_url 100% (KVS video_alt_url
# 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title
# fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować.
Porn00Scraper,
# PornDoeScraper, dołączony 2026-05-21 (theporndude audit). Każda scena ma
# PornDoeScraper — dołączony 2026-05-21 (theporndude audit). Każda scena ma
# kompletny JSON-LD VideoObject: title + uploadDate + duration + named studio
# (producer/publisher) + named performers (actor[]) + thumbnail. Najbogatsze
# strukturalne metadane spośród browse scraperów, composite fuzzy match ma
# strukturalne metadane spośród browse scraperów — composite fuzzy match ma
# komplet sygnałów. Phash hit-rate niski (własne crop-thumbnaile), studio +
# performer + date + duration nadrabiają.
PornDoeScraper,
# YesPornVipScraper, dołączony 2026-05-27 (user audit). JSON-LD VideoObject
# YesPornVipScraper — dołączony 2026-05-27 (user audit). JSON-LD VideoObject
# + `<meta property="video:duration|release_date|tag">` per scena (Goon ma
# duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio +
# performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`).
# 941k organic monthly (SE Ranking, comparable z porndoe 731k / porntrex 790k).
# Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen), wysokie expected
# Scraper-of-paysites (DogFart / HardX / TeamSkeet / Vixen) — wysokie expected
# canonical match dla studio scenes. Korekta: theporndude scorecard rank 26
# ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** pdude.link
# ('yespornvip.com', score -0.5, auth wall) dotyczył **innej domeny** — pdude.link
# redirect do porndudecams affiliate. Prawdziwa kanoniczna domena to TLD `.vip`.
YesPornVipScraper,
# FullmoviesScraper + HDPornGGScraper, dołączone 2026-06-01. KVS engine (sponsor_groups
# FullmoviesScraper + HDPornGGScraper — dołączone 2026-06-01. KVS engine (sponsor_groups
# stack, `/videos/<slug>/` + `/latest-updates/`). Studio teraz z PREFIKSU tytułu
# ("Studio - Scene"), sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match
# ("Studio - Scene") — sidebar `/networks/` listował WSZYSTKIE sieci, więc pierwszy match
# zawsze Brazzers (mis-attribution, dlatego nigdy nie były włączone). Niosą paysite studio
# content (TeamSkeet/Dad Crush/Brazzers/...) z title+performer+duration → composite fuzzy.
# Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest).
FullmoviesScraper,
HDPornGGScraper,
# EpornerApiScraper, dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
# JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner, odrzucone), ALE eporner ma
# EpornerApiScraper — dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
# JS-heavy KVS bez SSR metadanych (jak porntrex/hqporner — odrzucone), ALE eporner ma
# publiczne JSON API (api/v2/video/search): 1 call = 100 filmów z title+length_sec+
# keywords+added+thumb. ~100k filmów, deep-crawl przez crawl_page() (API, bez detail-fetch).
EpornerApiScraper,
# XVideosBrowseScraper, dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate)
# XVideosBrowseScraper — dołączony 2026-06-03. SSR JSON-LD (duration/title/uploadDate)
# + page-parse /models/ (performerzy) + /tags/. Sample: median ~10.5min, 93% ≥3min.
# Mega-katalog ~13M → deep_crawl._PAGE_CAP["xvideoscom"]=1800 (~50k najnowszych), nie
# full-crawl. (youporn pominięty, JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.)
# full-crawl. (youporn pominięty — JSON-LD bez actor/keywords, scene-perf/tagi = nav A-Z.)
XVideosBrowseScraper,
# HQFapScraper, PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA
# HQFapScraper — PlayTube CMS (re-uploader pornhd.pet). Usunięty 2026-06-25 gdy CAŁA
# biblioteka CDN serwowała `/upload/videos/video_down.mp4` stub; PRZYWRÓCONY 2026-07-07
# bo strona wróciła na CDN vstor.top z realnymi plikami (portable cross-IP), user request.
# HQFapScraper, ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła
# HQFapScraper — ZDJĘTY 2026-08-02 (druga i ostateczna deaktywacja). Strona wdrożyła
# Cloudflare z JS-challenge na /watch/: 403 z VPS, z residential IP przy pełnych
# nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy, czyli blokada nie jest
# nagłówkach przeglądarki ORAZ przez Bright Data ISP proxy — czyli blokada nie jest
# po IP, tylko wymaga wykonania JS. WebView też nie przechodzi automatycznie (user
# widzi challenge: zgłoszenia 60576a84 / 244131ba / 92e9133e). Telemetria: ZERO prób
# odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS,
# odtworzenia od 27.07. Utrzymanie wymagałoby headless-przeglądarki na VPS —
# nieproporcjonalne, decyzja: usunąć (jak 4k69 i pierwsze usunięcie hqfap 2026-06-25).
# HQPornerBrowseScraper, dołączony 2026-07-26. hqporner był TYLKO w
# HQPornerBrowseScraper — dołączony 2026-07-26. hqporner był TYLKO w
# ALL_DIRECT_SCRAPERS (search po performerze), więc świeże sceny wchodziły wyłącznie
# przy trafieniu na znanego performera → 0 nowych scen w 7 dni (user-report). Browse
# (listing / + ?p=N) daje ingest niezależny od stanu performerów. external_id ten sam
# co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę.
HQPornerBrowseScraper,
# PorndupeScraper, dodany 2026-09-05. Re-uploader scen studyjnych (Nubile, NF Busty,
# Family Strokes), 87% katalogu nowe dla nas, trafia w kanon. Chodzenie po id
# (listing = catch-all), dlugosc i data z api.ok.ru przy ingescie.
PorndupeScraper,
# TubePornClassicScraper, dodany 2026-09-07 (user request). Retro/vintage, TXXX
# (silnik vjav): listing to JSON API z pelnymi metadanymi (60 scen na request, bez
# fetchu per scena), wyzwanie 429 przez ciasteczko. Obsada u ~46% scen, studiow brak.
# Stream HLS przez _txxx.extract_hls + /proxy/hls.
TubePornClassicScraper,
# FourK69Scraper, USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
# JavflixScraper, JAV vertical (osobna sekcja). origin tube:javflix jest w
# FourK69Scraper — USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
# JavflixScraper — JAV vertical (osobna sekcja). origin tube:javflix jest w
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).
JavflixScraper,
# VjavScraper, JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS).
# VjavScraper — JAV vertical (osobna sekcja, origin tube:vjav w JAV_ORIGINS).
# TXXX network: sitemap id-walk (newest=max id) + JSON metadata API. Stream
# videofile.php → get_file HLS (portable, /proxy/hls passthrough). RE 2026-07-10.
VjavScraper,
# SupjavScraper, JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS).
# SupjavScraper — JAV vertical (osobna sekcja, origin tube:supjav w JAV_ORIGINS).
# CF-blokuje VPS → browse homepage przez Bright Data proxy. Embed-aggregator:
# data-link reverse-hex → lk1.supremejav.com → hoster (extractor supjav). RE 2026-07-10.
SupjavScraper,
# NepornScraper, dołączony 2026-06-10 (user request). KVS engine (jak freshporno/
# NepornScraper — dołączony 2026-06-10 (user request). KVS engine (jak freshporno/
# porn00), /latest-updates/N/. JSON-LD (title+desc+uploadDate+thumb) + video:duration
# meta + /models/ performerzy + /categories/ tagi. Brak studio (tytuł bywa
# "- HardX Update - ...", fuzzy match po tytule). Resolve server-side _kvs,
# "- HardX Update - ..." — fuzzy match po tytule). Resolve server-side _kvs,
# finalny remote_control.php portable cross-IP.
NepornScraper,
# SuperpornScraper, dołączony 2026-06-10 (user request). superporn blokuje VPS IP
# SuperpornScraper — dołączony 2026-06-10 (user request). superporn blokuje VPS IP
# twardym CF 403 (każda impersonacja TLS), więc ingest HTML idzie przez Bright Data
# ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy, `_proxy` w
# ISP proxy (BRIGHTDATA_PROXY_URL, ryczałt nie per-GB). Pierwszy scraper z proxy — `_proxy` w
# _browse_base. JSON-LD (title+desc+uploadDate+thumb+duration) + chipy pornstar/
# kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback).
# Bez proxy: scraper no-op (pusty iterator).
SuperpornScraper,
# porntrex/hqporner/youporn, NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
# ShyfapScraper, wyłączony 2026-05-12 (pilot fail, 0% match, orphan factory).
# porntrex/hqporner/youporn — NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
# ShyfapScraper — wyłączony 2026-05-12 (pilot fail, 0% match — orphan factory).
]
__all__ = [

View file

@ -1,15 +1,15 @@
"""BaseBrowseScraper, latest-vids browse mode (vs search-by-performer).
"""BaseBrowseScraper — latest-vids browse mode (vs search-by-performer).
Wzorzec: tube'y typu shyfap/freshporno/porn00/fullmovies/pornxp mają bogatą
metadata (title, studio, performers, tags, duration, release_date, description)
na detail page'u, wystarczy do canonical fuzzy match w resolverze. Browse mode
na detail page'u — wystarczy do canonical fuzzy match w resolverze. Browse mode
iteruje "latest" page (sorted by upload date) i fetchuje detail per scene.
Różnica vs `BaseSearchScraper`:
- **search**: tube wyszukuje sceny po performer name (dla performer-driven
backfill). Wymaga znanego performera.
- **browse**: tube listuje newest scenes (latest-vids endpoint). Nie wymaga
żadnego query, chodzi o świeże sceny independent of performer state.
żadnego query — chodzi o świeże sceny independent of performer state.
Browse jest komplementarny do search:
- search łapie sceny dla **znanych performerów** (TPDB/StashDB → tube)
@ -67,15 +67,15 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
"""Parsuj scene detail HTML → RawScene z metadata.
Zwraca None gdy scena niedostępna / parse fail, caller pominie ten URL,
Zwraca None gdy scena niedostępna / parse fail — caller pominie ten URL,
nie aborti całe browse."""
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest
(top-N) i deep_crawl (kursor). Zwraca:
None , transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
[] pusty listing = koniec katalogu (caller: exhausted),
[...], sceny z tej strony.
None — transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
[] — pusty listing = koniec katalogu (caller: exhausted),
[...] — sceny z tej strony.
API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio
(call API zamiast listing→detail). HTML browse subclasses dostarczają
@ -120,7 +120,7 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
break
yield from scenes
# Stub `search()`, BaseDirectTubeScraper wymaga implementacji. Dla browse-only
# Stub `search()` — BaseDirectTubeScraper wymaga implementacji. Dla browse-only
# tubes nie supportujemy performer-driven search; zwracamy pusty iterator. Tube'y
# które chcą *oba* tryby mogą override'ować search() osobno.
def search(
@ -148,7 +148,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
Format pasuje do `SceneFingerprint.value` w DB (TPDB/StashDB importują ten sam
8x8 phash). Resolver Path 3 `find_by_phash_within` matchuje Hamming ≤5 (default).
Wymaga lazy importu `imagehash`/`PIL`, żeby moduł browse_base importował się
Wymaga lazy importu `imagehash`/`PIL` — żeby moduł browse_base importował się
nawet gdy te lib-y są niedostępne (graceful degradation: phash=None → resolver
spadnie do composite scoring, jak gdyby fingerprintu nie było).
"""
@ -169,7 +169,7 @@ def compute_thumbnail_phash(thumbnail_url: str, *, referer: str | None = None, t
return None
img = Image.open(io.BytesIO(r.content))
# phash domyślnie hash_size=8 → 64-bit hash → 16 hex chars. Mode 'L' (greyscale)
# robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame,
# robi to wewnętrznie, ale niektóre webp/animated mogą mieć multi-frame —
# convert() bierze pierwszą klatkę, którą imagehash i tak zredukuje do grey.
return str(imagehash.phash(img.convert("RGB")))
except Exception as e:
@ -183,7 +183,7 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
Standardowy helper dla scraperów które używają OpenGraph / ya:ovs / itp.
Cache compiled regex w module-scope dict (te same selectory powtarzają się).
NB: separate patterns dla `"..."` i `'...'` content quote, wcześniej jeden
NB: separate patterns dla `"..."` i `'...'` content quote — wcześniej jeden
`[^"\']*` regex tnął title po wewnętrznym apostrofie (np. `<meta content="She's So Insatiable">`
→ `She`, bug-report 2026-05-20). Teraz matchujemy dokładnie ten sam quote co opening.
"""
@ -191,8 +191,8 @@ def meta_content(html: str, *, property: str | None = None, name: str | None = N
if key not in _META_RE_CACHE:
attr = "property" if property else "name"
val = re.escape(property or name or "")
# double-quoted content (HTML standard), preferred
# Pattern: <meta property="X" content="...inner..." > , inner allows apostrophes
# double-quoted content (HTML standard) — preferred
# Pattern: <meta property="X" content="...inner..." > — inner allows apostrophes
_META_RE_CACHE[key] = re.compile(
rf'<meta[^>]+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"'
rf'|<meta[^>]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'',

View file

@ -1,4 +1,4 @@
"""BasePlayTubeScraper, wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69).
"""BasePlayTubeScraper — wspólna baza dla tube'ów na PlayTube CMS (hqfap, 4k69).
Platforma rozpoznawalna po: `/watch/<slug>_<id>.html`, sitemap index
`/sitemaps/videos/sitemap-N.xml` (z `<lastmod>`), JSON-LD VideoObject na detail
@ -9,9 +9,9 @@ Listing NIE paginuje się GET-em (PlayTube doładowuje AJAX-em `aj/load-more/`),
więc crawl_page buduje katalog z sitemapów (sort lastmod desc = newest first)
i tnie na strony po `_PAGE_SIZE`. Działa dla browse_latest (pages 1-5) i
deep_crawl (kursor do końca katalogu). Minus: sitemap laguje ~dobę za
najświeższymi uploadami, akceptowalne przy dziennym harmonogramie.
najświeższymi uploadami — akceptowalne przy dziennym harmonogramie.
Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()`,
Subclass ustawia `base_url` + (opcjonalnie) override'uje `_pick_studio()` —
PlayTube nie ma strukturalnego pola studio na scenie, studio siedzi w
kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios).
@ -109,7 +109,7 @@ def _extract_video_object(html: str) -> dict | None:
class BasePlayTubeScraper(BaseBrowseScraper):
base_url: str # np. "https://hqfap.com", subclass ustawia
base_url: str # np. "https://hqfap.com" — subclass ustawia
def __init__(self) -> None:
super().__init__()
@ -123,9 +123,9 @@ class BasePlayTubeScraper(BaseBrowseScraper):
def _pick_studio(self, category_names: list[str]) -> str | None:
return None
# crawl_page override (jak EpornerApiScraper), listing nie jest stronicowalny
# crawl_page override (jak EpornerApiScraper) — listing nie jest stronicowalny
# przez GET, źródłem paginacji jest sitemap. _listing_url/_extract_scene_urls
# nieużywane, ale abstrakcyjne, dostarczamy no-op implementacje.
# nieużywane, ale abstrakcyjne — dostarczamy no-op implementacje.
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
return f"{self.base_url}/sitemap.xml"
@ -156,7 +156,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
sm = browser_get(sm_url, timeout=self._timeout)
sm.raise_for_status()
except Exception as e:
# Brak jednego sitemapa ≠ fail całości, reszta katalogu wystarczy.
# Brak jednego sitemapa ≠ fail całości — reszta katalogu wystarczy.
log.warning("%s: sitemap fetch failed %s: %s", self.sitetag, sm_url, e)
continue
for block in _URL_BLOCK_RE.findall(sm.text):
@ -227,7 +227,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
thumbnail_url = video.get("thumbnailUrl") or None
# Pille: pornstar → performer; category → studio (hook `_pick_studio`)
# albo tag. Ocenzurowane nazwy (`Te***`) pomijamy, gwiazdki to nie dane.
# albo tag. Ocenzurowane nazwy (`Te***`) pomijamy — gwiazdki to nie dane.
performers: list[RawPerformer] = []
category_names: list[str] = []
seen_perf: set[str] = set()
@ -275,7 +275,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph))
# Stream: JSON-LD contentUrl wygasa (token time-bound), NIE zapisujemy
# Stream: JSON-LD contentUrl wygasa (token time-bound) — NIE zapisujemy
# stream_url; extractor per-sitetag resolvuje świeży on-demand.
playback_sources = [
RawPlaybackSource(

View file

@ -1,20 +1,20 @@
"""BaseSearchScraper, shared search-page HTML scraping logika.
"""BaseSearchScraper — shared search-page HTML scraping logika.
Wzorzec stosowany przez wszystkie tube'y discovery scrapers:
1. Build search URL z `_search_url_template` (formatowane query+page).
2. Fetch HTML curl_cffi.
3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL,
opcjonalnie `slug` lub `id` jako tytuł source).
4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query),
4. Filtruj wyniki po query tokens (slug musi zawierać ≥1 token z query) —
fuzzy search tube'ów często zwraca niezwiązane wyniki.
5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`.
Subclass override:
- `sitetag: str`, np. "pornhubcom"
- `_search_url_template: str`, z `{query}` i `{page}` placeholderami
- `_scene_url_re: re.Pattern[str]`, regex z named group `url` (scene URL)
- `_title_from_match(match) -> str`, opcjonalny override (default: derive z URL slug)
- `_token_filter_text(match) -> str`, co testować na query tokens (default: cała URL)
- `sitetag: str` — np. "pornhubcom"
- `_search_url_template: str` — z `{query}` i `{page}` placeholderami
- `_scene_url_re: re.Pattern[str]` — regex z named group `url` (scene URL)
- `_title_from_match(match) -> str` — opcjonalny override (default: derive z URL slug)
- `_token_filter_text(match) -> str` — co testować na query tokens (default: cała URL)
"""
from __future__ import annotations
@ -30,7 +30,7 @@ from app.extractors import browser_get
# Image src extraction: matches src, data-src, data-original, data-lazy-src, data-lazy
# (lazy-load lib variants). Wymaga rozszerzenia obrazka żeby ograniczyć false positives
# (sprite icons, spinners), JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają
# (sprite icons, spinners) — JPG/PNG/WEBP są ~ jedynymi formatami które tube'y używają
# dla scene thumbnails.
_IMG_SRC_RE = re.compile(
r'<img[^>]+(?:src|data-src|data-original|data-lazy-src|data-lazy)=["\']'
@ -53,7 +53,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
#: Regex matchujący scene URL w search HTML. Wymagana grupa `url` (full scene URL),
#: opcjonalna grupa `slug` (do title derivation gdy dostępny w URL).
_scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder, subclass override
_scene_url_re: re.Pattern[str] = re.compile(r"$^") # placeholder — subclass override
#: Minimalna długość tokena query do filtrowania wyników (krótsze ignorujemy żeby
#: nie matchowały niezwiązanych slugów).
@ -74,12 +74,12 @@ class BaseSearchScraper(BaseDirectTubeScraper):
})
#: Window (chars) wokół scene URL match, w którym szukamy `<img>` jako thumbnail.
#: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>`,
#: WordPress-like tubes mają thumb w `<a href="..."><img src="...thumb.jpg"></a>` —
#: ±800 chars łapie ten pattern niezawodnie.
_thumbnail_window: int = 800
def _scene_url_from_match(self, m: re.Match[str]) -> str:
"""Domyślnie group(1), subclass override gdy regex używa named groups inaczej."""
"""Domyślnie group(1) — subclass override gdy regex używa named groups inaczej."""
try:
return m.group("url")
except IndexError:
@ -103,54 +103,23 @@ class BaseSearchScraper(BaseDirectTubeScraper):
def _format_query_for_url(self, query: str) -> str:
"""Default: URL-encode (spaces → `+`). Subclass override gdy tube wymaga
innego formatu, np. KVS-style sites użyją slug (spaces → `-`).
innego formatu — np. KVS-style sites użyją slug (spaces → `-`).
"""
return urllib.parse.quote_plus(query.strip())
def _thumbnail_map(self, listing_html: str) -> dict[str, str]:
"""Opcjonalny hak: mapa scene_url -> thumbnail_url z calej strony wynikow.
Domyslnie pusta i wtedy dziala szukanie `<img>` w oknie wokol linku. Tuby,
ktore renderuja karty z JSON-a (xhamster: `window.initials` z `pageURL` i
`thumbURL`), maja miniature dla KAZDEJ karty w jednym miejscu, a okno tekstu
gubilo polowe (54% zrodel xhamster bez miniatury, zgloszenie 8ec9dc96)."""
return {}
def _fetch_scene_metadata(
self, scene_url: str
) -> tuple[RawStudio | None, list[RawPerformer], list[RawTag]] | None:
"""Optional hook, subclass może override żeby fetch'ować scene detail page
"""Optional hook — subclass może override żeby fetch'ować scene detail page
i wyciągnąć studio/performerów/tagi. Default zwraca None (skip detail fetch).
Wywoływane PER SCENE w `search()`, dodaje +1 HTTP request per match. Subclass
Wywoływane PER SCENE w `search()` — dodaje +1 HTTP request per match. Subclass
powinien rzucić wyjątki swobodnie, base łapie i kontynuuje bez metadata.
Returns: (studio, performers, tags). Każde może być None / pusta lista.
"""
return None
@staticmethod
def _slug_matches_query(slug_lower: str, query: str) -> bool:
"""Czy slug zawiera nazwę z query jako spójną frazę, z granicami słowa.
Człony rozdzielamy dowolnym niealfanumerycznym separatorem (tube używa `-`,
`_` albo skleja), a po bokach wymagamy granicy, żeby „tiffany" nie łapało się
w „stiffany". Człony krótsze niż `_query_token_min_len` pomijamy przy budowie
frazy (np. „Mia Li"), inaczej wypadłyby wszystkie sceny tej osoby.
"""
toks = [
re.escape(tok)
for tok in query.lower().split()
if len(tok) >= BaseSearchScraper._query_token_min_len
]
if not toks:
return True
phrase = r"[^a-z0-9]*".join(toks)
# Dozwolone końcowe „s": slug gubi apostrof, więc dzierżawcze „TiffaniTime's"
# zapisuje jako „tiffanitimes". Nie luzuje to bramki, bo przed tym „s" i tak
# musi stać cała fraza (samo „first-times" nadal nie przejdzie dla „Tiffani Time").
return re.search(rf"(?<![a-z0-9]){phrase}s?(?![a-z0-9])", slug_lower) is not None
def search(
self,
query: str,
@ -178,18 +147,13 @@ class BaseSearchScraper(BaseDirectTubeScraper):
}
seen: set[str] = set()
try:
thumb_map = self._thumbnail_map(r.text)
except Exception as e: # noqa: BLE001
log.debug("%s thumbnail map failed: %s", self.sitetag, e)
thumb_map = {}
yielded = 0
for m in self._scene_url_re.finditer(r.text):
scene_url = self._scene_url_from_match(m).strip()
if scene_url.startswith("//"):
scene_url = "https:" + scene_url
elif scene_url.startswith("/"):
# Relative URL, prefix host z search URL.
# Relative URL — prefix host z search URL.
base = urllib.parse.urlparse(url)
scene_url = f"{base.scheme}://{base.netloc}{scene_url}"
if scene_url in seen:
@ -200,35 +164,26 @@ class BaseSearchScraper(BaseDirectTubeScraper):
slug_lower = slug.lower()
if slug_lower in self._nav_slug_blacklist:
continue
# Slug musi zawierać CAŁE nazwisko jako SPÓJNĄ FRAZĘ, nie luźno rozrzucone
# tokeny. Historia tej linijki to trzy poziomy:
#
# 1. `any(tok in slug)` — scena z „ava-devine" dostawała Ava Koxxx.
# 2. `all(tok in slug)` — lepiej, ale to wciąż test PODCIĄGU na luźnych
# tokenach, więc przy „Tiffani Time" przechodziło wszystko, co miało
# gdziekolwiek „time" (first-time, adult-time, multiple-times) plus
# cokolwiek zawierającego „tiffani". Przy „Tiffany Time" dochodziło
# jeszcze „tiffany" ⊂ „s-tiffany-love". Zgłoszenie 2026-08-13: strona
# Tiffani Time pełna scen o Tiffany Tatum, Tiffani Madison i Victorii.
# 3. (tu) fraza: „tiffani-time" musi wystąpić w slugu jako ciąg, z dowolnym
# separatorem między członami i granicą słowa po bokach.
#
# To ma znaczenie, bo `all_performers` niżej dokleja szukanego performera
# BEZWARUNKOWO do każdej sceny, która tu przejdzie. Ten warunek jest jedyną
# bramką między wyszukiwarką tuba (dopasowuje rozmyto) a obsadą w bazie.
if query_tokens and not self._slug_matches_query(slug_lower, query):
# Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()`
# przepuszczał scenę gdy choć jeden token był w slug — dla performera
# "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug
# (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx",
# bo `any("ava" in slug)` =True. User reports: scena "ava devine
# gangbanged..." miała Ava Koxxx w DB. Fix: `all()` — slug musi
# zawierać każdy ≥3-char token z imienia performera.
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
continue
title = self._title_from_slug(slug)
# Thumbnail: search ±N chars around scene_url match for nearest <img src=>.
# Większość tubes ma `<a href="<scene>"><img src="<thumb>"></a>` lub flat
# `<img src=><a href=>`, window 800 obejmuje oba.
# `<img src=><a href=>` — window 800 obejmuje oba.
window_start = max(0, m.start() - self._thumbnail_window)
window_end = min(len(r.text), m.end() + self._thumbnail_window)
window_html = r.text[window_start:window_end]
thumb_url: str | None = thumb_map.get(scene_url)
img_m = None if thumb_url else _IMG_SRC_RE.search(window_html)
thumb_url: str | None = None
img_m = _IMG_SRC_RE.search(window_html)
if img_m:
thumb_url = img_m.group(1).strip()
if thumb_url.startswith("//"):
@ -238,7 +193,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}"
# Opcjonalny metadata fetch (studio/dodatkowi performerzy/tagi). Default
# zwraca None, większość tube'ów ma tylko search HTML bez metadata.
# zwraca None — większość tube'ów ma tylko search HTML bez metadata.
# PornHat ma `data-setup='{...}'` w `js-ajax-{dvd,model,tag}` divach.
studio: RawStudio | None = None
extra_performers: list[RawPerformer] = []
@ -257,7 +212,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
description = meta[3]
# Performer z query zawsze obecny (driver scraping). Extra performers
# z detail page dorzucamy, dedupe po slug/name w resolverze.
# z detail page dorzucamy — dedupe po slug/name w resolverze.
all_performers = [RawPerformer(name=query.strip()), *extra_performers]
yield RawScene(

View file

@ -1,4 +1,4 @@
"""BaseDirectTubeScraper, kontrakt dla bezpośrednich scraperów tube'ów."""
"""BaseDirectTubeScraper — kontrakt dla bezpośrednich scraperów tube'ów."""
from __future__ import annotations
import abc
@ -13,7 +13,7 @@ class BaseDirectTubeScraper(abc.ABC):
żeby dziedziczyć logikę resolvera + idempotent merge per external_id."""
sitetag: str
"""Stabilny ID tube'a, używany w external_id `f"{sitetag}:{url}"`. Zgodny
"""Stabilny ID tube'a — używany w external_id `f"{sitetag}:{url}"`. Zgodny
z porn-app sitetag (hqpornercom, sxylandcom, itp.)."""
@abc.abstractmethod

View file

@ -1,4 +1,4 @@
"""eporner.com, direct HTML scrape search results.
"""eporner.com — direct HTML scrape search results.
Search: `https://www.eporner.com/search/<q>/<page>/` (1-indexed pages).
Scene URL: `https://www.eporner.com/hd-porn/<id>/<slug>/` lub `/video-<id>/<slug>/`.

View file

@ -1,11 +1,11 @@
"""eporner.com, deep-crawl przez oficjalne JSON API (api/v2/video/search).
"""eporner.com — deep-crawl przez oficjalne JSON API (api/v2/video/search).
Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów), ALE eporner ma
Detail HTML eporner jest JS-heavy (brak SSR duration/title/tagów) — ALE eporner ma
publiczne API zwracające KOMPLETNĄ metadatę w jednym callu: `title`, `length_sec`
(duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów,
`order=latest`, ~100/stronę → ~1000 szybkich calli (BEZ detail-fetch). To czyni
eporner idealnym SSR-bogatym źródłem deep-crawla (analiza 2026-06-03: porntrex/hqporner
odrzucone, KVS bez SSR duration; eporner-API je zastępuje).
odrzucone — KVS bez SSR duration; eporner-API je zastępuje).
Override `crawl_page()` (API flow). HTML-owe _listing_url/_extract/_parse to stuby
(BaseBrowseScraper ABC ich wymaga, ale nieużywane). Sitetag `epornercom` = ten sam co
@ -113,7 +113,7 @@ class EpornerApiScraper(BaseBrowseScraper):
)
return out
# HTML stuby, nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga.
# HTML stuby — nieużywane (crawl_page nadpisany API-flow), ale ABC ich wymaga.
def _listing_url(self, page: int) -> str: # pragma: no cover
raise NotImplementedError("EpornerApiScraper używa crawl_page (API), nie HTML listingu")

View file

@ -1,6 +1,6 @@
"""fpo.xxx, latest-vids browse scraper (KVS engine).
"""fpo.xxx — latest-vids browse scraper (KVS engine).
Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP,
Historia: dawniej WordPress-search scraper (`?s=`), ale fpo.xxx to KVS, nie WP —
search zwracał 0 (regex slug-URL nie pasował do `/video/<id>/`). Przerobione na
BROWSE (latest z `/new-<n>/`), 2026-06-22 (user request: ożywić zamrożone tuby).
@ -11,7 +11,7 @@ Listing tile (`/new-<n>/`):
→ tytuł, miniatura, duration, URL sceny. Performerów/tagów listing nie ma czysto
(tytuł bywa JAV-code "Imai Kaho-RKI-602 ..."), więc puste → dorabia canonical-merge.
Playback: KVS (kt_player + license_code na detail page), token IP-bound, resolve
Playback: KVS (kt_player + license_code na detail page) — token IP-bound, resolve
PO STRONIE TELEFONU (fpoxxxResolver.ts / WebView fallback, extractor `fpoxxx`).
"""
from __future__ import annotations

View file

@ -1,4 +1,4 @@
"""freshporno.org, latest-vids browse scraper.
"""freshporno.org — latest-vids browse scraper.
Pilot #2 (po shyfap fail). Hipoteza: freshporno zachowuje oryginalne studio titles
("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match
@ -9,7 +9,7 @@ URL patterns:
- Scene: `/videos/<slug>/`
Listing: świadomie `/latest-updates/` zamiast roota `/`. Root jest KVS-owym
homepage z `cache-control: no-store` i świeżym PHPSESSID per-request, serwer
homepage z `cache-control: no-store` i świeżym PHPSESSID per-request — serwer
rotuje tam blok "featured" i na zimnej sesji potrafi podać stary zestaw zamiast
najnowszych (zaobserwowane 2026-06-15: 3 dni browse-latest skipowało wszystko bo
root podawał sceny z 20 maja; freshporno.org report). `/latest-updates/` to jawny
@ -108,7 +108,7 @@ class FreshpornoScraper(BaseBrowseScraper):
# Channel = studio. Pierwszy `/channels/<slug>/` link na stronie body
# (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym)
studio: RawStudio | None = None
# Skipnij nav linki ze stringiem "Channels" jako anchor text, bierzemy specific channel
# Skipnij nav linki ze stringiem "Channels" jako anchor text — bierzemy specific channel
for m in _CHANNEL_LINK_RE.finditer(detail_html):
slug, name = m.group(1), m.group(2).strip()
if name.lower() in ("channels", ""):
@ -120,7 +120,7 @@ class FreshpornoScraper(BaseBrowseScraper):
)
break
# Performers, wszyscy `/models/<slug>/`
# Performers — wszyscy `/models/<slug>/`
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
for m in _MODEL_LINK_RE.finditer(detail_html):
@ -154,7 +154,7 @@ class FreshpornoScraper(BaseBrowseScraper):
)
# Phash z thumbnail. Wiemy że freshporno używa internal screenshots (preview.mp4.jpg)
# więc to też może nie matchować canonical phashy, ale test pokaże.
# więc to też może nie matchować canonical phashy — ale test pokaże.
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
@ -170,8 +170,8 @@ class FreshpornoScraper(BaseBrowseScraper):
)
]
# Release date, freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
# To data wrzucenia na freshporno, NIE oryginalna release_date studio, ale dla
# Release date — freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
# To data wrzucenia na freshporno, NIE oryginalna release_date studio — ale dla
# świeżych scen (uploaded niedługo po publikacji) różnica ≤ 3-7 dni, mieści się w
# `date_window_days=7` w resolverze. Bez tego pola scene NULL → match score 0 →
# duplicate scene zamiast freshporno PS dodane do TPDB canonical (bug-report

View file

@ -1,8 +1,8 @@
"""fullmovies.xxx, latest-vids browse scraper.
"""fullmovies.xxx — latest-vids browse scraper.
Identyczny engine co hdporn.gg (KVS sponsor_groups stack): `/videos/<slug>/`,
`/networks/<slug>/`, `/models/<slug>/`, `/tags/<slug>/`. og:image to `img.fullmovies.xxx/...`
**prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi.
— **prawdopodobnie auto-screenshot** (jak hdporn.gg → 8% match). Probe potwierdzi.
"""
from __future__ import annotations
@ -71,7 +71,7 @@ class FullmoviesScraper(BaseBrowseScraper):
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
# Sidebar listuje WSZYSTKIE sieci → `_NETWORK_LINK_RE.finditer().first()` zawsze
# zwracał pierwszą z listy (Brazzers) dla każdej sceny, mis-attribution. Tytuł
# zwracał pierwszą z listy (Brazzers) dla każdej sceny — mis-attribution. Tytuł
# po oczyszczeniu ma format "Studio - Opis" (np. "Fake Hostel - ...").
studio: RawStudio | None = None
if " - " in title:
@ -104,7 +104,7 @@ class FullmoviesScraper(BaseBrowseScraper):
tags.append(RawTag(external_id=f"fullmoviesxxx:tag:{slug}", name=name, slug=slug))
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
# canonical, auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio
# canonical — auto-screenshoty img.fullmovies.xxx, nie hot-linkowane studio
# thumbnaile). Matching trzyma się na title+performer+duration (seed: 92% tagged),
# więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
fingerprints: list[RawFingerprint] = []

View file

@ -1,4 +1,4 @@
"""fullporner.com, browse scraper. Dodany 2026-07-27.
"""fullporner.com — browse scraper. Dodany 2026-07-27.
Pełne sceny, nie klipy: mediana długości 2067 s na stronie 1, 1911 s na 40 i 1969 s
na 300, zero filmów poniżej 10 minut w próbce. To jest różnica, przez którą sexu
@ -11,17 +11,17 @@ Listing jest ściśle chronologiczny, mimo że nagłówek mówi „Featured Vide
**Metadane są cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest
mediana 3. Tytuły są w 100% przepisane pod SEO, więc nie niosą nic z tytułu
studyjnego („POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked
- Dylan Moore - Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
– Dylan Moore – Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
(dokładna, nie względna) i długość co do sekundy.
Dry-run resolvera na 120 scenach (w rollbacku): 37 (31%) dopięło się do scen, które
już mamy, przez `composite_auto`, composite radzi sobie mimo złych tytułów, bo waży
już mamy, przez `composite_auto` — composite radzi sobie mimo złych tytułów, bo waży
performera i długość. Reszta to nowa podaż. Zmierzony przeciek duplikatów, po
odjęciu tła z kontroli na przesuniętej długości: ~15% katalogu. Liczymy phash
miniaturki właśnie po to, żeby późniejszy `bulk_dedup` miał czym te duplikaty łapać.
**Bramka na obsadę.** Sceny bez performera (19%) w dry-runie scaliły się w 0 na 23
przypadków, bez performera composite nie ma na czym pracować, a studia tu nie ma
przypadków — bez performera composite nie ma na czym pracować, a studia tu nie ma
i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą
tylko sceny z obsadą.
@ -55,7 +55,7 @@ _THUMB = "https://imgs.xiaoshenke.net/thumb/{}.jpg"
_SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"')
_TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>")
# `<span class="create">1785081937</span></div><div>36:33</div>`, data i długość
# `<span class="create">1785081937</span></div><div>36:33</div>` — data i długość
# siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem.
_INFO_RE = re.compile(
r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>'
@ -64,7 +64,7 @@ _PERF_BLOCK_RE = re.compile(r"<b>\s*Pornstars?:\s*</b>(.*?)</p>", re.IGNORECASE
_PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
_TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>')
# `<iframe src="//xiaoshenke.net/video/4458723/14">`, id wspak, /14 = maska jakości.
# `<iframe src="//xiaoshenke.net/video/4458723/14">` — id wspak, /14 = maska jakości.
# Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu).
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
@ -101,7 +101,7 @@ class FullPornerScraper(BaseBrowseScraper):
pb = _PERF_BLOCK_RE.search(detail_html)
if pb:
for name in _PERF_RE.findall(pb.group(1)):
# Strona pisze performerów z małej („melissa straton"), do wyświetlania
# Strona pisze performerów z małej („melissa straton") — do wyświetlania
# podnosimy, dopasowanie i tak idzie po slugu.
name = html_mod.unescape(name).strip().title()
slug = slugify(name)

View file

@ -1,4 +1,4 @@
"""fullvideosporn.com (= sextu.com pod nową marką), browse scraper z BRAMKĄ NA OBSADĘ.
"""fullvideosporn.com (= sextu.com pod nową marką) — browse scraper z BRAMKĄ NA OBSADĘ.
To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
@ -6,15 +6,15 @@ pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany.
Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem, wtedy sygnał jest dobry: 88-89%
Ingestujemy WYŁĄCZNIE sceny z ≥1 performerem — wtedy sygnał jest dobry: 88-89%
nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia
~25-35% katalogu (~150-220k scen), czyli i tak dużo.
Pozostałe pułapki, wszystkie obsłużone niżej:
- **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** te bywają
- **tytuł bierzemy z `vit:"…"` (JS playera), NIE z `og:title`/`h1`** — te bywają
AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** na całej stronie
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: …</h3>`** — na całej stronie
jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles).
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
@ -65,7 +65,7 @@ _UNIT_DAYS = {
def _submitted_to_date(detail_html: str):
"""`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a
to jest data uploadu na tube (nie premiery studia), traktujemy jak inne tuby."""
to jest data uploadu na tube (nie premiery studia) — traktujemy jak inne tuby."""
m = _SUBMITTED_RE.search(detail_html)
if not m:
return None
@ -127,7 +127,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
# Sekcje metadanych, każda w swoim <h3 class="item">.
# Sekcje metadanych — każda w swoim <h3 class="item">.
performers: list[RawPerformer] = []
tags: list[RawTag] = []
studio: RawStudio | None = None
@ -162,7 +162,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
)
# BRAMKA: bez obsady nie ingestujemy (patrz docstring, 2/3 katalogu to
# BRAMKA: bez obsady nie ingestujemy (patrz docstring — 2/3 katalogu to
# spunowane tytuły bez performera, które weszłyby jako puste orphany).
if not performers:
return None

View file

@ -1,25 +1,25 @@
"""galaxyporn.net, browse scraper (WordPress retrotube). Dodany 2026-07-26.
"""galaxyporn.net — browse scraper (WordPress retrotube). Dodany 2026-07-26.
Ripy paysite ze studiami (BrazzersExxtra, EvilAngel, NubilesPorn, RealityKings,
MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem, 92% performerów
MYLF, BlackedRaw…). Ocena: orphan-risk LOW z twardym pomiarem — 92% performerów
z próbki 75 ma już u nas ref tpdb/stashdb, 79% tytułów matchuje istniejące sceny.
Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0).
Metadane:
- obsada: `<div id="video-actors">`, czysta, BEZ pollution (na całej stronie sceny
- obsada: `<div id="video-actors">` — czysta, BEZ pollution (na całej stronie sceny
są dokładnie te 2-3 linki `/actor/`, nie ma sekcji Related z obcymi performerami)
- studio + data: prefiks tytułu `Studio YY MM DD Performer - Title` albo `[Studio]`
- studio + data: prefiks tytułu `Studio YY MM DD Performer – Title` albo `[Studio]`
- tagi: `<div class="tags-list">` (wycinamy slug studia, żeby nie robić tag=studio)
- thumbnail: `data-main-thumb` / og:image
**Duration, uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
**Duration — uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
payload playera nie niosą długości. NULL duration jest GROŹNY: `/scenes` filtruje
`duration_sec >= min_duration_sec`, a NULL w SQL odpada z wyniku, więc sceny byłyby
NIEWIDOCZNE w apce mimo "new: N, errors: 0" (dokładnie incydent porntrex, 6479 scen).
Dlatego liczymy ją z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na
scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od
realnej (2226 s vs ~2276 s na próbce, ~2%), akceptowalne, bo alternatywą jest brak.
realnej (2226 s vs ~2276 s na próbce, ~2%) — akceptowalne, bo alternatywą jest brak.
Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro /
sport.seekplays.com / news.upns.pro) → extractor `galaxyporncom` → istniejący
@ -58,7 +58,7 @@ _TAGS_BLOCK_RE = re.compile(r'<div class="tags-list">(.*?)</div>', re.IGNORECASE
_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE)
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch - Swapping Off The Pervy Maid`
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch – Swapping Off The Pervy Maid`
_TITLE_STUDIO_DATE_RE = re.compile(r"^(?P<studio>[A-Za-z0-9][A-Za-z0-9._-]{1,30})\s+(?P<y>\d{2})\s+(?P<m>\d{2})\s+(?P<d>\d{2})\s+(?P<rest>.+)$")
# `[Onlyfans] Some Title` / `[Darkkotv] …`
_TITLE_BRACKET_RE = re.compile(r"^\[(?P<studio>[^\]]{2,30})\]\s*(?P<rest>.+)$")
@ -105,7 +105,7 @@ def _resolve_duration(iframe_url: str, *, timeout: float, attempts: int = 3) ->
data = json.loads(_decrypt(r.text))
thumb = (data.get("thumbnail") or "").strip()
if not thumb:
return None # payload OK, ale brak miniatur, retry nie pomoże
return None # payload OK, ale brak miniatur — retry nie pomoże
vtt_url = host + thumb if thumb.startswith("/") else thumb
vr = browser_get(vtt_url, headers=headers, timeout=timeout)
if vr.status_code != 200:
@ -145,7 +145,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
if not title:
return None
# Obsada, blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
# Obsada — blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
# ma dokładnie tyle linków /actor/ ile realnych aktorów).
performers: list[RawPerformer] = []
seen_p: set[str] = set()
@ -186,7 +186,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
slug=slugify(studio_name),
)
# Tagi, bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
# Tagi — bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
tags: list[RawTag] = []
seen_t: set[str] = set()
studio_slug = slugify(studio_name) if studio_name else ""

View file

@ -1,4 +1,4 @@
"""HDPorn92Scraper, direct HTML scrape hdporn92.com search.
"""HDPorn92Scraper — direct HTML scrape hdporn92.com search.
Search: `https://hdporn92.com/page/<n>/?s=<query>`. Scene URL format:
`https://hdporn92.com/<slug>/` (jeden segment ścieżki). Trzeba odsiać

View file

@ -1,9 +1,9 @@
"""hdporn.gg, latest-vids browse scraper.
"""hdporn.gg — latest-vids browse scraper.
Engine podobny do freshporno: `/videos/<slug>/` URL, `/networks/<slug>/` = studio,
`/models/<slug>/` = performer, `/tags/<slug>/` = tag.
Quirk: og:image to internal CDN `img.hdporn.gg/...`, przed merging do prod
Quirk: og:image to internal CDN `img.hdporn.gg/...` — przed merging do prod
sprawdzamy phash distance (gate-keeper: jeśli Hamming >5 dla >70% scen → orphan
factory, wyłącz; analogia do shyfap).
"""
@ -63,9 +63,9 @@ class HDPornGGScraper(BaseBrowseScraper):
title = meta_content(detail_html, property="og:title")
if not title:
return None
# og:title typowo zawiera ": Free HD Porn" suffix, usuń
# og:title typowo zawiera ": Free HD Porn" suffix — usuń
title = re.sub(r":\s*Free HD Porn\s*$", "", title, flags=re.IGNORECASE).strip()
# I "Brazzers - " prefix często też w title, zostaw, bo studio name w title
# I "Brazzers - " prefix często też w title — zostaw, bo studio name w title
# to silny sygnał dla fuzzy match.
description = meta_content(detail_html, property="og:description")
@ -112,7 +112,7 @@ class HDPornGGScraper(BaseBrowseScraper):
)
# Phash WYŁĄCZONY (pilot 2026-06-01: 0% trafień ≤5, mediana Hamming 14 do
# canonical, auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile).
# canonical — auto-screenshoty img.hdporn.gg, nie hot-linkowane studio thumbnaile).
# Matching trzyma się na title+performer+duration (seed: 92% tagged), więc download
# thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
fingerprints: list[RawFingerprint] = []

View file

@ -1,131 +0,0 @@
"""hentaigasm.com — browse scraper. Trzecie źródło wertykału hentai. Dodany 2026-08-04.
**Trzeci niezależny host w wertykale.** Te same odcinki co [[hentaimama]] i
[[xanimeporn]], ale znowu inne pliki: tutaj `hgasm1.com/<Tytuł Ze Spacjami>.mp4`,
tam `gdvid.info/N/...` i `download.xanimeporn.com/videos/1080p/[1080p]<id>.mp4`.
Żadna z tych trzech stron nie re-hostuje pozostałych.
Kryterium jak w całym wertykale: dla animacji nie istnieje kanon, więc każda pozycja
jest orphanem i to poprawne. Nie ma obsady ani canonical matcha.
**Wszystkie metadane bierzemy z RSS-a, nie ze strony odcinka**, i to nie jest skrót
tylko konieczność:
- strona odcinka NIE MA daty publikacji w żadnym standardowym miejscu (ani JSON-LD,
ani `article:published_time`, ani `<time>`), a feed ma `<pubDate>`
- `/genre/` w HTML strony to 38 linków, czyli kolejna chmura z sidebara, a feed daje
5 realnych kategorii: pierwsza to nazwa serii, reszta to gatunki
- `/page/2/` nie zwraca odcinków w ogóle, a `/feed/?paged=N` stronicuje poprawnie
i chronologicznie (strona 1 = dziś, strona 2 = tydzień wstecz)
Strona odcinka jest potrzebna wyłącznie do miniaturki, którą wyciąga z konfiguracji
JWPlayera razem z linkiem do wideo (patrz extractor `hentaigasm`).
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from email.utils import parsedate_to_datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://hentaigasm.com"
_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
_LINK_RE = re.compile(r"<link>\s*(https?://hentaigasm\.com/[^<\s]+)\s*</link>")
_TITLE_RE = re.compile(r"<title>(.*?)</title>", re.DOTALL)
_PUBDATE_RE = re.compile(r"<pubDate>(.*?)</pubDate>", re.DOTALL)
_CATEGORY_RE = re.compile(r"<category><!\[CDATA\[(.*?)\]\]></category>", re.DOTALL)
#: Miniaturka z konfiguracji JWPlayera — jedyna rzecz, po którą wchodzimy na stronę.
_JW_IMAGE_RE = re.compile(r"image:\s*[\"']([^\"']+)[\"']")
class HentaiGasmScraper(BaseBrowseScraper):
sitetag = "hentaigasm"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/feed/" if page <= 1 else f"{_BASE}/feed/?paged={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_LINK_RE.findall(listing_html)))
def crawl_page(self, page: int) -> list[RawScene] | None:
try:
res = browser_get(self._listing_url(page), timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.warning("%s: feed strona %d fail: %s", self.sitetag, page, e)
return None
items = _ITEM_RE.findall(res.text)
if not items:
return []
out: list[RawScene] = []
for item in items:
lm = _LINK_RE.search(item)
tm = _TITLE_RE.search(item)
if not lm or not tm:
continue
url = lm.group(1).strip()
title = html_mod.unescape(tm.group(1)).strip()
if not title:
continue
release_date = None
dm = _PUBDATE_RE.search(item)
if dm:
try:
release_date = parsedate_to_datetime(dm.group(1).strip()).date()
except (TypeError, ValueError):
pass
tags: list[RawTag] = []
seen: set[str] = set()
for raw_cat in _CATEGORY_RE.findall(item):
name = html_mod.unescape(raw_cat).strip()
slug = slugify(name)
if slug and slug not in seen:
seen.add(slug)
tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
thumb = None
try:
det = browser_get(url, timeout=self._timeout)
det.raise_for_status()
im = _JW_IMAGE_RE.search(det.text)
if im:
thumb = im.group(1).strip()
except Exception as e:
# Brak miniaturki nie dyskwalifikuje odcinka — reszta metadanych jest z feedu.
log.info("%s: miniaturka fail %s: %s", self.sitetag, url, e)
slug = url.rstrip("/").rsplit("/", 1)[-1]
out.append(
RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=release_date,
url=url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=url,
thumbnail_url=thumb,
)
],
)
)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
raise NotImplementedError("hentaigasm buduje sceny z RSS-a w crawl_page")

View file

@ -1,128 +0,0 @@
"""hentaimama.io — browse scraper. Dodany 2026-08-04. Pierwsze źródło wertykału hentai.
**Ten wertykał rządzi się inaczej niż reszta i to jest celowe.** Dla hentai NIE ISTNIEJE
kanon: TPDB i StashDB nie mają animacji ani postaci rysowanych, więc każda scena stąd
jest technicznie orphanem i to jest POPRAWNE, nie błędne. Kryterium „fabryka orphanów",
którym oceniamy tuby z żywymi aktorami, tutaj po prostu nie ma zastosowania — nie ma też
obsady, więc żadnej bramki na performera. Miarą jakości jest komplet seria + numer
odcinka + gatunki, a docelowo dedup MIĘDZY stronami hentai (re-hostują się nawzajem).
Jednostka treści to ODCINEK (`/episodes/<seria>-episode-N/`), nie seria. Każdy jest
samodzielną pozycją z własnym plakatem i playbackiem, więc mapuje się na scenę tak samo
jak w wertykale JAV — bez zmian w schemacie.
Motyw jest z rodziny Dooplay, ale `DooplayConnector` się NIE nadaje: nie ma tu
`data-fl-source` ani `data-post`, na których stoi tamten parser. Player idzie przez
`POST /wp-admin/admin-ajax.php` i jest za Cloudflare — obsługuje to extractor
`hentaimama`, patrz jego docstring.
Pułapka: `/genre/` w całym HTML to 118 linków (chmura w sidebarze, ten sam wzorzec co
przy sexu i yourdailypornmovies). Gatunki bierzemy WYŁĄCZNIE z `<div class="sgeneros">`,
co daje realne 9 pozycji na odcinek zamiast 118 śmieci.
Strona nie podaje długości odcinka.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://hentaimama.io"
_EPISODE_HREF_RE = re.compile(r'href="(https://hentaimama\.io/episodes/[^"]+)"')
# Sam listing `/episodes/` i jego paginacja też pasują do wzorca wyżej — bez tego
# filtra wchodziły jako „odcinek" o tytule „Recent Episodes".
_NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/)?$")
# `og:title` zamiast <h1>: na stronie są trzy <h1>, dwa to logo serwisu, a trzeci bywa
# nagłówkiem sekcji. og:title daje czysty „Shoujo Ramune Episode 7".
_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,150})")
# Gatunki TYLKO stąd — patrz pułapka w docstringu.
_GENRES_BLOCK_RE = re.compile(r'<div class="sgeneros">(.*?)</div>', re.DOTALL)
_ANCHOR_TEXT_RE = re.compile(r">\s*([^<]{2,40}?)\s*</a>")
_DATE_RE = re.compile(r'<span class="date">\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{4})')
_SERIES_RE = re.compile(r'href="https://hentaimama\.io/tvshows/([a-z0-9-]+)/"')
# Uwaga: atrybuty bywają w innej kolejności niż `<meta property=... content=...>`,
# a wartość ma na końcu \r — stąd luźny wzorzec i strip() przy użyciu.
_OG_IMAGE_RE = re.compile(r"property=['\"]og:image['\"][^>]*content=['\"]([^'\"]+)")
def _parse_date(value: str | None):
if not value:
return None
for fmt in ("%b. %d, %Y", "%b %d, %Y"):
try:
return datetime.strptime(value.strip(), fmt).date()
except ValueError:
continue
return None
class HentaiMamaScraper(BaseBrowseScraper):
sitetag = "hentaimama"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/episodes/" if page <= 1 else f"{_BASE}/episodes/page/{page}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return [
u
for u in dict.fromkeys(_EPISODE_HREF_RE.findall(listing_html))
if not _NOT_EPISODE_RE.search(u)
]
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _OG_TITLE_RE.search(detail_html)
title = html_mod.unescape(tm.group(1)).strip() if tm else ""
if not title or title.lower() == "hentaimama":
return None
tags: list[RawTag] = []
seen: set[str] = set()
gb = _GENRES_BLOCK_RE.search(detail_html)
if gb:
for name in _ANCHOR_TEXT_RE.findall(gb.group(1)):
name = html_mod.unescape(name).strip()
slug = slugify(name)
if slug and slug not in seen:
seen.add(slug)
tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
# Seria jako tag — pozwala grupować odcinki bez zmian w schemacie.
sm = _SERIES_RE.search(detail_html)
if sm and sm.group(1) not in seen:
series = sm.group(1).replace("-", " ").title()
tags.append(
RawTag(
external_id=f"{self.sitetag}:series:{sm.group(1)}",
name=series,
slug=slugify(series),
)
)
dm = _DATE_RE.search(detail_html)
om = _OG_IMAGE_RE.search(detail_html)
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
return RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=_parse_date(dm.group(1) if dm else None),
url=scene_url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
thumbnail_url=om.group(1).strip() if om else None,
)
],
)

View file

@ -1,131 +0,0 @@
"""hentaiplay.net — browse scraper. Czwarte źródło wertykału hentai. Dodany 2026-08-04.
**Czwarty niezależny host w wertykale**: pliki leżą na `hentaiplanet.info`, podczas gdy
[[hentaimama]] ma `gdvid.info`, [[xanimeporn]] `download.xanimeporn.com`, a
[[hentaigasm]] `hgasm<N>.com`. Cztery strony, cztery osobne pule plików — żadna nie
re-hostuje pozostałych, mimo że katalogi odcinków mocno się pokrywają.
Kryterium jak w całym wertykale: dla animacji nie istnieje kanon, więc każda pozycja
jest orphanem i to poprawne. Bez obsady i bez canonical matcha.
**Metadane z RSS-a**, tak samo jak przy hentaigasm i z tego samego powodu: `/genre/`
w HTML strony to **2084 linki**, największa chmura z sidebara w całym wertykale
(dla porównania hentaigasm 38, hentaimama 118). Feed daje 6 realnych kategorii na
odcinek plus datę i tytuł. `/page/2/` nie zwraca odcinków, a `/feed/?paged=N`
stronicuje chronologicznie.
Strona odcinka jest potrzebna tylko do miniaturki; wideo wyciąga extractor `hentaiplay`.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from email.utils import parsedate_to_datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://hentaiplay.net"
_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
_LINK_RE = re.compile(r"<link>\s*(https?://hentaiplay\.net/[^<\s]+?)\s*</link>")
_TITLE_RE = re.compile(r"<title>(.*?)</title>", re.DOTALL)
_PUBDATE_RE = re.compile(r"<pubDate>(.*?)</pubDate>", re.DOTALL)
_CATEGORY_RE = re.compile(r"<category><!\[CDATA\[(.*?)\]\]></category>", re.DOTALL)
# Miniaturka jest TYLKO w JSON-LD i z escapowanymi ukośnikami (`https:\/\/…`).
# `og:image` na tej stronie nie istnieje — pierwsza wersja scrapera dawała przez to
# 12/12 odcinków bez miniaturki.
_THUMB_RE = re.compile(r'"thumbnailUrl"\s*:\s*\[?\s*"([^"]{10,300})"')
#: Kategorie czysto techniczne — nie niosą nic o treści odcinka.
_SKIP_CATS = frozenset({"hentai anime", "new hentai", "english subbed", "raw"})
class HentaiPlayScraper(BaseBrowseScraper):
sitetag = "hentaiplay"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/feed/" if page <= 1 else f"{_BASE}/feed/?paged={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_LINK_RE.findall(listing_html)))
def crawl_page(self, page: int) -> list[RawScene] | None:
try:
res = browser_get(self._listing_url(page), timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.warning("%s: feed strona %d fail: %s", self.sitetag, page, e)
return None
items = _ITEM_RE.findall(res.text)
if not items:
return []
out: list[RawScene] = []
for item in items:
lm = _LINK_RE.search(item)
tm = _TITLE_RE.search(item)
if not lm or not tm:
continue
url = lm.group(1).strip()
title = html_mod.unescape(tm.group(1)).strip()
if not title:
continue
release_date = None
dm = _PUBDATE_RE.search(item)
if dm:
try:
release_date = parsedate_to_datetime(dm.group(1).strip()).date()
except (TypeError, ValueError):
pass
tags: list[RawTag] = []
seen: set[str] = set()
for raw_cat in _CATEGORY_RE.findall(item):
name = html_mod.unescape(raw_cat).strip()
if name.lower() in _SKIP_CATS or name.isdigit():
continue # „Hentai Anime"/„New Hentai"/rocznik nie są tagami treści
slug = slugify(name)
if slug and slug not in seen:
seen.add(slug)
tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
thumb = None
try:
det = browser_get(url, timeout=self._timeout)
det.raise_for_status()
im = _THUMB_RE.search(det.text)
if im:
thumb = im.group(1).strip().replace("\\/", "/")
except Exception as e:
log.info("%s: miniaturka fail %s: %s", self.sitetag, url, e)
slug = url.rstrip("/").rsplit("/", 1)[-1]
out.append(
RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=release_date,
url=url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=url,
thumbnail_url=thumb,
)
],
)
)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
raise NotImplementedError("hentaiplay buduje sceny z RSS-a w crawl_page")

View file

@ -1,4 +1,4 @@
"""hqfap.com, latest-vids browse scraper (PlayTube CMS, patrz _playtube.py).
"""hqfap.com — latest-vids browse scraper (PlayTube CMS, patrz _playtube.py).
Dołączony 2026-06-10 (user request), usunięty 2026-06-25 (cała biblioteka CDN padła
na stub), przywrócony 2026-07-07 (strona wróciła na CDN vstor.top z realnymi plikami,

View file

@ -1,12 +1,12 @@
"""HQPornerScraper, direct HTML scrape hqporner search page.
"""HQPornerScraper — direct HTML scrape hqporner search page.
Search URL: `https://hqporner.com/?q=<query>&p=<page>`. Static HTML zwraca ~50
linków `/hdporn/<id>-<slug>.html` per strona. Tytuł deducimy ze slug'a (porn-app
data API zwraca dokładniejszy ale wymaga round-trip, dla MVP slug-derived OK,
data API zwraca dokładniejszy ale wymaga round-trip — dla MVP slug-derived OK,
resolver i tak je sciagnie z TPDB merge).
Search fuzzy: hqporner zwraca "Lola Noir" gdy szukamy "Noir" itp. Dlatego
filtrujemy wyniki po tym czy slug zawiera query (lub jego token), analogicznie
filtrujemy wyniki po tym czy slug zawiera query (lub jego token) — analogicznie
jak `fetch_scenes_for_search` w pornapp connectorze.
"""
from __future__ import annotations
@ -17,7 +17,6 @@ import urllib.parse
from collections.abc import Iterator
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
from app.connectors.direct_scrapers.base import BaseDirectTubeScraper
from app.extractors import browser_get
@ -56,7 +55,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
# Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być).
query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3}
# Człon <3 znaki ("Lady Vi", "Mia Li", "Ai Uehara") wypada z `query_tokens`, więc
# zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'",
# zostaje sam pospolity człon i `all` degeneruje się do „slug zawiera 'lady'" —
# czyli KAŻDA scena z tym słowem (audit 2026-07-28: Lady Vi miała 116 przypisań
# z tube, z czego 115 tylko przez „lady", realnie o niej 1). Gdy nazwa ma taki
# człon, wymagamy dodatkowo CAŁEJ nazwy sklejonej bez separatorów (slug
@ -74,13 +73,9 @@ class HQPornerScraper(BaseDirectTubeScraper):
continue
seen_urls.add(scene_url)
# Nazwa musi być w slugu SPÓJNĄ FRAZĄ, nie luźnymi tokenami. Wspólna
# implementacja z `BaseSearchScraper`, żeby ta bramka nie rozjeżdżała się
# między scraperami (tu była własna kopia i przez to własny zestaw dziur:
# `all(tok in slug)` przepuszczał „tiffany" w „s-tiffany-love" i „time"
# w „first-time" — zgłoszenie 2026-08-13, strona Tiffani Time).
# Title-token filter
slug_lower = slug_part.lower()
if query_tokens and not BaseSearchScraper._slug_matches_query(slug_lower, query):
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
continue
if _needs_compact and _compact not in re.sub(r"[^a-z0-9]", "", slug_lower):
continue
@ -97,7 +92,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
page_url=scene_url,
)
],
# Wymuszamy hint performera = query, search per performer name znaczy
# Wymuszamy hint performera = query — search per performer name znaczy
# że scena prawie na pewno o nim. Resolver dorobi ScenePerformer link.
performers=[RawPerformer(name=query.strip())],
raw={

View file

@ -1,4 +1,4 @@
"""HQPornerBrowseScraper, latest-vids browse dla hqporner.com.
"""HQPornerBrowseScraper — latest-vids browse dla hqporner.com.
Dlaczego: hqporner był TYLKO w `ALL_DIRECT_SCRAPERS` (search po nazwisku performera),
więc nowe sceny wchodziły wyłącznie wtedy, gdy worker akurat szukał pasującego
@ -17,7 +17,7 @@ Detail (`/hdporn/<id>-<slug>.html`) ma komplet metadanych:
`external_id` MUSI być identyczny jak w `HQPornerScraper` (search): `hqpornercom:<url>`.
Inaczej ta sama scena z obu ścieżek zrobiłaby się dwoma wpisami.
Miniaturki fastporndelivery wymagają Referera (bez niego 403), backend i tak owija je
Miniaturki fastporndelivery wymagają Referera (bez niego 403) — backend i tak owija je
w `/proxy/img/`, a phash liczymy z jawnym refererem.
"""
from __future__ import annotations
@ -134,7 +134,7 @@ class HQPornerBrowseScraper(BaseBrowseScraper):
fingerprints.append(RawFingerprint(kind="phash", value=ph))
return RawScene(
# Ten sam format co HQPornerScraper.search, inaczej duplikat sceny.
# Ten sam format co HQPornerScraper.search — inaczej duplikat sceny.
external_id=f"{self.sitetag}:{scene_url}",
title=title,
duration_sec=duration_sec,

View file

@ -1,7 +1,7 @@
"""javflix.cc, JAV browse scraper (WordPress, English-subbed JAV).
"""javflix.cc — JAV browse scraper (WordPress, English-subbed JAV).
Osobna pula JAV (kody typu BKD-368, tytuły azjatyckie), origin `tube:javflix`.
NIE deduplikuje się z zachodnim katalogiem, świadomie orphan vertical (sekcja JAV).
NIE deduplikuje się z zachodnim katalogiem — świadomie orphan vertical (sekcja JAV).
Struktura (RE 2026-07-10):
- listing: `/page/N/` (WordPress archive), posty pod `/<slug>/` (np. /bkd-368-english-subtitle/)
@ -43,18 +43,7 @@ class JavflixScraper(BaseBrowseScraper):
sitetag = "javflix"
def _listing_url(self, page: int) -> str:
# `/new/` PRZED stroną główną, bo tylko tam trafiają świeże posty: audyt
# 2026-08-20 dał na `/new/` 7 nieznanych scen z 9, a na stronie głównej 0 z 16.
# Strona główna to stały zestaw, ktory od dawna mamy w calosci.
return f"{_BASE}/new/" if page <= 1 else f"{_BASE}/"
def crawl_page(self, page: int):
# Paginacji tu NIE MA, mimo ze WordPress ja udaje: `/page/2/` i `/page/3/`
# oddaja identyczna tresc co strona glowna, a `/new/page/2/` to samo co `/new/`.
# Bez tego ograniczenia browse-latest mielil ten sam zestaw piec razy na run.
if page > 2:
return []
return super().crawl_page(page)
return f"{_BASE}/" if page <= 1 else f"{_BASE}/page/{page}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
seen: set[str] = set()
@ -87,7 +76,7 @@ class JavflixScraper(BaseBrowseScraper):
up = _itemprop(detail_html, "uploadDate")
release_date = _parse_iso_date(up) if up else None
# JAV code (BKD-368) ze sluga, kanoniczny identyfikator, dodajemy jako tag
# JAV code (BKD-368) ze sluga — kanoniczny identyfikator, dodajemy jako tag
# (searchable) bo javflix nie ma osobnego pola kodu.
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
cm = _CODE_RE.match(slug)

View file

@ -1,4 +1,4 @@
"""8kporner.com, browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
"""8kporner.com — browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
Wyróżnik na tle innych kandydatów: **oryginalne tytuły studyjne**, nie przepisane
pod SEO. Do tego realne 4K (zweryfikowane parsowaniem `avc1`: 3840x2160) i przenośny
@ -9,8 +9,8 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
1. **Sitemap jest za Cloudflare** (`/sitemaps/videos/sitemap-N.xml` → 403 również
PRZEZ Bright Data proxy, sprawdzone). Baza buduje katalog właśnie z sitemapy, więc
`_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos&
page=latest` (20). To jednocześnie SUFIT, deep crawl jest niemożliwy.
2. **Paginacja nie działa** `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
page=latest` (20). To jednocześnie SUFIT — deep crawl jest niemożliwy.
2. **Paginacja nie działa** — `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
co strona 1, a `POST /aj/load-more/latest` wymaga zalogowania (`{"status":400}`).
3. **Forma `/watch/<slug>_<id>.html` jest CF-blokowana (403)**, ale `?link1=watch&id=
<id>` przechodzi czysto. Dlatego `page_url` trzymamy w formie kanonicznej (ładny
@ -21,22 +21,22 @@ Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
strona 1 (najświeższy blok) tylko 4/20, strony 2-6 od 50 do 75%. Reszta katalogu nie
ma żadnego performera i weszłaby jako puste orphany.
Bramka się broni, z 117 performerów wciągniętych pilotem **113 (97%) ma ref
Bramka się broni — z 117 performerów wciągniętych pilotem **113 (97%) ma ref
tpdb/stashdb**, najwyżej ze wszystkich źródeł dodanych w tej rundzie. Pilotowy ingest:
`seen 62, new 25, updated 37, errors 0`, czyli 60% scen przypięło się do czegoś, co
`seen 62, new 25, updated 37, errors 0` — czyli 60% scen przypięło się do czegoś, co
już mamy (to nie jest fabryka orphanów, tylko dodatkowe, lepsze jakościowo źródło).
**Studio.** Scena NIE linkuje studia osobno, na stronie sceny wszystko jest pillem
**Studio.** Scena NIE linkuje studia osobno — na stronie sceny wszystko jest pillem
`/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami.
Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które są realnymi studiami.
Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy
to jednak zwykłe słowa („Babes", „Premium", „Swallowed") i te pomijamy, bo inaczej
powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio.
Trafień jest mało (1 na 62 w pilocie), większość scen po prostu nie ma pilla studia,
Trafień jest mało (1 na 62 w pilocie) — większość scen po prostu nie ma pilla studia,
ale kosztuje to jeden fetch na run, więc zostaje.
Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w
canonical, dopasowanie musi opierać się na tytule, obsadzie i długości.
canonical — dopasowanie musi opierać się na tytule, obsadzie i długości.
"""
from __future__ import annotations
@ -61,7 +61,7 @@ _SCENE_HREF_RE = re.compile(
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
_STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE)
# Pozycje z `/studios`, które są normalnymi gatunkami, jako studio dałyby fałszywkę
# Pozycje z `/studios`, które są normalnymi gatunkami — jako studio dałyby fałszywkę
# na większości scen (patrz sxyland/"MILF"). Kilka z nich to realne marki
# (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe.
_AMBIGUOUS_STUDIOS = {
@ -119,7 +119,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
def _load_catalog(self) -> list[str] | None:
"""Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz
docstring), więc to jedyne dostępne źródło URL-i, i zarazem sufit katalogu."""
docstring), więc to jedyne dostępne źródło URL-i — i zarazem sufit katalogu."""
if self._catalog is not None:
return self._catalog
urls: list[str] = []
@ -145,7 +145,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/`
daje 403) i z pauzą, CF tnie przy szybszym tempie."""
daje 403) i z pauzą — CF tnie przy szybszym tempie."""
catalog = self._load_catalog()
if catalog is None:
return None
@ -174,7 +174,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
scene = super()._parse_detail(scene_url, detail_html)
if scene is None:
return None
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą), bez niej
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą) — bez niej
# większość katalogu wpadłaby jako puste orphany.
if not scene.performers:
return None

View file

@ -1,4 +1,4 @@
"""latestleaks.co, direct HTML scrape.
"""latestleaks.co — direct HTML scrape.
Search: `https://latestleaks.co/page/<n>/?s=<q>`.
Scene URL: `https://latestleaks.co/<slug>/`.

View file

@ -1,4 +1,4 @@
"""latestpornvideo.com, latest-vids browse scraper.
"""latestpornvideo.com — latest-vids browse scraper.
Historia: dawniej performer-driven search scraper (`/actor/<slug>/`). Problem
(user-report 1da0375e): search-scraper ingestuje TYLKO sceny performerów, których
@ -6,7 +6,7 @@ akurat szukamy → feed strony "latest" nigdy nie wpada, w apce widać stary zes
a na stronie jest świeży. Przerobione na BROWSE (latest chronologicznie z
`/page/<n>/`, page 1 = `/`), 2026-06-22.
Listing card (zero detail-fetchy, detail page nie ma performerów ani duration):
Listing card (zero detail-fetchy — detail page nie ma performerów ani duration):
<article class="... post-<id> ... category-<cat> tag-<x> tag-<y> ...">
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
data-main-thumb="<Studio>-YYYY-MM-DD-...-cover.jpg"
@ -51,7 +51,7 @@ _CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")
_THUMB_NAME_RE = re.compile(r"/([A-Za-z0-9][A-Za-z0-9-]*?)-(\d{4})-(\d{2})-(\d{2})-", re.IGNORECASE)
# Tytuł: `<Studio> YY MM DD <rest>` (np. "MySexMobile 20 10 23 Abella Danger").
_TITLE_DATE_RE = re.compile(r"^(.*?)\s*\b(\d{2})\s+(\d{2})\s+(\d{2})\b")
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos", to nie jest tag.
# Karty homepage zawsze siedzą w kategorii "latest-porn-videos" — to nie jest tag.
_CAT_SKIP = {"latest-porn-videos", "uncategorized", ""}
@ -137,7 +137,7 @@ class LatestPornVideoScraper(BaseBrowseScraper):
)
# Tagi: tylko prawdziwe kategorie (category-*), bez "latest-porn-videos".
# `tag-*` POMIJAMY, to mieszanka fragmentów imion performerów i gatunków,
# `tag-*` POMIJAMY — to mieszanka fragmentów imion performerów i gatunków,
# bez `actors-*` (jak na /actor/) nie da się ich rozdzielić → byłby szum.
tags: list[RawTag] = []
seen_tag: set[str] = set()

View file

@ -1,4 +1,4 @@
"""mypornerleak.com, performer-page listing scrape (search-based, performer-driven).
"""mypornerleak.com — performer-page listing scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 05-07): treść serwowana jest pod subdomeną
`w8.mypornerleak.com` (load-balancer wN.), a stary regex szukał scen na gołym
@ -6,7 +6,7 @@
listingu i sceny, więc fetchujemy `mypornerleak.com/actor/<slug>/` a linki scen
normalizujemy wN.→ kanoniczny (stabilny page_url/dedup).
Wszystko z LISTINGU (bez detail-fetcha), karta `<article>` koduje metadane:
Wszystko z LISTINGU (bez detail-fetcha) — karta `<article>` koduje metadane:
- klasa `actors-<slug>` (multi) → performerzy; `category-<slug>` (multi) → tagi
- `<a href title="...">` → URL sceny + czysty tytuł
- `<span class="duration">MM:SS|HH:MM:SS`
@ -33,9 +33,7 @@ _BASE = "https://mypornerleak.com"
_ARTICLE_RE = re.compile(r'<article[^>]*\bclass="([^"]+)"', re.IGNORECASE)
_LINK_RE = re.compile(r'<a\s+href="([^"]+)"\s+title="([^"]+)"', re.IGNORECASE)
_DURATION_RE = re.compile(r'class="duration">(?:<[^>]+>)*\s*(\d{1,2}:\d{2}(?::\d{2})?)', re.IGNORECASE)
# Do 2026-09 karty mialy lazy `data-src`, teraz zwykle `src` (class video-main-thumb);
# stary wzorzec zostawial 59% zrodel bez miniatury (zgloszenie 94e1da85).
_THUMB_RE = re.compile(r'<img[^>]+(?:data-src|src)="(https?://[^"]+\.(?:jpg|jpeg|png|webp)[^"]*)"', re.IGNORECASE)
_THUMB_RE = re.compile(r'data-src="([^"]+)"', re.IGNORECASE)
_WN_HOST_RE = re.compile(r"https?://w\d+\.mypornerleak\.com", re.IGNORECASE)
_CLASS_ACTOR_RE = re.compile(r"\bactors-([a-z0-9-]+)")
_CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")

View file

@ -1,4 +1,4 @@
"""mypornerleak.com, latest BROWSE scraper via WordPress REST API, obok search scrapera.
"""mypornerleak.com — latest BROWSE scraper via WordPress REST API, obok search scrapera.
MyPornerLeakScraper (search) zostaje w ALL_DIRECT_SCRAPERS; ten browse dokłada
świeżość wprost z WP REST (`/wp-json/wp/v2/posts?_embed=1`). W odróżnieniu od
@ -28,7 +28,6 @@ from app.connectors.direct_scrapers._browse_base import (
compute_thumbnail_phash,
)
from app.extractors import browser_get
from app.extractors.thumb_extract import extract_thumbnail_url
from app.normalize.text import slugify
log = logging.getLogger(__name__)
@ -86,15 +85,6 @@ class MyPornerLeakBrowseScraper(BaseBrowseScraper):
emb = p.get("_embedded") or {}
fm = emb.get("wp:featuredmedia") or []
thumb = (fm[0].get("source_url") if fm and isinstance(fm[0], dict) else None) or None
if not thumb:
# Od 2026-09 REST nie ma featured media (featured_media=0, brak img w
# tresci); strona posta ma og:image (zgloszenie 94e1da85).
try:
pr = browser_get(link, timeout=15.0)
if pr.status_code == 200:
thumb = extract_thumbnail_url(pr.text)
except Exception as e: # noqa: BLE001
log.debug("mypornerleak og:image fetch failed for %s: %s", link, e)
studio: RawStudio | None = None
tags: list[RawTag] = []

View file

@ -1,148 +0,0 @@
"""naughtymachinima.com — browse scraper. Szóste źródło wertykału, drugie 3D. Dodany 2026-08-13.
Machinima i animacje SFM/Blender, treść tworzona przez użytkowników. Kanon nie istnieje
tak samo jak przy hentai i [[rule34video]], więc każda pozycja jest orphanem i to
poprawne, bez obsady i bez canonical matcha.
**Najzdrowsza jednostka treści z całego wertykału 3D.** Próbka 25 najnowszych: mediana
289 s, ZERO pozycji poniżej minuty, 18/25 powyżej progu potoku (180 s). Dla porównania
rule34video ma medianę 68 s i traci ~70% katalogu na krótkich pętlach. Bramkę zostawiamy
i tak (ten sam `min_ingest_duration_sec` co przy rule34video), żeby `seen` mówiło, ile
realnie wchodzi, ale tutaj odsiewa tylko ~28%.
**Szósty niezależny host w wertykale**: pliki leżą na `videos.naughtymachinima.com`,
obok `gdvid.info` ([[hentaimama]]), `download.xanimeporn.com` ([[xanimeporn]]),
`hgasm<N>.com` ([[hentaigasm]]), `hentaiplanet.info` ([[hentaiplay]]) i `boomio-cdn.com`
([[rule34video]]). Żadna z tych stron nie re-hostuje pozostałych.
Metadane są czyste, bez chmury z sidebara, która wyskoczyła przy siedmiu poprzednich
kandydatach: na stronie filmu jest dokładnie 7 anchorów `/tags/` i wszystkie należą do
filmu (przy „Dreamhawk: The Price Of Joining" są to dreamhawk, aerith gainsborough,
don corneo). Mimo to trzymamy się `class="tag"`, żeby ewentualna przebudowa szablonu
nie wciągnęła nam nawigacji.
**Autora (`/user/`) zapisujemy jako studio**, tak samo jak przy rule34video: dla animacji
to wytwórca, a nie performer.
Paginacja `/videos?page=N` jest chronologiczna, strony 1, 2 i 3 mają zerowe pokrycie.
Playback: jawne `<source>` bez tokenu, patrz extractor `naughtymachinima`.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import date
from app.config import get_settings
from app.connectors.base import RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://www.naughtymachinima.com"
#: Fallback, gdy `min_ingest_duration_sec` jest wyłączone (0): tyle filtruje apka.
_APP_MIN_DURATION_SEC = 60
def _min_duration() -> int:
"""Próg zestrojony z potokiem, patrz docstring [[rule34video]]: czytamy ustawienie
zamiast wpisywać liczbę, żeby liczniki `seen`/`skipped` nie kłamały."""
return getattr(get_settings(), "min_ingest_duration_sec", 0) or _APP_MIN_DURATION_SEC
_SCENE_HREF_RE = re.compile(
r'href="(?:https?://(?:www\.)?naughtymachinima\.com)?(/video/\d+/[^"?#]+)"'
)
_TITLE_RE = re.compile(r"<h1[^>]*>\s*([^<]{2,200}?)\s*</h1>")
_DURATION_RE = re.compile(r'"duration"\s*:\s*"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"')
_UPLOAD_DATE_RE = re.compile(r'"uploadDate"\s*:\s*"(\d{4})-(\d{2})-(\d{2})')
_THUMB_RE = re.compile(r'property="og:image"[^>]*content="([^"]{10,300})"')
_TAG_RE = re.compile(r'<a class="tag"[^>]*href="[^"]*/tags/[^"]+"[^>]*>\s*([^<]{1,60}?)\s*</a>')
#: Autor: `<a href="/user/Zector95"><img …/><span>Zector95</span>`.
_USER_RE = re.compile(r'href="[^"]*/user/([A-Za-z0-9._-]{2,40})"')
def _duration(detail_html: str) -> int | None:
m = _DURATION_RE.search(detail_html)
if not m:
return None
hours, minutes, seconds = (int(x or 0) for x in m.groups())
return (hours * 3600 + minutes * 60 + seconds) or None
class NaughtyMachinimaScraper(BaseBrowseScraper):
sitetag = "naughtymachinima"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/videos" if page <= 1 else f"{_BASE}/videos?page={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return [_BASE + p for p in dict.fromkeys(_SCENE_HREF_RE.findall(listing_html))]
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _TITLE_RE.search(detail_html)
if not tm:
return None
title = html_mod.unescape(tm.group(1)).strip()
if not title:
return None
duration_sec = _duration(detail_html)
if duration_sec is None or duration_sec < _min_duration():
return None
release_date = None
dm = _UPLOAD_DATE_RE.search(detail_html)
if dm:
try:
release_date = date(int(dm.group(1)), int(dm.group(2)), int(dm.group(3)))
except ValueError:
pass
thumbnail_url = None
im = _THUMB_RE.search(detail_html)
if im:
thumbnail_url = im.group(1).strip()
tags: list[RawTag] = []
seen_tag: set[str] = set()
for raw_name in _TAG_RE.findall(detail_html):
name = html_mod.unescape(raw_name).strip()
slug = slugify(name)
if slug and slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
studio = None
um = _USER_RE.search(detail_html)
if um:
name = html_mod.unescape(um.group(1)).strip()
slug = slugify(name)
if slug:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slug}", name=name, slug=slug
)
video_id = scene_url.split("/video/")[-1].split("/")[0]
return RawScene(
external_id=f"{self.sitetag}:{video_id}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=[], # animacja, obsada nie istnieje
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -1,22 +1,22 @@
"""neporn.com, latest-vids browse scraper (KVS engine).
"""neporn.com — latest-vids browse scraper (KVS engine).
Dołączony 2026-06-10 (user request). Paysite re-upload (HardX, DAP itp.),
~40k+ scen po numeracji video id.
Sygnały per scena:
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl
(uwaga: bywa zmanglowany "https:https://...", normalizujemy)
(uwaga: bywa zmanglowany "https:https://..." — normalizujemy)
- `<meta property="video:duration">` = sekundy wprost
- Performerzy: `<a class="link" href="https://neporn.com/models/<slug>/">`
(nazwa w spanie z ikoną, bierzemy slug i tytułujemy). Świeże uploady NIE mają
jeszcze linków modelek, fallback: flashvars `video_tags` (na świeżych scenach
(nazwa w spanie z ikoną — bierzemy slug i tytułujemy). Świeże uploady NIE mają
jeszcze linków modelek — fallback: flashvars `video_tags` (na świeżych scenach
to czyste nazwiska, np. "emily willis, gianna dior"); bierzemy wpisy 2-3 słowa
obecne w tytule i bez generycznych fraz (stoplist), bo starsze sceny mają tam
też szum ("deep throat", "natural tits", "hd porn").
- Kategorie: linki `/categories/<slug>/` w bloku info (tagi z `/tags/` to szum
typu "hd porn", "2020", pomijamy)
typu "hd porn", "2020" — pomijamy)
Studio: brak strukturalnego pola, tytuł bywa "- HardX Update - ..." ale format
Studio: brak strukturalnego pola — tytuł bywa "- HardX Update - ..." ale format
niespójny, zostawiamy fuzzy matchowi po tytule.
Listing: standard KVS `/latest-updates/N/` (24 sceny/strona).
@ -111,7 +111,7 @@ class NepornScraper(BaseBrowseScraper):
description = (video.get("description") or "").strip() or None
thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None
if thumbnail_url:
# JSON-LD emituje "https:https://cdn...", utnij zdublowany scheme.
# JSON-LD emituje "https:https://cdn..." — utnij zdublowany scheme.
thumbnail_url = re.sub(r"^https?:(?=https?://)", "", thumbnail_url)
performers: list[RawPerformer] = []
@ -128,7 +128,7 @@ class NepornScraper(BaseBrowseScraper):
)
)
# Fallback dla świeżych uploadów bez linków modelek: nazwiska z flashvars
# video_tags, 2-3 słowa, obecne w tytule, bez generycznych fraz.
# video_tags — 2-3 słowa, obecne w tytule, bez generycznych fraz.
title_cf = title.casefold()
tags_m = _VIDEO_TAGS_RE.search(detail_html)
for entry in (tags_m.group(1).split(",") if tags_m else []):
@ -160,7 +160,7 @@ class NepornScraper(BaseBrowseScraper):
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
# Phash z KVS screenshotów, niski hit-rate oczekiwany (jak porn00),
# Phash z KVS screenshotów — niski hit-rate oczekiwany (jak porn00),
# graceful miss → composite scoring (performer+title+duration).
fingerprints: list[RawFingerprint] = []
if thumbnail_url:

View file

@ -1,4 +1,4 @@
"""perverzija.com, latest browse scraper via WordPress REST API.
"""perverzija.com — latest browse scraper via WordPress REST API.
Historia: dawniej search scraper (`?s=`), ale 2026-06 perverzija rate-limituje search
(429) a homepage jest JS-renderowane (brak linków postów w surowym HTML) → search
@ -8,7 +8,7 @@ na stronę. VPS dociera (curl_cffi bypassuje JA3; 200 nie 403). Przerobione na b
2026-06-22 (user request).
Z REST `?_embed=1` bierzemy: tytuł, datę, miniaturę (featured_media), STUDIO
(taksonomia `category`, np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi
(taksonomia `category` — np. "DadCrush"/"TeamSkeet", to studyjny re-up) i tagi
(`post_tag`). Performerów REST nie wystawia (custom taksonomia `stars` bez show_in_rest)
→ puste, dorabia canonical-merge (content studyjny dobrze matchuje TPDB/StashDB; tytuł
i tak ma nazwiska).

View file

@ -1,17 +1,17 @@
"""porn00.org, latest-vids browse scraper.
"""porn00.org — latest-vids browse scraper.
URL patterns:
- Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ...
- Scene: `/video/<slug>/`
- Performer: `/<slug>/` (np. `/august-skye/`), w sekcji "Pornstars:" na detail
- Performer: `/<slug>/` (np. `/august-skye/`) — w sekcji "Pornstars:" na detail
- Categories: `/category-name/<slug>/`
Sygnały dostępne:
- Title (listing card + h1 + og:title)
- Performer(s) (z sekcji "Pornstars:" na detail page, pojedynczy slug per link)
- Categories (z sekcji "Categories:", `/category-name/<slug>/`)
- Performer(s) (z sekcji "Pornstars:" na detail page — pojedynczy slug per link)
- Categories (z sekcji "Categories:" — `/category-name/<slug>/`)
- Duration (listing card `<div class="duration">MM:SS</div>`)
- Direct mp4 (KVS engine, `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`)
- Direct mp4 (KVS engine — `video_url: 'https://www.porn00.org/get_file/.../<id>.mp4'`)
- Thumbnail (own CDN `/contents/videos_screenshots/.../1.jpg`)
BRAK:
@ -78,7 +78,7 @@ _CATEGORY_LINK_RE = re.compile(
)
# Direct mp4 stream z KVS flashvars: `video_url: 'https://.../43144.mp4/?v-acctoken=...'`.
# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp., bierzemy
# URL może mieć cokolwiek po `.mp4`: `/?v-acctoken=...`, `?q=720p`, itp. — bierzemy
# wszystko do najbliższego `'` lub `"`.
_VIDEO_URL_RE = re.compile(
r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE,
@ -107,7 +107,7 @@ class Porn00Scraper(BaseBrowseScraper):
def __init__(self) -> None:
super().__init__()
# Cache listing card meta, duration + thumb + title. Detail page nie ma
# Cache listing card meta — duration + thumb + title. Detail page nie ma
# tych pól w meta (brak og:duration), więc listing jest source of truth.
self._listing_cache: dict[str, dict] = {}
@ -145,7 +145,7 @@ class Porn00Scraper(BaseBrowseScraper):
# Thumbnail: prefer og:image z detail (full-size preview), fallback listing 320x180.
thumb = meta_content(detail_html, property="og:image") or meta.get("thumb")
# Performers, porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`,
# Performers — porn00 konwencja `/star-name/<slug>/` (jak `/tags-name/`,
# `/category-name/`). Wszystkie linki tego pattern to performerzy.
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
@ -177,14 +177,14 @@ class Porn00Scraper(BaseBrowseScraper):
)
)
# Direct mp4 z KVS flashvars, preferujemy 720p (video_alt_url) nad 360p (video_url).
# Direct mp4 z KVS flashvars — preferujemy 720p (video_alt_url) nad 360p (video_url).
stream_url: str | None = None
if (vm := _VIDEO_ALT_URL_RE.search(detail_html)):
stream_url = vm.group(1)
elif (vm := _VIDEO_URL_RE.search(detail_html)):
stream_url = vm.group(1)
# Phash, porn00 robi własne screenshoty (`/contents/videos_screenshots/`),
# Phash — porn00 robi własne screenshoty (`/contents/videos_screenshots/`),
# więc canonical phash match raczej fail. Próbujemy mimo to.
fingerprints: list[RawFingerprint] = []
if thumb:

View file

@ -1,4 +1,4 @@
"""porn4days.pw, direct HTML scrape.
"""porn4days.pw — direct HTML scrape.
Search: `https://porn4days.pw/page/<n>/?s=<q>`.
Scene URL: `https://porn4days.pw/<slug>/`.

View file

@ -1,22 +1,22 @@
"""pornbusy.com, browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
"""pornbusy.com — browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
Ripy studyjne (~60%) + JAV z kodami (~30%). Ocena orphan-risk LOW z pomiarem:
80% scen z próbki 100 ma performera, który u nas MA już ref tpdb/stashdb, a 21%
tytułów mocno matchuje sceny które już mamy (czyli się scalą, nie zorphanują).
**Paginacja homepage jest ZEPSUTA** `/page/2/` i `/page/3/` zwracają ten sam
**Paginacja homepage jest ZEPSUTA** — `/page/2/` i `/page/3/` zwracają ten sam
zestaw co strona 1 (zweryfikowane). Dlatego listing bierzemy z SITEMAPY:
`sitemap_index.xml` → 10× `post-sitemapN.xml` po ~500 URL-i, każdy z `<lastmod>`,
posortowane od najnowszych. `crawl_page` tnie ten katalog na strony po `_PAGE_SIZE`
(wzorzec jak `_playtube.BasePlayTubeScraper`).
Metadane, każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
Metadane — każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
tytułu-slug:
- title `<h1 class="entry-title">`, duration ISO, uploadDate, thumbnailUrl, description
- obsada: `<div id="video-actors">`, CZYSTA, bez pollution (na stronie sceny jest
- obsada: `<div id="video-actors">` — CZYSTA, bez pollution (na stronie sceny jest
dokładnie tyle linków `/actor/` ilu realnych aktorów; brak sekcji Related)
- kategorie + tagi: `<div class="tags-list">` (`/category/` i `/tag/`)
- studio: **brak** pornbusy nie ma pola studia ani prefiksu w tytule
- studio: **brak** — pornbusy nie ma pola studia ani prefiksu w tytule
Playback: `itemprop="embedURL"` → extractor `pornbusycom` (loadvid / seekplayer /
zpi.cx ≈ 69% katalogu).
@ -96,7 +96,7 @@ class PornBusyScraper(BaseBrowseScraper):
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
self._catalog: list[str] | None = None
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta),
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta) —
# paginację robi sitemap w `crawl_page`. Te dwie metody są abstrakcyjne w bazie.
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
return _SITEMAP_INDEX
@ -131,7 +131,7 @@ class PornBusyScraper(BaseBrowseScraper):
if not loc:
continue
url = loc.group(1)
# Pomijamy strony taksonomii/nav, sceny to `/<slug>/` jednosegmentowe.
# Pomijamy strony taksonomii/nav — sceny to `/<slug>/` jednosegmentowe.
if any(x in url for x in ("/actor/", "/category/", "/tag/", "/page/")):
continue
lm = _LASTMOD_RE.search(block)
@ -189,7 +189,7 @@ class PornBusyScraper(BaseBrowseScraper):
dsm = _DESC_RE.search(detail_html)
description = html.unescape(dsm.group(1)).strip() if dsm else None
if description and description.strip().lower() == title.strip().lower():
description = None # opis bywa kopią tytułu, nie duplikujemy
description = None # opis bywa kopią tytułu — nie duplikujemy
performers: list[RawPerformer] = []
seen_p: set[str] = set()

View file

@ -1,13 +1,13 @@
"""porndish.com, latest browse scraper via WordPress REST API.
"""porndish.com — latest browse scraper via WordPress REST API.
Historia: dawniej search scraper (`?s=`), zamarzł 2026-05-07 (search przestał dawać
nowe sceny, 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS
nowe sceny — 1151h cisza, watchdog GOON-16). To WordPress (g1/bimber theme), VPS
dociera, więc czysty kanał to REST API: `/wp-json/wp/v2/posts?_embed=1` daje
ustrukturyzowany JSON jednym requestem na stronę. Przerobione na browse 2026-06-24
(ten sam wzorzec co perverzija).
Z REST `_embed`: tytuł, data, miniatura (featured_media), STUDIO (taksonomia
`category`, np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag`, porndish
`category` — np. "Freeuse Fantasy", content studyjny) i tagi (`post_tag` — porndish
miesza w nich performerów z gatunkami, bierzemy jak jest; canonical-merge i tak
dorabia performerów z TPDB/StashDB, a tytuł ma nazwiska). Performerów osobno nie
wyciągamy (post_tag ich nie rozdziela od gatunków bez listy known-performers).

View file

@ -1,4 +1,4 @@
"""porndoe.com, latest-vids browse scraper.
"""porndoe.com — latest-vids browse scraper.
Dołączony 2026-05-21 (theporndude audit). Jedyny verified high-value candidate
z 172 tube'ów na theporndude.com/top-porn-tube-sites + /full-porn-movies-sites.
@ -11,7 +11,7 @@ Czemu wart: każda scena ma kompletny **JSON-LD VideoObject** schema:
To wystarczy do composite fuzzy match w resolverze (studio + performer Jaccard +
date proximity + title token-set + duration). Phash hit-rate niski (porndoe robi
własne crop-thumbnaile 390x219, nie hot-linkuje studio art), ale rich metadata
własne crop-thumbnaile 390x219, nie hot-linkuje studio art) — ale rich metadata
nadrabia, jak pornxp/porn00.
URL patterns:
@ -21,7 +21,7 @@ URL patterns:
- Performer: `/pornstars-profile/<slug>`
- Tags/categories: `/categories/<slug>`
Playback: stream URL NIE jest inline w SSR HTML, player JS init dopiero po user
Playback: stream URL NIE jest inline w SSR HTML — player JS init dopiero po user
"Play" click. Dajemy playback_source z page_url + origin `tube:porndoecom`;
extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView
INJECTED_JS scrapuje `<video>.src` po phone IP (0 VPS bandwidth, zgodne z
@ -34,7 +34,6 @@ import logging
import re
from datetime import date, datetime
from app.config import get_settings
from app.connectors.base import (
RawFingerprint,
RawPerformer,
@ -52,7 +51,7 @@ log = logging.getLogger(__name__)
_BASE = "https://porndoe.com"
# Scene listing, `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
# Scene listing — `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
_SCENE_URL_RE = re.compile(r'href="(/watch/[a-z0-9]+)"', re.IGNORECASE)
_WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE)
@ -67,7 +66,7 @@ _TAG_LINK_RE = re.compile(
r'href="/category/\d+/([a-z0-9\-]+)"[^>]*>([^<]+)</a>', re.IGNORECASE
)
# ISO 8601 duration, porndoe emituje "PT8M0S" (czasem "T8M0S" bez P).
# ISO 8601 duration — porndoe emituje "PT8M0S" (czasem "T8M0S" bez P).
_ISO_DUR_RE = re.compile(
r"^P?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", re.IGNORECASE
)
@ -145,24 +144,6 @@ def _extract_video_object(html: str) -> dict | None:
class PornDoeScraper(BaseBrowseScraper):
sitetag = "porndoecom"
def __init__(self) -> None:
super().__init__()
# Bright Data ISP proxy, bo porndoe zaczal oddawac 403 na IP serwerowni.
# Objawialo sie to jako "tub sie wyczerpal", a nie jako blad: ostatnia nowa
# scena wpadla 2026-08-21, watchdog krzyczal 173 h, a scraper po cichu dostawal
# 403 i zero linkow. Sprawdzone 2026-08-31: bez proxy 403 i 0 scen, przez proxy
# 200 i 31 scen z pierwszej strony.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("porndoe: BRIGHTDATA_PROXY_URL nieustawione, scraper wylaczony")
def crawl_page(self, page: int):
# Bez proxy nie ma po co uderzac (gwarantowane 403). Pusta lista = "wyczerpany",
# dzieki czemu deep-crawl nie ponawia w kolko.
if not self._proxy:
return []
return super().crawl_page(page)
def _listing_url(self, page: int) -> str:
if page <= 1:
return f"{_BASE}/videos/most-recent"
@ -256,7 +237,7 @@ class PornDoeScraper(BaseBrowseScraper):
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
# Phash z thumbnail (porndoe robi własne crop-thumbnaile, niski hit-rate
# Phash z thumbnail (porndoe robi własne crop-thumbnaile — niski hit-rate
# oczekiwany, ale graceful: brak match → resolver spada do composite scoring).
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
@ -264,7 +245,7 @@ class PornDoeScraper(BaseBrowseScraper):
if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph))
# Playback, page_url do scena strony. Stream JS-rendered, więc extractor
# Playback — page_url do scena strony. Stream JS-rendered, więc extractor
# `porndoecom` → `_vps_blocked_fallback.extract` (mobile WebView scrape).
playback_sources = [
RawPlaybackSource(

View file

@ -1,207 +0,0 @@
"""porndupe.com, browse scraper po id. Dodany 2026-09-05.
Re-uploader zachodnich scen studyjnych (Nubile Films, NF Busty, Family Strokes, Bad MILFs,
Pure Taboo...), czyli treść, która TRAFIA W KANON TPDB/StashDB zamiast robić orphany.
Pomiar 2026-09-05 na 60 losowych: 52 sceny (87%) nie było w bazie po tytule i performerze.
**Dlaczego chodzenie po id, a nie listing.** Każda ścieżka listingu (`/latest-updates/`,
`/new/`, `/videos/`, `/page/2/`, KVS-owe bloki `mode=async`) oddaje IDENTYCZNĄ stronę
główną z tymi samymi 24 linkami, to catch-all. Sitemapy `videos-1..7.xml` są posortowane
ROSNĄCO i mają 3500 URL-i, a id sięgają 12 887, czyli pokrywają ~27% katalogu. Za to
`/video/<id>/` przekierowuje 301 na slug (istnieje) albo 302 na `/` (brak), więc pełny
katalog daje wzór z vjav: najnowsze = najwyższe id, strona N = kolejny wycinek malejąco.
**Długość przychodzi z OK.ru, nie z HTML.** Strona nie ma `duration` ani `uploadDate`
(JSON-LD tylko z tytułem i miniaturką), a scena bez długości znika w apce pod filtrem
60 s i nie deduplikuje się. Dlatego przy ingeście robimy to samo, co ekstraktor:
odszyfrowujemy klucz sesji i pytamy `api.ok.ru` o `duration` (ms). Jeden dodatkowy
request na scenę, ~12,9 tys. na cały katalog.
**Daty CELOWO nie bierzemy.** `created_ms` z OK.ru to data hurtowego wgrania pliku, nie
premiery: cały katalog ma dokładnie dwie (28-30.07.2024 dla id 1-6000 i 6-7.04.2025 dla
id 12000+). Wpisanie tego jako `release_date` zrobiłoby z 6 tys. scen „premiery" z jednego
dnia. Zostaje None, datę dołoży kanon przy dopasowaniu.
**Katalog jest ZAMROŻONY od kwietnia 2025** (ostatnia fala wgrań), czyli to jednorazowy
import, nie źródło świeżości. Wchodzi mimo to, bo 87% scen jest dla nas nowe i to treść
studyjna trafiająca w kanon, w odróżnieniu od odrzuconego hentaicloud (17% nowych).
Metadane z HTML: `<h1>` (czysty tytuł, bez dopisków), `/pornstar/` (jeden anchor, właściwa
osoba), `/studio/`, `/tag/` (sprawdzone na dwóch scenach: 16 i 11 tagów, 7 wspólnych,
czyli per-video, nie chmura z sidebara), `og:image`.
Playback: ekstraktor `porndupe` (OK.ru, cap 720p, `mobile_direct_ok`).
"""
from __future__ import annotations
import html as html_mod
import logging
import re
import httpx
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.extractors.tubes.porndupe import okru_duration_and_date, okru_video # data nieużywana, patrz docstring
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://www.porndupe.com"
_PER_PAGE = 50
#: Najwyższe id znane 2026-09-05. Od niego idziemy W GÓRĘ, żeby znaleźć bieżące maksimum.
_SEED_ID = 12887
#: Tyle kolejnych braków (302 na `/`) uznajemy za koniec katalogu przy szukaniu maksimum.
_MAX_MISS = 20
_UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/152.0.0.0 Safari/537.36"}
_TITLE_RE = re.compile(r"<h1[^>]*>\s*([^<]{2,200}?)\s*</h1>")
_OG_IMAGE_RE = re.compile(r'property="og:image"[^>]*content="([^"]+)"')
_PERF_RE = re.compile(r'href="[^"]*/pornstar/([^/"]+)/?"[^>]*>\s*([^<]{2,60}?)\s*<')
_STUDIO_RE = re.compile(r'href="[^"]*/studio/([^/"]+)/?"[^>]*>\s*([^<]{2,60}?)\s*<')
_TAG_RE = re.compile(r'<a[^>]*href="[^"]*/tag/([^/"]+)/?"[^>]*>\s*([^<]{1,50}?)\s*</a>')
_VID_RE = re.compile(r"generate_mp4\s*\([^)]*'(\d+)'\s*\)")
class PorndupeScraper(BaseBrowseScraper):
sitetag = "porndupe"
def __init__(self) -> None:
super().__init__()
self._max_id: int | None = None
# ---- ABC wymaga tych dwóch; przy chodzeniu po id są nieużywane -------------------
def _listing_url(self, page: int) -> str: # pragma: no cover
return f"{_BASE}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]: # pragma: no cover
return []
# ---- maksimum id -----------------------------------------------------------------
def _exists(self, vid: int) -> bool | None:
"""True = 301 na slug, False = 302 na `/`, None = błąd sieci (nie wiadomo)."""
try:
r = httpx.get(f"{_BASE}/video/{vid}/", headers=_UA, timeout=20, follow_redirects=False)
except Exception:
return None
return r.status_code == 301 and "/video/" in r.headers.get("location", "")
def _load_max_id(self) -> None:
"""Od `_SEED_ID` w górę do `_MAX_MISS` kolejnych braków. Błąd sieci zostawia None,
co `crawl_page` tłumaczy na transient-fail (NIE na koniec katalogu; patrz vjav)."""
vid, last_ok, miss = _SEED_ID, _SEED_ID, 0
while miss < _MAX_MISS:
vid += 1
ok = self._exists(vid)
if ok is None:
log.warning("porndupe: błąd sieci przy szukaniu maksimum id (%d)", vid)
return
if ok:
last_ok, miss = vid, 0
else:
miss += 1
self._max_id = last_ok
log.info("porndupe: maksimum id = %d", last_ok)
# ---- strona = wycinek id malejąco -----------------------------------------------
def crawl_page(self, page: int) -> list[RawScene] | None:
if self._max_id is None:
self._load_max_id()
if self._max_id is None:
return None
start = self._max_id - (page - 1) * _PER_PAGE
if start < 1:
return []
out: list[RawScene] = []
for vid in range(start, max(start - _PER_PAGE, 0), -1):
try:
res = browser_get(f"{_BASE}/video/{vid}/", timeout=self._timeout, follow_redirects=True)
except Exception as e:
log.info("porndupe: fetch %d fail: %s", vid, e)
continue
final = str(getattr(res, "url", "") or "")
if getattr(res, "status_code", 200) >= 400 or "/video/" not in final:
continue # 302 na `/`, czyli brak takiego id
try:
scene = self._parse_detail(final, res.text, vid=vid)
except Exception as e:
log.info("porndupe: parse %d fail: %s", vid, e)
continue
if scene is not None:
out.append(scene)
return out
def _parse_detail(self, scene_url: str, detail_html: str, *, vid: int | None = None) -> RawScene | None:
tm = _TITLE_RE.search(detail_html)
if not tm:
return None
title = html_mod.unescape(tm.group(1)).strip()
if not title:
return None
if vid is None:
m = _VID_RE.search(detail_html)
vid = int(m.group(1)) if m else None
if vid is None:
return None
# Długość i data z OK.ru. Gdy API padnie, scena wchodzi BEZ długości i loguje to,
# zamiast cicho znikać; potok i tak nie tnie nieznanej długości.
duration_sec = None
release_date = None # celowo: created_ms to data wgrania hurtowego, patrz docstring
v = okru_video(detail_html)
if v:
duration_sec, _upload_date = okru_duration_and_date(v)
else:
log.info("porndupe: brak danych OK.ru dla id=%s", vid)
thumbnail_url = None
im = _OG_IMAGE_RE.search(detail_html)
if im:
thumbnail_url = im.group(1).strip()
performers: list[RawPerformer] = []
seen: set[str] = set()
for _slug, name in _PERF_RE.findall(detail_html):
name = html_mod.unescape(name).strip()
s = slugify(name)
if s and s not in seen:
seen.add(s)
performers.append(RawPerformer(external_id=f"{self.sitetag}:performer:{s}", name=name))
studio = None
sm = _STUDIO_RE.search(detail_html)
if sm:
sname = html_mod.unescape(sm.group(2)).strip()
if slugify(sname):
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(sname)}", name=sname, slug=slugify(sname)
)
tags: list[RawTag] = []
seen_t: set[str] = set()
for _slug, name in _TAG_RE.findall(detail_html):
name = html_mod.unescape(name).strip()
s = slugify(name)
if s and s not in seen_t:
seen_t.add(s)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{s}", name=name, slug=s))
return RawScene(
external_id=f"{self.sitetag}:{vid}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -1,4 +1,4 @@
"""pornmike.com, browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
"""pornmike.com — browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
Ripy paysite (Tushy, Blacked Raw, Milfy, Deep Lush, Anilos) + nisze bez pokrycia
u nas (Jeffs Models, Golden Slut, Teen Erotica, Red-XXX, Erotik von Nebenan).
@ -6,7 +6,7 @@ Pomiar: 82% performerów ma u nas ref tpdb/stashdb, 19-20/20 kanałów znamy jak
Metadane w CAŁOŚCI z JSON-LD `@graph` → `ItemPage.mainEntity` (VideoObject): name,
duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero
`og:`, zero itemprop, więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
`og:`, zero itemprop — więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
jest przepisany pod SEO, patrz niżej).
**Świadome decyzje:**
@ -21,7 +21,7 @@ jest przepisany pod SEO, patrz niżej).
zgodnie z regułą `scenes.backfill`, żeby stary katalog nie udawał nowości.
- Paginacja: WYŁĄCZNIE `?p=N`. Forma `/newest-porn-videos/2/` daje 404, a `?page=2`
jest ignorowane (zwraca stronę 1). Na każdej stronie jest stały 10-elementowy blok
sidebara (stąd 62 linki, realnie 52 sceny), dedup po external_id go pochłania.
sidebara (stąd 62 linki, realnie 52 sceny) — dedup po external_id go pochłania.
- Ingest tylko EN (`/videos/`), pomijamy niemiecki mirror (`/de/filme/`).
**Uwaga jakościowa:** to KLIPY 5-12 min (mediana ~487 s) wobec 1800-2400 s w tubach,
@ -29,7 +29,7 @@ które przyjęliśmy. Dla ~20% katalogu (Tushy/Blacked Raw/Milfy/Anilos) będą
obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas,
czyli realnie nowa podaż.
Playback: `<source>` direct mp4 (twincdn), extractor `pornmike`, bez tokenu, bez
Playback: `<source>` direct mp4 (twincdn) — extractor `pornmike`, bez tokenu, bez
Referera, przenośny cross-IP.
"""
from __future__ import annotations

View file

@ -1,9 +1,9 @@
"""PornTrex.com, direct HTML scrape search results.
"""PornTrex.com — direct HTML scrape search results.
Search: `https://www.porntrex.com/search/<q>/` (single page, brak ?page=).
Scene URL: `https://www.porntrex.com/video/<id>/<slug>/`
Porntrex pagination niespójne między widokami, używamy `?from=<offset>` gdy page>1.
Porntrex pagination niespójne między widokami — używamy `?from=<offset>` gdy page>1.
"""
from __future__ import annotations

View file

@ -1,6 +1,6 @@
"""porntrex.com, latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera.
"""porntrex.com — latest-vids BROWSE scraper (KVS), obok istniejącego search scrapera.
PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS, daje
PornTrexScraper (search, performer-driven) zostaje w ALL_DIRECT_SCRAPERS — daje
pokrycie back-catalogu performerów. Ten browse dokłada gwarancję świeżości wprost
z feedu `/latest-updates/<n>/` (próg watchdog 48h zamiast 168h, nie zależy od kolejki
performerów). Wzorzec jak xvideos (search + browse równolegle).
@ -9,7 +9,7 @@ KVS listing tile:
<div ... data-item-id="<id>"><a href="https://www.porntrex.com/video/<id>/<slug>">
<img data-src="//ptx.cdntrex.com/contents/.../300x168/1.jpg" alt="<Tytuł>">
<div class="duration">MM:SS</div>
Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable), bez zmian.
Playback: KVS, natywny extractor `porntrexcom` (token expires+md5, portable) — bez zmian.
"""
from __future__ import annotations

View file

@ -1,9 +1,9 @@
"""pornxp.ph, latest-vids browse scraper.
"""pornxp.ph — latest-vids browse scraper.
URL patterns:
- Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination).
URL-e w listing mają randomized suffix per request (`/videos/94528971225` vs
`/videos/94528971837`), **`data-id` (np. `94528971`) jest stable** i tego
`/videos/94528971837`) — **`data-id` (np. `94528971`) jest stable** i tego
używamy dla external_id zamiast całego URL.
- Detail: `/videos/<id_with_suffix>`.
- Tags: `/tags/<URL-encoded-name>`. Trzy kategorie wnioskowane heurystyką
@ -15,10 +15,10 @@ Rich signals (perfekt dla canonical match scoring):
- Performers (z tags w `<div class="tags">`, Capital + space + Capital)
- Release year (regex `Released:` na detail page bodyText)
- Duration (`<div class="item_dur">MM:SS</div>` listing card)
- Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`), no hoster
- Direct mp4 streams (`<source src="https://sv.porn-xp.com/.../720.mp4">`) — no hoster
- Animated preview (`data-preview="//t.porn-xp.com/.../<id>.mp4"`)
Thumbnail: `<img class="item_img" src="/<id>.jpg">`, relatywny, pornxp's own CDN.
Thumbnail: `<img class="item_img" src="/<id>.jpg">` — relatywny, pornxp's own CDN.
Phash hit-rate niskie ale studio+performer+title fuzzy match wystarczy do canonical.
"""
from __future__ import annotations
@ -40,16 +40,15 @@ from app.connectors.direct_scrapers._browse_base import (
BaseBrowseScraper,
compute_thumbnail_phash,
)
from app.normalize.text import unescape_html
log = logging.getLogger(__name__)
_BASE = "https://pornxp.ph"
# Listing card, DOTALL bo HTML cards są wieloliniowe.
# Listing card — DOTALL bo HTML cards są wieloliniowe.
# Wariant 1 (eager): `<img class="item_img" src="/<id>.jpg">`
# Wariant 2 (lazy): `<img class="item_img lazy" src="/images/fluid_spinner.svg" data-src="/<id>.jpg">`
# Łapiemy obie warianty, w `_parse_listing_thumb` preferujemy `data-src` nad `src`.
# Łapiemy obie warianty — w `_parse_listing_thumb` preferujemy `data-src` nad `src`.
_LISTING_CARD_RE = re.compile(
r'<div class="item preview"\s+data-id="(?P<id>\d+)"'
r'(?:\s+data-preview="(?P<preview>[^"]*)")?[^>]*>'
@ -62,7 +61,7 @@ _LISTING_CARD_RE = re.compile(
_IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
_IMG_DATASRC_RE = re.compile(r'\bdata-src="([^"]+)"', re.IGNORECASE)
# Detail page, tags wrapper. Sometimes <div class="tags">, sometimes inline.
# Detail page — tags wrapper. Sometimes <div class="tags">, sometimes inline.
# Bierzemy do najbliższego </div> bo tagi tej sceny są w jednym divie.
_DETAIL_TAGS_BLOCK_RE = re.compile(
r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL,
@ -72,8 +71,8 @@ _TAG_LINK_RE = re.compile(
)
_RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE)
_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE)
# Direct mp4/m3u8 sources, preferujemy 720 nad 360. Format często protocol-relative:
# `<source src="//sv.porn-xp.com/.../720.mp4">`, normalize do `https://...` w consumerze.
# Direct mp4/m3u8 sources — preferujemy 720 nad 360. Format często protocol-relative:
# `<source src="//sv.porn-xp.com/.../720.mp4">` — normalize do `https://...` w consumerze.
_SOURCE_RE = re.compile(
r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"',
re.IGNORECASE,
@ -100,11 +99,11 @@ def _classify_tag(name: str) -> str:
- Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat
bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`)
- Performer: dokładnie 2 słowa Capital + Capital (`Alix Lynx`, `Reagan Foxx`)
- Tag/category: pozostałe, lowercase single word LUB Cap single word
- Tag/category: pozostałe — lowercase single word LUB Cap single word
(`oral`, `Lesbians`, `Incest`, `BBC`)
Edge case: single-word studio jak "Brazzers", "Vixen" → klasyfikowane jako tag.
To akceptowalne, composite score scoring tags ma niższą wagę niż studio match,
To akceptowalne — composite score scoring tags ma niższą wagę niż studio match,
więc fallback z 1+ performer match wystarczy.
"""
name = name.strip()
@ -137,7 +136,7 @@ class PornXPScraper(BaseBrowseScraper):
def __init__(self) -> None:
super().__init__()
# Cache listing card metadata per scene URL, populated w `_extract_scene_urls`,
# Cache listing card metadata per scene URL — populated w `_extract_scene_urls`,
# consumed w `_parse_detail`. Detail page sam nie ma `<div class="item_dur">`
# ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde
# _extract_scene_urls override'uje).
@ -183,33 +182,33 @@ class PornXPScraper(BaseBrowseScraper):
),
"thumb": thumb,
"duration_sec": _parse_mmss(m.group("dur") or ""),
"title": unescape_html(m.group("title")),
"title": m.group("title").strip(),
}
out.append(url)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
# Listing-card meta (preferowane, detail page nie ma duration/thumb)
# Listing-card meta (preferowane — detail page nie ma duration/thumb)
meta = self._listing_cache.get(scene_url, {})
data_id = meta.get("data_id")
if not data_id:
# URL nie pasuje do listingu (random suffix mismatch po pagination redo).
# Wyciągnij data-id z URL: /videos/<id>..., pierwsze 8-10 cyfr.
# Wyciągnij data-id z URL: /videos/<id>... — pierwsze 8-10 cyfr.
id_match = re.search(r"/videos/(\d{6,12})", scene_url)
data_id = id_match.group(1) if id_match else None
# Title: prefer h1 over listing card title (detail h1 jest cleaner)
title = meta.get("title") or ""
if (m := _H1_RE.search(detail_html)):
title = unescape_html(m.group(1)) or title
title = m.group(1).strip() or title
if not title:
return None
duration_sec = meta.get("duration_sec")
thumb = meta.get("thumb")
# Release year, `Released: 2016`. RawScene ma `release_date` (typu `date`),
# nie samo year, wpisujemy Jan 1 jako placeholder żeby resolver miał year
# Release year — `Released: 2016`. RawScene ma `release_date` (typu `date`),
# nie samo year — wpisujemy Jan 1 jako placeholder żeby resolver miał year
# signal (date proximity scoring tylko sprawdza year w composite).
release_date: date | None = None
if (m := _RELEASED_RE.search(detail_html)):
@ -252,7 +251,7 @@ class PornXPScraper(BaseBrowseScraper):
tags.append(RawTag(external_id=ext_id, name=display, slug=slug))
# Playback: direct mp4 streams `<source src="//sv.porn-xp.com/.../720.mp4">`.
# URL-e są protocol-relative, normalize do `https:`. Preferujemy 720 nad 360.
# URL-e są protocol-relative — normalize do `https:`. Preferujemy 720 nad 360.
def _norm(u: str) -> str:
return "https:" + u if u.startswith("//") else u
@ -265,7 +264,7 @@ class PornXPScraper(BaseBrowseScraper):
break
stream_url = stream_url or all_sources[0]
# Phash z thumbnail (pornxp własny CDN, expected niski match rate, ale
# Phash z thumbnail (pornxp własny CDN — expected niski match rate, ale
# try). Reseter ścieżek do canonical odbędzie się głównie przez
# studio+performer+year+title scoring.
fingerprints: list[RawFingerprint] = []
@ -276,7 +275,7 @@ class PornXPScraper(BaseBrowseScraper):
# Normalize page_url: pornxp homepage serwuje random URL suffix per request
# (`/videos/94528971225` vs `/videos/94528971836` ten sam scene). PlaybackSource
# unique key to `(origin, page_url)`, bez normalize generujemy 3x duplikaty
# unique key to `(origin, page_url)` — bez normalize generujemy 3x duplikaty
# na każdym scrape run. Canonical URL = `/videos/<data_id>`.
canonical_url = (
f"{_BASE}/videos/{data_id}" if data_id else scene_url

View file

@ -1,168 +0,0 @@
"""rule34video.com — browse scraper. Piąte źródło wertykału, pierwsze 3D. Dodany 2026-08-13.
Inna charakterystyka niż cztery strony hentai ([[hentaimama]], [[xanimeporn]],
[[hentaigasm]], [[hentaiplay]]): to nie są odcinki serii, tylko pojedyncze animacje
SFM/Blender robione przez autorów. Kanon nie istnieje tak samo jak przy hentai, więc
każda pozycja jest orphanem i to poprawne, bez obsady i bez canonical matcha.
**Krótka treść wycina ~70% katalogu.** Rozkład 30 najnowszych: mediana 68 s, 14 pozycji
poniżej minuty, najkrótsza 7 s. Dla animacji SFM taka pętla to kompletna praca, a nie
wycinek (inaczej niż przy sexu, gdzie klipy były okrojoną wersją scen istniejących gdzie
indziej), ale nie mieści się w jednostce treści, na której stoi reszta systemu.
Bramkujemy tym samym progiem co potok (`min_ingest_duration_sec`, na prodzie 180 s),
zamiast własną liczbą. Pierwsza wersja miała 60 s (tyle filtruje apka) i przez to liczniki
kłamały: pilot na 3 stronach dał `seen 57, new 31, skipped 26`, bo `_skip_short_tube_scene`
i tak dorzynał wszystko poniżej 3 minut. Przy zestrojonym progu `seen` mówi, ile realnie
wchodzi, a to jest sygnał, którego używa watchdog i `source_stats`.
**Pułapka tagów, ODWROTNA do intuicji.** Na stronie są dwa zestawy anchorów `/tags/`:
<a class="button" href="/tags/35455/">cell (dbz)</a> ← chmura „Top Tags", 25 szt.
<a class="tag_item" href="/tags/122/">handjob</a> ← realne tagi filmu, 4 szt.
`class="button"` wygląda na „ten właściwy", a jest chmurą z sidebara: przy filmie o Lux
z League of Legends siedzą tam „cell (dbz)" i „queen of pain (dota)". Bierzemy WYŁĄCZNIE
`tag_item`, inaczej każdy film dostałby ten sam zestaw 25 śmieci.
Autor i kategorie mają własną klasę `video_meta_pill` i występują po 1-2 razy, czyli tam
chmury nie ma. **Autora zapisujemy jako studio**, bo dla 3D to on jest wytwórcą
(Monarchnsfw, jackerman, derpixon), a nie żaden performer.
Paginacja `/latest-updates/N/` jest chronologiczna i sprawdzona: strony 1, 2 i 3 mają
zerowe pokrycie (w odróżnieniu od freshporno czy streamporn.vip, gdzie listing rotował).
Playback: `get_file` z tokenem ZWIĄZANYM Z IP, patrz extractor `rule34video`.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import date
from app.connectors.base import RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.config import get_settings
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://rule34video.com"
#: Fallback, gdy `min_ingest_duration_sec` jest wyłączone (0): tyle filtruje apka.
_APP_MIN_DURATION_SEC = 60
def _min_duration() -> int:
"""Próg zestrojony z potokiem, patrz docstring. Czytamy ustawienie zamiast wpisywać
liczbę, żeby zmiana `min_ingest_duration_sec` nie rozjechała się z tym scraperem."""
return getattr(get_settings(), "min_ingest_duration_sec", 0) or _APP_MIN_DURATION_SEC
_SCENE_HREF_RE = re.compile(r'href="(https?://rule34video\.com/video/\d+/[^"]+)"')
_TITLE_RE = re.compile(r'<h1 class="title_video">\s*([^<]{1,200}?)\s*</h1>')
_DURATION_RE = re.compile(r'"duration"\s*:\s*"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"')
_UPLOAD_DATE_RE = re.compile(r'"uploadDate"\s*:\s*"(\d{4})-(\d{2})-(\d{2})')
_THUMB_RE = re.compile(r'"thumbnailUrl"\s*:\s*\[?\s*"([^"]{10,300})"')
#: TYLKO `tag_item`, patrz odwrócona pułapka w docstringu.
_TAG_RE = re.compile(r'<a class="tag_item"[^>]*href="[^"]*/tags/\d+/"[^>]*>\s*([^<]{1,60}?)\s*</a>')
#: Autor i kategorie — `video_meta_pill`, nazwa bywa opakowana w zagnieżdżone tagi.
_PILL_RE = re.compile(
r'<a class="[^"]*video_meta_pill[^"]*"[^>]*href="[^"]*/(models|categories)/([a-z0-9-]+)/"[^>]*>(.*?)</a>',
re.DOTALL,
)
_STRIP_TAGS_RE = re.compile(r"<[^>]+>")
def _text(fragment: str) -> str:
return html_mod.unescape(_STRIP_TAGS_RE.sub(" ", fragment)).strip()
def _duration(detail_html: str) -> int | None:
m = _DURATION_RE.search(detail_html)
if not m:
return None
hours, minutes, seconds = (int(x or 0) for x in m.groups())
total = hours * 3600 + minutes * 60 + seconds
return total or None
class Rule34VideoScraper(BaseBrowseScraper):
sitetag = "rule34video"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/latest-updates/" if page <= 1 else f"{_BASE}/latest-updates/{page}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_SCENE_HREF_RE.findall(listing_html)))
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _TITLE_RE.search(detail_html)
if not tm:
return None
title = html_mod.unescape(tm.group(1)).strip()
if not title:
return None
duration_sec = _duration(detail_html)
if duration_sec is None or duration_sec < _min_duration():
return None # krótka pętla, potok i tak by ją odrzucił jako trailer
release_date = None
dm = _UPLOAD_DATE_RE.search(detail_html)
if dm:
try:
release_date = date(int(dm.group(1)), int(dm.group(2)), int(dm.group(3)))
except ValueError:
pass
thumbnail_url = None
im = _THUMB_RE.search(detail_html)
if im:
thumbnail_url = im.group(1).strip().replace("\\/", "/")
tags: list[RawTag] = []
seen_tag: set[str] = set()
for raw_name in _TAG_RE.findall(detail_html):
name = html_mod.unescape(raw_name).strip()
slug = slugify(name)
if slug and slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
# Autor animacji → studio (dla 3D to wytwórca, nie performer). Kategorie → tagi.
studio = None
for kind, _slug_in_url, inner in _PILL_RE.findall(detail_html):
name = _text(inner)
slug = slugify(name)
if not name or not slug:
continue
if kind == "models":
if studio is None:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slug}", name=name, slug=slug
)
elif slug not in seen_tag:
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
video_id = scene_url.rstrip("/").split("/video/")[-1].split("/")[0]
return RawScene(
external_id=f"{self.sitetag}:{video_id}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=[], # animacja, obsada nie istnieje
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -1,4 +1,4 @@
"""sexu.com, browse scraper. Dodany 2026-07-27.
"""sexu.com — browse scraper. Dodany 2026-07-27.
Najlepsze metadane z całej tegorocznej serii kandydatów. Próbka 25 najnowszych scen:
obsada 20/25 (80%), **kanał 25/25 i to realne studia** (New Sensations, Brutal X,
@ -15,7 +15,7 @@ odrzuciłaby dobre dane.
1. **Tagi trzeba wyciąć z bloku.** Linków `/tag/` jest na stronie 25, ale realnych
tagów sceny tylko 5 (reszta to sidebar „popularne wyszukiwania"). Bez zawężenia
do bloku „Tags:" wpisalibyśmy każdej scenie te same 20 śmieci.
2. **W bloku tagów siedzą też linki `/search?q=`** to fraz-y pod SEO („pleases",
2. **W bloku tagów siedzą też linki `/search?q=`** — to fraz-y pod SEO („pleases",
„hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`.
**Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po
@ -25,7 +25,7 @@ bo performer w tabeli studios to zanieczyszczenie, nie dana.
Data jest względna („7 hours ago", „yesterday"), więc liczymy ją wstecz od teraz.
To i tak data uploadu na tube, nie premiery studia.
Playback: `POST /api/video-info` (extractor `sexu`), wymaga Bright Data, bo
Playback: `POST /api/video-info` (extractor `sexu`) — wymaga Bright Data, bo
Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy.
"""
from __future__ import annotations
@ -67,7 +67,7 @@ _BLOCK_END_RE = re.compile(r"_container|player-tags__title")
def _block(detail_html: str, label: str) -> str:
"""Fragment HTML należący do bloku `<label>:`, patrz pułapka 1 w docstringu."""
"""Fragment HTML należący do bloku `<label>:` — patrz pułapka 1 w docstringu."""
start = detail_html.find(f'player-tags__title">{label}:')
if start < 0:
return ""

View file

@ -1,10 +1,10 @@
"""shyfap.net, latest-vids browse scraper.
"""shyfap.net — latest-vids browse scraper.
Browse-only (nie search-driven). Sitetag `shyfapnet`. Bogata metadata na detail
page'u (meta tags + body links): title, studio, performers, tags, duration,
description, upload_date, embed_url.
Pierwszy pilot scrapera browse-mode (2026-05-12), weryfikacja czy detail-page
Pierwszy pilot scrapera browse-mode (2026-05-12) — weryfikacja czy detail-page
metadata wystarcza do canonical match >5%. Jeśli tak → rozszerzamy o porn00,
fullmovies, pornxp, freshporno, 4k69, hdporn.gg.
@ -37,7 +37,7 @@ _PORNSTAR_LINK_RE = re.compile(
_TAG_LINK_RE = re.compile(
r'href="/tag/([a-z0-9\-]+)_t(\d+)/"[^>]*>([^<]+)', re.IGNORECASE
)
# /video/<slug>_v<id>/ id z URL używamy jako stable internal ID (np. w external_id),
# /video/<slug>_v<id>/ — id z URL używamy jako stable internal ID (np. w external_id),
# nie z meta `ya:ovs:id` żeby uniknąć rozjazdu meta vs URL.
_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE)
@ -46,7 +46,7 @@ class ShyfapScraper(BaseBrowseScraper):
sitetag = "shyfapnet"
def _listing_url(self, page: int) -> str:
# page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk, sufiks `_1`
# page 1 → /videos_1/, page 2 → /videos_1/2/ (shyfap quirk — sufiks `_1`
# zawsze, dodatkowy `/N/` dla pagination)
if page <= 1:
return f"{_BASE}/videos_1/"
@ -108,7 +108,7 @@ class ShyfapScraper(BaseBrowseScraper):
if not embed_url and internal_id:
embed_url = f"{_BASE}/embed/{internal_id}"
# Studio, pierwszy `/studio/<slug>_s<id>/` link na stronie
# Studio — pierwszy `/studio/<slug>_s<id>/` link na stronie
studio: RawStudio | None = None
m_studio = _STUDIO_LINK_RE.search(detail_html)
if m_studio:
@ -119,7 +119,7 @@ class ShyfapScraper(BaseBrowseScraper):
slug=slug,
)
# Performers, wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena)
# Performers — wszyscy `/pornstar/<slug>_p<id>/` (zwykle 1-3 per scena)
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
for m_p in _PORNSTAR_LINK_RE.finditer(detail_html):
@ -134,7 +134,7 @@ class ShyfapScraper(BaseBrowseScraper):
)
)
# Tags, wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena)
# Tags — wszystkie `/tag/<slug>_t<id>/` (zwykle 10-25 per scena)
tags: list[RawTag] = []
seen_tag: set[str] = set()
for m_t in _TAG_LINK_RE.finditer(detail_html):
@ -146,8 +146,8 @@ class ShyfapScraper(BaseBrowseScraper):
RawTag(external_id=f"shyfapnet:tag:{tid}", name=name, slug=slug)
)
# Playback source, embed_url (mobile WebView fallback). Stream extraction
# przez app/extractors/__init__.py wymaga osobnego registry entry, dla
# Playback source — embed_url (mobile WebView fallback). Stream extraction
# przez app/extractors/__init__.py wymaga osobnego registry entry — dla
# pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up.
playback_sources = [
RawPlaybackSource(
@ -161,7 +161,7 @@ class ShyfapScraper(BaseBrowseScraper):
# Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within,
# Hamming ≤5) auto-merguje gdy TPDB/StashDB ma fingerprint tej samej sceny.
# Niezależne od shyfap title-rebrandingu, bierze się z frame'u sceny.
# Niezależne od shyfap title-rebrandingu — bierze się z frame'u sceny.
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")

View file

@ -1,4 +1,4 @@
"""siska.video, latest-vids browse scraper.
"""siska.video — latest-vids browse scraper.
Historia: dawniej performer-driven search scraper (`?s=<q>`), ale siska zepsuła
wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest

View file

@ -1,22 +1,22 @@
"""superporn.com, latest-vids browse scraper (przez Bright Data ISP proxy).
"""superporn.com — latest-vids browse scraper (przez Bright Data ISP proxy).
Dołączony 2026-06-10 (user request). superporn twardo blokuje VPS IP Cloudflarem
(403 na KAŻDEJ impersonacji TLS, chrome/safari/firefox), więc ingest HTML idzie
(403 na KAŻDEJ impersonacji TLS — chrome/safari/firefox), więc ingest HTML idzie
przez Bright Data ISP proxy (`settings.brightdata_proxy_url`). Gdy proxy
nieskonfigurowane → scraper no-op (pusty iterator, log warning).
Proxy używamy TYLKO do scrape HTML. Playback NIE idzie przez proxy: `<source>` mp4
(cdnst*.superporn.com) ma token IP-bound do fetchera (403 cross-IP), więc resolve
musi nastąpić po stronie telefonu, extractor `superporncom` → `_vps_blocked_fallback`
musi nastąpić po stronie telefonu — extractor `superporncom` → `_vps_blocked_fallback`
(mobile WebView ładuje stronę z residential IP telefonu, INJECTED_JS bierze video.src).
Thumbnaile (img*.superporn.com) schodzą z VPS bez proxy (image proxy działa).
Sygnały (SSR HTML):
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl, duration
ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer, pomijamy).
ISO 8601 (`P0DT0H38M48S`). `author` = uploader (NIE performer — pomijamy).
- Performerzy: chip `<a class="chip-link" href=".../pornstar/<slug>"><span>Name</span>`
- Kategorie: chip `<a class="chip-link" href="/<slug>"><span>Name</span>` (bez
`/pornstar/`), w bloku `#collapse-categories`.
`/pornstar/`) — w bloku `#collapse-categories`.
- Duration backup: `<video ... data-video-duration="2328">` (sekundy).
Listing: `?page=N` (newest-first; sitemap lastmod jest z 2024, bezużyteczny).
@ -69,7 +69,7 @@ _ISO_DUR_FULL_RE = re.compile(
def _clean(text: str) -> str:
"""superporn dwukrotnie HTML-koduje encje w JSON-LD (`&amp;#39;` zamiast `'`).
Unescape iteracyjnie (max 2), bez over-decode dla zwykłych nazw."""
Unescape iteracyjnie (max 2) — bez over-decode dla zwykłych nazw."""
for _ in range(2):
new = html.unescape(text)
if new == text:
@ -95,7 +95,7 @@ class SuperpornScraper(BaseBrowseScraper):
def __init__(self) -> None:
super().__init__()
# Bright Data ISP proxy, bez niego superporn jest nieosiągalny z VPS.
# Bright Data ISP proxy — bez niego superporn jest nieosiągalny z VPS.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
@ -106,7 +106,7 @@ class SuperpornScraper(BaseBrowseScraper):
return f"{_BASE}/?page={page}"
def crawl_page(self, page: int):
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403), sygnalizuj
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403) — sygnalizuj
# "exhausted" (pusta lista), żeby deep-crawl nie retry'ował w kółko.
if not self._proxy:
return []
@ -164,7 +164,7 @@ class SuperpornScraper(BaseBrowseScraper):
seen_tag.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
# Phash z thumbnaila, proxy NIE potrzebny (img*.superporn.com działa z VPS).
# Phash z thumbnaila — proxy NIE potrzebny (img*.superporn.com działa z VPS).
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")

View file

@ -1,4 +1,4 @@
"""supjav.com, JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
"""supjav.com — JAV browse scraper (przez Bright Data proxy, CF-blokuje VPS IP).
Osobna pula JAV (sekcja JAV), origin `tube:supjav` w JAV_ORIGINS. supjav agreguje
embedy (serwery TV/FST/ST/VOE), gdzie realny hoster jest schowany za `data-link`
@ -10,7 +10,7 @@ Browse (RE 2026-07-10):
- karty: `<div class="post"><a href="/<id>.html" title="<title>" rel="bookmark"
class="img"><img src=".../images/YYYY/MM/<CODE>.jpg!320x216.jpg" class="thumb">`.
- Metadane bierzemy Z KARTY (tytuł/thumb/URL/kod JAV/data z roku-miesiąca ścieżki
thumb), NIE z detalu, detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
thumb), NIE z detalu — detal przez proxy jest flaky (CF przepuszcza ~40% IP), a
per-post fetch byłby wolny i lossy. Studio/tagi (poza kodem) dorabia enrich później.
supjav blokuje VPS IP twardym CF → listing idzie przez Bright Data ISP proxy z retry
@ -36,7 +36,7 @@ _BASE = "https://supjav.com"
# atrybutu nie zawiera surowego "), więc tytuły z `>` (np. `<<3-Day Offer>>`) przechodzą.
# Link-obraz karty: <a href="URL" ... title="TITLE"><img src|data-original="THUMB">.
# supjav miesza kolejność atrybutów (href/rel/class="img"/title w dwóch wariantach), więc
# NIE wymagamy pozycji class="img", wymóg `>\s*<img` i tak izoluje link-obraz od
# NIE wymagamy pozycji class="img" — wymóg `>\s*<img` i tak izoluje link-obraz od
# link-tekstu (ten drugi po `>` ma tekst, nie <img>). Thumb eager `src=` lub lazy
# `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original).
_CARD_RE = re.compile(

View file

@ -1,11 +1,11 @@
"""sxyland.com, performer-page scrape (search-based, performer-driven).
"""sxyland.com — performer-page scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 06-07): sxyland porzucił URL scen `/<numeric_id>/<slug>/`
na rzecz `/<slug>/`, więc stary regex (wymagał cyfry w ścieżce) dawał 0. WordPress `?s=`
filtruje, ale miesza, czystsze są **strony performera** `/actor/<slug>/`
filtruje, ale miesza — czystsze są **strony performera** `/actor/<slug>/`
(performer-driven query = nazwa performera → slugify → /actor/<slug>/).
Bogate metadane (per-scene detail fetch, sxyland to WP tube, taksonomie na scenie):
Bogate metadane (per-scene detail fetch — sxyland to WP tube, taksonomie na scenie):
- performerzy: WSZYSTKIE `/actor/<slug>/` linki (z co-starami; `title="Name"`)
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
- studio: heurystycznie z tagów-paysite (`_STUDIO_TAGS`); brak match → bez studio
@ -181,7 +181,7 @@ class SxyLandScraper(BaseDirectTubeScraper):
)
)
# Tagi, TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
# Tagi — TYLKO z bloku tagów sceny (nie z sidebara/popular widgetu).
tags: list[RawTag] = []
seen_tag: set[str] = set()
block_m = _TAGS_BLOCK_RE.search(detail)

View file

@ -1,4 +1,4 @@
"""sxyprn.com, performer-page scrape (search-based, performer-driven).
"""sxyprn.com — performer-page scrape (search-based, performer-driven).
2026-06-16 fix (zamrożony od 05-07): sxyprn NIE ma free-text searcha. Stary endpoint
`?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token
@ -19,14 +19,6 @@ Metadane z karty (bez +1 fetch detalu):
- thumbnail: `data-src='//...small.jpg'`
Playback resolwuje extractor `sxyprncom` (osobno; mp4 z trafficdeposit/lulustream).
Blokada uploaderow (2026-09-04). Kilku autorow wrzuca dobre sceny z tytulem w stylu
„Tytul ft. Obsada, Obsada by Studio Kategoria, Kategoria, ..." i 25-35 hashtagami
na kazdym poscie. Kazde nazwisko z tytulu to potencjalne przypisanie performera
(over-attribution), a hashtagi wchodzily jako tagi sceny (mediana 27 zamiast 12).
Karta nie ma pola autora, ale id autora siedzi w sciezce miniatury:
`.../<token>/<ts>/<id_autora>/<id_posta>/small.jpg`. Autorow z `_BLOCKED_AUTHORS`
pomijamy w calosci, a karty z wiecej niz `_MAX_HASHTAGS` hashtagami wchodza bez tagow.
"""
from __future__ import annotations
@ -43,42 +35,31 @@ from app.connectors.base import (
)
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
from app.extractors import browser_get
from app.normalize.text import person_named_in, slugify
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://sxyprn.com"
# js-pop anchor karty: /post/<id>.html(?sk=...&so=...&ss=latest na stronie performera)
# + aria-label (połamani performerzy, nieużywane).
# + aria-label (połamani performerzy — nieużywane).
_CARD_ANCHOR_RE = re.compile(
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
re.IGNORECASE,
)
# Uwaga: duration_small ma atrybut `title='s1->c10'` ze znakiem `>` w środku, więc
# NIE używamy `[^>]*` (łamie się na tym `>`), leniwe `.*?` do wartości HH:MM:SS.
# NIE używamy `[^>]*` (łamie się na tym `>`) — leniwe `.*?` do wartości HH:MM:SS.
_DURATION_RE = re.compile(
r"duration_small.*?>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*<", re.IGNORECASE | re.DOTALL
)
_SUBCAT_RE = re.compile(r"post_el_small_subcat[^>]*>([^<]+)<", re.IGNORECASE)
_THUMB_RE = re.compile(r"data-src='(//[^']+?small\.jpg)'", re.IGNORECASE)
# post_time z tytułem, keyowane po ID, bo leży w post_control PO vid_container.
# post_time z tytułem — keyowane po ID, bo leży w post_control PO vid_container.
_POSTTIME_RE = re.compile(
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
re.IGNORECASE,
)
_HASHTAG_RE = re.compile(r"#(\w[\w-]*)")
# `.../<id_autora>/<id_posta>/small.jpg`, oba 13 hex. Drugi segment MUSI byc id posta,
# inaczej to inny format miniatury i autora nie znamy.
_AUTHOR_IN_THUMB_RE = re.compile(r"/([a-f0-9]{13})/([a-f0-9]{13})/[^/]+$", re.IGNORECASE)
# id autora sxyprn -> nazwa (tylko do logow). Patrz docstring modulu.
_BLOCKED_AUTHORS: dict[str, str] = {
"69cab3467ec41": "Shizuka",
"68e92925d23d9": "Ajx",
"6a0d125a875d2": "Denzel",
}
# Powyzej tylu hashtagow to spam SEO, nie opis sceny. Mediana katalogu sxyprn to 12.
_MAX_HASHTAGS = 20
_BRACE_RE = re.compile(r"\{[^}]*\}")
_URL_TAIL_RE = re.compile(r"https?://\S+")
@ -95,16 +76,6 @@ def _parse_duration(s: str) -> int | None:
return None
def _author_id(thumb: str | None, post_id: str) -> str | None:
"""Id autora ze sciezki miniatury albo None, gdy format inny niz oczekiwany."""
if not thumb:
return None
m = _AUTHOR_IN_THUMB_RE.search(thumb)
if not m or m.group(2).lower() != post_id.lower():
return None
return m.group(1).lower()
def _clean_title(raw: str) -> tuple[str, list[str]]:
"""Zwraca (czysty tytuł, tagi). Usuwa {markery}, hashtagi i końcowy URL."""
text = html.unescape(raw)
@ -118,7 +89,7 @@ def _clean_title(raw: str) -> tuple[str, list[str]]:
class SxyPrnScraper(BaseSearchScraper):
sitetag = "sxyprncom"
# _search_url_template/_scene_url_re z bazy nieużywane, mamy custom search().
# _search_url_template/_scene_url_re z bazy nieużywane — mamy custom search().
_card_window = 2600 # od js-pop anchora do post_control z tytułem
def _performer_path(self, query: str) -> str:
@ -141,6 +112,9 @@ class SxyPrnScraper(BaseSearchScraper):
return
text = r.text
query_tokens = {
tok for tok in query.lower().split() if len(tok) >= self._query_token_min_len
}
anchors = list(_CARD_ANCHOR_RE.finditer(text))
seen: set[str] = set()
yielded = 0
@ -156,13 +130,10 @@ class SxyPrnScraper(BaseSearchScraper):
if not tm:
continue
title, tags = _clean_title(tm.group(1))
# Scena musi realnie dotyczyć performera. Sprawdzamy CAŁE nazwisko jako ciąg
# sąsiadujących członów, bo poprzedni wariant (każdy człon od trzech znaków
# gdziekolwiek w tytule) degenerował się do samego imienia, gdy nazwisko było
# krótsze: „Julia B." → człon „b." odpada na długości, zostaje „julia", i
# przypisanie łapało każdą scenę ze słowem Julia. Zgłoszenie `e28f2517`,
# 85 tys. błędnych przypisań na samym sxyprn.
if not person_named_in(title, query):
title_l = title.lower()
# Strict: scena musi realnie dotyczyć performera (oba tokeny w tytule) —
# chroni przed śmieciem na stronie i przed over-attribution.
if query_tokens and not all(tok in title_l for tok in query_tokens):
continue
if not title:
continue
@ -175,14 +146,6 @@ class SxyPrnScraper(BaseSearchScraper):
if thumb and thumb.startswith("//"):
thumb = "https:" + thumb
author = _author_id(thumb, post_id)
if author in _BLOCKED_AUTHORS:
log.debug("sxyprn: pomijam post %s autora %s", post_id, _BLOCKED_AUTHORS[author])
continue
if len(tags) > _MAX_HASHTAGS:
log.debug("sxyprn: post %s ma %d hashtagow, wchodzi bez tagow", post_id, len(tags))
tags = []
studio = None
sub_m = _SUBCAT_RE.search(window)
if sub_m:

View file

@ -1,239 +0,0 @@
"""tubepornclassic.com, browse scraper po JSON API listingu (silnik TXXX). Dodany 2026-09-07.
Retro i vintage (lata 70-00), ~168 tys. wideo, świeże wgrania codziennie (2026-09-06 przy
dodawaniu). Zachodnia treść studyjna, więc wchodzi do głównego feedu, nie do wertykału.
**Listing z API, nie z HTML.** Strona to SPA: HTML ma pusty `<title>` i zero linków do
scen. Aplikacja woła `GET /api/json/videos2/<cache>/str/latest-updates/<count>/
..<page>.all...json` (sekcja, object_id, typ, długość i data puste) i dostaje
60 rekordów Z PEŁNYMI METADANYMI: tytuł, `dir` (slug), `duration`, `post_date`,
`models` (obsada, CSV), `categories` + `tags` (CSV), `scr` (miniatura), `is_private`.
Nie ma więc żadnego fetchu per scena, jedna strona = jeden request, w odróżnieniu od
vjav (sitemap + meta per id). Koniec katalogu = 200 z `{"error":1,"code":"videos_not_found"}`
(strona 2801 wciąż oddaje rekordy mimo `pages: 2800`, licznik jest z Sphinxa i przybliżony).
**Wyzwanie 429.** Z serwerowni pierwszy request dostaje 429 i stronkę ustawiającą
ciasteczko (`_txxx.CHALLENGE_COOKIE_RE`), z ciasteczkiem 200. Trzymamy je w instancji,
gdyby i tak padło, a jest Bright Data, powtarzamy przez proxy (przez proxy 200 od razu).
**Obsada tylko u 46 % scen** (28/60 w próbce), studia brak (`content_source_name` puste
we wszystkich 60). Jan o tym wie („gorzej ze studiami i aktorami").
**Bramka na spun-tytuły BEZ obsady.** 38 % tytułów (23/60) to generator SEO w stylu
„Fabulous Sex Movie Vintage Try To Watch For Ever Seen" albo „Hottest Xxx Clip Fat Great
Will Enslaves Your Mind" (ten sam szablon co na fullvideosporn, to jedna sieć). Gdy do
takiego tytułu jest doczepione nazwisko („... - Marissa Malibu"), scena jest w apce
odnajdywalna po performerze i zostaje. Gdy nazwiska nie ma (13/60 = 22 %), nie ma po
czym jej znaleźć ani z czym zdeduplikować, więc NIE wchodzi. Zwykłe tytuły bez obsady
(19/60, „Unforgettable Sensation") wchodzą: dopasowanie po tytule ma sens, retro ma
w kanonie sporo scen.
**Miniatura:** `scr` to 240x180. Ten sam katalog ma `preview.jpg` 1280x720 (sprawdzone),
inne rozmiary w `videos_screenshots/<id>/<WxH>/1.jpg` dają 404, bierzemy preview.
**Data:** `post_date` to data wgrania na tube, nie premiery (retro z lat 80. ma
`2026-09-06`). Tak samo jak inne tuby; deep_crawl od strony 3 ustawia `backfill`.
Playback: ekstraktor `tubepornclassic` (`_txxx.extract_hls`, HLS przez /proxy/hls).
"""
from __future__ import annotations
import html as html_mod
import json
import logging
import re
from datetime import date, datetime
from app.config import get_settings
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene, RawStudio, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.extractors.tubes._txxx import CHALLENGE_COOKIE_RE
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://tubepornclassic.com"
_PER_PAGE = 60
#: Cache po stronie serwera (sekundy). Aplikacja używa 86400; bierzemy krótszy, żeby
#: strona 1 nie była o dobę stara przy godzinnym browse.
_CACHE_LIFETIME = 3600
_THUMB_SMALL_RE = re.compile(r"/\d+x\d+/\d+\.jpg$")
#: Tagi-śmieci z pola `tags` (techniczne, nie opisowe).
_SKIP_TAGS = frozenset({"hd", "4k", "1080p", "720p"})
#: Szablon tytułu z generatora SEO: przymiotnik, potem (porn|xxx|sex|adult) i zaraz
#: (video|clip|movie|scene). Zwykłe tytuły („Unforgettable Sensation", „Oceane, Amazing
#: Blonde Bimbo, With 1 Or 3 Dicks") go nie łapią, bo brak im pary porn+video obok siebie.
_SPUN_TITLE_RE = re.compile(
r"\b(fabulous|incredible|exotic|amazing|hottest|crazy|horny|best|excellent|astonishing|"
r"exclusive|fantastic|great|wild)\b.{0,24}\b(porn|xxx|sex|adult)\b.{0,3}\b(video|clip|movie|scene)\b",
re.IGNORECASE,
)
def _parse_duration(value: str | None) -> int | None:
"""`1:16:43` / `14:17` -> sekundy."""
if not value:
return None
try:
parts = [int(p) for p in value.strip().split(":")]
except ValueError:
return None
sec = 0
for p in parts:
sec = sec * 60 + p
return sec or None
def _parse_post_date(value: str | None) -> date | None:
if not value:
return None
try:
return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S").date()
except ValueError:
return None
def _csv(value) -> list[str]:
if not isinstance(value, str):
return []
return [html_mod.unescape(x).strip() for x in value.split(",") if x.strip()]
class TubePornClassicScraper(BaseBrowseScraper):
sitetag = "tubepornclassic"
def __init__(self) -> None:
super().__init__()
self._cookie: str | None = None
self._proxy = get_settings().brightdata_proxy_url
# ---- fetch z wyzwaniem 429 ------------------------------------------------------
def _gated_get(self, url: str):
"""GET z ciasteczkiem wyzwania; None przy błędzie sieci albo trwałym 429."""
hdr = {"Referer": _BASE + "/", "Accept": "application/json,text/plain,*/*"}
if self._cookie:
hdr["Cookie"] = self._cookie
try:
res = browser_get(url, timeout=self._timeout, headers=hdr)
if res.status_code == 429:
ck = CHALLENGE_COOKIE_RE.search(res.text or "")
if ck is not None:
self._cookie = f"{ck.group(1)}={ck.group(2)}"
res = browser_get(url, timeout=self._timeout, headers=dict(hdr, Cookie=self._cookie))
if res.status_code == 429 and self._proxy:
res = browser_get(url, timeout=self._timeout, headers=hdr, proxy=self._proxy)
except Exception as e:
log.warning("tubepornclassic: fetch fail %s: %s", url, e)
return None
if res.status_code >= 400:
log.warning("tubepornclassic: HTTP %s na %s", res.status_code, url)
return None
return res
def _listing_url(self, page: int) -> str:
return (
f"{_BASE}/api/json/videos2/{_CACHE_LIFETIME}/str/latest-updates/{_PER_PAGE}/"
f"..{page}.all...json"
)
def crawl_page(self, page: int) -> list[RawScene] | None:
res = self._gated_get(self._listing_url(page))
if res is None:
return None # transient: NIE oznaczaj końca katalogu
try:
data = json.loads(res.text)
except Exception as e:
log.warning("tubepornclassic: listing parse fail (page %d): %s", page, e)
return None
# Za końcem katalogu API oddaje 200 z `{"error":1,"code":"videos_not_found"}`
# (bez klucza `videos`). To jest wyczerpanie, nie awaria, stąd [] a nie None.
if data.get("code") == "videos_not_found":
return []
videos = data.get("videos")
if not isinstance(videos, list):
return None
out: list[RawScene] = []
for v in videos:
if not isinstance(v, dict):
continue
try:
scene = self._parse_item(v)
except Exception as e:
log.info("tubepornclassic: parse fail id=%s: %s", v.get("video_id"), e)
continue
if scene is not None:
out.append(scene)
return out
# ---- rekord listingu -> RawScene ------------------------------------------------
def _parse_item(self, v: dict) -> RawScene | None:
if str(v.get("is_private") or "0") != "0":
return None
try:
vid = int(v.get("video_id"))
except (TypeError, ValueError):
return None
title = html_mod.unescape(v.get("title") or "").strip()
slug = (v.get("dir") or "").strip()
if not title or not slug:
return None
page_url = f"{_BASE}/videos/{vid}/{slug}/"
duration_sec = _parse_duration(v.get("duration"))
thumb = (v.get("scr") or "").strip() or None
if thumb:
thumb = _THUMB_SMALL_RE.sub("/preview.jpg", thumb)
performers: list[RawPerformer] = []
seen: set[str] = set()
for name in _csv(v.get("models")):
s = slugify(name)
if s and s not in seen:
seen.add(s)
performers.append(RawPerformer(external_id=f"{self.sitetag}:model:{s}", name=name))
if not performers and _SPUN_TITLE_RE.search(title):
return None # nieodnajdywalna: spun-tytuł i zero obsady, patrz docstring
studio = None
sname = (v.get("content_source_name") or "").strip()
if sname and slugify(sname):
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(sname)}", name=sname, slug=slugify(sname)
)
tags: list[RawTag] = []
seen_t: set[str] = set()
for name in _csv(v.get("categories")) + _csv(v.get("tags")):
s = slugify(name)
if not s or s in seen_t or s in _SKIP_TAGS or len(s) > 60:
continue
seen_t.add(s)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{s}", name=name, slug=s))
return RawScene(
external_id=f"{self.sitetag}:{vid}",
title=title,
release_date=_parse_post_date(v.get("post_date")),
duration_sec=duration_sec,
url=page_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=page_url,
duration_sec=duration_sec,
thumbnail_url=thumb,
)
],
raw={"source": "tubepornclassic_api", "id": vid},
)
# ---- HTML stuby (ABC wymaga, nieużywane: listing i metadane idą z JSON) --------
def _extract_scene_urls(self, listing_html: str) -> list[str]: # pragma: no cover
return []
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
return None

View file

@ -1,15 +1,15 @@
"""vjav.com, JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
"""vjav.com — JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
Osobna pula JAV (tytuły azjatyckie + `title_jp`, studia typu JAVHD), origin `tube:vjav`.
Świadomie orphan vertical (sekcja JAV), NIE deduplikuje się z zachodnim katalogiem.
Świadomie orphan vertical (sekcja JAV) — NIE deduplikuje się z zachodnim katalogiem.
vjav to SPA (ktk_player + blob), listing renderuje się JS-owo, więc HTML listingu
vjav to SPA (ktk_player + blob) — listing renderuje się JS-owo, więc HTML listingu
nie ma linków do scen. Zamiast tego:
- **browse**: `sitemap.xml` -> `sitemap_vids_N.xml` (id-e wszystkich wideo). Newest
= najwyższe id (auto-increment). Cache posortowanej listy id malejąco, page N =
kolejny wycinek PER_PAGE.
- **metadata**: bogaty JSON API `GET /api/json/video/1/<floor>/<id>/<id>.json`
(floor = id//1000*1000), title (+title_jp), duration, post_date, channel.title
(floor = id//1000*1000) — title (+title_jp), duration, post_date, channel.title
(studio), models{} (performerki), categories{}+tags{}.
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
get_file HLS). RE 2026-07-10.
@ -62,7 +62,7 @@ def _parse_duration(value: str | None) -> int | None:
# --- bramka jakości tytułu ----------------------------------------------------------
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
# w apce nieodnajdywalne, nie mają ani tytułu, ani performera, po którym da się trafić.
# w apce nieodnajdywalne — nie mają ani tytułu, ani performera, po którym da się trafić.
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
# bo sprawdzała tylko jeden z czterech wzorców).
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
@ -74,7 +74,7 @@ def _has_cjk(text: str) -> bool:
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły „za mało
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
wyszła przy pomiarze pierwszej wersji bramki, bez tego wyjątku poszłoby 708 z 708
wyszła przy pomiarze pierwszej wersji bramki — bez tego wyjątku poszłoby 708 z 708
tytułów CJK z próbki."""
return sum(1 for c in text if " " <= c <= "鿿" or "＀" <= c <= "￯") >= 3
@ -119,7 +119,7 @@ class VjavScraper(BaseBrowseScraper):
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
# działania, nie optymalizacją, bez klucza scraper się wyłącza.
# działania, nie optymalizacją — bez klucza scraper się wyłącza.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
@ -130,7 +130,7 @@ class VjavScraper(BaseBrowseScraper):
zwrócenia [], czyli „koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
wyglądał na zdrowy. Zostawiamy None = „nie wiadomo", co crawl_page tłumaczy na
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx,
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx —
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
try:
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
@ -238,7 +238,7 @@ class VjavScraper(BaseBrowseScraper):
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć „koniec
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
kończyło CAŁY run, źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
kończyło CAŁY run — źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
2-5. To ta sama pułapka „[] znaczy wyczerpane" co w `_load_ids`.
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
@ -268,7 +268,7 @@ class VjavScraper(BaseBrowseScraper):
out: list[RawScene] = []
for vid in chunk:
try:
# Proxy także tutaj, API metadanych siedzi na tym samym hoście, więc
# Proxy także tutaj — API metadanych siedzi na tym samym hoście, więc
# obejmuje je ten sam 429 co sitemap.
res = browser_get(
self._meta_url(vid),

View file

@ -1,7 +1,7 @@
"""watchporn.to, browse scraper (KVS engine). Re-enabled 2026-07-02.
"""watchporn.to — browse scraper (KVS engine). Re-enabled 2026-07-02.
Był search-scraperem (`?s=`), zamarzł, a potem site przebudowano na KVS z nowym
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął, teraz KVS
layoutem. DoodStream-CAPTCHA (powód wyłączenia 2026-05-12) zniknął — teraz KVS
flashvars `get_file` direct mp4 (extractor `watchporn`, VPS-side, token nie IP-bound).
Browse `/latest-updates/` → detail page:

View file

@ -1,172 +0,0 @@
"""xanimeporn.com — browse scraper. Drugie źródło wertykału hentai. Dodany 2026-08-04.
**NIE jest re-hostem hentaimamy — sprawdzone na wspólnym odcinku.** Shoujo Ramune 7
leży na obu stronach, ale to zupełnie inne pliki: hentaimama serwuje
`gdvid.info/S/shoujo-ramune-7.mp4` (jedna jakość, bez etykiety), a tutaj jest
`download.xanimeporn.com/videos/1080p/[1080p]69951.mp4` z **jawnym wyborem 1080p (222 MB)
i 720p (84 MB)**. To realna przewaga jakościowa, nie duplikat.
Kryterium jak przy [[hentaimama]]: dla animacji nie istnieje kanon, więc każda pozycja
jest orphanem i to jest poprawne. Nie ma obsady, nie ma canonical matcha — jakość mierzy
komplet seria + odcinek + jakość pliku.
**Listing idzie z RSS-a i to jedyna droga, która działa.** Sprawdzone trzy inne i każda
ma inną wadę:
- `/page/2/`, `/page/3/` — oddają ten sam zestaw co strona 1 (28 z 32 wspólnych),
czyli paginacja jest ZEPSUTA, identycznie jak przy 8kporner
- `/uncensored/` i `/censored/` — stronicują poprawnie, ale NIE są chronologiczne:
pierwsze pozycje są z 2024, a zakres sięga 2015-2026. Browse-latest nigdy nie
zobaczyłby nowego odcinka, czyli dokładnie pułapka z freshporno
- `/hentai-list/` — cały katalog (1048 pozycji) na jednej stronie, dobre do
przyszłego deep-crawlu, bezużyteczne do „najnowszych"
`/feed/` i `/feed/?paged=N` są chronologiczne (strona 1 = dziś, strona 2 = miesiąc
wstecz) i dają po 10 pozycji, co przy kilku odcinkach dziennie w zupełności wystarcza.
**Drugi filtr, też wymuszony przez dane:** slugi gatunków (`/futanari/`, `/big-tits/`,
`/betrayal/`) są takimi samymi slugami najwyższego poziomu jak odcinki i przechodziły
przez listę nazw nawigacji. Dlatego `_parse_detail` wymaga `postid-<n>` w klasie
`<body>` — mają go tylko pojedyncze wpisy, nie archiwa. Ten sam identyfikator jest
zresztą potrzebny extractorowi, więc brak = pozycja i tak bezużyteczna.
Playback: `videos.xanimeporn.com/download.php?id=<postId>&quality=…` (extractor
`xanimeporn`). Bez Cloudflare i bez proxy, w odróżnieniu od hentaimamy.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import datetime
from app.connectors.base import RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://xanimeporn.com"
_FEED_LINK_RE = re.compile(r"<link>(https://xanimeporn\.com/[a-z0-9][a-z0-9-]+/)</link>")
#: Tylko pojedyncze wpisy mają `postid-<n>` w klasie <body>; archiwa gatunków nie.
_POST_ID_RE = re.compile(r"\bpostid-(\d+)\b")
#: Kategoria z RSS-a — na stronie odcinka oba linki są w menu, więc czytanie ich
#: stamtąd dałoby ZAWSZE „censored" i „uncensored" naraz.
_FEED_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
_FEED_CATEGORY_RE = re.compile(r"<category><!\[CDATA\[([^\]]+)\]\]></category>")
_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,200})")
_PUBLISHED_RE = re.compile(
r"property=['\"]article:published_time['\"][^>]*content=['\"]([^'\"]+)"
)
_POSTER_RE = re.compile(r"posterImage:\s*'([^']+)'")
#: „Shoujo Ramune Episode 7 [Sub-ENG] | X Anime Porn" → tytuł bez sufiksu serwisu.
_TITLE_SUFFIX_RE = re.compile(r"\s*\|\s*X Anime Porn\s*$", re.IGNORECASE)
_SERIES_RE = re.compile(r"^(.*?)\s+Episode\s+\d+", re.IGNORECASE)
class XAnimePornScraper(BaseBrowseScraper):
sitetag = "xanimeporn"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/feed/" if page <= 1 else f"{_BASE}/feed/?paged={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return list(dict.fromkeys(_FEED_LINK_RE.findall(listing_html)))
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Jak w bazie, ale kategorię (censored/uncensored) bierzemy z RSS-a, bo na
stronie odcinka oba linki siedzą w menu nawigacyjnym."""
try:
res = browser_get(self._listing_url(page), timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.warning("%s: feed strona %d fail: %s", self.sitetag, page, e)
return None
cats: dict[str, str] = {}
for item in _FEED_ITEM_RE.findall(res.text):
lm = _FEED_LINK_RE.search(item)
if not lm:
continue
for cat in _FEED_CATEGORY_RE.findall(item):
if cat.strip().lower() in ("censored", "uncensored"):
cats[lm.group(1)] = cat.strip().lower()
break
urls = self._extract_scene_urls(res.text)
if not urls:
return []
out: list[RawScene] = []
for url in urls:
try:
det = browser_get(url, timeout=self._timeout)
det.raise_for_status()
raw = self._parse_detail(url, det.text, cats.get(url, ""))
except Exception as e:
log.info("%s detail fail %s: %s", self.sitetag, url, e)
continue
if raw is not None:
out.append(raw)
return out
def _parse_detail( # type: ignore[override]
self, scene_url: str, detail_html: str, archive: str = ""
) -> RawScene | None:
# Archiwa gatunków (`/futanari/`, `/big-tits/`) mają takie same slugi jak odcinki
# i wchodziły jako pozycje bez daty i miniaturki. Tylko pojedynczy wpis ma postid.
if not _POST_ID_RE.search(detail_html):
return None
tm = _OG_TITLE_RE.search(detail_html)
if not tm:
return None
title = _TITLE_SUFFIX_RE.sub("", html_mod.unescape(tm.group(1))).strip()
if not title:
return None
release_date = None
pm = _PUBLISHED_RE.search(detail_html)
if pm:
try:
release_date = datetime.fromisoformat(pm.group(1)).date()
except ValueError:
pass
tags: list[RawTag] = []
if archive:
tags.append(
RawTag(
external_id=f"{self.sitetag}:tag:{archive}",
name=archive.capitalize(),
slug=archive,
)
)
# Seria jako tag — pozwala grupować odcinki, tak samo jak w hentaimamie.
sm = _SERIES_RE.match(title)
if sm and slugify(sm.group(1)):
series = sm.group(1).strip()
tags.append(
RawTag(
external_id=f"{self.sitetag}:series:{slugify(series)}",
name=series,
slug=slugify(series),
)
)
om = _POSTER_RE.search(detail_html)
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
return RawScene(
external_id=f"{self.sitetag}:{slug}",
title=title,
release_date=release_date,
url=scene_url,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
thumbnail_url=om.group(1).strip() if om else None,
)
],
)

View file

@ -1,4 +1,4 @@
"""XHamster.com, direct HTML scrape search results.
"""XHamster.com — direct HTML scrape search results.
Search: `https://xhamster.com/search/<q>?page=<n>`
Scene URL: `https://xhamster.com/videos/<slug>-<id>`
@ -7,13 +7,6 @@ from __future__ import annotations
import re
# Karty sa renderowane z JSON-a w HTML (`window.initials`): kazda ma `pageURL` i
# `thumbURL` (526x298 webp). Szukanie `<img>` w oknie tekstu gubilo polowe scen, bo
# ten sam link wystepuje dwa razy (miniatura i tytul), a img jest tylko przy pierwszym.
_JSON_CARD_RE = re.compile(
r'"pageURL":"(https:\\/\\/xhamster\.com\\/videos\\/[^"]+)","thumbURL":"([^"]+)"'
)
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
@ -24,9 +17,3 @@ class XHamsterScraper(BaseSearchScraper):
r'href="(?P<url>https://xhamster\.com/videos/(?P<slug>[a-z0-9_\-]+))"',
re.IGNORECASE,
)
def _thumbnail_map(self, listing_html: str) -> dict[str, str]:
out: dict[str, str] = {}
for page_url, thumb in _JSON_CARD_RE.findall(listing_html):
out[page_url.replace("\\/", "/")] = thumb.replace("\\/", "/")
return out

View file

@ -1,4 +1,4 @@
"""xmoviesforyou.com, direct HTML scrape.
"""xmoviesforyou.com — direct HTML scrape.
Search: WordPress `?s=<q>` (lub `/page/<n>/?s=<q>` dla pagination).
Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment).

View file

@ -1,4 +1,4 @@
"""XNXX.com, direct HTML scrape search results.
"""XNXX.com — direct HTML scrape search results.
Search: `https://www.xnxx.com/search/<q>/<page-1>` (xnxx 0-indexed)
Scene URL: `https://www.xnxx.com/video-<id>/<slug>`
@ -12,7 +12,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
class XnxxScraper(BaseSearchScraper):
sitetag = "xnxxcom"
# `/<page-1>`, handle override in search() by replacing {page}.
# `/<page-1>` — handle override in search() by replacing {page}.
_search_url_template = "https://www.xnxx.com/search/{query}/{page}"
_scene_url_re = re.compile(
r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"',

View file

@ -1,6 +1,6 @@
"""xnxx.com, BROWSE scraper (JSON-LD), obok search scrapera.
"""xnxx.com — BROWSE scraper (JSON-LD), obok search scrapera.
Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail), i TYLKO to
Detail page ma JSON-LD VideoObject (name/duration/uploadDate/thumbnail) — i TYLKO to
bierzemy. Mimo wspólnego silnika z xvideos, xnxx detail NIE wystawia w SSR linków
`/models/` ani `/tags/` (0 wystąpień, ładowane JS-em) → performerów/tagi dorabia
canonical-merge + istniejący performer-search. XnxxScraper (search) zostaje; browse
@ -8,13 +8,13 @@ dokłada sygnał świeżości. Tytuł z JSON-LD bywa HTML-encoded (`&comma;`/`&e
html.unescape.
Listing: xnxx NIE ma czystego SSR `/new/` (404), ale `/best/<YYYY-MM>/<page>` jest
SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc, pokrywa świeży
SSR (linki /video-<id>/ w surowym HTML). Bierzemy bieżący miesiąc — pokrywa świeży
content (sortowanie best-of-month, nie ściśle chronologiczne, ale dla sygnału
świeżości wystarcza; ścisłą chronologię i tak daje performer-search). Homepage
xnxx jest JS-renderowany (0 linków w surowym HTML), stąd /best/.
Playback bez zmian (extractor `xnxxcom`). Phash pominięty (xnxx crop-thumbnaile,
0% hit do canonical, jak xvideos).
0% hit do canonical — jak xvideos).
"""
from __future__ import annotations
@ -108,7 +108,7 @@ class XnxxBrowseScraper(BaseBrowseScraper):
if isinstance(thumbnail_url, list):
thumbnail_url = thumbnail_url[0] if thumbnail_url else None
# performers/tags puste, xnxx detail nie ma ich w SSR (patrz docstring).
# performers/tags puste — xnxx detail nie ma ich w SSR (patrz docstring).
return RawScene(
external_id=f"{self.sitetag}:{scene_url}",
title=title,

View file

@ -1,4 +1,4 @@
"""XVideos.com, direct HTML scrape search results.
"""XVideos.com — direct HTML scrape search results.
Search: `https://www.xvideos.com/?k=<q>&p=<page-1>` (xvideos używa 0-indexed pages)
Scene URL: `https://www.xvideos.com/video<digits>/<slug>`
@ -13,7 +13,7 @@ from app.connectors.direct_scrapers._search_base import BaseSearchScraper
class XVideosScraper(BaseSearchScraper):
sitetag = "xvideoscom"
# 0-indexed page, w base classie computed jako `page=N`, więc override _build_url.
# 0-indexed page — w base classie computed jako `page=N`, więc override _build_url.
_search_url_template = "https://www.xvideos.com/?k={query}&p={page}"
_scene_url_re = re.compile(
r'href="(?P<url>/video[a-z0-9.\-]+/(?P<slug>[a-z0-9_\-]+))"',
@ -21,10 +21,10 @@ class XVideosScraper(BaseSearchScraper):
)
def search(self, query, *, page=1, limit=None):
# XVideos używa 0-indexed pages, `page=1` w API → `&p=0` w URL.
# XVideos używa 0-indexed pages — `page=1` w API → `&p=0` w URL.
# Override żeby base class fetch'nął zewnętrzny URL z (page-1).
# Najprościej: dostosujmy URL w override przed wywołaniem super().search().
# Ale super() używa self._search_url_template, robimy clone z poprawionym page.
# Ale super() używa self._search_url_template — robimy clone z poprawionym page.
original = self._search_url_template
self._search_url_template = original.replace("{page}", str(page - 1))
try:

View file

@ -1,14 +1,14 @@
"""xvideos.com, deep-crawl browse scraper (JSON-LD + page-parse).
"""xvideos.com — deep-crawl browse scraper (JSON-LD + page-parse).
xvideos SSR-uje JSON-LD VideoObject (duration, name, uploadDate) ORAZ na detail-stronie
linki `/models/<slug>` (performerzy tej sceny) + `/tags/<slug>` (tagi). Sample 2026-06-03
(15 scen): median ~10.5min, 93% ≥3min, dobry full-scene content (nie trailery).
(15 scen): median ~10.5min, 93% ≥3min — dobry full-scene content (nie trailery).
Mega-katalog (~13M) → deep_crawl z per-tube page-cap (xvideoscom w deep_crawl._PAGE_CAP),
żeby nie monopolizował round-robin ani nie zalał bazy. Listing: /new/<page> (newest).
Scene: /video.<hash>/<slug>. Playback: page_url + origin tube:xvideoscom (istniejący
extractor `xvideoscom` resolvuje stream mobile-side). Phash pominięty (xvideos robi
własne crop-thumbnaile, 0% hit do canonical, jak fullmovies/hdporn).
własne crop-thumbnaile — 0% hit do canonical, jak fullmovies/hdporn).
"""
from __future__ import annotations
@ -19,7 +19,7 @@ from datetime import date, datetime
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene, RawTag
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper, meta_content
from app.normalize.text import slugify, unescape_html
from app.normalize.text import slugify
log = logging.getLogger(__name__)
@ -29,7 +29,7 @@ _JSONLD_RE = re.compile(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL
)
# Model anchor: `<a href="/models/<slug>" ...><span ...></span><span class="name">Display Name</span>...`.
# Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora,
# Nazwa siedzi w ZAGNIEŻDŻONYM <span class="name">, nie jako bezpośredni tekst anchora —
# poprzedni wzorzec `>([^<]{2,60})</a>` wymagał tekstu zaraz po `>` i NIGDY nie pasował do
# obecnego markupu xvideos → wszystkie browse-scraped sceny xvideos lądowały z 0 performerami
# (bug-report 2026-06-07 "czemu nie ma aktorów, są na stronie"). `(?:(?!</a>).)*?` trzyma match
@ -99,11 +99,10 @@ class XVideosBrowseScraper(BaseBrowseScraper):
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
video = _video_object(detail_html) or {}
# JSON-LD `name` niesie encje HTML5 (`we&apos;re`, `&lbrack;AI&rsqb;`), og:title tez.
title = unescape_html(video.get("name"))
title = (video.get("name") or "").strip()
if not title:
m = _SETTITLE_RE.search(detail_html)
title = unescape_html(m.group(1) if m else meta_content(detail_html, property="og:title"))
title = m.group(1).strip() if m else (meta_content(detail_html, property="og:title") or "").strip()
if not title:
return None

View file

@ -1,4 +1,4 @@
"""xxxfree.watch, direct HTML scrape.
"""xxxfree.watch — direct HTML scrape.
Domain: `xxxfree.watch` (sitetag `xxxfreewatch` is legacy from porn-app DEFAULT_SITETAGS).

View file

@ -1,25 +1,25 @@
"""yesporn.vip, latest-vids browse scraper.
"""yesporn.vip — latest-vids browse scraper.
Dołączony 2026-05-27. Identyfikowany przez user audit jako "scraper-of-paysites"
(DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude
audit pomylił domeny: `yespornvip.com` (z theporndude rankingu) redirectuje przez
pdude.link do `porndudecams.com` affiliate spam, kanoniczna domena ma TLD `.vip`.
pdude.link do `porndudecams.com` affiliate spam — kanoniczna domena ma TLD `.vip`.
Czemu wart (parity z porndoe):
- **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate
(ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl
(BunnyCDN: `yesnn.b-cdn.net/contents/videos_screenshots/...`).
- **`<meta property="video:duration" content="2340">`** durations już w sekundach
- **`<meta property="video:duration" content="2340">`** — durations już w sekundach
(fallback gdy ISO-duration parse fail).
- **`<meta property="video:release_date">`** ISO 8601 z timezone, redundant z
- **`<meta property="video:release_date">`** — ISO 8601 z timezone, redundant z
JSON-LD uploadDate ale czystszy format.
- **`<meta property="video:tag">`** (multiple), kanoniczna lista tagów (np.
- **`<meta property="video:tag">`** (multiple) — kanoniczna lista tagów (np.
"Big Ass", "Threesome"). Główne źródło tagów; alternatywnie DOM ma `btn gold`
linki ale te miksują performerów/studio z tagami.
- **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">`
(studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy,
multiple). Slugi mają stable per-type salt (`*-i459s7` dla modeli, `*-7p72tp`
dla channels), zachowują się jak hash z site-version, ale stabilne przez
dla channels) — zachowują się jak hash z site-version, ale stabilne przez
sesje.
External_id strategia: `yespornvip:<numeric_video_id>` (`/video/69841/...` → `69841`).
@ -31,11 +31,11 @@ URL patterns:
- Scene: `/video/<id>/<slug>/` (id numeryczny, slug = title slug + 6-char salt)
- Studio: `/channels/<slug>/`
- Performer: `/models/<slug>/`
- Search: `/search/<query>/` (nie używane w browse-mode, można dorobić jako
- Search: `/search/<query>/` (nie używane w browse-mode — można dorobić jako
osobny tryb dla performer-driven backfill jeśli będzie potrzeba)
Playback: download endpoint `/view_video_download.php?id=<id>&format=<480|720|1080>`
z `data-attach-session="PHPSESSID"`, wymaga session cookie, więc nie direct mp4
z `data-attach-session="PHPSESSID"` — wymaga session cookie, więc nie direct mp4
z server-side. Plus jest `embedUrl: /embed/<id>` w JSON-LD. Extractor →
`_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy):
mobile WebView fetcha embed z phone IP, INJECTED_JS scrape'uje `<video>.src`.
@ -66,7 +66,7 @@ log = logging.getLogger(__name__)
_BASE = "https://yesporn.vip"
# Listing, scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`.
# Listing — scene URLs w listing HTML: `<a href="https://yesporn.vip/video/<id>/<slug>/">`.
# Slug `<slug>` zawiera stable per-type salt (`*-npu57w` dla videos).
_SCENE_URL_RE = re.compile(
r'href="(https://yesporn\.vip/video/(\d+)/[a-z0-9\-]+/)"',
@ -87,13 +87,13 @@ _PERFORMER_LINK_RE = re.compile(
)
_HTML_TAG_RE = re.compile(r"<[^>]+>")
# JSON-LD VideoObject, pełny blok między `<script type="application/ld+json">` tagami.
# JSON-LD VideoObject — pełny blok między `<script type="application/ld+json">` tagami.
_JSONLD_RE = re.compile(
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
re.IGNORECASE | re.DOTALL,
)
# `<meta property="video:tag" content="Big Ass">`, multiple, jeden tag per meta.
# `<meta property="video:tag" content="Big Ass">` — multiple, jeden tag per meta.
_META_TAG_RE = re.compile(
r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']',
re.IGNORECASE,
@ -122,7 +122,7 @@ def _parse_iso_date(value: str | None) -> date | None:
"""`2026-05-26T19:23:29Z` / `2026-05-26T19:23:29.EDT` → date."""
if not value:
return None
# yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate, strip żeby
# yesporn emituje `.EDT` jako "timezone" w JSON-LD uploadDate — strip żeby
# `fromisoformat` nie crash'ował. video:release_date meta ma czysty `Z`.
cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip())
try:
@ -265,7 +265,7 @@ class YesPornVipScraper(BaseBrowseScraper):
# lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep
# Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone
# w 20-scene dry-run 2026-05-27. Trade-off: stracimy hipotetyczne legit
# lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował),
# lowercase tagi (np. "interracial" gdyby site je nie capitalize'ował) —
# akceptowalne bo tags mają wagę tylko 0.05 w composite scoring resolvera.
tags: list[RawTag] = []
seen_tag: set[str] = set()
@ -283,17 +283,17 @@ class YesPornVipScraper(BaseBrowseScraper):
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
)
# Phash z thumbnailUrl, BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene.
# Phash z thumbnailUrl — BunnyCDN `yesnn.b-cdn.net` hostuje 1.jpg per scene.
# Hit-rate vs canonical TPDB/StashDB nieznany do pilot run; graceful: brak
# phash → resolver spada do composite scoring (studio + performer + date +
# duration + title token-set), wszystkie dostępne dzięki JSON-LD.
# duration + title token-set) — wszystkie dostępne dzięki JSON-LD.
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph))
# Playback, page_url do strony sceny. Direct mp4 (`view_video_download.php`)
# Playback — page_url do strony sceny. Direct mp4 (`view_video_download.php`)
# wymaga PHPSESSID cookie (data-attach-session attribute), więc nie usable
# server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`:
# mobile WebView z phone IP łapie session natively, INJECTED_JS scrape.

Some files were not shown because too many files have changed in this diff Show more