Compare commits
139 commits
c849c4c8be
...
cf9741b319
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf9741b319 | ||
|
|
d2b3378d93 | ||
|
|
d471bb3293 | ||
|
|
7ed878ce16 | ||
|
|
37e6b7f03c | ||
|
|
f0d49889d8 | ||
|
|
080fc4b70e | ||
|
|
747d0cec3b | ||
|
|
8462ab50df | ||
|
|
9872d5e4ca | ||
|
|
538a72107e | ||
|
|
0df2236b37 | ||
|
|
cd4803ed51 | ||
|
|
4f5de92bfc | ||
|
|
f28acf8f0d | ||
|
|
656604e868 | ||
|
|
bd8af5f44c | ||
|
|
be6c18b3b4 | ||
|
|
d5fb3fb521 | ||
|
|
c55596dd6f | ||
|
|
320d928927 | ||
|
|
385aeb3ddb | ||
|
|
44842478ae | ||
|
|
b0350baff0 | ||
|
|
e13463cba4 | ||
|
|
8c75b12066 | ||
|
|
d9fbc08218 | ||
|
|
90724af8ef | ||
|
|
dce738b8ab | ||
|
|
b847b300bf | ||
|
|
7fe9a11136 | ||
|
|
994e4e98ad | ||
|
|
9906c665ec | ||
|
|
25416a2858 | ||
|
|
ded80d3bc7 | ||
|
|
01298c9699 | ||
|
|
9c988bc12a | ||
|
|
f1b8b8d466 | ||
|
|
59b7b5a651 | ||
|
|
c4845e9f61 | ||
|
|
6874eec076 | ||
|
|
ec38403e40 | ||
|
|
e5eb27228e | ||
|
|
ca80ae44b6 | ||
|
|
ad59baea09 | ||
|
|
5e41285d44 | ||
|
|
dd87f646cc | ||
|
|
ef43b61a12 | ||
|
|
b84ff5bdbc | ||
|
|
4df82b0bbe | ||
|
|
f10d999410 | ||
|
|
bbdff6bc90 | ||
|
|
ecca52b8bd | ||
|
|
9219c94c5a | ||
|
|
1fd1a750e3 | ||
|
|
38eee03902 | ||
|
|
365c97a1de | ||
|
|
62c1aaf250 | ||
|
|
c0a5d02ece | ||
|
|
ccba96376c | ||
|
|
360af9fabc | ||
|
|
2c92fab2a9 | ||
|
|
031c309091 | ||
|
|
bdac60f022 | ||
|
|
dad4bbf0ab | ||
|
|
b7a8911441 | ||
|
|
5a48175a85 | ||
|
|
3c8bcada84 | ||
|
|
c9fa6edc6d | ||
|
|
646f03d7be | ||
|
|
2071b36311 | ||
|
|
9fe6d5c1ed | ||
|
|
612558c880 | ||
|
|
46e7839fb2 | ||
|
|
a1717e4b5e | ||
|
|
1f73dad7d3 | ||
|
|
1154834170 | ||
|
|
a62d438500 | ||
|
|
95c51f78d2 | ||
|
|
83a6092d89 | ||
|
|
ae5f2aa7ac | ||
|
|
1e6be1926e | ||
|
|
5065df6220 | ||
|
|
e26c78df24 | ||
|
|
fa7fb668b6 | ||
|
|
34276afb71 | ||
|
|
8f6fa8c732 | ||
|
|
65f768cf8a | ||
|
|
fa2a65b408 | ||
|
|
17601fd0b8 | ||
|
|
b4c1216133 | ||
|
|
544de9c5ba | ||
|
|
cddf60acc2 | ||
|
|
ae3c4ac9bb | ||
|
|
20c04698b1 | ||
|
|
068a65dd8c | ||
|
|
bbc2059728 | ||
|
|
576058497a | ||
|
|
17e4ecaa8a | ||
|
|
6d338219ea | ||
|
|
0fed952c5c | ||
|
|
085c0c1dd6 | ||
|
|
0949091f78 | ||
|
|
f48fbbd4ad | ||
|
|
3818cd6141 | ||
|
|
8c8f22d388 | ||
|
|
38a2724963 | ||
|
|
a1f604a7a4 | ||
|
|
d9177febf7 | ||
|
|
96d16b5488 | ||
|
|
0d4e11bc31 | ||
|
|
f70082e7de | ||
|
|
47547fe694 | ||
|
|
e074229bbb | ||
|
|
2a163cbf2c | ||
|
|
7deda639bf | ||
|
|
f3f16b5c67 | ||
|
|
1a75433281 | ||
|
|
dde21f2e6e | ||
|
|
276fb4c24d | ||
|
|
91c2ce6e0a | ||
|
|
5217d07222 | ||
|
|
a16883d2f3 | ||
|
|
ff898b0783 | ||
|
|
bb1b502e9a | ||
|
|
1c1e46130a | ||
|
|
2939e03d59 | ||
|
|
fcfac697cf | ||
|
|
a5295be10e | ||
|
|
7a8e10c175 | ||
|
|
85ee42d5a3 | ||
|
|
a4486980a4 | ||
|
|
017927793a | ||
|
|
007f84a236 | ||
|
|
635017c993 | ||
|
|
e747b38d70 | ||
|
|
1a6ddfef40 | ||
|
|
0c818ac1df | ||
|
|
72b7b561e5 |
388 changed files with 19013 additions and 3436 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Latki patch-package musza miec LF, inaczej nie naloza sie na pliki z npm (autocrlf na Windowsie).
|
||||||
|
mobile/patches/*.patch text eol=lf
|
||||||
47
alembic/versions/20260813_0028_phash_backfill_queue.py
Normal file
47
alembic/versions/20260813_0028_phash_backfill_queue.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""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")
|
||||||
50
alembic/versions/20260818_0029_scenes_max_quality.py
Normal file
50
alembic/versions/20260818_0029_scenes_max_quality.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""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")
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""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")
|
||||||
55
alembic/versions/20260820_0031_duration_backfill_queue.py
Normal file
55
alembic/versions/20260820_0031_duration_backfill_queue.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""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")
|
||||||
51
alembic/versions/20260820_0032_origin_release_sort_idx.py
Normal file
51
alembic/versions/20260820_0032_origin_release_sort_idx.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""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")
|
||||||
48
alembic/versions/20260820_0033_effective_date_idx.py
Normal file
48
alembic/versions/20260820_0033_effective_date_idx.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""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")
|
||||||
34
alembic/versions/20260902_0034_favorite_sites.py
Normal file
34
alembic/versions/20260902_0034_favorite_sites.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""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")
|
||||||
35
alembic/versions/20260914_0035_scenes_duration_idx.py
Normal file
35
alembic/versions/20260914_0035_scenes_duration_idx.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""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")
|
||||||
70
alembic/versions/20260914_0036_clips.py
Normal file
70
alembic/versions/20260914_0036_clips.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""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")
|
||||||
86
alembic/versions/20260914_0037_clip_feed.py
Normal file
86
alembic/versions/20260914_0037_clip_feed.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""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")
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""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")
|
||||||
|
|
@ -30,7 +30,7 @@ router = APIRouter(
|
||||||
def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
|
def _raw_to_thumb(raw: dict, kind: SourceKind) -> str | None:
|
||||||
"""Wyciąga thumbnail URL z external_records.raw dla danego źródła.
|
"""Wyciąga thumbnail URL z external_records.raw dla danego źródła.
|
||||||
TPDB ma `image`/`poster`/`background.large`. StashDB raw nie zawiera image
|
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:
|
if kind == SourceKind.tpdb:
|
||||||
for k in ("image", "poster"):
|
for k in ("image", "poster"):
|
||||||
v = raw.get(k)
|
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"):
|
if isinstance(v, str) and v.startswith("http"):
|
||||||
return v
|
return v
|
||||||
elif kind == SourceKind.stashdb:
|
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.
|
# tego w raw obecnie. TODO: dorzucić mirror do `paths.screenshot` przy ingest.
|
||||||
paths = raw.get("paths")
|
paths = raw.get("paths")
|
||||||
if isinstance(paths, dict):
|
if isinstance(paths, dict):
|
||||||
|
|
@ -142,7 +142,7 @@ def list_candidates(
|
||||||
|
|
||||||
# Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue
|
# Pre-fetch po jednym statycznym i animowanym thumbnailu per scenę (mobile queue
|
||||||
# używa statycznego do listy + animowanego po hold-to-preview). Wybieramy najpierw
|
# 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.
|
# ale dla triage to wystarcza.
|
||||||
thumbs: dict[uuid.UUID, str] = {}
|
thumbs: dict[uuid.UUID, str] = {}
|
||||||
animated_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:
|
def bandwidth_stats() -> BandwidthStats:
|
||||||
"""Per-CDN VPS proxy bytes-out + Hetzner traffic stats.
|
"""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.
|
spotted Mixdrop / bandwidth-heavy CDN-y przed Hetzner overage charge.
|
||||||
"""
|
"""
|
||||||
from app.api.stream_proxy import get_bandwidth_stats
|
from app.api.stream_proxy import get_bandwidth_stats
|
||||||
|
|
@ -308,7 +308,7 @@ def bandwidth_stats() -> BandwidthStats:
|
||||||
s_24h = get_bandwidth_stats(24)
|
s_24h = get_bandwidth_stats(24)
|
||||||
s_7d = get_bandwidth_stats(168)
|
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
|
hetzner_data = None
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.hetzner_api_token and settings.hetzner_server_id:
|
if settings.hetzner_api_token and settings.hetzner_server_id:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""htmx + Jinja2 admin UI dla MergeCandidate triage.
|
"""htmx + Jinja2 admin UI dla MergeCandidate triage.
|
||||||
|
|
||||||
Endpointy:
|
Endpointy:
|
||||||
GET /ui/ — lista pending (filter status)
|
GET /ui/ lista pending (filter status)
|
||||||
GET /ui/candidate/{id} — side-by-side scen
|
GET /ui/candidate/{id} side-by-side scen
|
||||||
POST /ui/candidate/{id}/resolve — htmx form submit (action=merge_keep_left|merge_keep_right|reject)
|
POST /ui/candidate/{id}/resolve, htmx form submit (action=merge_keep_left|merge_keep_right|reject)
|
||||||
zwraca fragment HTML z potwierdzeniem
|
zwraca fragment HTML z potwierdzeniem
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -197,9 +197,9 @@ def resolve_form(
|
||||||
|
|
||||||
|
|
||||||
def mount_static(app) -> None: # pragma: no cover - dev convenience
|
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
|
# (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
|
import mimetypes
|
||||||
mimetypes.add_type("application/vnd.android.package-archive", ".apk")
|
mimetypes.add_type("application/vnd.android.package-archive", ".apk")
|
||||||
if _STATIC_DIR.exists():
|
if _STATIC_DIR.exists():
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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`.
|
performer scenes, tag scenes). Auto-apply w `app/api/scenes.py`.
|
||||||
|
|
||||||
Endpointy:
|
Endpointy:
|
||||||
GET /blacklist — wszystkie 3 listy w jednym response
|
GET /blacklist wszystkie 3 listy w jednym response
|
||||||
POST /blacklist/{kind}/{entity_id} — dodaj (idempotent)
|
POST /blacklist/{kind}/{entity_id} dodaj (idempotent)
|
||||||
DELETE /blacklist/{kind}/{entity_id} — usuń
|
DELETE /blacklist/{kind}/{entity_id} usuń
|
||||||
|
|
||||||
`kind` ∈ {performer, studio, tag}.
|
`kind` ∈ {performer, studio, tag}.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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).
|
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
|
- mały ekran scene-list: ~200-400KB
|
||||||
- duży scene-detail z thumbnail: ~600KB-1MB
|
- duży scene-detail z thumbnail: ~600KB-1MB
|
||||||
"""
|
"""
|
||||||
|
|
@ -92,7 +92,7 @@ def create_bug_report(
|
||||||
scene_id = payload.scene_id
|
scene_id = payload.scene_id
|
||||||
elif session.get(Movie, payload.scene_id) is not None:
|
elif session.get(Movie, payload.scene_id) is not None:
|
||||||
movie_id = payload.scene_id
|
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(
|
br = BugReport(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
|
@ -211,7 +211,7 @@ def get_bug_report_screenshot(
|
||||||
bug_id: uuid.UUID,
|
bug_id: uuid.UUID,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> dict[str, str | None]:
|
) -> 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)
|
br = session.get(BugReport, bug_id)
|
||||||
if br is None:
|
if br is None:
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
|
|
||||||
664
app/api/clips.py
Normal file
664
app/api/clips.py
Normal file
|
|
@ -0,0 +1,664 @@
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
|
|
@ -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`.
|
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
|
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`
|
współdzieloną pulę, dopóki nie zaktualizuje bundla. Endpoint `/me/adopt-legacy`
|
||||||
przepina LEGACY rows na konkretne device (Jan robi to raz po update).
|
przepina LEGACY rows na konkretne device (Jan robi to raz po update).
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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>/<update_id>/`. Plik
|
||||||
`app/static/expo-updates/<runtime>/current.json` wskazuje aktywny update_id.
|
`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
|
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.
|
manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -35,7 +35,7 @@ def get_manifest(
|
||||||
expo_platform: str | None = Header(default=None, alias="expo-platform"),
|
expo_platform: str | None = Header(default=None, alias="expo-platform"),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Zwraca aktualny manifest dla podanego `expo-runtime-version` (default 1.0)
|
"""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
|
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
|
embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest
|
||||||
|
|
@ -87,7 +87,7 @@ def get_asset(
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Serwuje pojedynczy asset (JS bundle, image, font) z update directory.
|
"""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
|
zwykle `<update_id>/_expo/static/js/android/<hash>.js` lub
|
||||||
`<update_id>/assets/<hash>`. Path traversal blocked przez resolve+is_relative.
|
`<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")
|
raise HTTPException(status_code=400, detail="invalid asset path")
|
||||||
if not target.exists() or not target.is_file():
|
if not target.exists() or not target.is_file():
|
||||||
raise HTTPException(status_code=404, detail="asset not found")
|
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
|
media_type = None
|
||||||
if target.suffix in (".js", ".bundle"):
|
if target.suffix in (".js", ".bundle"):
|
||||||
media_type = "application/javascript"
|
media_type = "application/javascript"
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
można dodać dorzuceniem `user_id` query/header bez breaking change.
|
||||||
|
|
||||||
Endpointy (performers — `/favorites/...` zostawione żeby nie łamać starego mobile):
|
Endpointy (performers, `/favorites/...` zostawione żeby nie łamać starego mobile):
|
||||||
GET /favorites — lista ulubionych performerek
|
GET /favorites lista ulubionych performerek
|
||||||
POST /favorites/{performer_id} — dodaj (idempotent)
|
POST /favorites/{performer_id} dodaj (idempotent)
|
||||||
DELETE /favorites/{performer_id} — usuń
|
DELETE /favorites/{performer_id} usuń
|
||||||
POST /favorites/{performer_id}/seen — mark-as-seen (zeruje badge)
|
POST /favorites/{performer_id}/seen mark-as-seen (zeruje badge)
|
||||||
|
|
||||||
Endpointy (studios):
|
Endpointy (studios):
|
||||||
GET /favorites/studios — lista ulubionych studiów
|
GET /favorites/studios lista ulubionych studiów
|
||||||
POST /favorites/studios/{studio_id} — dodaj
|
POST /favorites/studios/{studio_id} dodaj
|
||||||
DELETE /favorites/studios/{studio_id} — usuń
|
DELETE /favorites/studios/{studio_id} usuń
|
||||||
POST /favorites/studios/{studio_id}/seen — mark-as-seen
|
POST /favorites/studios/{studio_id}/seen, mark-as-seen
|
||||||
|
|
||||||
"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at:
|
"Nowa scena" = scena której Scene.created_at > favorite.last_seen_at:
|
||||||
- dla performerki: ScenePerformer.performer_id = X
|
- dla performerki: ScenePerformer.performer_id = X
|
||||||
|
|
@ -21,26 +21,32 @@ Endpointy (studios):
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import exists, select
|
from sqlalchemy import exists, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.device import get_device_id
|
from app.api.device import get_device_id
|
||||||
from app.auth import require_api_key
|
from app.auth import require_api_key
|
||||||
from app.db import get_session
|
from app.db import get_session, session_scope
|
||||||
from app.models.favorite_movie import FavoriteMovie
|
from app.models.favorite_movie import FavoriteMovie
|
||||||
from app.models.favorite_performer import FavoritePerformer
|
from app.models.favorite_performer import FavoritePerformer
|
||||||
|
from app.models.favorite_site import FavoriteSite
|
||||||
from app.models.favorite_studio import FavoriteStudio
|
from app.models.favorite_studio import FavoriteStudio
|
||||||
from app.models.movie import Movie
|
from app.models.movie import Movie
|
||||||
from app.models.performer import Performer
|
from app.models.performer import Performer
|
||||||
from app.models.scene import Scene, ScenePerformer
|
from app.models.scene import Scene, ScenePerformer
|
||||||
from app.models.studio import Studio
|
from app.models.studio import Studio
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)]
|
prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)]
|
||||||
)
|
)
|
||||||
|
|
@ -60,55 +66,71 @@ 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
|
"""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
|
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
|
playback + blacklist + stub) i to samo okno (top-_FAVORITES_PAGE_CAP pod sortem
|
||||||
release_date desc), z pominięciem backfillu. Liczy w SQL (count(*) FILTER), zwraca
|
`release_date` z list_scenes, czyli data premiery albo dzień dodania), z pominięciem
|
||||||
{group_id: n} — bez streamowania N*200 wierszy do Pythona.
|
backfillu. Liczy w SQL (count(*) FILTER), zwraca {group_id: n}.
|
||||||
|
|
||||||
kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers.
|
kind="performer": grupuje po ScenePerformer.performer_id, join favorite_performers.
|
||||||
kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują
|
kind="studio": grupuje po Scene.studio_id, join favorite_studios. Studia potrzebują
|
||||||
odsiewu stub (mogą nie mieć performera); performerki nie (mają).
|
odsiewu stub (mogą nie mieć performera); performerki nie (mają).
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import and_, func
|
from sqlalchemy import and_, func, true
|
||||||
|
|
||||||
from app.api.scenes import blacklist_clauses, live_playback_exists, stub_exclusion_clause
|
from app.api.scenes import (
|
||||||
|
_effective_date_desc,
|
||||||
|
blacklist_clauses,
|
||||||
|
live_playback_exists,
|
||||||
|
stub_exclusion_clause,
|
||||||
|
)
|
||||||
|
|
||||||
clauses = [live_playback_exists(), *blacklist_clauses(session, device_id)]
|
clauses = [live_playback_exists(), *blacklist_clauses(session, device_id)]
|
||||||
|
list_order = (_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc())
|
||||||
|
|
||||||
if kind == "performer":
|
if kind == "studio":
|
||||||
gid = ScenePerformer.performer_id
|
# Po kolei indeksem ix_scenes_studio_effective_date_desc (migracja 0038) i stop po
|
||||||
last_seen = FavoritePerformer.last_seen_at
|
# 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.
|
||||||
base = (
|
base = (
|
||||||
select(
|
select(
|
||||||
gid.label("gid"),
|
ScenePerformer.performer_id.label("gid"),
|
||||||
Scene.created_at.label("created_at"),
|
Scene.created_at.label("created_at"),
|
||||||
Scene.backfill.label("backfill"),
|
Scene.backfill.label("backfill"),
|
||||||
last_seen.label("last_seen"),
|
FavoritePerformer.last_seen_at.label("last_seen"),
|
||||||
)
|
)
|
||||||
.select_from(FavoritePerformer)
|
.select_from(FavoritePerformer)
|
||||||
.join(ScenePerformer, ScenePerformer.performer_id == FavoritePerformer.performer_id)
|
.join(ScenePerformer, ScenePerformer.performer_id == FavoritePerformer.performer_id)
|
||||||
.join(Scene, Scene.id == ScenePerformer.scene_id)
|
.join(Scene, Scene.id == ScenePerformer.scene_id)
|
||||||
.where(FavoritePerformer.device_id == device_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(
|
rn = func.row_number().over(
|
||||||
partition_by=partition,
|
partition_by=ScenePerformer.performer_id, order_by=list_order
|
||||||
order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()),
|
|
||||||
).label("rn")
|
).label("rn")
|
||||||
inner = base.add_columns(rn).where(*clauses).subquery()
|
inner = base.add_columns(rn).where(*clauses).subquery()
|
||||||
rows = session.execute(
|
rows = session.execute(
|
||||||
|
|
@ -134,7 +156,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
|
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
|
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
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
|
@ -185,6 +207,94 @@ def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
||||||
return counts
|
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):
|
class FavoriteOut(BaseModel):
|
||||||
performer_id: uuid.UUID
|
performer_id: uuid.UUID
|
||||||
canonical_name: str
|
canonical_name: str
|
||||||
|
|
@ -198,13 +308,17 @@ class FavoriteOut(BaseModel):
|
||||||
class FavoriteListOut(BaseModel):
|
class FavoriteListOut(BaseModel):
|
||||||
items: list[FavoriteOut]
|
items: list[FavoriteOut]
|
||||||
total: int
|
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)
|
@router.get("", response_model=FavoriteListOut)
|
||||||
def list_favorites(
|
def list_favorites(
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
device_id: Annotated[str, Depends(get_device_id)],
|
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:
|
) -> FavoriteListOut:
|
||||||
rows = session.execute(
|
rows = session.execute(
|
||||||
select(FavoritePerformer, Performer)
|
select(FavoritePerformer, Performer)
|
||||||
|
|
@ -216,10 +330,10 @@ def list_favorites(
|
||||||
return FavoriteListOut(items=[], total=0, new_total=0)
|
return FavoriteListOut(items=[], total=0, new_total=0)
|
||||||
|
|
||||||
# scene_count: czytamy zdenormalizowany Performer.scene_count (refresh w tle przez
|
# 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.
|
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
||||||
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
||||||
new_counts = _merged_new_counts(session, device_id, kind="performer")
|
new_counts = _cached_new_counts(device_id, kind="performer") if counts else {}
|
||||||
|
|
||||||
items: list[FavoriteOut] = []
|
items: list[FavoriteOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
|
|
@ -274,7 +388,7 @@ def remove_favorite(
|
||||||
) -> None:
|
) -> None:
|
||||||
fav = session.get(FavoritePerformer, (device_id, performer_id))
|
fav = session.get(FavoritePerformer, (device_id, performer_id))
|
||||||
if fav is None:
|
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
|
return
|
||||||
session.delete(fav)
|
session.delete(fav)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
@ -296,6 +410,7 @@ def mark_seen(
|
||||||
raise HTTPException(status_code=404, detail="not in favorites")
|
raise HTTPException(status_code=404, detail="not in favorites")
|
||||||
fav.last_seen_at = datetime.now(UTC)
|
fav.last_seen_at = datetime.now(UTC)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
_mark_counts_seen(device_id, "performer", performer_id)
|
||||||
return SeenOut(performer_id=performer_id, last_seen_at=fav.last_seen_at)
|
return SeenOut(performer_id=performer_id, last_seen_at=fav.last_seen_at)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -307,6 +422,10 @@ class FavoriteStudioOut(BaseModel):
|
||||||
slug: str
|
slug: str
|
||||||
network: str | None = None
|
network: str | None = None
|
||||||
scene_count: int
|
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
|
new_count: int
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
@ -322,6 +441,10 @@ class FavoriteStudioListOut(BaseModel):
|
||||||
def list_favorite_studios(
|
def list_favorite_studios(
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
device_id: Annotated[str, Depends(get_device_id)],
|
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:
|
) -> FavoriteStudioListOut:
|
||||||
rows = session.execute(
|
rows = session.execute(
|
||||||
select(FavoriteStudio, Studio)
|
select(FavoriteStudio, Studio)
|
||||||
|
|
@ -331,10 +454,51 @@ def list_favorite_studios(
|
||||||
).all()
|
).all()
|
||||||
if not rows:
|
if not rows:
|
||||||
return FavoriteStudioListOut(items=[], total=0, new_total=0)
|
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_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
|
||||||
scene_counts: dict = {st.id: st.scene_count for _, st in rows}
|
scene_counts: dict = {st.id: st.scene_count for _, st in rows}
|
||||||
new_counts = _merged_new_counts(session, device_id, kind="studio")
|
# 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")
|
||||||
|
|
||||||
items: list[FavoriteStudioOut] = []
|
items: list[FavoriteStudioOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
|
|
@ -348,6 +512,7 @@ def list_favorite_studios(
|
||||||
slug=st.slug,
|
slug=st.slug,
|
||||||
network=st.network,
|
network=st.network,
|
||||||
scene_count=scene_counts.get(st.id, 0),
|
scene_count=scene_counts.get(st.id, 0),
|
||||||
|
movie_count=movie_counts.get(st.id, 0),
|
||||||
new_count=nc,
|
new_count=nc,
|
||||||
last_seen_at=fav.last_seen_at,
|
last_seen_at=fav.last_seen_at,
|
||||||
created_at=fav.created_at,
|
created_at=fav.created_at,
|
||||||
|
|
@ -411,12 +576,77 @@ def mark_studio_seen(
|
||||||
raise HTTPException(status_code=404, detail="not in favorites")
|
raise HTTPException(status_code=404, detail="not in favorites")
|
||||||
fav.last_seen_at = datetime.now(UTC)
|
fav.last_seen_at = datetime.now(UTC)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
_mark_counts_seen(device_id, "studio", studio_id)
|
||||||
return SeenStudioOut(studio_id=studio_id, last_seen_at=fav.last_seen_at)
|
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 ────────────────────────────────────────────────────────
|
# ── Favorite movies ────────────────────────────────────────────────────────
|
||||||
# Movies nie mają child scenes per-favorite (jak performerki/studia), więc
|
# 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
|
# 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).
|
# globalny last_seen z AsyncStorage (client-side, brak backendowego state).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`.
|
Po migracji device-scopingu (0022) stare wiersze mają `device_id='legacy-shared'`.
|
||||||
`POST /me/adopt-legacy` przepina WSZYSTKIE legacy wiersze (favorites/progress/blacklisty)
|
`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
|
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
|
dla tej samej encji, legacy duplikat jest pomijany (ON CONFLICT DO NOTHING → potem
|
||||||
kasujemy resztki legacy).
|
kasujemy resztki legacy).
|
||||||
|
|
@ -22,7 +22,7 @@ from app.db import get_session
|
||||||
|
|
||||||
router = APIRouter(prefix="/me", tags=["me"], dependencies=[Depends(require_api_key)])
|
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]] = [
|
_TABLES: list[tuple[str, str]] = [
|
||||||
("favorite_performers", "performer_id"),
|
("favorite_performers", "performer_id"),
|
||||||
("favorite_studios", "studio_id"),
|
("favorite_studios", "studio_id"),
|
||||||
|
|
@ -33,6 +33,11 @@ _TABLES: list[tuple[str, str]] = [
|
||||||
("blacklisted_performers", "performer_id"),
|
("blacklisted_performers", "performer_id"),
|
||||||
("blacklisted_studios", "studio_id"),
|
("blacklisted_studios", "studio_id"),
|
||||||
("blacklisted_tags", "tag_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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,7 +53,10 @@ class ImportOut(BaseModel):
|
||||||
|
|
||||||
# Tabele do przeniesienia przy transferze między urządzeniami + kolumna-encji do dedupu.
|
# 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`.
|
# 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")]
|
_COPY_TABLES: list[tuple[str, str]] = _TABLES + [
|
||||||
|
("saved_searches", "query"),
|
||||||
|
("clip_blacklist", "value"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import/{source_device}", response_model=ImportOut)
|
@router.post("/import/{source_device}", response_model=ImportOut)
|
||||||
|
|
@ -59,7 +67,7 @@ def import_from_device(
|
||||||
) -> ImportOut:
|
) -> ImportOut:
|
||||||
"""Backup/transfer stanu (ulubione/blacklisty/progres/zapisane wyszukiwania) z innego
|
"""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
|
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
|
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()."""
|
urządzenie już ma. Idempotentne. `id`-PK (saved_searches) dostaje nowy gen_random_uuid()."""
|
||||||
|
|
@ -114,7 +122,7 @@ def adopt_legacy(
|
||||||
).bindparams(dev=device_id, legacy=LEGACY_DEVICE)
|
).bindparams(dev=device_id, legacy=LEGACY_DEVICE)
|
||||||
)
|
)
|
||||||
moved[table] = res.rowcount or 0
|
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(
|
session.execute(
|
||||||
text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE)
|
text(f"DELETE FROM {table} WHERE device_id = :legacy").bindparams(legacy=LEGACY_DEVICE)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""GET /movies — lista i szczegóły filmów."""
|
"""GET /movies, lista i szczegóły filmów."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
@ -132,14 +133,14 @@ def list_movies(
|
||||||
|
|
||||||
base = base.limit(per_page).offset((page - 1) * per_page)
|
base = base.limit(per_page).offset((page - 1) * per_page)
|
||||||
|
|
||||||
movies = session.execute(base).scalars().all()
|
movies = list(session.execute(base).scalars().all())
|
||||||
items = [_movie_to_out(session, m, device_id=device_id) for m in movies]
|
items = _movies_to_out(session, movies, device_id=device_id)
|
||||||
|
|
||||||
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
|
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
|
||||||
|
|
||||||
|
|
||||||
# Movie playback origin policy — module-level (kiedyś było inline per-request
|
# Movie playback origin policy, module-level (kiedyś było inline per-request
|
||||||
# definition, code-review #19 — perf hit + dorosły kod).
|
# definition, code-review #19, perf hit + dorosły kod).
|
||||||
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
|
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
|
||||||
# random per origin).
|
# random per origin).
|
||||||
_MOVIE_PREFERRED_ORIGINS = (
|
_MOVIE_PREFERRED_ORIGINS = (
|
||||||
|
|
@ -150,9 +151,9 @@ _MOVIE_PREFERRED_ORIGINS = (
|
||||||
"streamporn",
|
"streamporn",
|
||||||
"pandamovies",
|
"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
|
# 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.
|
# ~5% URLów żyje.
|
||||||
_MOVIE_DROP_ORIGINS = frozenset({
|
_MOVIE_DROP_ORIGINS = frozenset({
|
||||||
"mangoporn:rapidgator",
|
"mangoporn:rapidgator",
|
||||||
|
|
@ -163,7 +164,49 @@ _MOVIE_DROP_ORIGINS = frozenset({
|
||||||
_MOVIE_LANDING_HIDE = frozenset({"mangoporn", "pandamovies", "streamporn"})
|
_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:
|
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:
|
try:
|
||||||
return _MOVIE_PREFERRED_ORIGINS.index(origin)
|
return _MOVIE_PREFERRED_ORIGINS.index(origin)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -183,63 +226,108 @@ def get_movie(
|
||||||
|
|
||||||
|
|
||||||
def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEVICE) -> MovieOut:
|
def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEVICE) -> MovieOut:
|
||||||
studio_out: StudioOut | None = None
|
return _movies_to_out(session, [movie], device_id=device_id)[0]
|
||||||
if movie.studio_id:
|
|
||||||
studio = session.get(Studio, movie.studio_id)
|
|
||||||
if studio is not None:
|
|
||||||
studio_out = StudioOut.model_validate(studio)
|
|
||||||
|
|
||||||
performer_rows = session.execute(
|
|
||||||
select(Performer, MoviePerformer.as_alias)
|
def _movies_to_out(
|
||||||
.join(MoviePerformer, MoviePerformer.performer_id == Performer.id)
|
session: Session, movies: list[Movie], *, device_id: str = LEGACY_DEVICE
|
||||||
.where(MoviePerformer.movie_id == movie.id)
|
) -> 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))
|
||||||
.order_by(MoviePerformer.position.asc().nulls_last())
|
.order_by(MoviePerformer.position.asc().nulls_last())
|
||||||
).all()
|
).all():
|
||||||
performers = [
|
perf_by[mid].append((perf, alias))
|
||||||
PerformerOut(
|
|
||||||
id=p.id,
|
|
||||||
canonical_name=p.canonical_name,
|
|
||||||
slug=p.slug,
|
|
||||||
gender=p.gender.value if p.gender else None,
|
|
||||||
as_alias=alias,
|
|
||||||
)
|
|
||||||
for p, alias in performer_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
tag_rows = session.execute(
|
tags_by: dict = defaultdict(list)
|
||||||
select(Tag).join(MovieTag, MovieTag.tag_id == Tag.id)
|
for mid, tag in session.execute(
|
||||||
.where(MovieTag.movie_id == movie.id)
|
select(MovieTag.movie_id, Tag)
|
||||||
|
.join(Tag, MovieTag.tag_id == Tag.id)
|
||||||
|
.where(MovieTag.movie_id.in_(ids))
|
||||||
.order_by(Tag.name.asc())
|
.order_by(Tag.name.asc())
|
||||||
).scalars().all()
|
).all():
|
||||||
tags = [TagOut.model_validate(t) for t in tag_rows]
|
tags_by[mid].append(tag)
|
||||||
|
|
||||||
chapter_rows = session.execute(
|
chapters_by: dict = defaultdict(list)
|
||||||
select(MovieChapter).where(MovieChapter.movie_id == movie.id)
|
for ch in session.execute(
|
||||||
.order_by(MovieChapter.chapter_index.asc())
|
select(MovieChapter).where(MovieChapter.movie_id.in_(ids)).order_by(MovieChapter.chapter_index.asc())
|
||||||
).scalars().all()
|
).scalars():
|
||||||
chapters = [MovieChapterOut.model_validate(c) for c in chapter_rows]
|
chapters_by[ch.movie_id].append(ch)
|
||||||
|
|
||||||
ref_rows = session.execute(
|
refs_by: dict = defaultdict(list)
|
||||||
|
for ref, name in session.execute(
|
||||||
select(MovieExternalRef, Source.name)
|
select(MovieExternalRef, Source.name)
|
||||||
.join(Source, Source.id == MovieExternalRef.source_id)
|
.join(Source, Source.id == MovieExternalRef.source_id)
|
||||||
.where(MovieExternalRef.movie_id == movie.id)
|
.where(MovieExternalRef.movie_id.in_(ids))
|
||||||
).all()
|
).all():
|
||||||
external_refs = [
|
refs_by[ref.movie_id].append((ref, name))
|
||||||
ExternalRefOut(
|
|
||||||
source=name,
|
|
||||||
external_id=ref.external_id,
|
|
||||||
url=ref.url,
|
|
||||||
last_seen=ref.last_seen,
|
|
||||||
)
|
|
||||||
for ref, name in ref_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
pb_rows = session.execute(
|
pb_by: dict = defaultdict(list)
|
||||||
|
for pb in session.execute(
|
||||||
select(MoviePlaybackSource)
|
select(MoviePlaybackSource)
|
||||||
.where(MoviePlaybackSource.movie_id == movie.id)
|
.where(MoviePlaybackSource.movie_id.in_(ids))
|
||||||
.where(MoviePlaybackSource.dead_at.is_(None))
|
.where(MoviePlaybackSource.dead_at.is_(None))
|
||||||
.order_by(MoviePlaybackSource.created_at.desc())
|
.order_by(MoviePlaybackSource.created_at.desc())
|
||||||
).scalars().all()
|
).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
|
||||||
|
performers = [
|
||||||
|
PerformerOut(
|
||||||
|
id=perf.id,
|
||||||
|
canonical_name=perf.canonical_name,
|
||||||
|
slug=perf.slug,
|
||||||
|
gender=perf.gender.value if perf.gender else None,
|
||||||
|
as_alias=alias,
|
||||||
|
)
|
||||||
|
for perf, alias in perf_by.get(movie.id, [])
|
||||||
|
]
|
||||||
|
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, [])]
|
||||||
|
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, [])
|
||||||
|
]
|
||||||
|
pb_rows = list(pb_by.get(movie.id, []))
|
||||||
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_DROP_ORIGINS]
|
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`/
|
# Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/
|
||||||
# `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi
|
# `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi
|
||||||
|
|
@ -267,10 +355,9 @@ def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEV
|
||||||
pb_rows = deduped
|
pb_rows = deduped
|
||||||
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
|
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
|
||||||
|
|
||||||
is_fav = session.get(FavoriteMovie, (device_id, movie.id)) is not None
|
progress = progress_by.get(movie.id)
|
||||||
progress = session.get(MoviePlayProgress, (device_id, movie.id))
|
out.append(
|
||||||
|
MovieOut(
|
||||||
return MovieOut(
|
|
||||||
id=movie.id,
|
id=movie.id,
|
||||||
title=movie.title,
|
title=movie.title,
|
||||||
slug=movie.slug,
|
slug=movie.slug,
|
||||||
|
|
@ -290,8 +377,10 @@ def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEV
|
||||||
external_refs=external_refs,
|
external_refs=external_refs,
|
||||||
playback_sources=playback_sources,
|
playback_sources=playback_sources,
|
||||||
created_at=movie.created_at,
|
created_at=movie.created_at,
|
||||||
is_favorite=is_fav,
|
is_favorite=movie.id in favs,
|
||||||
last_played_at=progress.last_played_at if progress else None,
|
last_played_at=progress.last_played_at if progress else None,
|
||||||
finished=progress.finished if progress else False,
|
finished=progress.finished if progress else False,
|
||||||
position_sec=progress.position_sec if progress else 0,
|
position_sec=progress.position_sec if progress else 0,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
|
||||||
|
|
@ -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
|
stream URL (m3u8/mp4) z page tube'a i zwraca go. Mobile otwiera URL przez
|
||||||
Linking.openURL → Android player chooser (MX Player / VLC / browser).
|
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`.
|
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",
|
**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.
|
nie pokaże martwego buttonu.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -39,23 +39,23 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/scenes", tags=["playback"], dependencies=[Depends(require_api_key)])
|
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
|
# 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).
|
# bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc).
|
||||||
#
|
#
|
||||||
# Verified time-bound:
|
# Verified time-bound:
|
||||||
# - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding) — signed token w path + exp_time
|
# - 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
|
# - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube), validfrom+validto+hash
|
||||||
# - privatehost.com (pornhat CDN) — sign + exp_time, brak Referer requirement
|
# - privatehost.com (pornhat CDN), sign + exp_time, brak Referer requirement
|
||||||
# - sxyprn.com — signed path
|
# - sxyprn.com, signed path
|
||||||
# - eporner.com CDN — IP literal w path ale CDN go ignoruje
|
# - eporner.com CDN, IP literal w path ale CDN go ignoruje
|
||||||
#
|
#
|
||||||
# NIE w allowlist (IP-bound, wymagają proxy):
|
# NIE w allowlist (IP-bound, wymagają proxy):
|
||||||
# - premilkyway.com (latestpornvideo) — 403 cross-IP
|
# - premilkyway.com (latestpornvideo), 403 cross-IP
|
||||||
# - tnmr.org (mypornerleak) — 403 cross-IP
|
# - tnmr.org (mypornerleak), 403 cross-IP
|
||||||
# - porntrex.com/get_file — single-use token (410 po reuse)
|
# - porntrex.com/get_file, single-use token (410 po reuse)
|
||||||
# - freshporno.org/get_file — cv= signed token IP-bound
|
# - freshporno.org/get_file, cv= signed token IP-bound
|
||||||
# - sn.porn-xp.com, porn00.org — force_proxy explicit
|
# - sn.porn-xp.com, porn00.org, force_proxy explicit
|
||||||
_TIME_BOUND_CDN_RE = re.compile(
|
_TIME_BOUND_CDN_RE = re.compile(
|
||||||
r"\b(?:"
|
r"\b(?:"
|
||||||
r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream
|
r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn" # mainstream
|
||||||
|
|
@ -65,10 +65,42 @@ _TIME_BOUND_CDN_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# IP-BOUND CDN signature — token bind do requester IP, cross-IP fetch = 403.
|
# 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.
|
||||||
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
|
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
|
||||||
# Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak,
|
# 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(
|
_IP_BOUND_CDN_RE = re.compile(
|
||||||
r"\b(?:"
|
r"\b(?:"
|
||||||
r"premilkyway\.com" # latestpornvideo
|
r"premilkyway\.com" # latestpornvideo
|
||||||
|
|
@ -82,17 +114,17 @@ _IP_BOUND_CDN_RE = re.compile(
|
||||||
class StreamLink(BaseModel):
|
class StreamLink(BaseModel):
|
||||||
"""Pojedynczy variant stream URL (różne quality / kontener).
|
"""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
|
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.
|
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
|
`direct_url` + `headers` = surowy CDN URL z headers do bezpośredniego fetchu z
|
||||||
urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
|
urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
|
||||||
poprawnie content gdy mobile player wysyła `Referer` + `User-Agent` z `headers`.
|
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.
|
`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'.
|
otwiera w WebView. Type: 'hoster'.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -122,7 +154,7 @@ def resolve_movie_playback(
|
||||||
playback_id: uuid.UUID,
|
playback_id: uuid.UUID,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> ResolveOut:
|
) -> ResolveOut:
|
||||||
"""Movies playback resolve — analog do `/scenes/{id}/playback/{pb}/resolve`.
|
"""Movies playback resolve, analog do `/scenes/{id}/playback/{pb}/resolve`.
|
||||||
|
|
||||||
Origin patterns:
|
Origin patterns:
|
||||||
- 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc
|
- 'paradisehill' → tylko page_url (Yii2 player wymaga login session, więc
|
||||||
|
|
@ -150,7 +182,7 @@ def resolve_movie_playback(
|
||||||
if pb.origin == "paradisehill":
|
if pb.origin == "paradisehill":
|
||||||
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
|
# Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
|
||||||
# Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane
|
# 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").
|
# Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N").
|
||||||
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
|
# Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
|
||||||
# nie ma sposobu przejść do następnego.
|
# nie ma sposobu przejść do następnego.
|
||||||
|
|
@ -162,7 +194,7 @@ def resolve_movie_playback(
|
||||||
parts = []
|
parts = []
|
||||||
if parts:
|
if parts:
|
||||||
for url, label in 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).
|
# wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL).
|
||||||
links.append(
|
links.append(
|
||||||
StreamLink(
|
StreamLink(
|
||||||
|
|
@ -174,7 +206,7 @@ def resolve_movie_playback(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: brak videoList (np. login-only movie) — WebView na całość.
|
# Fallback: brak videoList (np. login-only movie), WebView na całość.
|
||||||
links = [
|
links = [
|
||||||
StreamLink(
|
StreamLink(
|
||||||
stream_url=None,
|
stream_url=None,
|
||||||
|
|
@ -191,7 +223,7 @@ def resolve_movie_playback(
|
||||||
try:
|
try:
|
||||||
stream = extract_stream_from_hoster(target, referer=referer)
|
stream = extract_stream_from_hoster(target, referer=referer)
|
||||||
except HosterDead as e:
|
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
|
# embed fallback (mobile ExoPlayer dostałby 404 HTML page i
|
||||||
# próbowałby zapisać jako .bin file; bug-report 2026-05-16
|
# próbowałby zapisać jako .bin file; bug-report 2026-05-16
|
||||||
# "streamtape ściąga hurtowo pliki .bin").
|
# "streamtape ściąga hurtowo pliki .bin").
|
||||||
|
|
@ -203,7 +235,7 @@ def resolve_movie_playback(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("movie hoster extract failed for %s: %s", target, e)
|
log.warning("movie hoster extract failed for %s: %s", target, e)
|
||||||
if stream and _IP_BOUND_CDN_RE.search(stream):
|
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).
|
# mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView).
|
||||||
log.info(
|
log.info(
|
||||||
"movie playback %s: stream URL IP-bound CDN — skip, WebView fallback",
|
"movie playback %s: stream URL IP-bound CDN — skip, WebView fallback",
|
||||||
|
|
@ -225,11 +257,11 @@ def resolve_movie_playback(
|
||||||
type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4"
|
type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4"
|
||||||
raw_meta: dict = {"origin": pb.origin, "host": target}
|
raw_meta: dict = {"origin": pb.origin, "host": target}
|
||||||
# seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/
|
# 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`).
|
# (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):
|
# Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
|
||||||
# manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest
|
# 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
|
# 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).
|
# 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łąź
|
# Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź
|
||||||
|
|
@ -246,8 +278,23 @@ def resolve_movie_playback(
|
||||||
raw=raw_meta,
|
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:
|
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(
|
links.append(
|
||||||
StreamLink(
|
StreamLink(
|
||||||
stream_url=None,
|
stream_url=None,
|
||||||
|
|
@ -297,7 +344,7 @@ def mark_playback_dead(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> None:
|
) -> 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
|
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
|
przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead
|
||||||
|
|
@ -362,7 +409,7 @@ def resolve_playback(
|
||||||
|
|
||||||
sitetag: str | None = None
|
sitetag: str | None = None
|
||||||
if pb.origin.startswith("pornapp:"):
|
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]
|
sitetag = pb.origin.split(":", 1)[1]
|
||||||
elif pb.origin.startswith("tube:"):
|
elif pb.origin.startswith("tube:"):
|
||||||
sitetag = pb.origin.split(":", 1)[1]
|
sitetag = pb.origin.split(":", 1)[1]
|
||||||
|
|
@ -382,7 +429,7 @@ def resolve_playback(
|
||||||
log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e)
|
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
|
raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e
|
||||||
except TubePageError as 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):
|
if e.status_code in (404, 410):
|
||||||
reason = f"tube page {e.status_code} {pb.page_url}"
|
reason = f"tube page {e.status_code} {pb.page_url}"
|
||||||
pb.dead_at = datetime.now(UTC)
|
pb.dead_at = datetime.now(UTC)
|
||||||
|
|
@ -397,7 +444,7 @@ def resolve_playback(
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
if not sources:
|
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`
|
# ad-network response zmieniony, race condition). NIE oznaczamy `dead_at`
|
||||||
# bo wcześniej powodowało false-positive permanent dead dla freshporno scen
|
# 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).
|
# które działały przy następnym attempt (bug-report 2026-05-12).
|
||||||
|
|
@ -410,9 +457,9 @@ def resolve_playback(
|
||||||
"extractor None for playback %s (origin=%s) — transient, not marking dead",
|
"extractor None for playback %s (origin=%s) — transient, not marking dead",
|
||||||
pb.id, pb.origin,
|
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.
|
# 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).
|
# bez tego GOON-3 spam-floodował issue list (16 events/5h dla expected case).
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
|
|
@ -446,7 +493,7 @@ DEFAULT_PLAYER_UA = (
|
||||||
def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
||||||
"""Wzbogaca StreamLink o:
|
"""Wzbogaca StreamLink o:
|
||||||
- `stream_url`: proxy URL (fallback gdy direct fails)
|
- `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
|
- `headers`: Referer + User-Agent dla direct fetch
|
||||||
Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410.
|
Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410.
|
||||||
"""
|
"""
|
||||||
|
|
@ -462,21 +509,21 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
||||||
use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate"))
|
use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate"))
|
||||||
# force_proxy=True (extractor flag) → direct_url=proxied od razu. Dla CDN-ów
|
# 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
|
# 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).
|
# Bez tego: każdy playback "mrugnie" (direct fail → fallback na proxy).
|
||||||
force_proxy = bool(link.raw and link.raw.get("force_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
|
# 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
|
# CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera
|
||||||
# manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
|
# manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
|
||||||
mobile_direct_ok = bool(link.raw and link.raw.get("mobile_direct_ok"))
|
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/
|
# Critical dla public release: wszystkie mainstream tubes (xvideos/xnxx/pornhub/
|
||||||
# youporn/redtube + pornhat) zwracają time-bound URLs które działają cross-IP.
|
# 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):
|
if not mobile_direct_ok and raw_url and _TIME_BOUND_CDN_RE.search(raw_url):
|
||||||
mobile_direct_ok = True
|
mobile_direct_ok = True
|
||||||
refetch_url = (link.raw or {}).get("refetch_url")
|
refetch_url = (link.raw or {}).get("refetch_url")
|
||||||
refetch_hoster = (link.raw or {}).get("refetch_hoster")
|
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.
|
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
|
||||||
manifest_producer = (link.raw or {}).get("manifest_producer")
|
manifest_producer = (link.raw or {}).get("manifest_producer")
|
||||||
token = make_token(
|
token = make_token(
|
||||||
|
|
@ -498,15 +545,15 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
||||||
else:
|
else:
|
||||||
ext = "mp4"
|
ext = "mp4"
|
||||||
proxied = f"/proxy/{token}/play.{ext}"
|
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
|
# 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
|
# 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
|
# → 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
|
# 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).
|
# żeby manifest+segments były spójne i ExoPlayer wybrał HlsMediaSource).
|
||||||
# Dla CDNs które wymagają chrome JA3 (mxcontent) direct_url też zawsze przez
|
# 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.
|
# → extra round-trip + ExoPlayer "no extractors" przed retry.
|
||||||
# mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token
|
# 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.
|
# (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -22,12 +22,12 @@ router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[
|
||||||
|
|
||||||
|
|
||||||
class PlaybackEventIn(BaseModel):
|
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)
|
origin: str = Field(min_length=1, max_length=64)
|
||||||
status: Literal["success", "error"]
|
status: Literal["success", "error"]
|
||||||
scene_id: uuid.UUID | None = None
|
scene_id: uuid.UUID | None = None
|
||||||
error_kind: str | None = Field(default=None, max_length=64)
|
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).
|
# padów których nie da się odtworzyć na emulatorze (np. watchporn player_error).
|
||||||
error_detail: str | None = Field(default=None, max_length=512)
|
error_detail: str | None = Field(default=None, max_length=512)
|
||||||
ttff_ms: int | None = Field(default=None, ge=0, le=600_000)
|
ttff_ms: int | None = Field(default=None, ge=0, le=600_000)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
|
||||||
|
|
||||||
Endpointy:
|
Endpointy:
|
||||||
GET /saved-searches — lista zapisów tego urządzenia (najnowsze pierwsze)
|
GET /saved-searches lista zapisów tego urządzenia (najnowsze pierwsze)
|
||||||
POST /saved-searches — dodaj {query} (idempotent na device+query)
|
POST /saved-searches dodaj {query} (idempotent na device+query)
|
||||||
DELETE /saved-searches/{id} — usuń
|
DELETE /saved-searches/{id} usuń
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -17,6 +17,7 @@ from typing import Annotated
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.device import get_device_id
|
from app.api.device import get_device_id
|
||||||
|
|
@ -30,7 +31,7 @@ router = APIRouter(
|
||||||
dependencies=[Depends(require_api_key)],
|
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
|
MAX_PER_DEVICE = 50
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,7 +72,7 @@ def add_saved_search(
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
device_id: Annotated[str, Depends(get_device_id)],
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
) -> SavedSearch:
|
) -> SavedSearch:
|
||||||
# Idempotent — ten sam (device, query) zwraca istniejący wiersz.
|
# Idempotent, ten sam (device, query) zwraca istniejący wiersz.
|
||||||
existing = session.execute(
|
existing = session.execute(
|
||||||
select(SavedSearch).where(
|
select(SavedSearch).where(
|
||||||
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
|
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
|
||||||
|
|
@ -89,7 +90,23 @@ def add_saved_search(
|
||||||
)
|
)
|
||||||
row = SavedSearch(device_id=device_id, query=payload.query)
|
row = SavedSearch(device_id=device_id, query=payload.query)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
|
try:
|
||||||
session.commit()
|
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)
|
session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
Endpointy:
|
||||||
GET /scene-favorites — lista ulubionych scen (pełen SceneOut)
|
GET /scene-favorites lista ulubionych scen (pełen SceneOut)
|
||||||
POST /scene-favorites/{scene_id} — dodaj (idempotent)
|
POST /scene-favorites/{scene_id} dodaj (idempotent)
|
||||||
DELETE /scene-favorites/{scene_id} — usuń
|
DELETE /scene-favorites/{scene_id} usuń
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.device import get_device_id
|
from app.api.device import get_device_id
|
||||||
from app.api.scenes import _build_scene_out
|
from app.api.scenes import _build_scenes_out_batch
|
||||||
from app.api.schemas import SceneOut
|
from app.api.schemas import SceneOut
|
||||||
from app.auth import require_api_key
|
from app.auth import require_api_key
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
|
@ -54,7 +54,11 @@ def list_scene_favorites(
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
items = [_build_scene_out(session, scene, device_id=device_id) for scene, _ in rows]
|
# 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
|
||||||
|
)
|
||||||
return SceneFavoriteListOut(items=items, total=len(items))
|
return SceneFavoriteListOut(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
"""GET /scenes — lista i szczegóły scen z bazy kanonicznej."""
|
"""GET /scenes, lista i szczegóły scen z bazy kanonicznej."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import distinct, exists, false, func, literal_column, select
|
from sqlalchemy import Date, and_, distinct, exists, false, func, literal_column, select, union
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@ from app.models.favorite_scene import FavoriteScene
|
||||||
from app.models.performer import Performer, PerformerExternalRef
|
from app.models.performer import Performer, PerformerExternalRef
|
||||||
from app.models.play_progress import ScenePlayProgress
|
from app.models.play_progress import ScenePlayProgress
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
|
from app import verticals
|
||||||
from app.models.scene import Scene, SceneExternalRef, ScenePerformer, SceneTag
|
from app.models.scene import Scene, SceneExternalRef, ScenePerformer, SceneTag
|
||||||
from app.models.source import Source, SourceKind
|
from app.models.source import Source, SourceKind
|
||||||
from app.models.studio import Studio
|
from app.models.studio import Studio
|
||||||
|
|
@ -39,7 +41,29 @@ log = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)])
|
router = APIRouter(prefix="/scenes", tags=["scenes"], dependencies=[Depends(require_api_key)])
|
||||||
|
|
||||||
|
|
||||||
_VALID_SORTS = {"created_at", "release_date", "title", "studio"}
|
_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()
|
||||||
|
|
||||||
# TTL-cache dla count'u scen-z-żywym-playback (default lista bez filtra). Full-scan
|
# 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
|
# 1.69M scen + EXISTS ~950ms; liczba zmienia się wolno i jest przybliżona (header
|
||||||
|
|
@ -60,8 +84,8 @@ def _default_scene_count(session: Session) -> int:
|
||||||
PlaybackSource.dead_at.is_(None),
|
PlaybackSource.dead_at.is_(None),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
# Domyślny feed wyklucza JAV (osobna sekcja) → licznik też.
|
# Domyślny feed wyklucza wertykały (JAV, animacja) → licznik też.
|
||||||
~_jav_source_exists(),
|
~_vertical_source_exists(),
|
||||||
).subquery()
|
).subquery()
|
||||||
)
|
)
|
||||||
total = session.execute(count_query).scalar_one()
|
total = session.execute(count_query).scalar_one()
|
||||||
|
|
@ -70,30 +94,36 @@ def _default_scene_count(session: Session) -> int:
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
# JAV vertical — osobna sekcja. Sceny z tych originów NIE wchodzą do głównego feedu
|
# Wertykały (JAV, animacja) mają własne sekcje w apce i NIE wchodzą do głównego feedu.
|
||||||
# (domyślnie wykluczone), tylko do zakładki JAV (?jav=true). JAV to osobny namespace
|
# Definicje i uzasadnienie: `app/verticals.py`. Tu zostaje tylko budowa predykatu.
|
||||||
# (kody typu BKD-368, azjatyckie tytuły), nie deduplikuje się z zachodnim katalogiem,
|
# `JAV_ORIGINS` re-eksportowane, bo importują je inne moduły i skrypty.
|
||||||
# więc scena JAV ma WYŁĄCZNIE origin JAV → wykluczenie/inkluzja po tym originie jest pewna.
|
JAV_ORIGINS = verticals.JAV_ORIGINS
|
||||||
JAV_ORIGINS = ("tube:javflix", "tube:javguru", "tube:vjav", "tube:supjav")
|
|
||||||
|
|
||||||
|
|
||||||
def _jav_source_exists():
|
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."""
|
||||||
return exists(
|
return exists(
|
||||||
select(1).where(
|
select(1).where(
|
||||||
PlaybackSource.scene_id == Scene.id,
|
PlaybackSource.scene_id == Scene.id,
|
||||||
PlaybackSource.dead_at.is_(None),
|
PlaybackSource.dead_at.is_(None),
|
||||||
PlaybackSource.origin.in_(JAV_ORIGINS),
|
PlaybackSource.origin.in_(verticals.origins_for(vertical)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
# 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
|
# 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).
|
# 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
|
# 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.
|
# 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_CACHE: dict[str, tuple[float, bool]] = {}
|
||||||
_BLACKLIST_EMPTY_TTL = 300.0
|
_BLACKLIST_EMPTY_TTL = 300.0
|
||||||
|
|
||||||
|
|
@ -241,6 +271,14 @@ def list_scenes(
|
||||||
"z jedynym playback z hqporner (~7-min Brazzers trailer clipy zalewają katalog)."
|
"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(
|
jav: bool = Query(
|
||||||
default=False,
|
default=False,
|
||||||
description=(
|
description=(
|
||||||
|
|
@ -248,7 +286,15 @@ def list_scenes(
|
||||||
"JAV to osobna sekcja w apce, nie zalewa głównego feedu (origin javflix/javguru/vjav/supjav)."
|
"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"),
|
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.",
|
||||||
|
),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
per_page: int = Query(default=50, ge=1, le=200),
|
per_page: int = Query(default=50, ge=1, le=200),
|
||||||
) -> SceneListOut:
|
) -> SceneListOut:
|
||||||
|
|
@ -283,7 +329,7 @@ def list_scenes(
|
||||||
if len(tag_slug_list) > _MAX_TAGS:
|
if len(tag_slug_list) > _MAX_TAGS:
|
||||||
raise HTTPException(status_code=422, detail=f"too many tag filters (max {_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
|
# 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
|
# 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
|
# tag_id (NIE JOIN po Tag.slug). Z literałem planner zna kardynalność tagu ze
|
||||||
|
|
@ -337,32 +383,49 @@ def list_scenes(
|
||||||
base = base.where(~live_playback_exists())
|
base = base.where(~live_playback_exists())
|
||||||
|
|
||||||
if origin:
|
if origin:
|
||||||
# Substring match na origin — 'hqporner' złapie 'tube:hqpornercom'.
|
# 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}%")
|
||||||
|
)
|
||||||
base = base.where(
|
base = base.where(
|
||||||
exists(
|
exists(
|
||||||
select(1).where(
|
select(1).where(
|
||||||
PlaybackSource.scene_id == Scene.id,
|
PlaybackSource.scene_id == Scene.id,
|
||||||
PlaybackSource.dead_at.is_(None),
|
PlaybackSource.dead_at.is_(None),
|
||||||
PlaybackSource.origin.ilike(f"%{origin}%"),
|
origin_clause,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# JAV vertical gate: domyślnie JAV wykluczone z głównego feedu; ?jav=true → TYLKO JAV.
|
# Brama wertykałów: domyślnie WSZYSTKIE wykluczone z głównego feedu, `?vertical=X`
|
||||||
# ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/vjav/supjav → SiteScenes),
|
# → TYLKO ten wertykał. ALE gdy user browse'uje KONKRETNY origin (Sites → javflix/
|
||||||
# bramy nie stosujemy — jawnie chce ten origin, więc pokazujemy (regresja "No videos"
|
# vjav/hentaigasm → SiteScenes), bramy nie stosujemy, bo jawnie chce ten origin
|
||||||
# na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko głównego feedu (origin=None).
|
# (regresja "No videos" na stronach JAV, bug-report 2026-07-19). Brama dotyczy tylko
|
||||||
if jav:
|
# głównego feedu (origin=None).
|
||||||
base = base.where(_jav_source_exists())
|
wanted = vertical if vertical in verticals.VERTICALS else ("jav" if jav else None)
|
||||||
|
if wanted:
|
||||||
|
base = base.where(_vertical_source_exists(wanted))
|
||||||
elif origin is None:
|
elif origin is None:
|
||||||
base = base.where(~_jav_source_exists())
|
base = base.where(~_vertical_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).
|
# licznikiem +N ulubionych. Puste blacklisty → [] (typowy single-user, zero kosztu).
|
||||||
for _bl_clause in blacklist_clauses(session, device_id):
|
for _bl_clause in blacklist_clauses(session, device_id):
|
||||||
base = base.where(_bl_clause)
|
base = base.where(_bl_clause)
|
||||||
|
|
||||||
if min_duration_sec is not None:
|
# `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:
|
||||||
base = base.where(Scene.duration_sec >= min_duration_sec)
|
base = base.where(Scene.duration_sec >= min_duration_sec)
|
||||||
if max_duration_sec is not None:
|
if max_duration_sec is not None:
|
||||||
base = base.where(Scene.duration_sec <= max_duration_sec)
|
base = base.where(Scene.duration_sec <= max_duration_sec)
|
||||||
|
|
@ -373,28 +436,14 @@ def list_scenes(
|
||||||
base = base.where(Scene.release_date >= cutoff)
|
base = base.where(Scene.release_date >= cutoff)
|
||||||
|
|
||||||
if min_quality_p is not None:
|
if min_quality_p is not None:
|
||||||
# PlaybackSource.quality to wolny string — szukamy liczb w prefixie ('1080p',
|
# Skalarny filtr po zdenormalizowanej kolumnie. Wcześniejsze podejścia (EXISTS
|
||||||
# '1080p Full HD', '2160p'). Heurystyka: wystarczy że scena ma JEDEN żywy
|
# na playback_sources, potem JOIN z DISTINCT) dawały 32-52 s na produkcji, bo
|
||||||
# playback z quality liczbą >= min. '4K'/'UHD' aliasujemy na 2160.
|
# planer i tak przechodził `scenes` po `created_at` scena po scenie. Pomiary
|
||||||
from sqlalchemy import Integer, cast, or_
|
# i uzasadnienie: migracja 0029_scenes_max_quality.
|
||||||
numeric_q = cast(
|
#
|
||||||
func.coalesce(func.substring(PlaybackSource.quality, r"\d+"), "0"),
|
# NULL (93,5% katalogu) NIE przechodzi, i tak ma być: filtr obiecuje jakość
|
||||||
Integer,
|
# potwierdzoną, a nie zgadywaną. UI mówi o tym wprost pod przełącznikiem.
|
||||||
)
|
base = base.where(Scene.max_quality_p >= min_quality_p)
|
||||||
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:
|
if not include_stubs:
|
||||||
# Stub scene heuristic (współdzielona z licznikiem +N ulubionych): tube-only scena
|
# Stub scene heuristic (współdzielona z licznikiem +N ulubionych): tube-only scena
|
||||||
|
|
@ -406,9 +455,9 @@ def list_scenes(
|
||||||
_is_pure_default = (
|
_is_pure_default = (
|
||||||
not include_stubs and not q and not studio_slug_list and not tag_slug_list
|
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 perf_id_strings and origin is None and has_playback is None
|
||||||
and min_duration_sec is None
|
and not min_duration_sec
|
||||||
and max_duration_sec is None and released_within_days is None
|
and max_duration_sec is None and released_within_days is None
|
||||||
and min_quality_p is None and not jav
|
and min_quality_p is None and not jav and vertical is None
|
||||||
)
|
)
|
||||||
# Count strategy:
|
# Count strategy:
|
||||||
# - PURE default: cached pełny licznik katalogu (TTL 10 min).
|
# - PURE default: cached pełny licznik katalogu (TTL 10 min).
|
||||||
|
|
@ -416,45 +465,88 @@ def list_scenes(
|
||||||
# dominującym kosztem (~4s na has_playback / min_duration / duży tag) i plan
|
# 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,
|
# 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
|
# 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
|
# "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.
|
# + flaga "jest więcej". Eliminuje cały koszt count z każdej filtrowanej listy.
|
||||||
total_capped = False
|
total_capped = False
|
||||||
total: int | None = _default_scene_count(session) if _is_pure_default else None
|
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
|
# 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,
|
# 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
|
# 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.
|
# (report 304c9258). created_at desc dalej pierwszy, więc index-walk zostaje.
|
||||||
if sort == "release_date":
|
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(
|
ordered = base.order_by(
|
||||||
Scene.release_date.desc().nullslast(), Scene.created_at.desc(), Scene.id.desc()
|
_effective_date_desc(), Scene.created_at.desc(), Scene.id.desc()
|
||||||
)
|
)
|
||||||
elif sort == "title":
|
elif sort == "title":
|
||||||
ordered = base.order_by(
|
ordered = base.order_by(
|
||||||
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
|
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
|
||||||
)
|
)
|
||||||
elif sort == "studio":
|
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 = (
|
ordered = (
|
||||||
base.outerjoin(Studio, Studio.id == Scene.studio_id)
|
base.outerjoin(Studio, Studio.id == Scene.studio_id)
|
||||||
.order_by(
|
.order_by(
|
||||||
Studio.name_normalized.asc().nullslast(),
|
Studio.name_normalized.asc().nullslast(),
|
||||||
Scene.release_date.desc().nullslast(),
|
_effective_date_desc(),
|
||||||
Scene.created_at.desc(),
|
Scene.created_at.desc(),
|
||||||
Scene.id.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
|
else: # created_at
|
||||||
ordered = base.order_by(
|
ordered = base.order_by(
|
||||||
Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc()
|
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
|
# To źródło prawdy dla paginacji (mobile getNextPageParam), niezależne od bounded
|
||||||
# `total`. Nadmiarowy wiersz odcinamy przed serializacją.
|
# `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)
|
# 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
|
# 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.
|
# walidowane inty (Query ge=1, le=200), więc literal_column jest bezpieczne.
|
||||||
|
|
@ -475,7 +567,9 @@ def list_scenes(
|
||||||
total = (page - 1) * per_page + len(rows)
|
total = (page - 1) * per_page + len(rows)
|
||||||
total_capped = has_more
|
total_capped = has_more
|
||||||
|
|
||||||
items = _build_scenes_out_batch(session, list(rows), light=True, device_id=device_id)
|
items = _build_scenes_out_batch(
|
||||||
|
session, list(rows), light=True, device_id=device_id, prefer_origin=origin
|
||||||
|
)
|
||||||
|
|
||||||
return SceneListOut(
|
return SceneListOut(
|
||||||
items=items,
|
items=items,
|
||||||
|
|
@ -506,14 +600,14 @@ def similar_scenes(
|
||||||
device_id: Annotated[str, Depends(get_device_id)],
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
limit: int = Query(default=12, ge=1, le=30),
|
limit: int = Query(default=12, ge=1, le=30),
|
||||||
) -> SceneListOut:
|
) -> 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
|
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.
|
(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
|
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
|
_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
|
_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."""
|
wierszy. Cały ranking i sort robimy w Pythonie nad OGRANICZONYMI zbiorami id."""
|
||||||
scene = session.get(Scene, scene_id)
|
scene = session.get(Scene, scene_id)
|
||||||
if scene is None:
|
if scene is None:
|
||||||
|
|
@ -527,7 +621,7 @@ def similar_scenes(
|
||||||
select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id)
|
select(ScenePerformer.performer_id).where(ScenePerformer.scene_id == scene_id)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
# Odsiej ŚMIECIOWYCH performerów: kategorie/studia podszywające się pod performera
|
# 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).
|
# 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
|
# 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.
|
# ref → zostają. Bez tego similar klastrował się po "Ball Licking" zamiast po treści.
|
||||||
|
|
@ -584,15 +678,38 @@ def similar_scenes(
|
||||||
.group_by(SceneTag.scene_id)
|
.group_by(SceneTag.scene_id)
|
||||||
).all()
|
).all()
|
||||||
tag_shared = {r[0]: r[1] for r in rows}
|
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.
|
# bo szeroki tag skanuje setki tysięcy scen.
|
||||||
if tag_ids and len(cand_ids) < limit * 4:
|
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(
|
rows = session.execute(
|
||||||
select(SceneTag.scene_id, func.count(distinct(SceneTag.tag_id)).label("c"))
|
select(pool.c.scene_id, shared.label("c"))
|
||||||
.where(SceneTag.tag_id.in_(tag_ids), SceneTag.scene_id != scene_id)
|
.join(
|
||||||
.group_by(SceneTag.scene_id)
|
SceneTag,
|
||||||
.having(func.count(distinct(SceneTag.tag_id)) >= 2)
|
and_(SceneTag.scene_id == pool.c.scene_id, SceneTag.tag_id.in_(tag_ids)),
|
||||||
.order_by(func.count(distinct(SceneTag.tag_id)).desc())
|
)
|
||||||
|
.group_by(pool.c.scene_id)
|
||||||
|
.having(shared >= 2)
|
||||||
|
.order_by(shared.desc())
|
||||||
.limit(_TAG_FILL_CAP)
|
.limit(_TAG_FILL_CAP)
|
||||||
).all()
|
).all()
|
||||||
for sid, c in rows:
|
for sid, c in rows:
|
||||||
|
|
@ -602,19 +719,24 @@ def similar_scenes(
|
||||||
if not cand_ids:
|
if not cand_ids:
|
||||||
return _empty
|
return _empty
|
||||||
|
|
||||||
# Filtr widoczności (ta sama definicja co lista): grywalne + zgodne z bramą JAV +
|
# Filtr widoczności (ta sama definicja co lista): grywalne + ten sam wertykał +
|
||||||
# blacklist + nie-stub + nie-self.
|
# blacklist + nie-stub + nie-self.
|
||||||
target_is_jav = bool(
|
# Podobne muszą zostać w tym samym wertykale co scena-cel: dla sceny animowanej
|
||||||
session.execute(
|
# nie proponujemy zachodnich i odwrotnie (inne namespace'y, zero dedupu).
|
||||||
select(_jav_source_exists()).select_from(Scene).where(Scene.id == scene_id)
|
target_vertical: str | None = None
|
||||||
).scalar()
|
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
|
||||||
filt = select(Scene.id).where(
|
filt = select(Scene.id).where(
|
||||||
Scene.id.in_(cand_ids),
|
Scene.id.in_(cand_ids),
|
||||||
Scene.id != scene_id,
|
Scene.id != scene_id,
|
||||||
live_playback_exists(),
|
live_playback_exists(),
|
||||||
stub_exclusion_clause(),
|
stub_exclusion_clause(),
|
||||||
_jav_source_exists() if target_is_jav else ~_jav_source_exists(),
|
_vertical_source_exists(target_vertical) if target_vertical
|
||||||
|
else ~_vertical_source_exists(),
|
||||||
)
|
)
|
||||||
for _bl in blacklist_clauses(session, device_id):
|
for _bl in blacklist_clauses(session, device_id):
|
||||||
filt = filt.where(_bl)
|
filt = filt.where(_bl)
|
||||||
|
|
@ -642,7 +764,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:
|
def _sxyprn_thumb_url(page_url: str | None) -> str | None:
|
||||||
"""Dla źródła sxyprn zwraca STABILNY endpoint on-demand resolvera
|
"""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)."""
|
więc poster resolvujemy przy serwowaniu (bug 2026-06-10)."""
|
||||||
if not page_url:
|
if not page_url:
|
||||||
return None
|
return None
|
||||||
|
|
@ -655,7 +777,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:
|
def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
|
||||||
"""Dla youporn z PODPISANYM posterem (imgproxy `?hash=...&validto=`) zwraca stabilny
|
"""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`
|
oddaje 410 (bug-report bebf8c92; 8666 z 62930 źródeł). Statycznych `.../original/N.jpg`
|
||||||
NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy)."""
|
NIE ruszamy (działają bezterminowo, zero dodatkowych fetchy)."""
|
||||||
if not page_url or not thumb or "hash=" not in thumb:
|
if not page_url or not thumb or "hash=" not in thumb:
|
||||||
|
|
@ -667,40 +789,94 @@ def _youporn_thumb_url(page_url: str | None, thumb: str | None) -> str | None:
|
||||||
def _is_rotting_thumb(url: str) -> bool:
|
def _is_rotting_thumb(url: str) -> bool:
|
||||||
"""sxyprn/trafficdeposit miniaturki są czasowo podpisane i rotują (asset 404 po
|
"""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
|
~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:
|
if "trafficdeposit.com" in url:
|
||||||
return True
|
return True
|
||||||
# imgs.xiaoshenke.net (fullporner) oddaje 200 z ~153-bajtową ATRAPĄ zamiast obrazu.
|
# imgs.xiaoshenke.net (fullporner) było tu wpisane 2026-08-02, bo host oddawał 200
|
||||||
# Formalnie „ma miniaturę", więc przechodziło warunek `find(s => s.thumbnail_url)`
|
# z ~153-bajtową atrapą zamiast obrazu (zgłoszenia a01dc891 / 2fbba822 / b437d5f7).
|
||||||
# w apce i WYGRYWAŁO jako pierwsze źródło — a działająca miniatura (np. hqporner,
|
# To był stan PRZEJŚCIOWY: pomiar 2026-08-11 na 300 losowych URL-ach z bazy dał
|
||||||
# 22 kB) leżała niżej i nigdy nie była użyta. Objawiało się dopiero po scaleniu scen
|
# 298 prawdziwych JPEG-ów (5-25 kB), tak samo z VPS jak i z łącza domowego.
|
||||||
# („3 źródła live, a miniaturki nie ma", zgłoszenia a01dc891 / 2fbba822 / b437d5f7):
|
# Blokada zdjęta, bo szkodziła bardziej niż pomagała: w SceneDetail (linia ~879)
|
||||||
# przy jednym źródle brało się to dobre, po merge na czoło wchodziła atrapa.
|
# rotting-thumb jest ZEROWANY, więc sceny mające tylko fullporner traciły miniaturę
|
||||||
if "imgs.xiaoshenke.net" in url:
|
# całkowicie („Jakiś problem z miniaturkami z fullporner"). Dotyczyło 53 tys. źródeł.
|
||||||
return True
|
# 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
|
# 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
|
# 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.
|
# jak martwą, żeby scena po merge sięgnęła po działającą z innego źródła.
|
||||||
if "tn.sextu.com" in url:
|
if "tn.sextu.com" in url:
|
||||||
return True
|
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:
|
# oddać obraz (RemoteProtocolError), czyli martwy. Zerowanie ma tu dodatkowy zysk:
|
||||||
# scena bez miniatury odpala w apce auto-enrich, który pobiera świeżą ze strony
|
# 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.
|
# tube'a. Zgłoszenie c2f1dd41; dotyczy 240 z 20213 źródeł galaxyporn.
|
||||||
return "fastpic.org" in url
|
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:
|
def _needs_proxy(url: str) -> bool:
|
||||||
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend.
|
"""Wszystkie thumbnaile z playback_sources są proxowane przez backend.
|
||||||
Większość CDN-ów porn-tube'ów wymaga Refera (hqporner, mypornerleak/58img,
|
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."""
|
Self-hosted lub backend-internal URL-e (zaczynające się od `/`) skipujemy."""
|
||||||
return url.startswith("http") and not url.startswith("/proxy/")
|
return url.startswith("http") and not url.startswith("/proxy/")
|
||||||
|
|
||||||
|
|
||||||
def _wrap_image_proxy(url: str, referer: str) -> str:
|
def _wrap_image_proxy(url: str, referer: str) -> str:
|
||||||
"""Wraps a thumbnail URL through /proxy/img/{token}/img.jpg. Klient nie musi
|
"""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."""
|
są stabilne, krótkie ttl by tylko niepotrzebnie zaśmiecało cache."""
|
||||||
from app.api.stream_proxy import make_token
|
from app.api.stream_proxy import make_token
|
||||||
# stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image
|
# stable_bucket_sec=7d → proxied URL identyczny przez tydzień → expo-image
|
||||||
|
|
@ -713,11 +889,16 @@ def _wrap_image_proxy(url: str, referer: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _build_scenes_out_batch(
|
def _build_scenes_out_batch(
|
||||||
session: Session, scenes: list[Scene], *, light: bool = False, device_id: str = LEGACY_DEVICE
|
session: Session,
|
||||||
|
scenes: list[Scene],
|
||||||
|
*,
|
||||||
|
light: bool = False,
|
||||||
|
device_id: str = LEGACY_DEVICE,
|
||||||
|
prefer_origin: str | None = None,
|
||||||
) -> list[SceneOut]:
|
) -> list[SceneOut]:
|
||||||
"""Batch-fetch wszystkich relacji dla N scen w 7 zapytaniach (zamiast 7×N).
|
"""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`
|
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.
|
bo overhead na batch nie ma sensu dla N=1.
|
||||||
|
|
||||||
|
|
@ -760,7 +941,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}).
|
# (SceneDetail re-fetchuje pełną scenę przez /scenes/{id}).
|
||||||
tags_by_scene: dict = defaultdict(list)
|
tags_by_scene: dict = defaultdict(list)
|
||||||
refs_by_scene: dict = defaultdict(list)
|
refs_by_scene: dict = defaultdict(list)
|
||||||
|
|
@ -788,9 +969,11 @@ 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.
|
# czyta wyłącznie playback_sources[].thumbnail_url / animated_thumbnail_url.
|
||||||
pb_by_scene: dict = defaultdict(list)
|
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:
|
if light:
|
||||||
pb_light = session.execute(
|
pb_light = session.execute(
|
||||||
select(
|
select(
|
||||||
|
|
@ -798,6 +981,10 @@ def _build_scenes_out_batch(
|
||||||
PlaybackSource.thumbnail_url,
|
PlaybackSource.thumbnail_url,
|
||||||
PlaybackSource.animated_thumbnail_url,
|
PlaybackSource.animated_thumbnail_url,
|
||||||
PlaybackSource.page_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(
|
.where(
|
||||||
PlaybackSource.scene_id.in_(scene_ids),
|
PlaybackSource.scene_id.in_(scene_ids),
|
||||||
|
|
@ -806,31 +993,46 @@ def _build_scenes_out_batch(
|
||||||
.order_by(PlaybackSource.origin.asc())
|
.order_by(PlaybackSource.origin.asc())
|
||||||
).all()
|
).all()
|
||||||
# Pierwsza miniaturka + pierwszy animated per scena (1 slim wpis). De-prioritize
|
# 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
|
# ~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
|
# ź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).
|
# działają, martwe → mobile pokazuje placeholder zamiast broken-image).
|
||||||
thumb_by_scene: dict = {}
|
thumb_by_scene: dict = {}
|
||||||
thumb_fallback: dict = {}
|
thumb_fallback: dict = {}
|
||||||
anim_by_scene: dict = {}
|
anim_by_scene: dict = {}
|
||||||
for sid, thumb, anim, page_url in pb_light:
|
for sid, thumb, anim, page_url, origin in pb_light:
|
||||||
|
origins_by_scene.setdefault(sid, []).append(origin)
|
||||||
sxy = _sxyprn_thumb_url(page_url)
|
sxy = _sxyprn_thumb_url(page_url)
|
||||||
yp = _youporn_thumb_url(page_url, thumb)
|
yp = _youporn_thumb_url(page_url, thumb)
|
||||||
if sxy:
|
if sxy:
|
||||||
# sxyprn → żywy on-demand resolver (martwy stored URL ignorujemy),
|
# sxyprn → żywy on-demand resolver (martwy stored URL ignorujemy),
|
||||||
# tier fallback: użyty tylko gdy scena nie ma stabilniejszej miniatury.
|
# tier fallback: użyty tylko gdy scena nie ma stabilniejszej miniatury.
|
||||||
thumb_fallback.setdefault(sid, (sxy, page_url))
|
thumb_fallback.setdefault(sid, (sxy, page_url, origin))
|
||||||
elif yp:
|
elif yp:
|
||||||
# youporn z podpisanym (wygasającym) posterem → on-demand resolver.
|
# youporn z podpisanym (wygasającym) posterem → on-demand resolver.
|
||||||
# Też tier fallback: statyczna miniatura z innego źródła jest tańsza.
|
# Też tier fallback: statyczna miniatura z innego źródła jest tańsza.
|
||||||
thumb_fallback.setdefault(sid, (yp, page_url))
|
thumb_fallback.setdefault(sid, (yp, page_url, origin))
|
||||||
elif thumb:
|
elif thumb:
|
||||||
if _is_rotting_thumb(thumb):
|
if _is_rotting_thumb(thumb) or _is_flaky_thumb(thumb):
|
||||||
thumb_fallback.setdefault(sid, (thumb, page_url))
|
# Martwe ORAZ chwiejne lądują w tierze fallback: użyte tylko wtedy,
|
||||||
elif sid not in thumb_by_scene:
|
# gdy scena nie ma stabilniejszej miniatury z innego źródła. W obrębie
|
||||||
thumb_by_scene[sid] = (thumb, page_url)
|
# samego fallbacku wybieramy mniejsze zło wg `_FLAKY_RANK`, bo inaczej
|
||||||
if sid not in anim_by_scene and anim:
|
# decydowałaby kolejność alfabetyczna originu i scena z fullmovies +
|
||||||
anim_by_scene[sid] = (anim, page_url)
|
# 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)
|
||||||
# Uzupełnij scenami które mają TYLKO rotting thumbnail (sxyprn-only).
|
# Uzupełnij scenami które mają TYLKO rotting thumbnail (sxyprn-only).
|
||||||
for sid, val in thumb_fallback.items():
|
for sid, val in thumb_fallback.items():
|
||||||
thumb_by_scene.setdefault(sid, val)
|
thumb_by_scene.setdefault(sid, val)
|
||||||
|
|
@ -846,11 +1048,12 @@ def _build_scenes_out_batch(
|
||||||
t_url = _wrap_image_proxy(t_url, ref)
|
t_url = _wrap_image_proxy(t_url, ref)
|
||||||
if a_url and _needs_proxy(a_url):
|
if a_url and _needs_proxy(a_url):
|
||||||
a_url = _wrap_image_proxy(a_url, ref)
|
a_url = _wrap_image_proxy(a_url, ref)
|
||||||
# id/origin/page_url wymagane przez schemat ale nieużywane przez kafelek
|
# `origin` niesiemy PRAWDZIWY (źródła wybranej miniatury): kafelek pokazuje
|
||||||
# (SceneDetail re-fetchuje pełne źródła) — dummy sentinel.
|
# 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.
|
||||||
pb_by_scene[sid].append(
|
pb_by_scene[sid].append(
|
||||||
PlaybackSourceOut(
|
PlaybackSourceOut(
|
||||||
id=uuid.UUID(int=0), origin="", page_url="",
|
id=uuid.UUID(int=0), origin=(t or a)[2] or "", page_url="",
|
||||||
thumbnail_url=t_url, animated_thumbnail_url=a_url,
|
thumbnail_url=t_url, animated_thumbnail_url=a_url,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -864,6 +1067,7 @@ def _build_scenes_out_batch(
|
||||||
.order_by(PlaybackSource.origin.asc())
|
.order_by(PlaybackSource.origin.asc())
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for p in pb_rows:
|
for p in pb_rows:
|
||||||
|
origins_by_scene.setdefault(p.scene_id, []).append(p.origin)
|
||||||
out = PlaybackSourceOut.model_validate(p)
|
out = PlaybackSourceOut.model_validate(p)
|
||||||
# sxyprn/trafficdeposit stored thumb rotuje (404 po ~tygodniach). SceneDetail
|
# sxyprn/trafficdeposit stored thumb rotuje (404 po ~tygodniach). SceneDetail
|
||||||
# bierze PIERWSZE źródło z thumbem (origin ASC → sxyprncom często pierwsze), więc
|
# bierze PIERWSZE źródło z thumbem (origin ASC → sxyprncom często pierwsze), więc
|
||||||
|
|
@ -925,6 +1129,8 @@ def _build_scenes_out_batch(
|
||||||
tags=tags_by_scene.get(scene.id, []),
|
tags=tags_by_scene.get(scene.id, []),
|
||||||
external_refs=refs_by_scene.get(scene.id, []),
|
external_refs=refs_by_scene.get(scene.id, []),
|
||||||
playback_sources=pb_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,
|
created_at=scene.created_at,
|
||||||
backfill=scene.backfill,
|
backfill=scene.backfill,
|
||||||
last_played_at=progress.last_played_at if progress else None,
|
last_played_at=progress.last_played_at if progress else None,
|
||||||
|
|
@ -999,7 +1205,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
|
# 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,
|
# 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
|
# samego hostera (resolvują tym samym extractorem). Zostawiamy jeden najlepszy per
|
||||||
# origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość →
|
# origin: preferuj długość zgodną ze sceną (realny match) → jakąkolwiek długość →
|
||||||
# pierwszy (stabilnie, query jest origin-asc). Martwe już odfiltrowane (dead_at).
|
# pierwszy (stabilnie, query jest origin-asc). Martwe już odfiltrowane (dead_at).
|
||||||
|
|
@ -1022,7 +1228,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
|
||||||
for p in playback_rows:
|
for p in playback_rows:
|
||||||
out = PlaybackSourceOut.model_validate(p)
|
out = PlaybackSourceOut.model_validate(p)
|
||||||
# sxyprn: podmień martwy trafficdeposit URL na żywy on-demand resolver
|
# 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
|
# 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.
|
# thumbnail" nie pomagał (sxyprn IP-bound, enrich z VPS pada). Report 2026-07-22.
|
||||||
sxy = _sxyprn_thumb_url(p.page_url)
|
sxy = _sxyprn_thumb_url(p.page_url)
|
||||||
|
|
@ -1034,7 +1240,7 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
|
||||||
elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url):
|
elif out.thumbnail_url and _is_rotting_thumb(out.thumbnail_url):
|
||||||
out.thumbnail_url = None
|
out.thumbnail_url = None
|
||||||
# Wrap thumbnail URL-e przez backend image proxy gdy CDN wymaga Refera
|
# 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.
|
# 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):
|
if out.thumbnail_url and _needs_proxy(out.thumbnail_url):
|
||||||
out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url)
|
out.thumbnail_url = _wrap_image_proxy(out.thumbnail_url, p.page_url)
|
||||||
|
|
@ -1073,6 +1279,8 @@ def _build_scene_out(session: Session, scene: Scene, *, device_id: str = LEGACY_
|
||||||
tags=tags_out,
|
tags=tags_out,
|
||||||
external_refs=refs_out,
|
external_refs=refs_out,
|
||||||
playback_sources=playback_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,
|
created_at=scene.created_at,
|
||||||
backfill=scene.backfill,
|
backfill=scene.backfill,
|
||||||
last_played_at=progress.last_played_at if progress else None,
|
last_played_at=progress.last_played_at if progress else None,
|
||||||
|
|
@ -1090,7 +1298,7 @@ def remove_tag_from_scene(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Usuwa relację scene↔tag (np. user uznał że tag jest błędny dla tej sceny).
|
"""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.
|
z niego korzystać. Sam tag zostaje w słowniku tagów.
|
||||||
"""
|
"""
|
||||||
rel = session.execute(
|
rel = session.execute(
|
||||||
|
|
@ -1142,7 +1350,7 @@ def hide_scene(
|
||||||
) -> SceneHideOut:
|
) -> SceneHideOut:
|
||||||
"""Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources
|
"""Ukryj scenę (user long-press → „usuń"). Oznacza wszystkie playback_sources
|
||||||
jako dead → scena wypada z list (has_playback=false). Odwracalne w DB (dead_at).
|
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 datetime import UTC, datetime
|
||||||
|
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
|
|
@ -1273,27 +1481,45 @@ def enrich_tags_from_tube(
|
||||||
# Upsert: dla każdego taga utwórz/znajdź Tag, dorzuć SceneTag idempotentnie.
|
# Upsert: dla każdego taga utwórz/znajdź Tag, dorzuć SceneTag idempotentnie.
|
||||||
# Używamy PostgreSQL INSERT ... ON CONFLICT DO NOTHING zamiast ORM session.add()
|
# 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
|
# 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
|
# (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.
|
# w 10h mimo wcześniejszego seen_tag_ids fix). ON CONFLICT skip'uje silently.
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
added = 0
|
|
||||||
seen_tag_ids: set = set()
|
from app.normalize.tag_stoplist import is_junk_tag
|
||||||
for name in tag_names:
|
|
||||||
norm = NormalizedTag(name=name, slug=slugify(name), external_id=None)
|
# Istniejące tagi jednym zapytaniem po slugach. resolve_tag robi 1-4 zapytania na
|
||||||
tag = resolve_tag(session, norm=norm)
|
# tag (Sentry N+1 przy 20-40 tagach ze strony), więc woła się go już tylko dla
|
||||||
if tag is None or tag.id in seen_tag_ids:
|
# nowych nazw i dla slugów z końcówką litera+"2", które kanonizuje po swojemu.
|
||||||
continue
|
slugs = {slugify(n) for n in tag_names}
|
||||||
seen_tag_ids.add(tag.id)
|
direct = [
|
||||||
stmt = (
|
s for s in slugs
|
||||||
pg_insert(SceneTag.__table__)
|
if s and len(s) <= 120 and not is_junk_tag(s)
|
||||||
.values(scene_id=scene_id, tag_id=tag.id, source_id=None)
|
and not (len(s) > 1 and s[-1] == "2" and s[-2].isalpha())
|
||||||
.on_conflict_do_nothing(index_elements=["scene_id", "tag_id"])
|
]
|
||||||
|
by_slug = (
|
||||||
|
{t.slug: t for t in session.execute(select(Tag).where(Tag.slug.in_(direct))).scalars()}
|
||||||
|
if direct else {}
|
||||||
)
|
)
|
||||||
result = session.execute(stmt)
|
tag_ids: list = []
|
||||||
# rowcount == 1 gdy faktycznie wstawiony, 0 gdy ON CONFLICT skip
|
for name in tag_names:
|
||||||
if result.rowcount and result.rowcount > 0:
|
slug = slugify(name)
|
||||||
added += 1
|
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(
|
||||||
|
pg_insert(SceneTag.__table__)
|
||||||
|
.values([{"scene_id": scene_id, "tag_id": t, "source_id": None} for t in sorted(tag_ids)])
|
||||||
|
.on_conflict_do_nothing(index_elements=["scene_id", "tag_id"])
|
||||||
|
.returning(SceneTag.__table__.c.tag_id)
|
||||||
|
)
|
||||||
|
added = len(result.fetchall())
|
||||||
session.commit()
|
session.commit()
|
||||||
return EnrichTagsOut(scene_id=scene_id, added=added, tube_used=sitetag, tags=tag_names)
|
return EnrichTagsOut(scene_id=scene_id, added=added, tube_used=sitetag, tags=tag_names)
|
||||||
|
|
||||||
|
|
@ -1309,11 +1535,11 @@ def enrich_duration_from_tube(
|
||||||
scene_id: uuid.UUID,
|
scene_id: uuid.UUID,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> EnrichDurationOut:
|
) -> 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).
|
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
|
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).
|
niego sceny z weak title-only score są capowane na 0.85 (review queue).
|
||||||
|
|
||||||
Idempotent: zwraca aktualne duration_sec jeśli już ustawione.
|
Idempotent: zwraca aktualne duration_sec jeśli już ustawione.
|
||||||
|
|
@ -1379,7 +1605,7 @@ def enrich_studio_from_tube(
|
||||||
"""Wyciąga studio (DVD/series) z pornhat scene page'a.
|
"""Wyciąga studio (DVD/series) z pornhat scene page'a.
|
||||||
|
|
||||||
Pornhat ma `class="info-video js-ajax-dvd" data-setup='{"title": "Adult Time", ...}'`
|
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).
|
tylko pornhat (najczystsze studio metadata wśród free tubes).
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
@ -1476,7 +1702,7 @@ def enrich_thumbnail_from_tube(
|
||||||
Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration).
|
Mobile auto-wywoła to przy otwarciu SceneDetail bez thumb (jak duration).
|
||||||
|
|
||||||
`force=true` (przycisk "Refresh thumbnail" na SceneDetail, zgłoszenie d3376a71):
|
`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)."""
|
(rotting sxyprn/trafficdeposit, błędna grafika)."""
|
||||||
from app.extractors._fetch import browser_get
|
from app.extractors._fetch import browser_get
|
||||||
from app.extractors._models import TubePageError
|
from app.extractors._models import TubePageError
|
||||||
|
|
@ -1497,7 +1723,7 @@ def enrich_thumbnail_from_tube(
|
||||||
|
|
||||||
sources_with_thumb = [s for s in sources if s.thumbnail_url]
|
sources_with_thumb = [s for s in sources if s.thumbnail_url]
|
||||||
if sources_with_thumb and not force:
|
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(
|
return EnrichThumbOut(
|
||||||
scene_id=scene_id,
|
scene_id=scene_id,
|
||||||
thumbnail_url=sources_with_thumb[0].thumbnail_url,
|
thumbnail_url=sources_with_thumb[0].thumbnail_url,
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,17 @@ class SceneOut(BaseModel):
|
||||||
tags: list[TagOut] = []
|
tags: list[TagOut] = []
|
||||||
external_refs: list[ExternalRefOut] = []
|
external_refs: list[ExternalRefOut] = []
|
||||||
playback_sources: list[PlaybackSourceOut] = []
|
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
|
# 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 > last_seen_at` (favorite) → badge.
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
|
# True = scena z masowego backfillu katalogu (deep-crawl). Mobile NIE pokazuje na niej
|
||||||
|
|
@ -87,7 +96,7 @@ class SceneListOut(BaseModel):
|
||||||
page: int
|
page: int
|
||||||
per_page: int
|
per_page: int
|
||||||
# has_more: czy istnieje kolejna strona. Liczone z fetcha per_page+1 (≈darmowe),
|
# 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.
|
# uniknąć ~5s exhaustive count. Mobile paginuje po has_more, nie po total.
|
||||||
has_more: bool = False
|
has_more: bool = False
|
||||||
# total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+".
|
# total_capped: True gdy `total` to bounded cap (są >total wyników). UI: "{total}+".
|
||||||
|
|
|
||||||
|
|
@ -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
|
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,
|
(nazwy performerów / studiów / tytuły scen), którego mainstream-SEO nie indeksuje,
|
||||||
a który realnie generuje organiczny ruch → pobranie apki.
|
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):
|
Zasady (świadome, nie przypadkowe):
|
||||||
|
|
||||||
* **LINK-OUT only.** Strony renderują metadane i odsyłają do źródeł (`page_url`).
|
* **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).
|
legalny profil "agregatora/wyszukiwarki" (dowozimy ruch tubom, nie re-streamujemy).
|
||||||
* **Age-gate = client-side overlay** (cookie `age_ok`). Treść jest w HTML, więc
|
* **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+.
|
crawler ją indeksuje; overlay zasłania ją tylko ludziom do potwierdzenia 18+.
|
||||||
Dodatkowo `RTA` meta tag dla filtrów rodzicielskich.
|
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.
|
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,
|
(performer/studio z ≥1 żywą sceną; scena z ≥1 żywym source). Pusta encja → 404,
|
||||||
nie pusty doorway (Google karze masowe cienkie strony).
|
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_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
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).
|
# 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.
|
# Włączenie = ustaw zmienną w .env na VPS + restart, bez zmian w kodzie.
|
||||||
templates.env.globals["gtm_id"] = os.environ.get("GOON_GTM_ID", "")
|
templates.env.globals["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.
|
# Limit URL-i na pojedynczy plik sitemap (spec: max 50k). Trzymamy z zapasem.
|
||||||
_SITEMAP_PAGE = 25_000
|
_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.
|
# to thin/slow; bierzemy najświeższe N, reszta i tak wpada przez sitemap scen.
|
||||||
_SCENES_PER_ENTITY = 120
|
_SCENES_PER_ENTITY = 120
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ def _live_playback_exists():
|
||||||
def _not_blacklisted():
|
def _not_blacklisted():
|
||||||
"""Lista warunków WHERE wykluczających zblacklistowaną treść (performer/studio/tag).
|
"""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 [
|
return [
|
||||||
~exists(
|
~exists(
|
||||||
|
|
@ -109,7 +109,7 @@ def _not_blacklisted():
|
||||||
|
|
||||||
|
|
||||||
def _indexable_scenes():
|
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())
|
stmt = select(Scene).where(_live_playback_exists())
|
||||||
for cond in _not_blacklisted():
|
for cond in _not_blacklisted():
|
||||||
stmt = stmt.where(cond)
|
stmt = stmt.where(cond)
|
||||||
|
|
@ -120,7 +120,7 @@ def _performer_indexable_exists():
|
||||||
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
|
"""EXISTS: performer ma ≥1 indeksowalną (żywą, nie-blacklisted) scenę.
|
||||||
|
|
||||||
Trzyma sitemap performerów w zgodzie z tym, co realnie renderuje
|
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 = (
|
sub = (
|
||||||
select(1)
|
select(1)
|
||||||
|
|
@ -180,7 +180,7 @@ def performer_page(
|
||||||
if performer is None:
|
if performer is None:
|
||||||
raise HTTPException(status_code=404, detail="performer not found")
|
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 = (
|
scenes = (
|
||||||
session.execute(
|
session.execute(
|
||||||
_indexable_scenes()
|
_indexable_scenes()
|
||||||
|
|
@ -283,7 +283,7 @@ def scene_page(
|
||||||
if scene is None:
|
if scene is None:
|
||||||
raise HTTPException(status_code=404, detail="scene not found")
|
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 = (
|
sources_raw = (
|
||||||
session.execute(
|
session.execute(
|
||||||
select(PlaybackSource)
|
select(PlaybackSource)
|
||||||
|
|
@ -354,7 +354,7 @@ def landing(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Strona główna — crawl-entry. Najświeższe indeksowalne sceny + CTA."""
|
"""Strona główna, crawl-entry. Najświeższe indeksowalne sceny + CTA."""
|
||||||
scenes = (
|
scenes = (
|
||||||
session.execute(
|
session.execute(
|
||||||
_indexable_scenes()
|
_indexable_scenes()
|
||||||
|
|
@ -386,8 +386,8 @@ def landing(
|
||||||
|
|
||||||
@router.get("/get", response_class=HTMLResponse)
|
@router.get("/get", response_class=HTMLResponse)
|
||||||
def get_app(request: Request) -> HTMLResponse:
|
def get_app(request: Request) -> HTMLResponse:
|
||||||
"""Paid-traffic landing page — odchudzona pod konwersję instalacji APK.
|
"""Paid-traffic landing page, odchudzona pod konwersję instalacji APK.
|
||||||
noindex (patrz get.html) — nie ma konkurować w SERP z entity-stronami."""
|
noindex (patrz get.html), nie ma konkurować w SERP z entity-stronami."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"seo/get.html",
|
"seo/get.html",
|
||||||
|
|
@ -411,7 +411,7 @@ def page_2257(request: Request) -> HTMLResponse:
|
||||||
def gsc_site_verification(fname: str) -> PlainTextResponse:
|
def gsc_site_verification(fname: str) -> PlainTextResponse:
|
||||||
"""Plik weryfikacyjny Google Search Console (metoda 'Plik HTML').
|
"""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'.
|
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
|
Każda inna nazwa .html → 404. Nie koliduje z innymi trasami (żadna inna nie jest
|
||||||
jednosegmentowym *.html w rootcie).
|
jednosegmentowym *.html w rootcie).
|
||||||
|
|
@ -440,7 +440,7 @@ def _count(session: Session, stmt) -> int:
|
||||||
|
|
||||||
@router.get("/sitemap.xml")
|
@router.get("/sitemap.xml")
|
||||||
def sitemap_index(session: Annotated[Session, Depends(get_session)]) -> Response:
|
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(
|
n_perf = _count(
|
||||||
session,
|
session,
|
||||||
select(Performer.id).where(_performer_indexable_exists()),
|
select(Performer.id).where(_performer_indexable_exists()),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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.
|
najnowsze sceny z konkretnego źródła.
|
||||||
|
|
||||||
Endpoint enumeruje distinct `playback_sources.origin` z ŻYWYCH playback_sources
|
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
|
(`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).
|
intencji feature'a).
|
||||||
|
|
||||||
Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
|
Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
|
||||||
|
|
@ -14,8 +14,11 @@ Sortowanie: scene_count DESC (najbardziej "wypełnione" tubey na górze).
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
@ -42,7 +45,7 @@ class SourceRating(BaseModel):
|
||||||
richness: int
|
richness: int
|
||||||
"""0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość)."""
|
"""0-5: bogactwo metadanych (miniaturka/tagi/desc/aktorzy/studio/długość)."""
|
||||||
health: int | None
|
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
|
health_basis: str | None = None
|
||||||
"""'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve)."""
|
"""'telemetry' (realne pingi z apki) albo 'proxy' (oszacowanie z typu resolve)."""
|
||||||
components: dict | None = None
|
components: dict | None = None
|
||||||
|
|
@ -51,30 +54,48 @@ class SourceRating(BaseModel):
|
||||||
|
|
||||||
class SourceOut(BaseModel):
|
class SourceOut(BaseModel):
|
||||||
origin: str
|
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)."""
|
`origin=` filtra w GET /scenes (substring match)."""
|
||||||
|
|
||||||
sitetag: str
|
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`)."""
|
z `BaseDirectTubeScraper.sitetag`)."""
|
||||||
|
|
||||||
display_name: str
|
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."""
|
`_sitetag_to_display`. Tylko presentation; logikę trzymamy na sitetag/origin."""
|
||||||
|
|
||||||
scene_count: int
|
scene_count: int
|
||||||
"""Liczba ŻYWYCH playback_sources (dead_at IS NULL) per origin. Approx scenes
|
"""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."""
|
więc trochę zawyża rzeczywistą scene-distinct count, ale dla orientacji OK."""
|
||||||
|
|
||||||
last_scraped_at: datetime | None
|
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ść."""
|
'scrapowane Xh temu' i sortować świeżość."""
|
||||||
|
|
||||||
rating: SourceRating | None = None
|
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)."""
|
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):
|
class SourceListOut(BaseModel):
|
||||||
items: list[SourceOut]
|
items: list[SourceOut]
|
||||||
|
|
@ -119,9 +140,14 @@ def list_sources(
|
||||||
) -> SourceListOut:
|
) -> SourceListOut:
|
||||||
"""Zwraca listę tube źródeł z ŻYWYMI playback_sources.
|
"""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).
|
||||||
"""
|
"""
|
||||||
rows = session.execute(
|
# 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(
|
||||||
select(
|
select(
|
||||||
PlaybackSource.origin,
|
PlaybackSource.origin,
|
||||||
func.count(PlaybackSource.id).label("scene_count"),
|
func.count(PlaybackSource.id).label("scene_count"),
|
||||||
|
|
@ -131,6 +157,8 @@ def list_sources(
|
||||||
.where(PlaybackSource.origin.like("tube:%"))
|
.where(PlaybackSource.origin.like("tube:%"))
|
||||||
.group_by(PlaybackSource.origin)
|
.group_by(PlaybackSource.origin)
|
||||||
).all()
|
).all()
|
||||||
|
_SOURCES_CACHE["at"] = now
|
||||||
|
rows = _SOURCES_CACHE["rows"]
|
||||||
|
|
||||||
# Oceny z source_stats (policzone offline przez run_source_stats). Origin → row.
|
# 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()}
|
stats = {s.origin: s for s in session.execute(select(SourceStats)).scalars().all()}
|
||||||
|
|
@ -159,6 +187,7 @@ def list_sources(
|
||||||
scene_count=scene_count,
|
scene_count=scene_count,
|
||||||
last_scraped_at=last_scraped_at,
|
last_scraped_at=last_scraped_at,
|
||||||
rating=rating,
|
rating=rating,
|
||||||
|
icon_url=_icon_url(sitetag),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
*przez backend* (tym samym IP co podczas extracji) → CDN sprawdza signature
|
||||||
poprawnie i serwuje content.
|
poprawnie i serwuje content.
|
||||||
|
|
||||||
|
|
@ -31,6 +31,7 @@ import time
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
@ -41,8 +42,8 @@ router = APIRouter(prefix="/proxy", tags=["proxy"])
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# In-memory bandwidth counter — bytes-out per CDN domain per hour bucket.
|
# In-memory bandwidth counter, bytes-out per CDN domain per hour bucket.
|
||||||
# Restart api resetuje counter (akceptowalne — to operational metric, nie billing).
|
# Restart api resetuje counter (akceptowalne, to operational metric, nie billing).
|
||||||
# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage.
|
# Critical dla widzenia gdzie VPS bandwidth wycieka przed Hetzner overage.
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|
@ -90,7 +91,7 @@ TOKEN_TTL_SEC = 4 * 60 * 60 # 4h
|
||||||
|
|
||||||
|
|
||||||
# URL-level redirect cache: target_url -> (final_resolved_url, expires_ts).
|
# 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
|
# 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),
|
# 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
|
# drugi GET → 410. Bez cache: drugi range = 410 → ExoPlayer fail → mobile fallback do
|
||||||
|
|
@ -147,6 +148,36 @@ HOP_BY_HOP = {
|
||||||
"content-length",
|
"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:
|
def _secret() -> bytes:
|
||||||
s = os.environ.get("STREAM_PROXY_SECRET") or os.environ.get("API_KEYS", "")
|
s = os.environ.get("STREAM_PROXY_SECRET") or os.environ.get("API_KEYS", "")
|
||||||
|
|
@ -170,10 +201,10 @@ def make_token(
|
||||||
|
|
||||||
`refresh`: URL embed page do refetch gdy `url` zwraca 4xx. Proxy odbierze
|
`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.
|
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.
|
dispatch do dedicated re-extract logic.
|
||||||
`impersonate`: użyć curl_cffi chrome120 zamiast httpx (dla hosterów z JA3 bot
|
`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
|
`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`
|
(→ 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
|
zawiera bieżący timestamp → URL inny przy każdym fetchu listy → expo-image cache
|
||||||
|
|
@ -190,7 +221,7 @@ def make_token(
|
||||||
if impersonate:
|
if impersonate:
|
||||||
payload["i"] = 1
|
payload["i"] = 1
|
||||||
if producer:
|
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.
|
# zamiast GET-a. Patrz app/extractors/hosters/loadvid.py.
|
||||||
payload["mp"] = producer
|
payload["mp"] = producer
|
||||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
@ -220,7 +251,7 @@ def parse_token(token: str) -> dict:
|
||||||
|
|
||||||
def _ascii_safe_url(url: str) -> str:
|
def _ascii_safe_url(url: str) -> str:
|
||||||
"""Encode non-ASCII chars w URL path/query, zachowując reserved chars dla URI.
|
"""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`
|
`Honies_№2.html`) wcześniej throw'ował UnicodeEncodeError (GOON-A). `quote`
|
||||||
z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko
|
z `safe=":/?#[]@!$&'()*+,;=%"` zostawia URI structure nietkniętą, tylko
|
||||||
enkoduje znaki spoza ASCII."""
|
enkoduje znaki spoza ASCII."""
|
||||||
|
|
@ -255,8 +286,8 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
|
||||||
"""Rewrite m3u8 manifest tak, że wszystkie sub-resourcey idą przez proxy.
|
"""Rewrite m3u8 manifest tak, że wszystkie sub-resourcey idą przez proxy.
|
||||||
|
|
||||||
HLS manifest ma:
|
HLS manifest ma:
|
||||||
- linie URI (segmenty .ts / sub-playlisty .m3u8) — relatywne lub absolute
|
- linie URI (segmenty .ts / sub-playlisty .m3u8), relatywne lub absolute
|
||||||
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"` — też potrzebują rewrite
|
- tagi typu `#EXT-X-KEY:METHOD=AES-128,URI="key.bin"`, też potrzebują rewrite
|
||||||
Każdy URL → token + /proxy/{token}/<basename>.<ext>.
|
Każdy URL → token + /proxy/{token}/<basename>.<ext>.
|
||||||
"""
|
"""
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
|
|
@ -284,7 +315,7 @@ def _rewrite_m3u8(content: str, base_url: str, referer: str | None) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _absolutize_m3u8(content: str, base_url: str) -> 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
|
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),
|
bezpośrednio z CDN-a (phone IP, time-bound token). Relatywne → absolute (urljoin),
|
||||||
absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje)."""
|
absolute → bez zmian. Odwrotność `_rewrite_m3u8` (który wszystko proxifikuje)."""
|
||||||
|
|
@ -310,21 +341,34 @@ def sign_url(
|
||||||
referer: str | None = Query(default=None),
|
referer: str | None = Query(default=None),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Pomocniczy endpoint dla mobile do uzyskania świeżego tokena (np. po expiry).
|
"""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}
|
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}")
|
@router.get("/img/{token}/{_basename:path}")
|
||||||
async def proxy_image(
|
async def proxy_image(
|
||||||
token: str,
|
token: str,
|
||||||
_basename: str,
|
_basename: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> Response:
|
) -> 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
|
(hqporner i inne porn-app sourcy). Mobile expo-image nie wysyła Referera
|
||||||
domyślnie, CDN zwraca 403. Backend dodaje Referer i streamuje obrazek.
|
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)
|
payload = parse_token(token)
|
||||||
target = payload["u"]
|
target = payload["u"]
|
||||||
referer = payload["r"] or None
|
referer = payload["r"] or None
|
||||||
|
|
@ -334,18 +378,59 @@ async def proxy_image(
|
||||||
try:
|
try:
|
||||||
r = await client.get(target, headers=headers)
|
r = await client.get(target, headers=headers)
|
||||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
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.
|
# 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
|
# 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)
|
log.info("img proxy connect/timeout for %s: %s", target, e)
|
||||||
return Response(content=b"", status_code=503, media_type="image/jpeg")
|
return Response(content=b"", status_code=503, media_type="image/jpeg")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("img proxy fetch failed for %s: %s", target, 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
|
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:
|
if r.status_code >= 400:
|
||||||
# Upstream 4xx/5xx dla thumba — degraded zamiast raise (placeholder w mobile).
|
# Upstream 4xx/5xx dla thumba, degraded zamiast raise (placeholder w mobile).
|
||||||
# GOON-5 (Cloudflare 523) i GOON-D — bezsensowny noise w Sentry, lepiej
|
# GOON-5 (Cloudflare 523) i GOON-D, bezsensowny noise w Sentry, lepiej
|
||||||
# info log + 502 pass-through bez exception.
|
# info log + 502 pass-through bez exception.
|
||||||
log.info("img proxy upstream %d for %s", r.status_code, target)
|
log.info("img proxy upstream %d for %s", r.status_code, target)
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -362,7 +447,7 @@ async def proxy_image(
|
||||||
|
|
||||||
|
|
||||||
# sxyprn on-demand thumbnail resolve (bug 2026-06-10). trafficdeposit poster token
|
# 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
|
# 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
|
# (< 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.
|
# stałe (treść postera niezmienna), więc fetchujemy stronę post ~raz per post.
|
||||||
|
|
@ -376,10 +461,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
|
# 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
|
# 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
|
# 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ą
|
# ź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>`.
|
# świeży URL przy serwowaniu; klient dostaje stabilny `/proxy/youporn-thumb/<video_id>`.
|
||||||
_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {}
|
_YOUPORN_POSTER_CACHE: dict[str, tuple[str, float]] = {}
|
||||||
_YOUPORN_POSTER_TTL = 1800
|
_YOUPORN_POSTER_TTL = 1800
|
||||||
|
|
@ -417,7 +502,7 @@ async def youporn_thumb(video_id: str) -> Response:
|
||||||
)
|
)
|
||||||
if not m:
|
if not m:
|
||||||
return Response(content=b"", status_code=404, media_type="image/jpeg")
|
return Response(content=b"", status_code=404, media_type="image/jpeg")
|
||||||
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&`) — bez unescape
|
# JSON-LD escapuje `\/`, a og:image ma encje HTML (`&`), bez unescape
|
||||||
# podpis w query jest zepsuty i CDN oddaje 403.
|
# podpis w query jest zepsuty i CDN oddaje 403.
|
||||||
poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/"))
|
poster = _htmllib.unescape(m.group(1).strip().replace("\\/", "/"))
|
||||||
if poster.startswith("//"):
|
if poster.startswith("//"):
|
||||||
|
|
@ -492,7 +577,7 @@ async def sxyprn_thumb(post_id: str) -> Response:
|
||||||
async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None:
|
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.
|
"""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).
|
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
|
import re
|
||||||
from yt_dlp.utils import decode_packed_codes
|
from yt_dlp.utils import decode_packed_codes
|
||||||
|
|
@ -563,9 +648,9 @@ async def _curl_cffi_stream(
|
||||||
await session.close()
|
await session.close()
|
||||||
return _upstream_error_response(upstream.status_code, dict(upstream.headers), target)
|
return _upstream_error_response(upstream.status_code, dict(upstream.headers), target)
|
||||||
|
|
||||||
out_headers = {
|
out_headers = _with_cast_cors(
|
||||||
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
|
{k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
|
||||||
}
|
)
|
||||||
|
|
||||||
async def streamer():
|
async def streamer():
|
||||||
bytes_out = 0
|
bytes_out = 0
|
||||||
|
|
@ -610,6 +695,28 @@ async def _produce_manifest(producer: str, embed_url: str) -> str | None:
|
||||||
return 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}")
|
@router.get("/hls/{token}/{_basename:path}")
|
||||||
async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
||||||
"""Direct-HLS manifest passthrough dla time-bound (mobile_direct_ok) m3u8 hosterów.
|
"""Direct-HLS manifest passthrough dla time-bound (mobile_direct_ok) m3u8 hosterów.
|
||||||
|
|
@ -622,7 +729,7 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
||||||
|
|
||||||
Tu serwujemy SAM manifest pod `play.m3u8` (ExoPlayer → HlsMediaSource), a child-URL-e
|
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,
|
(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.
|
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
|
Tylko dla mobile_direct_ok (gdzie segmenty z definicji działają cross-IP); IP-bound
|
||||||
hostery dalej idą pełnym `/proxy/`."""
|
hostery dalej idą pełnym `/proxy/`."""
|
||||||
|
|
@ -647,16 +754,32 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
||||||
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
|
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
|
||||||
try:
|
try:
|
||||||
r = await client.get(target, headers=headers)
|
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:
|
except httpx.HTTPError as e:
|
||||||
log.info("proxy-hls fetch failed %s: %s", target, e)
|
log.info("proxy-hls fetch failed %s: %s", target, e)
|
||||||
raise HTTPException(status_code=502, detail="manifest fetch failed") from e
|
raise HTTPException(status_code=502, detail="manifest fetch failed") from e
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
return _upstream_error_response(r.status_code, dict(r.headers), target)
|
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))
|
rewritten = _absolutize_m3u8(r.text, base_url=str(r.url))
|
||||||
return Response(
|
return Response(
|
||||||
content=rewritten,
|
content=rewritten,
|
||||||
media_type="application/vnd.apple.mpegurl",
|
media_type="application/vnd.apple.mpegurl",
|
||||||
headers={"Cache-Control": "no-store"},
|
headers=_with_cast_cors({"Cache-Control": "no-store"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -674,7 +797,7 @@ async def proxy_stream(
|
||||||
refetch_hoster = payload.get("rh")
|
refetch_hoster = payload.get("rh")
|
||||||
|
|
||||||
# Jeśli ten target był już wcześniej follow-redirect-ed, użyj cached final URL.
|
# 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)
|
cached_target = _redirect_cache_get(original_target)
|
||||||
target = cached_target or original_target
|
target = cached_target or original_target
|
||||||
|
|
||||||
|
|
@ -684,10 +807,10 @@ async def proxy_stream(
|
||||||
if range_h:
|
if range_h:
|
||||||
headers["Range"] = 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
|
# 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).
|
# Token `i=1` flag ustawiana przez extractor dla tych hostów (mixdrop.py).
|
||||||
if use_impersonate:
|
if use_impersonate:
|
||||||
return await _curl_cffi_stream(
|
return await _curl_cffi_stream(
|
||||||
|
|
@ -707,19 +830,41 @@ async def proxy_stream(
|
||||||
|
|
||||||
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
client = httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
||||||
try:
|
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.
|
# potem decyzja: rewrite manifest vs stream binary.
|
||||||
upstream = await client.send(
|
upstream = await client.send(
|
||||||
client.build_request(method, target, headers=headers),
|
client.build_request(method, target, headers=headers),
|
||||||
stream=True,
|
stream=True,
|
||||||
follow_redirects=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:
|
if upstream.status_code >= 400:
|
||||||
status = upstream.status_code
|
status = upstream.status_code
|
||||||
ups_headers = dict(upstream.headers)
|
ups_headers = dict(upstream.headers)
|
||||||
await upstream.aclose()
|
await upstream.aclose()
|
||||||
await client.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
|
# invaliduj cache i daj mobile retry przez fresh /resolve. Bez tego stale
|
||||||
# cache trzymałby martwy CDN URL przez 30 min (TTL).
|
# cache trzymałby martwy CDN URL przez 30 min (TTL).
|
||||||
if cached_target is not None and status in (401, 403, 404, 410):
|
if cached_target is not None and status in (401, 403, 404, 410):
|
||||||
|
|
@ -728,7 +873,7 @@ async def proxy_stream(
|
||||||
|
|
||||||
# Pierwszy successful pass dla single-use targets (np. porntrex get_file):
|
# Pierwszy successful pass dla single-use targets (np. porntrex get_file):
|
||||||
# cache resolved final URL (po follow_redirects). Następne range-requesty
|
# 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:
|
if cached_target is None:
|
||||||
final_url = str(upstream.url)
|
final_url = str(upstream.url)
|
||||||
if final_url != original_target:
|
if final_url != original_target:
|
||||||
|
|
@ -741,7 +886,7 @@ async def proxy_stream(
|
||||||
or "application/x-mpegurl" in ct
|
or "application/x-mpegurl" in ct
|
||||||
)
|
)
|
||||||
if is_m3u8:
|
if is_m3u8:
|
||||||
# Manifest content — buffer fully, rewrite, return as m3u8.
|
# Manifest content, buffer fully, rewrite, return as m3u8.
|
||||||
body = await upstream.aread()
|
body = await upstream.aread()
|
||||||
await upstream.aclose()
|
await upstream.aclose()
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|
@ -757,9 +902,9 @@ async def proxy_stream(
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
|
|
||||||
out_headers = {
|
out_headers = _with_cast_cors(
|
||||||
k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
|
{k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP}
|
||||||
}
|
)
|
||||||
|
|
||||||
async def streamer():
|
async def streamer():
|
||||||
bytes_out = 0
|
bytes_out = 0
|
||||||
|
|
@ -769,7 +914,7 @@ async def proxy_stream(
|
||||||
yield chunk
|
yield chunk
|
||||||
except httpx.TransportError as e:
|
except httpx.TransportError as e:
|
||||||
# Upstream CDN zerwał połączenie w trakcie streamu (peer closed before
|
# 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.
|
# uciętą odpowiedź i ponawia Range-requestem; to NIE jest bug aplikacji.
|
||||||
# Bez tego wyjątek leci z generatora PO zwróceniu StreamingResponse, omija
|
# 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).
|
# outer try/except i ląduje w Sentry jako unhandled (GOON-Y 2026-06-11).
|
||||||
|
|
@ -792,7 +937,7 @@ async def proxy_stream(
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
raise
|
raise
|
||||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
|
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.
|
# Zwracamy 503 zamiast 502 + Retry-After, mobile może retry-ować bez panic.
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
log.info("proxy connect/timeout for %s: %s", target, e)
|
log.info("proxy connect/timeout for %s: %s", target, e)
|
||||||
|
|
@ -818,10 +963,10 @@ def _upstream_error_response(
|
||||||
Rationale per status:
|
Rationale per status:
|
||||||
- **429 Too Many Requests**: CDN rate-limit (np. fpo.xxx gdy proxy hammeruje
|
- **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.
|
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ł.
|
- **404/410**: video deleted/expired token. Pass-through żeby player wiedział.
|
||||||
- **5xx upstream**: pochodzi z CDN-a, nie z naszego kodu. Log INFO.
|
- **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")
|
retry_after = upstream_headers.get("retry-after") or upstream_headers.get("Retry-After")
|
||||||
if status == 429:
|
if status == 429:
|
||||||
|
|
@ -845,7 +990,7 @@ def _upstream_error_response(
|
||||||
media_type="text/plain",
|
media_type="text/plain",
|
||||||
)
|
)
|
||||||
if 500 <= status < 600:
|
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.
|
# 502 ale log INFO bo to nie nasza wina.
|
||||||
log.info("proxy upstream %d for %s", status, target)
|
log.info("proxy upstream %d for %s", status, target)
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -854,5 +999,28 @@ def _upstream_error_response(
|
||||||
headers={"Retry-After": "5"},
|
headers={"Retry-After": "5"},
|
||||||
media_type="text/plain",
|
media_type="text/plain",
|
||||||
)
|
)
|
||||||
# 4xx other (403 itp.) — raise żeby Sentry zarejestrował (może bug naszego kodu)
|
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.
|
||||||
raise HTTPException(status_code=502, detail=f"upstream {status}")
|
raise HTTPException(status_code=502, detail=f"upstream {status}")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
Każdy endpoint wspiera:
|
||||||
- q: substring search po name_normalized (trgm fallback ilike)
|
- q: substring search po name_normalized (trgm fallback ilike)
|
||||||
|
|
@ -9,6 +9,7 @@ Zwraca też scene_count żeby UI pokazywał "(123)" przy każdym tagu/performerz
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
|
@ -26,6 +27,9 @@ from app.models.playback_source import PlaybackSource
|
||||||
from app.models.scene import ScenePerformer, SceneTag
|
from app.models.scene import ScenePerformer, SceneTag
|
||||||
from app.models.studio import Studio
|
from app.models.studio import Studio
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
|
from app.verticals import VERTICALS
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)])
|
router = APIRouter(tags=["taxonomies"], dependencies=[Depends(require_api_key)])
|
||||||
|
|
||||||
|
|
@ -102,12 +106,19 @@ def list_tags(
|
||||||
" Filtruje krótkie listy filtrów żeby nie pokazywać tagów-sierot."
|
" 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:
|
) -> TagListOut:
|
||||||
if order not in ("popular", "scene_count", "name"):
|
if order not in ("popular", "scene_count", "name"):
|
||||||
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
|
raise HTTPException(status_code=400, detail="order must be 'popular' or 'name'")
|
||||||
|
|
||||||
if for_movies:
|
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ą
|
# 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.
|
# małe (~41k) więc live-aggregat OK; scenes path niżej używa denormalizacji.
|
||||||
_movie_live = exists().where(
|
_movie_live = exists().where(
|
||||||
|
|
@ -146,7 +157,55 @@ def list_tags(
|
||||||
]
|
]
|
||||||
return TagListOut(items=items, total=total, page=page, per_page=per_page)
|
return TagListOut(items=items, total=total, page=page, per_page=per_page)
|
||||||
|
|
||||||
# Scenes path — czyta zdenormalizowany Tag.scene_count (refresh w tle przez
|
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
|
||||||
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
|
# _job_refresh_taxonomy_counts). Wcześniej liczył agregat 6.3M scene_tags +
|
||||||
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
|
# EXISTS playback per-request (~4.3s ×2). Patrz migracja 0019.
|
||||||
base = select(Tag)
|
base = select(Tag)
|
||||||
|
|
@ -182,10 +241,72 @@ def list_performers(
|
||||||
order: str = Query(default="scene_count", description="scene_count|name"),
|
order: str = Query(default="scene_count", description="scene_count|name"),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
per_page: int = Query(default=50, ge=1, le=500),
|
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:
|
) -> PerformerListOut:
|
||||||
if order not in ("scene_count", "popular", "name"):
|
if order not in ("scene_count", "popular", "name"):
|
||||||
raise HTTPException(status_code=400, detail="order must be 'scene_count' or '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
|
# Czyta zdenormalizowany Performer.scene_count (refresh w tle). Wcześniej agregat
|
||||||
# 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019.
|
# 3M scene_performers + EXISTS playback per-request. Patrz migracja 0019.
|
||||||
base = select(Performer)
|
base = select(Performer)
|
||||||
|
|
@ -236,13 +357,13 @@ def list_studios(
|
||||||
description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.",
|
description="True: ukrywa studia z 0 wystąpieniami w wybranym typie.",
|
||||||
),
|
),
|
||||||
) -> StudioListOut:
|
) -> 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"):
|
if order not in ("name", "scene_count", "popular"):
|
||||||
raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'")
|
raise HTTPException(status_code=400, detail="order must be 'name' or 'scene_count'")
|
||||||
|
|
||||||
if for_movies:
|
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(
|
_movie_live = exists().where(
|
||||||
and_(
|
and_(
|
||||||
MoviePlaybackSource.movie_id == Movie.id,
|
MoviePlaybackSource.movie_id == Movie.id,
|
||||||
|
|
@ -280,7 +401,7 @@ def list_studios(
|
||||||
]
|
]
|
||||||
return StudioListOut(items=items, total=total, page=page, per_page=per_page)
|
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.
|
# Wcześniej agregat 1.69M scenes + EXISTS playback per-request. Patrz migracja 0019.
|
||||||
base = select(Studio)
|
base = select(Studio)
|
||||||
if q:
|
if q:
|
||||||
|
|
@ -322,6 +443,10 @@ class PerformerRefreshOut(BaseModel):
|
||||||
counters: dict[str, dict[str, int]]
|
counters: dict[str, dict[str, int]]
|
||||||
new_scenes: int
|
new_scenes: int
|
||||||
last_searched_at: str | None
|
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):
|
class PerformerRescrapeOut(BaseModel):
|
||||||
|
|
@ -340,13 +465,13 @@ class PerformerRescrapeOut(BaseModel):
|
||||||
# przy partial commits. 45s wall-clock + 50 scen max = ~12 fetches × 3s budgetowo.
|
# 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/
|
# Większe rescrape'y user może odpalać wielokrotnie (idempotent dzięki has_thumb/
|
||||||
# tag_count check).
|
# 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
|
_RESCRAPE_MAX_SCENES = 50
|
||||||
# Re-fetch tagów dla scen z < N tagami. Niektórzy performerzy mają legit 1-2 tagi
|
# 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
|
# (niche), no harm w sprawdzeniu pierwszy raz; powtarzane wywołania są idempotent
|
||||||
# bo INSERT ... ON CONFLICT DO NOTHING.
|
# bo INSERT ... ON CONFLICT DO NOTHING.
|
||||||
_TAG_RESCRAPE_THRESHOLD = 3
|
_TAG_RESCRAPE_THRESHOLD = 3
|
||||||
# Mainstream tubes priority dla tagów — bogate metadane.
|
# Mainstream tubes priority dla tagów, bogate metadane.
|
||||||
_TAG_PRIORITY = [
|
_TAG_PRIORITY = [
|
||||||
"xhamstercom", "porntrexcom", "epornercom", "youporncom",
|
"xhamstercom", "porntrexcom", "epornercom", "youporncom",
|
||||||
"xvideoscom", "xnxxcom",
|
"xvideoscom", "xnxxcom",
|
||||||
|
|
@ -360,7 +485,7 @@ def performer_top_tags(
|
||||||
limit: int = Query(default=20, ge=1, le=50),
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
) -> TagListOut:
|
) -> TagListOut:
|
||||||
"""Top tagi/kategorie agregowane ze scen performera (PerformerScenes UI chips,
|
"""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
|
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."""
|
pokrywa oba joiny; performer ma rzędu setek scen → tani agregat per-request."""
|
||||||
live = exists().where(
|
live = exists().where(
|
||||||
|
|
@ -418,7 +543,7 @@ def rescrape_performer_scenes(
|
||||||
if perf is None:
|
if perf is None:
|
||||||
raise HTTPException(status_code=404, detail="performer not found")
|
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(
|
scene_ids = session.execute(
|
||||||
select(Scene.id)
|
select(Scene.id)
|
||||||
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
|
.join(ScenePerformer, ScenePerformer.scene_id == Scene.id)
|
||||||
|
|
@ -468,7 +593,7 @@ def rescrape_performer_scenes(
|
||||||
capped = False
|
capped = False
|
||||||
cap_reason: str | None = None
|
cap_reason: str | None = None
|
||||||
started = _time.monotonic()
|
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.
|
# `Exception` catch-all blokował KeyboardInterrupt + maskował pool exhaustion.
|
||||||
NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError)
|
NET_EXC = (TubePageError, _httpx.HTTPError, OSError, ValueError)
|
||||||
|
|
||||||
|
|
@ -486,7 +611,7 @@ def rescrape_performer_scenes(
|
||||||
has_thumb = any(s.thumbnail_url for s in sources)
|
has_thumb = any(s.thumbnail_url for s in sources)
|
||||||
existing_tag_count = tag_counts.get(scene_id, 0)
|
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
|
# insert nie odpaliłby outer transaction; bez nested rollback całe N scen
|
||||||
# po niej miałoby PendingRollbackError.
|
# po niej miałoby PendingRollbackError.
|
||||||
sp = session.begin_nested()
|
sp = session.begin_nested()
|
||||||
|
|
@ -504,7 +629,7 @@ def rescrape_performer_scenes(
|
||||||
thumb = extract_thumbnail_url(r.text)
|
thumb = extract_thumbnail_url(r.text)
|
||||||
if thumb:
|
if thumb:
|
||||||
# Update tylko źródła z którego pochodzi thumb (single playback).
|
# 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).
|
# cross-attribution (np. xhamster thumb na porntrex entry).
|
||||||
# `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile
|
# `scene.thumbnail_url` w UI bierze pierwszy z thumb (mobile
|
||||||
# find()), więc 1 wystarczy.
|
# find()), więc 1 wystarczy.
|
||||||
|
|
@ -586,11 +711,18 @@ def refresh_performer(
|
||||||
performer_id: uuid.UUID,
|
performer_id: uuid.UUID,
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
) -> PerformerRefreshOut:
|
) -> PerformerRefreshOut:
|
||||||
"""On-demand search across all tubes dla pojedynczego performera. Synchronous —
|
"""On-demand search across all tubes dla pojedynczego performera. Startuje w TLE
|
||||||
blokujemy aż search skończy. Mobile pokazuje spinner.
|
i odpowiada od razu (`status="started"`), a sceny dopisują się w miarę mielenia.
|
||||||
|
|
||||||
Rate-guard: jeśli refresh był < 60s temu, zwraca cached result (HTTP 429-style
|
Dlaczego nie synchronicznie, jak do 2026-08-26: search po ~25 tubach trwa dłużej,
|
||||||
detail). Continuous worker w tle też robi swoje, więc cache jest częsty.
|
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.
|
||||||
"""
|
"""
|
||||||
from datetime import UTC as _UTC, datetime as _dt, timedelta as _td
|
from datetime import UTC as _UTC, datetime as _dt, timedelta as _td
|
||||||
|
|
||||||
|
|
@ -606,27 +738,37 @@ def refresh_performer(
|
||||||
detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit",
|
detail=f"recently searched {int(elapsed.total_seconds())}s ago, try in a bit",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Lazy import — performer_driven ma ciężki connector tree
|
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:
|
||||||
from app.scheduler.performer_driven import run_performer_driven
|
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(
|
counters_obj = run_performer_driven(
|
||||||
performer_ids=[performer_id],
|
performer_ids=[performer_id],
|
||||||
top_n=0,
|
top_n=0,
|
||||||
per_performer_limit=200,
|
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())
|
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(
|
return PerformerRefreshOut(
|
||||||
performer_id=performer_id,
|
performer_id=performer_id,
|
||||||
canonical_name=perf.canonical_name,
|
canonical_name=name,
|
||||||
counters=counters_obj.per_source,
|
counters={},
|
||||||
new_scenes=new_total,
|
new_scenes=0,
|
||||||
last_searched_at=perf.last_searched_at.isoformat() if perf.last_searched_at else None,
|
last_searched_at=searched_at,
|
||||||
|
status="started",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Watch history + continue watching.
|
"""Watch history + continue watching.
|
||||||
|
|
||||||
Single-user. Mobile pingu POST /scenes/{id}/progress przy:
|
Single-user. Mobile pingu POST /scenes/{id}/progress przy:
|
||||||
- Klik Watch (position_sec=0) — wciąga scenę do recent watch
|
- 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ą
|
- 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,
|
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
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.device import get_device_id
|
from app.api.device import get_device_id
|
||||||
from app.api.scenes import _build_scene_out
|
from app.api.scenes import _build_scenes_out_batch
|
||||||
from app.api.schemas import SceneOut
|
from app.api.schemas import SceneOut
|
||||||
from app.auth import require_api_key
|
from app.auth import require_api_key
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
|
@ -55,7 +55,7 @@ def upsert_progress(
|
||||||
if session.get(Scene, scene_id) is None:
|
if session.get(Scene, scene_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="scene not found")
|
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 →
|
# (np. 2 instancje playera lub auto-save + manual save). Wcześniej `get → add →
|
||||||
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
|
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
@ -139,7 +139,7 @@ def upsert_movie_progress(
|
||||||
session: Annotated[Session, Depends(get_session)],
|
session: Annotated[Session, Depends(get_session)],
|
||||||
device_id: Annotated[str, Depends(get_device_id)],
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
) -> MovieProgressOut:
|
) -> 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")."""
|
"przydałoby się oznaczenie filmów już obejrzanych")."""
|
||||||
if session.get(Movie, movie_id) is None:
|
if session.get(Movie, movie_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="movie not found")
|
raise HTTPException(status_code=404, detail="movie not found")
|
||||||
|
|
@ -237,11 +237,20 @@ def list_recent(
|
||||||
if not include_finished:
|
if not include_finished:
|
||||||
stmt = stmt.where(ScenePlayProgress.finished.is_(False))
|
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] = []
|
items: list[WatchEntry] = []
|
||||||
for prog, scene in session.execute(stmt).all():
|
for prog, scene in rows:
|
||||||
|
scene_out = out_by_id.get(scene.id)
|
||||||
|
if scene_out is None:
|
||||||
|
continue
|
||||||
items.append(
|
items.append(
|
||||||
WatchEntry(
|
WatchEntry(
|
||||||
scene=_build_scene_out(session, scene, device_id=device_id),
|
scene=scene_out,
|
||||||
position_sec=prog.position_sec,
|
position_sec=prog.position_sec,
|
||||||
duration_sec=prog.duration_sec,
|
duration_sec=prog.duration_sec,
|
||||||
finished=prog.finished,
|
finished=prog.finished,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""API key authentication.
|
"""API key authentication.
|
||||||
|
|
||||||
Klucz przyjmowany z header `X-API-Key` lub `Authorization: Bearer <key>`.
|
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
|
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.
|
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:
|
if not settings.auth_enabled:
|
||||||
return # local/dev — wszystko otwarte
|
return # local/dev, wszystko otwarte
|
||||||
|
|
||||||
candidate: str | None = None
|
candidate: str | None = None
|
||||||
if x_api_key:
|
if x_api_key:
|
||||||
|
|
|
||||||
243
app/clips_feed.py
Normal file
243
app/clips_feed.py
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
"""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))
|
||||||
|
|
@ -25,7 +25,7 @@ class Settings(BaseSettings):
|
||||||
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
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.
|
# 5k errors/mies wystarczy dla 1-user app.
|
||||||
sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
|
sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
|
||||||
sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT")
|
sentry_environment: str = Field(default="dev", validation_alias="SENTRY_ENVIRONMENT")
|
||||||
|
|
@ -48,12 +48,12 @@ class Settings(BaseSettings):
|
||||||
title_token_set_min: int = 88
|
title_token_set_min: int = 88
|
||||||
date_window_days: int = 7
|
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.
|
# 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).
|
# 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")
|
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).
|
# 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")
|
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(
|
sched_performer_continuous_refresh_days: int = Field(
|
||||||
default=30, validation_alias="GOON_SCHED_PERFORMER_CONTINUOUS_REFRESH_DAYS"
|
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
|
# 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
|
# 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.
|
# niż tube'y (~5-30 nowych dziennie), nie ma sensu wymiatać częściej.
|
||||||
|
|
@ -92,50 +92,80 @@ class Settings(BaseSettings):
|
||||||
sched_browse_latest_max_pages: int = Field(
|
sched_browse_latest_max_pages: int = Field(
|
||||||
default=5, validation_alias="GOON_SCHED_BROWSE_LATEST_MAX_PAGES"
|
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).
|
# 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).
|
# 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")
|
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")
|
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")
|
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).
|
# scoring pominął. 12h cadence: leci 2x dziennie (po porannym browse-latest run).
|
||||||
sched_bulk_dedup_hours: int = Field(
|
sched_bulk_dedup_hours: int = Field(
|
||||||
default=12, validation_alias="GOON_SCHED_BULK_DEDUP_HOURS"
|
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ł).
|
# 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.
|
# Re-ingesty pod nowymi tytułami → dupy odrastają, stąd cykliczny job. 12h. 0 = off.
|
||||||
sched_thumb_dedup_hours: int = Field(
|
sched_thumb_dedup_hours: int = Field(
|
||||||
default=12, validation_alias="GOON_SCHED_THUMB_DEDUP_HOURS"
|
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
|
# 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ą
|
# 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.
|
# przy re-ingeście, stąd cyklicznie. 12h, playback-only (to co user widzi). 0 = off.
|
||||||
sched_title_dedup_hours: int = Field(
|
sched_title_dedup_hours: int = Field(
|
||||||
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
|
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
|
# 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ąć
|
# 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.
|
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
|
||||||
sched_phash_blacklist_hours: int = Field(
|
sched_phash_blacklist_hours: int = Field(
|
||||||
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
|
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
|
# 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.
|
# codziennie, więc 72h to bezpieczny margines na jeden nieudany cykl.
|
||||||
ingest_watchdog_movie_max_age_hours: int = Field(
|
ingest_watchdog_movie_max_age_hours: int = Field(
|
||||||
default=72, validation_alias="GOON_INGEST_WATCHDOG_MOVIE_MAX_AGE_HOURS"
|
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.
|
# „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.
|
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
|
||||||
sched_junk_performers_hours: int = Field(
|
sched_junk_performers_hours: int = Field(
|
||||||
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
|
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
|
||||||
)
|
)
|
||||||
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin
|
# 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
|
||||||
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
|
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
|
||||||
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
|
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
|
||||||
# widzi (np. freshporno browse z rotującego roota, report 14f3a655). 6h cadence
|
# widzi (np. freshporno browse z rotującego roota, report 14f3a655). 6h cadence
|
||||||
|
|
@ -143,19 +173,19 @@ class Settings(BaseSettings):
|
||||||
sched_ingest_watchdog_hours: int = Field(
|
sched_ingest_watchdog_hours: int = Field(
|
||||||
default=6, validation_alias="GOON_SCHED_INGEST_WATCHDOG_HOURS"
|
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.
|
# listingu, więc 48h ciszy = anomalia.
|
||||||
ingest_watchdog_max_age_hours: int = Field(
|
ingest_watchdog_max_age_hours: int = Field(
|
||||||
default=48, validation_alias="GOON_INGEST_WATCHDOG_MAX_AGE_HOURS"
|
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
|
# 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
|
# 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.
|
# (continuous tick hituje wszystkie tuby per performer), zamrożone ≥73h → ~28× margines.
|
||||||
ingest_watchdog_search_max_age_hours: int = Field(
|
ingest_watchdog_search_max_age_hours: int = Field(
|
||||||
default=168, validation_alias="GOON_INGEST_WATCHDOG_SEARCH_MAX_AGE_HOURS"
|
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)
|
# 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
|
# 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.
|
# najświeższych GRYWALNYCH filmów bez obsady/studia per run; 6h cadence. 0 = off.
|
||||||
|
|
@ -165,20 +195,20 @@ class Settings(BaseSettings):
|
||||||
tpdb_movie_enrich_batch: int = Field(
|
tpdb_movie_enrich_batch: int = Field(
|
||||||
default=200, validation_alias="GOON_TPDB_MOVIE_ENRICH_BATCH"
|
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
|
# 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.
|
# counts do tego stale, dla sortu "popular" + badge "(N)" bez znaczenia. 0 = off.
|
||||||
sched_taxonomy_counts_hours: int = Field(
|
sched_taxonomy_counts_hours: int = Field(
|
||||||
default=3, validation_alias="GOON_SCHED_TAXONOMY_COUNTS_HOURS"
|
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).
|
# w panelu Hetzner Cloud). Bez tokenu monitor wyłączony (warning w log).
|
||||||
# Free traffic per server: CX22=20TB, CPX21=20TB itd. Overage = €1/TB.
|
# 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_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")
|
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_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_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")
|
hetzner_alert_error_pct: int = Field(default=95, validation_alias="HETZNER_ALERT_ERROR_PCT")
|
||||||
|
|
@ -188,14 +218,27 @@ class Settings(BaseSettings):
|
||||||
default=6, validation_alias="GOON_SCHED_HETZNER_MONITOR_HOURS"
|
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
|
# 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).
|
# ~2M live playback_sources; częściej bez sensu, dane zmieniają się powoli).
|
||||||
sched_source_stats_hours: int = Field(
|
sched_source_stats_hours: int = Field(
|
||||||
default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS"
|
default=6, validation_alias="GOON_SCHED_SOURCE_STATS_HOURS"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bright Data ISP proxy (stałe IP od ISP, rozliczane ryczałtem NIE per-GB) —
|
# 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),
|
||||||
# używany do ingestu HTML (scrape) tubów które blokują VPS IP twardym Cloudflare
|
# 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
|
# 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.
|
# IP-bound). Format env: `host:port:user:pass` (panel Bright Data). Pusty = brak.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
"""Connector registry helpers.
|
"""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
|
ż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).
|
mirror playback sources).
|
||||||
|
|
||||||
## Jak dodać nowe movie site
|
## Jak dodać nowe movie site
|
||||||
|
|
||||||
1. Napisz subclass `DooplayConnector` w `app/connectors/dooplay.py` (jeśli site używa
|
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()`.
|
napisz osobny connector implementujący `BaseMovieConnector.fetch_movies()`.
|
||||||
2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej.
|
2. Dodaj entry do `_MOVIE_CONNECTORS` poniżej.
|
||||||
3. Backend job `_job_movie_ingest` w `app/scheduler/jobs.py` automatycznie weźmie
|
3. Backend job `_job_movie_ingest` w `app/scheduler/jobs.py` automatycznie weźmie
|
||||||
|
|
@ -31,11 +31,10 @@ from __future__ import annotations
|
||||||
def get_movie_connectors() -> list[tuple[str, type]]:
|
def get_movie_connectors() -> list[tuple[str, type]]:
|
||||||
"""Zwraca listę (name, ConnectorCls) tuples w kolejności ingestu.
|
"""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 (
|
from app.connectors.dooplay import (
|
||||||
MangopornConnector,
|
MangopornConnector,
|
||||||
PandamoviesConnector,
|
|
||||||
StreampornVipConnector,
|
StreampornVipConnector,
|
||||||
)
|
)
|
||||||
from app.connectors.freeomovie import FreeoMovieConnector
|
from app.connectors.freeomovie import FreeoMovieConnector
|
||||||
|
|
@ -43,10 +42,10 @@ def get_movie_connectors() -> list[tuple[str, type]]:
|
||||||
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
|
from app.connectors.yourdailypornmovies import YourDailyPornMoviesConnector
|
||||||
|
|
||||||
# Kolejność ingestu: paradisehill FIRST (canonical primary, mirrory się do
|
# 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
|
# 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
|
# 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.
|
# timeout w _job_movie_ingest.
|
||||||
# streamporn.nl padł (0 new od 2026-05-23) → zastąpiony żywym streamporn.vip
|
# 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
|
# (StreampornVipConnector, ocena 2026-07-02). Stara klasa .nl zostaje w dooplay.py
|
||||||
|
|
@ -55,16 +54,26 @@ def get_movie_connectors() -> list[tuple[str, type]]:
|
||||||
("paradisehill", ParadisehillConnector),
|
("paradisehill", ParadisehillConnector),
|
||||||
("mangoporn", MangopornConnector),
|
("mangoporn", MangopornConnector),
|
||||||
("streampornvip", StreampornVipConnector),
|
("streampornvip", StreampornVipConnector),
|
||||||
("pandamovies", PandamoviesConnector),
|
# pandamovies WYLACZONY 2026-08-20: serwis padl, zostal im tylko zapamietany
|
||||||
# freeomovie.to (bestia theme, nie dooplay) — mirror: świeże filmy DVD,
|
# 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,
|
||||||
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
|
# title-trigram attach do canonical + playback z TABS. Ocena 2026-07-02.
|
||||||
("freeomovie", FreeoMovieConnector),
|
("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
|
# 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
|
# 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.
|
# których siedzą mangoporn/pandamovies/speedporn). Katalog min. 8000, do 2003.
|
||||||
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
|
# Metadane: obsada, studio (kryje się pod `/director/`), tagi, rok, synopsis,
|
||||||
# id produktu Adult Empire. Bez długości. Playback: voe + mixdrop (oba mają
|
# 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),
|
("yourdailypornmovies", YourDailyPornMoviesConnector),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ class RawScene(BaseModel):
|
||||||
(np. 'tpdb', 'stashdb')."""
|
(np. 'tpdb', 'stashdb')."""
|
||||||
|
|
||||||
raw: dict[str, Any] = Field(default_factory=dict)
|
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):
|
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):
|
class RawMovieChapter(BaseModel):
|
||||||
"""Pojedynczy rozdział filmu (movies czasem dzielą się na "Part 1/2/3" itp.).
|
"""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
|
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")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
@ -133,7 +133,7 @@ class RawMovieChapter(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class RawMovie(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
|
Performers / studio / tags reusable z RawPerformer / RawStudio / RawTag (te same
|
||||||
typy w obu pipelinach). Playback sources to lista mirrorów odtwarzania (paradisehill
|
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).
|
"""Connector dla source'a movies (paradisehill, psyplay, wp_movies).
|
||||||
|
|
||||||
Symetrycznie do BaseConnector ale yielduje RawMovie. Każde źródło zna własną
|
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
|
kind: SourceKind
|
||||||
|
|
|
||||||
2
app/connectors/clips/__init__.py
Normal file
2
app/connectors/clips/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
"""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."""
|
||||||
223
app/connectors/clips/redgifs.py
Normal file
223
app/connectors/clips/redgifs.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""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")
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
"""Direct tube scrapers.
|
"""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
|
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
|
`Source(name=SCRAPER_SOURCE_NAME)` z external_id `f"{sitetag}:{url}"`. Resolver
|
||||||
mergeuje idempotentnie po tym kluczu.
|
mergeuje idempotentnie po tym kluczu.
|
||||||
|
|
||||||
Nazwa źródła: do 2026-06-07 brzmiała `"pornapp"` — myląca pozostałość po usuniętym
|
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 —
|
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
|
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.
|
`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`.
|
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,
|
watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak,
|
||||||
porndittcom, perverzija, fpoxxx, ...) ma URL templates + regex'y oparte na typowych
|
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.
|
wyników, sprawdź real search HTML + popraw template/regex w odpowiednim pliku.
|
||||||
"""
|
"""
|
||||||
# Umbrella Source.name dla wszystkich direct-scraperów (deep-crawl, browse-latest,
|
# 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).
|
# od zewnętrznego porn-app API).
|
||||||
SCRAPER_SOURCE_NAME = "tube-scraper"
|
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.base import BaseDirectTubeScraper
|
||||||
from app.connectors.direct_scrapers.eporner import EpornerScraper
|
from app.connectors.direct_scrapers.eporner import EpornerScraper
|
||||||
from app.connectors.direct_scrapers.fpoxxx import FpoxxxScraper
|
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.hqporner import HQPornerScraper
|
||||||
from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper
|
from app.connectors.direct_scrapers.latestleaks import LatestLeaksScraper
|
||||||
from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper
|
from app.connectors.direct_scrapers.latestpornvideo import LatestPornVideoScraper
|
||||||
|
|
@ -48,6 +48,12 @@ from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper
|
||||||
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
|
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
|
||||||
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
|
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
|
||||||
from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper
|
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.fullporner import FullPornerScraper
|
||||||
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
|
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
|
||||||
from app.connectors.direct_scrapers.youperv import YoupervScraper
|
from app.connectors.direct_scrapers.youperv import YoupervScraper
|
||||||
|
|
@ -55,21 +61,21 @@ from app.connectors.direct_scrapers.xhamster import XHamsterScraper
|
||||||
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
|
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
|
||||||
from app.connectors.direct_scrapers.xnxx import XnxxScraper
|
from app.connectors.direct_scrapers.xnxx import XnxxScraper
|
||||||
from app.connectors.direct_scrapers.xvideos import XVideosScraper
|
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
|
from app.connectors.direct_scrapers.youporn import YouPornScraper
|
||||||
|
|
||||||
ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
|
ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
|
||||||
# Existing 4 (verified, in production)
|
# Existing 4 (verified, in production)
|
||||||
HQPornerScraper,
|
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/...`
|
# (tylko happyleafmotion ads), JS hijackuje wszystkie kliki → `go.rmishe.com/smartpop/...`
|
||||||
# popunder redirect. Mobile WebView page-as-hoster pokazuje ad redirect zamiast video.
|
# 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.
|
# 33,598 playback_sources mass-marked dead, 27,374 solo-orphan scenes deleted.
|
||||||
SxyLandScraper,
|
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.
|
# factory (0.1% canonical), zastępujemy lepszymi źródłami. Dane/pliki/extractor skasowane.
|
||||||
# Mainstream (URL templates well-known)
|
# 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
|
# 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.
|
# i ekstraktory usunięte. Powód: skrócone amatorskie clipy, nigdy nie matchują studio.
|
||||||
XVideosScraper,
|
XVideosScraper,
|
||||||
|
|
@ -78,60 +84,60 @@ ALL_DIRECT_SCRAPERS: list[type[BaseDirectTubeScraper]] = [
|
||||||
YouPornScraper,
|
YouPornScraper,
|
||||||
PornTrexScraper,
|
PornTrexScraper,
|
||||||
EpornerScraper,
|
EpornerScraper,
|
||||||
# Aggregators (WordPress-like ?s= search; speculative — verify post-deploy)
|
# Aggregators (WordPress-like ?s= search; speculative, verify post-deploy)
|
||||||
# XMoviesForYouScraper — wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
|
# XMoviesForYouScraper, wyłączony 2026-05-12 (post audit fix). 100% scen serwuje
|
||||||
# streamtape (DEAD_HOSTER_RE — malware drive-by .reg) + opcjonalnie playmogo/mixdrop.
|
# 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.
|
# 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
|
# Playmogo = DoodStream CAPTCHA. Porn-app sam olewa xmoviesforyou (brak handlera w
|
||||||
# jadx). 1,321 solo-orphan scen.
|
# 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
|
# Site przebudowany na KVS (DoodStream-CAPTCHA z 2026-05-12 zniknął); browse
|
||||||
# /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py.
|
# /latest-updates/ + flashvars get_file direct mp4 (extractor watchporn). Patrz watchporn.py.
|
||||||
# SiskaScraper — przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
|
# SiskaScraper, przeniesiony do ALL_BROWSE_SCRAPERS (browse-konwersja 2026-06-20,
|
||||||
# bo search siski zepsuty site-side — `?s=` ignoruje query). Patrz siska.py.
|
# 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
|
# 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 =
|
# only (DEAD_HOSTER_RE blacklist - malware drive-by .reg downloads). SERVER1_URL =
|
||||||
# streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346
|
# streamtape, brak SERVER2/SERVER3 backup. Porn-app sam olewa porn4days. 10,346
|
||||||
# solo-orphan scen.
|
# 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
|
# 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.
|
# 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
|
# 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.
|
# 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).
|
# 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
|
# canonical match. Slug-concat tytuły, brak studio/duration/date signali. Solo orphany
|
||||||
# usunięte (~15k scen).
|
# usunięte (~15k scen).
|
||||||
MyPornerLeakScraper,
|
MyPornerLeakScraper,
|
||||||
# Added 2026-05-12 (theporndude survey): jeden z 14 free tubes na liście który
|
# 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
|
# 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.
|
# 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
|
# (user request). Orphan factories (0.2% / weak-signal canonical match), zastępujemy
|
||||||
# lepszymi źródłami. Dane/pliki scraperów/extractory skasowane.
|
# lepszymi źródłami. Dane/pliki scraperów/extractory skasowane.
|
||||||
# Special
|
# Special
|
||||||
SxyPrnScraper,
|
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
|
# 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.
|
# (/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
|
# 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).
|
# `/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
|
# Phash thumbnail fingerprint (waga 0.40 w composite scoring) auto-mergeuje do
|
||||||
# canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane
|
# canonical (TPDB/StashDB) gdy tube hot-linkuje studio thumbnail. Schedulowane
|
||||||
# raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` +
|
# raz dziennie, pages 1-5. Patrz `_browse_base.BaseBrowseScraper` +
|
||||||
# `app/scheduler/browse_latest.py`.
|
# `app/scheduler/browse_latest.py`.
|
||||||
#
|
#
|
||||||
# **Pilot results (2026-05-12):**
|
# **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.**
|
# (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.**
|
# (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.freshporno import FreshpornoScraper # noqa: E402
|
||||||
from app.connectors.direct_scrapers.porn00 import Porn00Scraper # noqa: E402
|
from app.connectors.direct_scrapers.porn00 import Porn00Scraper # noqa: E402
|
||||||
|
|
@ -147,6 +153,8 @@ 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.vjav import VjavScraper # noqa: E402
|
||||||
from app.connectors.direct_scrapers.supjav import SupjavScraper # 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.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.superporn import SuperpornScraper # noqa: E402
|
||||||
from app.connectors.direct_scrapers.eporner_api import EpornerApiScraper # noqa: E402
|
from app.connectors.direct_scrapers.eporner_api import EpornerApiScraper # noqa: E402
|
||||||
from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper # noqa: E402
|
from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper # noqa: E402
|
||||||
|
|
@ -154,62 +162,93 @@ from app.connectors.direct_scrapers.xvideos_browse import XVideosBrowseScraper
|
||||||
ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
||||||
PerverzijaScraper,
|
PerverzijaScraper,
|
||||||
PornDishScraper,
|
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/
|
# DoodStream-CAPTCHA zniknął). Browse /latest-updates/: og:title, JSON-LD duration/
|
||||||
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
|
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
|
||||||
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
|
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
|
||||||
WatchPornScraper,
|
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
|
# 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
|
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
|
||||||
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
|
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
|
||||||
YoupervScraper,
|
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
|
# wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu
|
||||||
# tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go
|
# 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
|
# w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe
|
||||||
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
|
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
|
||||||
GalaxyPornScraper,
|
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
|
# uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage
|
||||||
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
|
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
|
||||||
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
|
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
|
||||||
PornBusyScraper,
|
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
|
# 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%
|
# 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).
|
# 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.
|
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
|
||||||
PornMikeScraper,
|
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
|
# 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`
|
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
|
||||||
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
|
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
|
||||||
# tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka
|
# 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.
|
# 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,
|
KPorner8Scraper,
|
||||||
# FullPornerScraper — dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
|
# 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
|
||||||
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
|
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
|
||||||
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
|
# 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
|
# 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ść),
|
# 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
|
# 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.
|
# bulk_dedup na te duplikaty, po tytule nie pójdzie.
|
||||||
FullPornerScraper,
|
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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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.
|
# 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%
|
# = 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
|
# 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).
|
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
|
||||||
|
|
@ -224,105 +263,114 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
||||||
XnxxBrowseScraper,
|
XnxxBrowseScraper,
|
||||||
FreshpornoScraper,
|
FreshpornoScraper,
|
||||||
FpoxxxScraper,
|
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"),
|
# 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
|
# thumb (studio+date w nazwie), category-* jako tag. Performerów listing nie ma
|
||||||
# czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid
|
# czysto (brak `actors-*`) → puste, dorabia canonical-merge. Playback: luluvid
|
||||||
# iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje.
|
# iframe → extractor latestpornvideocom (_embed_iframe) → telefon resolwuje.
|
||||||
LatestPornVideoScraper,
|
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>/`.
|
# zepsuty site-side (`?s=` ignoruje query), więc latest-browse z `/page/<n>/`.
|
||||||
# Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/
|
# Komplet metadanych z kafelka listingu (tytuł/duration/thumb/performer/studio/
|
||||||
# kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side.
|
# kategoria). Playback: playmogo + luluvid → telefon resolwuje phone-side.
|
||||||
SiskaScraper,
|
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
|
# 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)
|
# 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.
|
# 360/720 quality. Release year z `Released: <year>` na detail.
|
||||||
PornXPScraper,
|
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
|
# date (0%) ALE performer 100%, duration 100%, stream_url 100% (KVS video_alt_url
|
||||||
# 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title
|
# 720p). Tytuł zachowuje studio prefix ("Studio Title - Scene Name") → title
|
||||||
# fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować.
|
# fuzzy match (rapidfuzz token_set_ratio) może załapać canonical. Monitorować.
|
||||||
Porn00Scraper,
|
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
|
# kompletny JSON-LD VideoObject: title + uploadDate + duration + named studio
|
||||||
# (producer/publisher) + named performers (actor[]) + thumbnail. Najbogatsze
|
# (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 +
|
# komplet sygnałów. Phash hit-rate niski (własne crop-thumbnaile), studio +
|
||||||
# performer + date + duration nadrabiają.
|
# performer + date + duration nadrabiają.
|
||||||
PornDoeScraper,
|
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
|
# + `<meta property="video:duration|release_date|tag">` per scena (Goon ma
|
||||||
# duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio +
|
# duration w sekundach gotowe + ISO 8601 release_date z timezone). Studio +
|
||||||
# performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`).
|
# performerzy z `btn gold` linków (`/channels/<slug>/` + `/models/<slug>/`).
|
||||||
# 941k organic monthly (SE Ranking, comparable z porndoe 731k / porntrex 790k).
|
# 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
|
# 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`.
|
# redirect do porndudecams affiliate. Prawdziwa kanoniczna domena to TLD `.vip`.
|
||||||
YesPornVipScraper,
|
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
|
# 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
|
# 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.
|
# content (TeamSkeet/Dad Crush/Brazzers/...) z title+performer+duration → composite fuzzy.
|
||||||
# Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest).
|
# Nawet bez canonical match: grywalny content z inferred tagami (mission: daily tagged ingest).
|
||||||
FullmoviesScraper,
|
FullmoviesScraper,
|
||||||
HDPornGGScraper,
|
HDPornGGScraper,
|
||||||
# EpornerApiScraper — dołączony 2026-06-03 (Faza 2b alternatywa). eporner detail to
|
# 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
|
# 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+
|
# 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).
|
# keywords+added+thumb. ~100k filmów, deep-crawl przez crawl_page() (API, bez detail-fetch).
|
||||||
EpornerApiScraper,
|
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.
|
# + page-parse /models/ (performerzy) + /tags/. Sample: median ~10.5min, 93% ≥3min.
|
||||||
# Mega-katalog ~13M → deep_crawl._PAGE_CAP["xvideoscom"]=1800 (~50k najnowszych), nie
|
# 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,
|
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
|
# 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.
|
# 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
|
# 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
|
# po IP, tylko wymaga wykonania JS. WebView też nie przechodzi automatycznie (user
|
||||||
# widzi challenge: zgłoszenia 60576a84 / 244131ba / 92e9133e). Telemetria: ZERO prób
|
# 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).
|
# 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
|
# 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
|
# 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
|
# (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ę.
|
# co w search (`hqpornercom:<url>`), więc obie ścieżki trafiają w tę samą scenę.
|
||||||
HQPornerBrowseScraper,
|
HQPornerBrowseScraper,
|
||||||
# FourK69Scraper — USUNIĘTY 2026-06-25 (ten sam stub), NIE sprawdzany ponownie.
|
# PorndupeScraper, dodany 2026-09-05. Re-uploader scen studyjnych (Nubile, NF Busty,
|
||||||
# JavflixScraper — JAV vertical (osobna sekcja). origin tube:javflix jest w
|
# 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
|
||||||
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).
|
# JAV_ORIGINS → list_scenes wyklucza je z głównego feedu (tylko zakładka JAV).
|
||||||
JavflixScraper,
|
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
|
# 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.
|
# videofile.php → get_file HLS (portable, /proxy/hls passthrough). RE 2026-07-10.
|
||||||
VjavScraper,
|
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:
|
# 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.
|
# data-link reverse-hex → lk1.supremejav.com → hoster (extractor supjav). RE 2026-07-10.
|
||||||
SupjavScraper,
|
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
|
# porn00), /latest-updates/N/. JSON-LD (title+desc+uploadDate+thumb) + video:duration
|
||||||
# meta + /models/ performerzy + /categories/ tagi. Brak studio (tytuł bywa
|
# 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.
|
# finalny remote_control.php portable cross-IP.
|
||||||
NepornScraper,
|
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
|
# 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/
|
# _browse_base. JSON-LD (title+desc+uploadDate+thumb+duration) + chipy pornstar/
|
||||||
# kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback).
|
# kategorie. Playback IP-bound → WebView (extractor superporncom → _vps_blocked_fallback).
|
||||||
# Bez proxy: scraper no-op (pusty iterator).
|
# Bez proxy: scraper no-op (pusty iterator).
|
||||||
SuperpornScraper,
|
SuperpornScraper,
|
||||||
# porntrex/hqporner/youporn — NIE: KVS/JS bez SSR duration → niewidoczne orphany (2026-06-03).
|
# 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).
|
# ShyfapScraper, wyłączony 2026-05-12 (pilot fail, 0% match, orphan factory).
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
|
||||||
|
|
@ -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ą
|
Wzorzec: tube'y typu shyfap/freshporno/porn00/fullmovies/pornxp mają bogatą
|
||||||
metadata (title, studio, performers, tags, duration, release_date, description)
|
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.
|
iteruje "latest" page (sorted by upload date) i fetchuje detail per scene.
|
||||||
|
|
||||||
Różnica vs `BaseSearchScraper`:
|
Różnica vs `BaseSearchScraper`:
|
||||||
- **search**: tube wyszukuje sceny po performer name (dla performer-driven
|
- **search**: tube wyszukuje sceny po performer name (dla performer-driven
|
||||||
backfill). Wymaga znanego performera.
|
backfill). Wymaga znanego performera.
|
||||||
- **browse**: tube listuje newest scenes (latest-vids endpoint). Nie wymaga
|
- **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:
|
Browse jest komplementarny do search:
|
||||||
- search łapie sceny dla **znanych performerów** (TPDB/StashDB → tube)
|
- 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:
|
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
||||||
"""Parsuj scene detail HTML → RawScene z metadata.
|
"""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."""
|
nie aborti całe browse."""
|
||||||
|
|
||||||
def crawl_page(self, page: int) -> list[RawScene] | None:
|
def crawl_page(self, page: int) -> list[RawScene] | None:
|
||||||
"""Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest
|
"""Crawl JEDNEJ strony listingu → lista RawScene. Wspólne dla browse_latest
|
||||||
(top-N) i deep_crawl (kursor). Zwraca:
|
(top-N) i deep_crawl (kursor). Zwraca:
|
||||||
None — transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
|
None , transient fetch-fail listingu (caller: stop, NIE oznaczaj exhausted),
|
||||||
[] — pusty listing = koniec katalogu (caller: exhausted),
|
[] pusty listing = koniec katalogu (caller: exhausted),
|
||||||
[...] — sceny z tej strony.
|
[...], sceny z tej strony.
|
||||||
|
|
||||||
API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio
|
API-based subclasses (np. EpornerApiScraper) override'ują crawl_page bezpośrednio
|
||||||
(call API zamiast listing→detail). HTML browse subclasses dostarczają
|
(call API zamiast listing→detail). HTML browse subclasses dostarczają
|
||||||
|
|
@ -120,7 +120,7 @@ class BaseBrowseScraper(BaseDirectTubeScraper, abc.ABC):
|
||||||
break
|
break
|
||||||
yield from scenes
|
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
|
# tubes nie supportujemy performer-driven search; zwracamy pusty iterator. Tube'y
|
||||||
# które chcą *oba* tryby mogą override'ować search() osobno.
|
# które chcą *oba* tryby mogą override'ować search() osobno.
|
||||||
def search(
|
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
|
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).
|
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
|
nawet gdy te lib-y są niedostępne (graceful degradation: phash=None → resolver
|
||||||
spadnie do composite scoring, jak gdyby fingerprintu nie było).
|
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
|
return None
|
||||||
img = Image.open(io.BytesIO(r.content))
|
img = Image.open(io.BytesIO(r.content))
|
||||||
# phash domyślnie hash_size=8 → 64-bit hash → 16 hex chars. Mode 'L' (greyscale)
|
# 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.
|
# convert() bierze pierwszą klatkę, którą imagehash i tak zredukuje do grey.
|
||||||
return str(imagehash.phash(img.convert("RGB")))
|
return str(imagehash.phash(img.convert("RGB")))
|
||||||
except Exception as e:
|
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.
|
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ę).
|
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">`
|
`[^"\']*` 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.
|
→ `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:
|
if key not in _META_RE_CACHE:
|
||||||
attr = "property" if property else "name"
|
attr = "property" if property else "name"
|
||||||
val = re.escape(property or name or "")
|
val = re.escape(property or name or "")
|
||||||
# double-quoted content (HTML standard) — preferred
|
# double-quoted content (HTML standard), preferred
|
||||||
# Pattern: <meta property="X" content="...inner..." > — inner allows apostrophes
|
# Pattern: <meta property="X" content="...inner..." > , inner allows apostrophes
|
||||||
_META_RE_CACHE[key] = re.compile(
|
_META_RE_CACHE[key] = re.compile(
|
||||||
rf'<meta[^>]+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"'
|
rf'<meta[^>]+{attr}=["\']{val}["\'][^>]*?content="([^"]*)"'
|
||||||
rf'|<meta[^>]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'',
|
rf'|<meta[^>]+{attr}=["\']{val}["\'][^>]*?content=\'([^\']*)\'',
|
||||||
|
|
|
||||||
|
|
@ -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
|
Platforma rozpoznawalna po: `/watch/<slug>_<id>.html`, sitemap index
|
||||||
`/sitemaps/videos/sitemap-N.xml` (z `<lastmod>`), JSON-LD VideoObject na detail
|
`/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)
|
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
|
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
|
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
|
PlayTube nie ma strukturalnego pola studio na scenie, studio siedzi w
|
||||||
kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios).
|
kategoriach (hqfap: suffix " Clips"; 4k69: nazwa z listy /studios).
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ def _extract_video_object(html: str) -> dict | None:
|
||||||
|
|
||||||
|
|
||||||
class BasePlayTubeScraper(BaseBrowseScraper):
|
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:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -123,9 +123,9 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
||||||
def _pick_studio(self, category_names: list[str]) -> str | None:
|
def _pick_studio(self, category_names: list[str]) -> str | None:
|
||||||
return 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
|
# 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
|
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
|
||||||
return f"{self.base_url}/sitemap.xml"
|
return f"{self.base_url}/sitemap.xml"
|
||||||
|
|
||||||
|
|
@ -156,7 +156,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
||||||
sm = browser_get(sm_url, timeout=self._timeout)
|
sm = browser_get(sm_url, timeout=self._timeout)
|
||||||
sm.raise_for_status()
|
sm.raise_for_status()
|
||||||
except Exception as e:
|
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)
|
log.warning("%s: sitemap fetch failed %s: %s", self.sitetag, sm_url, e)
|
||||||
continue
|
continue
|
||||||
for block in _URL_BLOCK_RE.findall(sm.text):
|
for block in _URL_BLOCK_RE.findall(sm.text):
|
||||||
|
|
@ -227,7 +227,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
||||||
thumbnail_url = video.get("thumbnailUrl") or None
|
thumbnail_url = video.get("thumbnailUrl") or None
|
||||||
|
|
||||||
# Pille: pornstar → performer; category → studio (hook `_pick_studio`)
|
# 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] = []
|
performers: list[RawPerformer] = []
|
||||||
category_names: list[str] = []
|
category_names: list[str] = []
|
||||||
seen_perf: set[str] = set()
|
seen_perf: set[str] = set()
|
||||||
|
|
@ -275,7 +275,7 @@ class BasePlayTubeScraper(BaseBrowseScraper):
|
||||||
if ph:
|
if ph:
|
||||||
fingerprints.append(RawFingerprint(kind="phash", value=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.
|
# stream_url; extractor per-sitetag resolvuje świeży on-demand.
|
||||||
playback_sources = [
|
playback_sources = [
|
||||||
RawPlaybackSource(
|
RawPlaybackSource(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
Wzorzec stosowany przez wszystkie tube'y discovery scrapers:
|
||||||
1. Build search URL z `_search_url_template` (formatowane query+page).
|
1. Build search URL z `_search_url_template` (formatowane query+page).
|
||||||
2. Fetch HTML curl_cffi.
|
2. Fetch HTML curl_cffi.
|
||||||
3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL,
|
3. Match `_scene_url_re` (regex z grupą `url` lub group(1) jako scene URL,
|
||||||
opcjonalnie `slug` lub `id` jako tytuł source).
|
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.
|
fuzzy search tube'ów często zwraca niezwiązane wyniki.
|
||||||
5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`.
|
5. Yield RawScene z `external_id=f"{sitetag}:{scene_url}"`.
|
||||||
|
|
||||||
Subclass override:
|
Subclass override:
|
||||||
- `sitetag: str` — np. "pornhubcom"
|
- `sitetag: str`, np. "pornhubcom"
|
||||||
- `_search_url_template: str` — z `{query}` i `{page}` placeholderami
|
- `_search_url_template: str`, z `{query}` i `{page}` placeholderami
|
||||||
- `_scene_url_re: re.Pattern[str]` — regex z named group `url` (scene URL)
|
- `_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)
|
- `_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)
|
- `_token_filter_text(match) -> str`, co testować na query tokens (default: cała URL)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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
|
# 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
|
# (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.
|
# dla scene thumbnails.
|
||||||
_IMG_SRC_RE = re.compile(
|
_IMG_SRC_RE = re.compile(
|
||||||
r'<img[^>]+(?:src|data-src|data-original|data-lazy-src|data-lazy)=["\']'
|
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),
|
#: 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).
|
#: 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
|
#: Minimalna długość tokena query do filtrowania wyników (krótsze ignorujemy żeby
|
||||||
#: nie matchowały niezwiązanych slugów).
|
#: 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.
|
#: 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.
|
#: ±800 chars łapie ten pattern niezawodnie.
|
||||||
_thumbnail_window: int = 800
|
_thumbnail_window: int = 800
|
||||||
|
|
||||||
def _scene_url_from_match(self, m: re.Match[str]) -> str:
|
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:
|
try:
|
||||||
return m.group("url")
|
return m.group("url")
|
||||||
except IndexError:
|
except IndexError:
|
||||||
|
|
@ -103,23 +103,54 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
|
|
||||||
def _format_query_for_url(self, query: str) -> str:
|
def _format_query_for_url(self, query: str) -> str:
|
||||||
"""Default: URL-encode (spaces → `+`). Subclass override gdy tube wymaga
|
"""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())
|
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(
|
def _fetch_scene_metadata(
|
||||||
self, scene_url: str
|
self, scene_url: str
|
||||||
) -> tuple[RawStudio | None, list[RawPerformer], list[RawTag]] | None:
|
) -> 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).
|
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.
|
powinien rzucić wyjątki swobodnie, base łapie i kontynuuje bez metadata.
|
||||||
|
|
||||||
Returns: (studio, performers, tags). Każde może być None / pusta lista.
|
Returns: (studio, performers, tags). Każde może być None / pusta lista.
|
||||||
"""
|
"""
|
||||||
return None
|
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(
|
def search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
|
|
@ -147,13 +178,18 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
}
|
}
|
||||||
|
|
||||||
seen: set[str] = set()
|
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
|
yielded = 0
|
||||||
for m in self._scene_url_re.finditer(r.text):
|
for m in self._scene_url_re.finditer(r.text):
|
||||||
scene_url = self._scene_url_from_match(m).strip()
|
scene_url = self._scene_url_from_match(m).strip()
|
||||||
if scene_url.startswith("//"):
|
if scene_url.startswith("//"):
|
||||||
scene_url = "https:" + scene_url
|
scene_url = "https:" + scene_url
|
||||||
elif scene_url.startswith("/"):
|
elif scene_url.startswith("/"):
|
||||||
# Relative URL — prefix host z search URL.
|
# Relative URL, prefix host z search URL.
|
||||||
base = urllib.parse.urlparse(url)
|
base = urllib.parse.urlparse(url)
|
||||||
scene_url = f"{base.scheme}://{base.netloc}{scene_url}"
|
scene_url = f"{base.scheme}://{base.netloc}{scene_url}"
|
||||||
if scene_url in seen:
|
if scene_url in seen:
|
||||||
|
|
@ -164,26 +200,35 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
slug_lower = slug.lower()
|
slug_lower = slug.lower()
|
||||||
if slug_lower in self._nav_slug_blacklist:
|
if slug_lower in self._nav_slug_blacklist:
|
||||||
continue
|
continue
|
||||||
# Strict: WSZYSTKIE query tokens muszą być w slug. Wcześniej `any()`
|
# Slug musi zawierać CAŁE nazwisko jako SPÓJNĄ FRAZĘ, nie luźno rozrzucone
|
||||||
# przepuszczał scenę gdy choć jeden token był w slug — dla performera
|
# tokeny. Historia tej linijki to trzy poziomy:
|
||||||
# "Ava Koxxx" (query="ava koxxx") wszystkie sceny z "ava-*" slug
|
#
|
||||||
# (Ava Devine, Ava Addams itp.) były labelowane jako "Ava Koxxx",
|
# 1. `any(tok in slug)` — scena z „ava-devine" dostawała Ava Koxxx.
|
||||||
# bo `any("ava" in slug)` =True. User reports: scena "ava devine
|
# 2. `all(tok in slug)` — lepiej, ale to wciąż test PODCIĄGU na luźnych
|
||||||
# gangbanged..." miała Ava Koxxx w DB. Fix: `all()` — slug musi
|
# tokenach, więc przy „Tiffani Time" przechodziło wszystko, co miało
|
||||||
# zawierać każdy ≥3-char token z imienia performera.
|
# gdziekolwiek „time" (first-time, adult-time, multiple-times) plus
|
||||||
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
|
# 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):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
title = self._title_from_slug(slug)
|
title = self._title_from_slug(slug)
|
||||||
|
|
||||||
# Thumbnail: search ±N chars around scene_url match for nearest <img src=>.
|
# 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
|
# 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_start = max(0, m.start() - self._thumbnail_window)
|
||||||
window_end = min(len(r.text), m.end() + self._thumbnail_window)
|
window_end = min(len(r.text), m.end() + self._thumbnail_window)
|
||||||
window_html = r.text[window_start:window_end]
|
window_html = r.text[window_start:window_end]
|
||||||
thumb_url: str | None = None
|
thumb_url: str | None = thumb_map.get(scene_url)
|
||||||
img_m = _IMG_SRC_RE.search(window_html)
|
img_m = None if thumb_url else _IMG_SRC_RE.search(window_html)
|
||||||
if img_m:
|
if img_m:
|
||||||
thumb_url = img_m.group(1).strip()
|
thumb_url = img_m.group(1).strip()
|
||||||
if thumb_url.startswith("//"):
|
if thumb_url.startswith("//"):
|
||||||
|
|
@ -193,7 +238,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}"
|
thumb_url = f"{base.scheme}://{base.netloc}{thumb_url}"
|
||||||
|
|
||||||
# Opcjonalny metadata fetch (studio/dodatkowi performerzy/tagi). Default
|
# 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.
|
# PornHat ma `data-setup='{...}'` w `js-ajax-{dvd,model,tag}` divach.
|
||||||
studio: RawStudio | None = None
|
studio: RawStudio | None = None
|
||||||
extra_performers: list[RawPerformer] = []
|
extra_performers: list[RawPerformer] = []
|
||||||
|
|
@ -212,7 +257,7 @@ class BaseSearchScraper(BaseDirectTubeScraper):
|
||||||
description = meta[3]
|
description = meta[3]
|
||||||
|
|
||||||
# Performer z query zawsze obecny (driver scraping). Extra performers
|
# 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]
|
all_performers = [RawPerformer(name=query.strip()), *extra_performers]
|
||||||
|
|
||||||
yield RawScene(
|
yield RawScene(
|
||||||
|
|
|
||||||
|
|
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
|
|
@ -13,7 +13,7 @@ class BaseDirectTubeScraper(abc.ABC):
|
||||||
żeby dziedziczyć logikę resolvera + idempotent merge per external_id."""
|
żeby dziedziczyć logikę resolvera + idempotent merge per external_id."""
|
||||||
|
|
||||||
sitetag: str
|
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.)."""
|
z porn-app sitetag (hqpornercom, sxylandcom, itp.)."""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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>/`.
|
Scene URL: `https://www.eporner.com/hd-porn/<id>/<slug>/` lub `/video-<id>/<slug>/`.
|
||||||
|
|
|
||||||
|
|
@ -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`
|
publiczne API zwracające KOMPLETNĄ metadatę w jednym callu: `title`, `length_sec`
|
||||||
(duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów,
|
(duration), `keywords` (tagi), `added` (data), thumb, embed, url. ~100k filmów,
|
||||||
`order=latest`, ~100/stronę → ~1000 szybkich calli (BEZ detail-fetch). To czyni
|
`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
|
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
|
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
|
(BaseBrowseScraper ABC ich wymaga, ale nieużywane). Sitetag `epornercom` = ten sam co
|
||||||
|
|
@ -113,7 +113,7 @@ class EpornerApiScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
return out
|
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
|
def _listing_url(self, page: int) -> str: # pragma: no cover
|
||||||
raise NotImplementedError("EpornerApiScraper używa crawl_page (API), nie HTML listingu")
|
raise NotImplementedError("EpornerApiScraper używa crawl_page (API), nie HTML listingu")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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).
|
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ł, 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.
|
(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`).
|
PO STRONIE TELEFONU (fpoxxxResolver.ts / WebView fallback, extractor `fpoxxx`).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -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
|
Pilot #2 (po shyfap fail). Hipoteza: freshporno zachowuje oryginalne studio titles
|
||||||
("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match
|
("Straighten Her Out" zamiast custom rebranding jak shyfap) → title fuzzy match
|
||||||
|
|
@ -9,7 +9,7 @@ URL patterns:
|
||||||
- Scene: `/videos/<slug>/`
|
- Scene: `/videos/<slug>/`
|
||||||
|
|
||||||
Listing: świadomie `/latest-updates/` zamiast roota `/`. Root jest KVS-owym
|
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
|
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
|
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
|
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
|
# Channel = studio. Pierwszy `/channels/<slug>/` link na stronie body
|
||||||
# (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym)
|
# (top nav też ma channels list ale to inny pattern z `/channels/" zatrzymanym)
|
||||||
studio: RawStudio | None = None
|
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):
|
for m in _CHANNEL_LINK_RE.finditer(detail_html):
|
||||||
slug, name = m.group(1), m.group(2).strip()
|
slug, name = m.group(1), m.group(2).strip()
|
||||||
if name.lower() in ("channels", ""):
|
if name.lower() in ("channels", ""):
|
||||||
|
|
@ -120,7 +120,7 @@ class FreshpornoScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Performers — wszyscy `/models/<slug>/`
|
# Performers, wszyscy `/models/<slug>/`
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_perf: set[str] = set()
|
seen_perf: set[str] = set()
|
||||||
for m in _MODEL_LINK_RE.finditer(detail_html):
|
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)
|
# 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] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
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...">`.
|
# Release date, freshporno emituje `<meta itemprop="uploadDate" content="2026-05-20T...">`.
|
||||||
# To data wrzucenia na freshporno, NIE oryginalna release_date studio — ale dla
|
# 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
|
# ś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 →
|
# `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
|
# duplicate scene zamiast freshporno PS dodane do TPDB canonical (bug-report
|
||||||
|
|
|
||||||
|
|
@ -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>/`,
|
Identyczny engine co hdporn.gg (KVS sponsor_groups stack): `/videos/<slug>/`,
|
||||||
`/networks/<slug>/`, `/models/<slug>/`, `/tags/<slug>/`. og:image to `img.fullmovies.xxx/...`
|
`/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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ class FullmoviesScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
|
# Studio z PREFIKSU tytułu ("Studio - Scene Title"), nie z sidebara /networks/.
|
||||||
# Sidebar listuje WSZYSTKIE sieci → `_NETWORK_LINK_RE.finditer().first()` zawsze
|
# 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 - ...").
|
# po oczyszczeniu ma format "Studio - Opis" (np. "Fake Hostel - ...").
|
||||||
studio: RawStudio | None = None
|
studio: RawStudio | None = None
|
||||||
if " - " in title:
|
if " - " in title:
|
||||||
|
|
@ -104,7 +104,7 @@ class FullmoviesScraper(BaseBrowseScraper):
|
||||||
tags.append(RawTag(external_id=f"fullmoviesxxx:tag:{slug}", name=name, slug=slug))
|
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
|
# 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),
|
# thumbnaile). Matching trzyma się na title+performer+duration (seed: 92% tagged),
|
||||||
# więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
# więc download thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
**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
|
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
|
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.
|
(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
|
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
|
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
|
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ć.
|
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
|
**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ą
|
i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą
|
||||||
tylko sceny z obsadą.
|
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]+)"')
|
_SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"')
|
||||||
_TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>")
|
_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.
|
# siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem.
|
||||||
_INFO_RE = re.compile(
|
_INFO_RE = re.compile(
|
||||||
r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>'
|
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>')
|
_PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
|
||||||
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
|
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
|
||||||
_TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>')
|
_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).
|
# Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu).
|
||||||
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
|
_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)
|
pb = _PERF_BLOCK_RE.search(detail_html)
|
||||||
if pb:
|
if pb:
|
||||||
for name in _PERF_RE.findall(pb.group(1)):
|
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.
|
# podnosimy, dopasowanie i tak idzie po slugu.
|
||||||
name = html_mod.unescape(name).strip().title()
|
name = html_mod.unescape(name).strip().title()
|
||||||
slug = slugify(name)
|
slug = slugify(name)
|
||||||
|
|
|
||||||
|
|
@ -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
|
To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
|
||||||
pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
|
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
|
**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
|
||||||
obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
|
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.
|
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
|
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.
|
~25-35% katalogu (~150-220k scen), czyli i tak dużo.
|
||||||
|
|
||||||
Pozostałe pułapki, wszystkie obsłużone niżej:
|
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
|
AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
|
||||||
tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
|
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).
|
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
|
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
|
||||||
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
|
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
|
||||||
|
|
@ -65,7 +65,7 @@ _UNIT_DAYS = {
|
||||||
|
|
||||||
def _submitted_to_date(detail_html: str):
|
def _submitted_to_date(detail_html: str):
|
||||||
"""`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a
|
"""`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)
|
m = _SUBMITTED_RE.search(detail_html)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
|
|
@ -127,7 +127,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
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] = []
|
performers: list[RawPerformer] = []
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
studio: RawStudio | None = None
|
studio: RawStudio | None = None
|
||||||
|
|
@ -162,7 +162,7 @@ class FullVideosPornScraper(BaseBrowseScraper):
|
||||||
RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
|
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).
|
# spunowane tytuły bez performera, które weszłyby jako puste orphany).
|
||||||
if not performers:
|
if not performers:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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.
|
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).
|
Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0).
|
||||||
|
|
||||||
Metadane:
|
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)
|
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)
|
- tagi: `<div class="tags-list">` (wycinamy slug studia, żeby nie robić tag=studio)
|
||||||
- thumbnail: `data-main-thumb` / og:image
|
- 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
|
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
|
`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).
|
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
|
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
|
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 /
|
Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro /
|
||||||
sport.seekplays.com / news.upns.pro) → extractor `galaxyporncom` → istniejący
|
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)
|
_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE)
|
||||||
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', 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>.+)$")
|
_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] …`
|
# `[Onlyfans] Some Title` / `[Darkkotv] …`
|
||||||
_TITLE_BRACKET_RE = re.compile(r"^\[(?P<studio>[^\]]{2,30})\]\s*(?P<rest>.+)$")
|
_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))
|
data = json.loads(_decrypt(r.text))
|
||||||
thumb = (data.get("thumbnail") or "").strip()
|
thumb = (data.get("thumbnail") or "").strip()
|
||||||
if not thumb:
|
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
|
vtt_url = host + thumb if thumb.startswith("/") else thumb
|
||||||
vr = browser_get(vtt_url, headers=headers, timeout=timeout)
|
vr = browser_get(vtt_url, headers=headers, timeout=timeout)
|
||||||
if vr.status_code != 200:
|
if vr.status_code != 200:
|
||||||
|
|
@ -145,7 +145,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
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).
|
# ma dokładnie tyle linków /actor/ ile realnych aktorów).
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_p: set[str] = set()
|
seen_p: set[str] = set()
|
||||||
|
|
@ -186,7 +186,7 @@ class GalaxyPornScraper(BaseBrowseScraper):
|
||||||
slug=slugify(studio_name),
|
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] = []
|
tags: list[RawTag] = []
|
||||||
seen_t: set[str] = set()
|
seen_t: set[str] = set()
|
||||||
studio_slug = slugify(studio_name) if studio_name else ""
|
studio_slug = slugify(studio_name) if studio_name else ""
|
||||||
|
|
|
||||||
|
|
@ -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:
|
Search: `https://hdporn92.com/page/<n>/?s=<query>`. Scene URL format:
|
||||||
`https://hdporn92.com/<slug>/` (jeden segment ścieżki). Trzeba odsiać
|
`https://hdporn92.com/<slug>/` (jeden segment ścieżki). Trzeba odsiać
|
||||||
|
|
|
||||||
|
|
@ -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,
|
Engine podobny do freshporno: `/videos/<slug>/` URL, `/networks/<slug>/` = studio,
|
||||||
`/models/<slug>/` = performer, `/tags/<slug>/` = tag.
|
`/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
|
sprawdzamy phash distance (gate-keeper: jeśli Hamming >5 dla >70% scen → orphan
|
||||||
factory, wyłącz; analogia do shyfap).
|
factory, wyłącz; analogia do shyfap).
|
||||||
"""
|
"""
|
||||||
|
|
@ -63,9 +63,9 @@ class HDPornGGScraper(BaseBrowseScraper):
|
||||||
title = meta_content(detail_html, property="og:title")
|
title = meta_content(detail_html, property="og:title")
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
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()
|
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.
|
# to silny sygnał dla fuzzy match.
|
||||||
|
|
||||||
description = meta_content(detail_html, property="og:description")
|
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
|
# 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
|
# Matching trzyma się na title+performer+duration (seed: 92% tagged), więc download
|
||||||
# thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
# thumbnaila pod phash to czysty narzut. thumbnail_url zostaje (display).
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
|
|
|
||||||
131
app/connectors/direct_scrapers/hentaigasm.py
Normal file
131
app/connectors/direct_scrapers/hentaigasm.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""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")
|
||||||
128
app/connectors/direct_scrapers/hentaimama.py
Normal file
128
app/connectors/direct_scrapers/hentaimama.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
131
app/connectors/direct_scrapers/hentaiplay.py
Normal file
131
app/connectors/direct_scrapers/hentaiplay.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""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")
|
||||||
|
|
@ -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
|
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,
|
na stub), przywrócony 2026-07-07 (strona wróciła na CDN vstor.top z realnymi plikami,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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).
|
resolver i tak je sciagnie z TPDB merge).
|
||||||
|
|
||||||
Search fuzzy: hqporner zwraca "Lola Noir" gdy szukamy "Noir" itp. Dlatego
|
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.
|
jak `fetch_scenes_for_search` w pornapp connectorze.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -17,6 +17,7 @@ import urllib.parse
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from app.connectors.base import RawPerformer, RawPlaybackSource, RawScene
|
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.connectors.direct_scrapers.base import BaseDirectTubeScraper
|
||||||
from app.extractors import browser_get
|
from app.extractors import browser_get
|
||||||
|
|
||||||
|
|
@ -55,7 +56,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
|
||||||
# Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być).
|
# Pojedyncze nazwy ("Belladonna") nadal działają (jeden token musi być).
|
||||||
query_tokens = {tok for tok in query.lower().split() if len(tok) >= 3}
|
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
|
# 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ń
|
# 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
|
# 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
|
# człon, wymagamy dodatkowo CAŁEJ nazwy sklejonej bez separatorów (slug
|
||||||
|
|
@ -73,9 +74,13 @@ class HQPornerScraper(BaseDirectTubeScraper):
|
||||||
continue
|
continue
|
||||||
seen_urls.add(scene_url)
|
seen_urls.add(scene_url)
|
||||||
|
|
||||||
# Title-token filter
|
# 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).
|
||||||
slug_lower = slug_part.lower()
|
slug_lower = slug_part.lower()
|
||||||
if query_tokens and not all(tok in slug_lower for tok in query_tokens):
|
if query_tokens and not BaseSearchScraper._slug_matches_query(slug_lower, query):
|
||||||
continue
|
continue
|
||||||
if _needs_compact and _compact not in re.sub(r"[^a-z0-9]", "", slug_lower):
|
if _needs_compact and _compact not in re.sub(r"[^a-z0-9]", "", slug_lower):
|
||||||
continue
|
continue
|
||||||
|
|
@ -92,7 +97,7 @@ class HQPornerScraper(BaseDirectTubeScraper):
|
||||||
page_url=scene_url,
|
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.
|
# że scena prawie na pewno o nim. Resolver dorobi ScenePerformer link.
|
||||||
performers=[RawPerformer(name=query.strip())],
|
performers=[RawPerformer(name=query.strip())],
|
||||||
raw={
|
raw={
|
||||||
|
|
|
||||||
|
|
@ -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),
|
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
|
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>`.
|
`external_id` MUSI być identyczny jak w `HQPornerScraper` (search): `hqpornercom:<url>`.
|
||||||
Inaczej ta sama scena z obu ścieżek zrobiłaby się dwoma wpisami.
|
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.
|
w `/proxy/img/`, a phash liczymy z jawnym refererem.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -134,7 +134,7 @@ class HQPornerBrowseScraper(BaseBrowseScraper):
|
||||||
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
||||||
|
|
||||||
return RawScene(
|
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}",
|
external_id=f"{self.sitetag}:{scene_url}",
|
||||||
title=title,
|
title=title,
|
||||||
duration_sec=duration_sec,
|
duration_sec=duration_sec,
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
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):
|
Struktura (RE 2026-07-10):
|
||||||
- listing: `/page/N/` (WordPress archive), posty pod `/<slug>/` (np. /bkd-368-english-subtitle/)
|
- listing: `/page/N/` (WordPress archive), posty pod `/<slug>/` (np. /bkd-368-english-subtitle/)
|
||||||
|
|
@ -43,7 +43,18 @@ class JavflixScraper(BaseBrowseScraper):
|
||||||
sitetag = "javflix"
|
sitetag = "javflix"
|
||||||
|
|
||||||
def _listing_url(self, page: int) -> str:
|
def _listing_url(self, page: int) -> str:
|
||||||
return f"{_BASE}/" if page <= 1 else f"{_BASE}/page/{page}/"
|
# `/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)
|
||||||
|
|
||||||
def _extract_scene_urls(self, listing_html: str) -> list[str]:
|
def _extract_scene_urls(self, listing_html: str) -> list[str]:
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
@ -76,7 +87,7 @@ class JavflixScraper(BaseBrowseScraper):
|
||||||
up = _itemprop(detail_html, "uploadDate")
|
up = _itemprop(detail_html, "uploadDate")
|
||||||
release_date = _parse_iso_date(up) if up else None
|
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.
|
# (searchable) bo javflix nie ma osobnego pola kodu.
|
||||||
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
|
slug = scene_url.rstrip("/").rsplit("/", 1)[-1]
|
||||||
cm = _CODE_RE.match(slug)
|
cm = _CODE_RE.match(slug)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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ż
|
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
|
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&
|
`_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos&
|
||||||
page=latest` (20). To jednocześnie SUFIT — deep crawl jest niemożliwy.
|
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
|
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}`).
|
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=
|
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
|
<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
|
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.
|
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:
|
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).
|
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.
|
`/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami.
|
||||||
Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które są realnymi 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
|
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
|
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.
|
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.
|
ale kosztuje to jeden fetch na run, więc zostaje.
|
||||||
|
|
||||||
Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ _SCENE_HREF_RE = re.compile(
|
||||||
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
|
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
|
||||||
_STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE)
|
_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
|
# 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.
|
# (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe.
|
||||||
_AMBIGUOUS_STUDIOS = {
|
_AMBIGUOUS_STUDIOS = {
|
||||||
|
|
@ -119,7 +119,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
|
||||||
|
|
||||||
def _load_catalog(self) -> list[str] | None:
|
def _load_catalog(self) -> list[str] | None:
|
||||||
"""Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz
|
"""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:
|
if self._catalog is not None:
|
||||||
return self._catalog
|
return self._catalog
|
||||||
urls: list[str] = []
|
urls: list[str] = []
|
||||||
|
|
@ -145,7 +145,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
|
||||||
|
|
||||||
def crawl_page(self, page: int) -> list[RawScene] | None:
|
def crawl_page(self, page: int) -> list[RawScene] | None:
|
||||||
"""Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/`
|
"""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()
|
catalog = self._load_catalog()
|
||||||
if catalog is None:
|
if catalog is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -174,7 +174,7 @@ class KPorner8Scraper(BasePlayTubeScraper):
|
||||||
scene = super()._parse_detail(scene_url, detail_html)
|
scene = super()._parse_detail(scene_url, detail_html)
|
||||||
if scene is None:
|
if scene is None:
|
||||||
return 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.
|
# większość katalogu wpadłaby jako puste orphany.
|
||||||
if not scene.performers:
|
if not scene.performers:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""latestleaks.co — direct HTML scrape.
|
"""latestleaks.co, direct HTML scrape.
|
||||||
|
|
||||||
Search: `https://latestleaks.co/page/<n>/?s=<q>`.
|
Search: `https://latestleaks.co/page/<n>/?s=<q>`.
|
||||||
Scene URL: `https://latestleaks.co/<slug>/`.
|
Scene URL: `https://latestleaks.co/<slug>/`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
Historia: dawniej performer-driven search scraper (`/actor/<slug>/`). Problem
|
||||||
(user-report 1da0375e): search-scraper ingestuje TYLKO sceny performerów, których
|
(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
|
a na stronie jest świeży. Przerobione na BROWSE (latest chronologicznie z
|
||||||
`/page/<n>/`, page 1 = `/`), 2026-06-22.
|
`/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> ...">
|
<article class="... post-<id> ... category-<cat> tag-<x> tag-<y> ...">
|
||||||
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
|
<a href="https://latestpornvideo.com/<id>/" title="<Tytuł>">
|
||||||
data-main-thumb="<Studio>-YYYY-MM-DD-...-cover.jpg"
|
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)
|
_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").
|
# 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")
|
_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", ""}
|
_CAT_SKIP = {"latest-porn-videos", "uncategorized", ""}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -137,7 +137,7 @@ class LatestPornVideoScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tagi: tylko prawdziwe kategorie (category-*), bez "latest-porn-videos".
|
# 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.
|
# bez `actors-*` (jak na /actor/) nie da się ich rozdzielić → byłby szum.
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
seen_tag: set[str] = set()
|
seen_tag: set[str] = set()
|
||||||
|
|
|
||||||
|
|
@ -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ą
|
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
|
`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
|
listingu i sceny, więc fetchujemy `mypornerleak.com/actor/<slug>/` a linki scen
|
||||||
normalizujemy wN.→ kanoniczny (stabilny page_url/dedup).
|
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
|
- klasa `actors-<slug>` (multi) → performerzy; `category-<slug>` (multi) → tagi
|
||||||
- `<a href title="...">` → URL sceny + czysty tytuł
|
- `<a href title="...">` → URL sceny + czysty tytuł
|
||||||
- `<span class="duration">MM:SS|HH:MM:SS`
|
- `<span class="duration">MM:SS|HH:MM:SS`
|
||||||
|
|
@ -33,7 +33,9 @@ _BASE = "https://mypornerleak.com"
|
||||||
_ARTICLE_RE = re.compile(r'<article[^>]*\bclass="([^"]+)"', re.IGNORECASE)
|
_ARTICLE_RE = re.compile(r'<article[^>]*\bclass="([^"]+)"', re.IGNORECASE)
|
||||||
_LINK_RE = re.compile(r'<a\s+href="([^"]+)"\s+title="([^"]+)"', 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)
|
_DURATION_RE = re.compile(r'class="duration">(?:<[^>]+>)*\s*(\d{1,2}:\d{2}(?::\d{2})?)', re.IGNORECASE)
|
||||||
_THUMB_RE = re.compile(r'data-src="([^"]+)"', 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)
|
||||||
_WN_HOST_RE = re.compile(r"https?://w\d+\.mypornerleak\.com", 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_ACTOR_RE = re.compile(r"\bactors-([a-z0-9-]+)")
|
||||||
_CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")
|
_CLASS_CAT_RE = re.compile(r"\bcategory-([a-z0-9-]+)")
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
świeżość wprost z WP REST (`/wp-json/wp/v2/posts?_embed=1`). W odróżnieniu od
|
||||||
|
|
@ -28,6 +28,7 @@ from app.connectors.direct_scrapers._browse_base import (
|
||||||
compute_thumbnail_phash,
|
compute_thumbnail_phash,
|
||||||
)
|
)
|
||||||
from app.extractors import browser_get
|
from app.extractors import browser_get
|
||||||
|
from app.extractors.thumb_extract import extract_thumbnail_url
|
||||||
from app.normalize.text import slugify
|
from app.normalize.text import slugify
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -85,6 +86,15 @@ class MyPornerLeakBrowseScraper(BaseBrowseScraper):
|
||||||
emb = p.get("_embedded") or {}
|
emb = p.get("_embedded") or {}
|
||||||
fm = emb.get("wp:featuredmedia") or []
|
fm = emb.get("wp:featuredmedia") or []
|
||||||
thumb = (fm[0].get("source_url") if fm and isinstance(fm[0], dict) else None) or None
|
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
|
studio: RawStudio | None = None
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
|
|
|
||||||
148
app/connectors/direct_scrapers/naughtymachinima.py
Normal file
148
app/connectors/direct_scrapers/naughtymachinima.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -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.),
|
Dołączony 2026-06-10 (user request). Paysite re-upload (HardX, DAP itp.),
|
||||||
~40k+ scen po numeracji video id.
|
~40k+ scen po numeracji video id.
|
||||||
|
|
||||||
Sygnały per scena:
|
Sygnały per scena:
|
||||||
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl
|
- 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
|
- `<meta property="video:duration">` = sekundy wprost
|
||||||
- Performerzy: `<a class="link" href="https://neporn.com/models/<slug>/">`
|
- 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ą
|
(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
|
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
|
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
|
obecne w tytule i bez generycznych fraz (stoplist), bo starsze sceny mają tam
|
||||||
też szum ("deep throat", "natural tits", "hd porn").
|
też szum ("deep throat", "natural tits", "hd porn").
|
||||||
- Kategorie: linki `/categories/<slug>/` w bloku info (tagi z `/tags/` to szum
|
- 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.
|
niespójny, zostawiamy fuzzy matchowi po tytule.
|
||||||
|
|
||||||
Listing: standard KVS `/latest-updates/N/` (24 sceny/strona).
|
Listing: standard KVS `/latest-updates/N/` (24 sceny/strona).
|
||||||
|
|
@ -111,7 +111,7 @@ class NepornScraper(BaseBrowseScraper):
|
||||||
description = (video.get("description") or "").strip() or None
|
description = (video.get("description") or "").strip() or None
|
||||||
thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None
|
thumbnail_url = (video.get("thumbnailUrl") or "").strip() or None
|
||||||
if thumbnail_url:
|
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)
|
thumbnail_url = re.sub(r"^https?:(?=https?://)", "", thumbnail_url)
|
||||||
|
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
|
|
@ -128,7 +128,7 @@ class NepornScraper(BaseBrowseScraper):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Fallback dla świeżych uploadów bez linków modelek: nazwiska z flashvars
|
# 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()
|
title_cf = title.casefold()
|
||||||
tags_m = _VIDEO_TAGS_RE.search(detail_html)
|
tags_m = _VIDEO_TAGS_RE.search(detail_html)
|
||||||
for entry in (tags_m.group(1).split(",") if tags_m else []):
|
for entry in (tags_m.group(1).split(",") if tags_m else []):
|
||||||
|
|
@ -160,7 +160,7 @@ class NepornScraper(BaseBrowseScraper):
|
||||||
seen_tag.add(slug)
|
seen_tag.add(slug)
|
||||||
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=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).
|
# graceful miss → composite scoring (performer+title+duration).
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
(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).
|
2026-06-22 (user request).
|
||||||
|
|
||||||
Z REST `?_embed=1` bierzemy: tytuł, datę, miniaturę (featured_media), STUDIO
|
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)
|
(`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ł
|
→ puste, dorabia canonical-merge (content studyjny dobrze matchuje TPDB/StashDB; tytuł
|
||||||
i tak ma nazwiska).
|
i tak ma nazwiska).
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
"""porn00.org — latest-vids browse scraper.
|
"""porn00.org, latest-vids browse scraper.
|
||||||
|
|
||||||
URL patterns:
|
URL patterns:
|
||||||
- Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ...
|
- Listing: `/latest-vids/` (page 1), `/latest-vids/2/`, ...
|
||||||
- Scene: `/video/<slug>/`
|
- 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>/`
|
- Categories: `/category-name/<slug>/`
|
||||||
|
|
||||||
Sygnały dostępne:
|
Sygnały dostępne:
|
||||||
- Title (listing card + h1 + og:title)
|
- Title (listing card + h1 + og:title)
|
||||||
- Performer(s) (z sekcji "Pornstars:" na detail page — pojedynczy slug per link)
|
- Performer(s) (z sekcji "Pornstars:" na detail page, pojedynczy slug per link)
|
||||||
- Categories (z sekcji "Categories:" — `/category-name/<slug>/`)
|
- Categories (z sekcji "Categories:", `/category-name/<slug>/`)
|
||||||
- Duration (listing card `<div class="duration">MM:SS</div>`)
|
- 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`)
|
- Thumbnail (own CDN `/contents/videos_screenshots/.../1.jpg`)
|
||||||
|
|
||||||
BRAK:
|
BRAK:
|
||||||
|
|
@ -78,7 +78,7 @@ _CATEGORY_LINK_RE = re.compile(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Direct mp4 stream z KVS flashvars: `video_url: 'https://.../43144.mp4/?v-acctoken=...'`.
|
# 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 `"`.
|
# wszystko do najbliższego `'` lub `"`.
|
||||||
_VIDEO_URL_RE = re.compile(
|
_VIDEO_URL_RE = re.compile(
|
||||||
r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE,
|
r"""video_url:\s*['"]([^'"]+\.mp4[^'"]*)['"]""", re.IGNORECASE,
|
||||||
|
|
@ -107,7 +107,7 @@ class Porn00Scraper(BaseBrowseScraper):
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
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.
|
# tych pól w meta (brak og:duration), więc listing jest source of truth.
|
||||||
self._listing_cache: dict[str, dict] = {}
|
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.
|
# Thumbnail: prefer og:image z detail (full-size preview), fallback listing 320x180.
|
||||||
thumb = meta_content(detail_html, property="og:image") or meta.get("thumb")
|
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.
|
# `/category-name/`). Wszystkie linki tego pattern to performerzy.
|
||||||
performers: list[RawPerformer] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_perf: set[str] = set()
|
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
|
stream_url: str | None = None
|
||||||
if (vm := _VIDEO_ALT_URL_RE.search(detail_html)):
|
if (vm := _VIDEO_ALT_URL_RE.search(detail_html)):
|
||||||
stream_url = vm.group(1)
|
stream_url = vm.group(1)
|
||||||
elif (vm := _VIDEO_URL_RE.search(detail_html)):
|
elif (vm := _VIDEO_URL_RE.search(detail_html)):
|
||||||
stream_url = vm.group(1)
|
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.
|
# więc canonical phash match raczej fail. Próbujemy mimo to.
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumb:
|
if thumb:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""porn4days.pw — direct HTML scrape.
|
"""porn4days.pw, direct HTML scrape.
|
||||||
|
|
||||||
Search: `https://porn4days.pw/page/<n>/?s=<q>`.
|
Search: `https://porn4days.pw/page/<n>/?s=<q>`.
|
||||||
Scene URL: `https://porn4days.pw/<slug>/`.
|
Scene URL: `https://porn4days.pw/<slug>/`.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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%
|
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ą).
|
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:
|
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>`,
|
`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`
|
posortowane od najnowszych. `crawl_page` tnie ten katalog na strony po `_PAGE_SIZE`
|
||||||
(wzorzec jak `_playtube.BasePlayTubeScraper`).
|
(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:
|
tytułu-slug:
|
||||||
- title `<h1 class="entry-title">`, duration ISO, uploadDate, thumbnailUrl, description
|
- 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)
|
dokładnie tyle linków `/actor/` ilu realnych aktorów; brak sekcji Related)
|
||||||
- kategorie + tagi: `<div class="tags-list">` (`/category/` i `/tag/`)
|
- 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 /
|
Playback: `itemprop="embedURL"` → extractor `pornbusycom` (loadvid / seekplayer /
|
||||||
zpi.cx ≈ 69% katalogu).
|
zpi.cx ≈ 69% katalogu).
|
||||||
|
|
@ -96,7 +96,7 @@ class PornBusyScraper(BaseBrowseScraper):
|
||||||
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
|
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
|
||||||
self._catalog: list[str] | None = None
|
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.
|
# 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
|
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
|
||||||
return _SITEMAP_INDEX
|
return _SITEMAP_INDEX
|
||||||
|
|
@ -131,7 +131,7 @@ class PornBusyScraper(BaseBrowseScraper):
|
||||||
if not loc:
|
if not loc:
|
||||||
continue
|
continue
|
||||||
url = loc.group(1)
|
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/")):
|
if any(x in url for x in ("/actor/", "/category/", "/tag/", "/page/")):
|
||||||
continue
|
continue
|
||||||
lm = _LASTMOD_RE.search(block)
|
lm = _LASTMOD_RE.search(block)
|
||||||
|
|
@ -189,7 +189,7 @@ class PornBusyScraper(BaseBrowseScraper):
|
||||||
dsm = _DESC_RE.search(detail_html)
|
dsm = _DESC_RE.search(detail_html)
|
||||||
description = html.unescape(dsm.group(1)).strip() if dsm else None
|
description = html.unescape(dsm.group(1)).strip() if dsm else None
|
||||||
if description and description.strip().lower() == title.strip().lower():
|
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] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_p: set[str] = set()
|
seen_p: set[str] = set()
|
||||||
|
|
|
||||||
|
|
@ -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ć
|
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
|
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
|
ustrukturyzowany JSON jednym requestem na stronę. Przerobione na browse 2026-06-24
|
||||||
(ten sam wzorzec co perverzija).
|
(ten sam wzorzec co perverzija).
|
||||||
|
|
||||||
Z REST `_embed`: tytuł, data, miniatura (featured_media), STUDIO (taksonomia
|
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
|
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
|
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).
|
wyciągamy (post_tag ich nie rozdziela od gatunków bez listy known-performers).
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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 +
|
To wystarczy do composite fuzzy match w resolverze (studio + performer Jaccard +
|
||||||
date proximity + title token-set + duration). Phash hit-rate niski (porndoe robi
|
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.
|
nadrabia, jak pornxp/porn00.
|
||||||
|
|
||||||
URL patterns:
|
URL patterns:
|
||||||
|
|
@ -21,7 +21,7 @@ URL patterns:
|
||||||
- Performer: `/pornstars-profile/<slug>`
|
- Performer: `/pornstars-profile/<slug>`
|
||||||
- Tags/categories: `/categories/<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`;
|
"Play" click. Dajemy playback_source z page_url + origin `tube:porndoecom`;
|
||||||
extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView
|
extractor w `_REGISTRY` mapuje na `_vps_blocked_fallback.extract` → mobile WebView
|
||||||
INJECTED_JS scrapuje `<video>.src` po phone IP (0 VPS bandwidth, zgodne z
|
INJECTED_JS scrapuje `<video>.src` po phone IP (0 VPS bandwidth, zgodne z
|
||||||
|
|
@ -34,6 +34,7 @@ import logging
|
||||||
import re
|
import re
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.connectors.base import (
|
from app.connectors.base import (
|
||||||
RawFingerprint,
|
RawFingerprint,
|
||||||
RawPerformer,
|
RawPerformer,
|
||||||
|
|
@ -51,7 +52,7 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BASE = "https://porndoe.com"
|
_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)
|
_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)
|
_WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
@ -66,7 +67,7 @@ _TAG_LINK_RE = re.compile(
|
||||||
r'href="/category/\d+/([a-z0-9\-]+)"[^>]*>([^<]+)</a>', re.IGNORECASE
|
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(
|
_ISO_DUR_RE = re.compile(
|
||||||
r"^P?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", re.IGNORECASE
|
r"^P?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
@ -144,6 +145,24 @@ def _extract_video_object(html: str) -> dict | None:
|
||||||
class PornDoeScraper(BaseBrowseScraper):
|
class PornDoeScraper(BaseBrowseScraper):
|
||||||
sitetag = "porndoecom"
|
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:
|
def _listing_url(self, page: int) -> str:
|
||||||
if page <= 1:
|
if page <= 1:
|
||||||
return f"{_BASE}/videos/most-recent"
|
return f"{_BASE}/videos/most-recent"
|
||||||
|
|
@ -237,7 +256,7 @@ class PornDoeScraper(BaseBrowseScraper):
|
||||||
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
|
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).
|
# oczekiwany, ale graceful: brak match → resolver spada do composite scoring).
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
|
|
@ -245,7 +264,7 @@ class PornDoeScraper(BaseBrowseScraper):
|
||||||
if ph:
|
if ph:
|
||||||
fingerprints.append(RawFingerprint(kind="phash", value=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).
|
# `porndoecom` → `_vps_blocked_fallback.extract` (mobile WebView scrape).
|
||||||
playback_sources = [
|
playback_sources = [
|
||||||
RawPlaybackSource(
|
RawPlaybackSource(
|
||||||
|
|
|
||||||
207
app/connectors/direct_scrapers/porndupe.py
Normal file
207
app/connectors/direct_scrapers/porndupe.py
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -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
|
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).
|
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,
|
Metadane w CAŁOŚCI z JSON-LD `@graph` → `ItemPage.mainEntity` (VideoObject): name,
|
||||||
duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero
|
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).
|
jest przepisany pod SEO, patrz niżej).
|
||||||
|
|
||||||
**Świadome decyzje:**
|
**Ś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.
|
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`
|
- 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
|
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/`).
|
- 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,
|
**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,
|
obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas,
|
||||||
czyli realnie nowa podaż.
|
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.
|
Referera, przenośny cross-IP.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -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=).
|
Search: `https://www.porntrex.com/search/<q>/` (single page, brak ?page=).
|
||||||
Scene URL: `https://www.porntrex.com/video/<id>/<slug>/`
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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).
|
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>">
|
<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ł>">
|
<img data-src="//ptx.cdntrex.com/contents/.../300x168/1.jpg" alt="<Tytuł>">
|
||||||
<div class="duration">MM:SS</div>
|
<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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""pornxp.ph — latest-vids browse scraper.
|
"""pornxp.ph, latest-vids browse scraper.
|
||||||
|
|
||||||
URL patterns:
|
URL patterns:
|
||||||
- Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination).
|
- Listing: `https://pornxp.ph/` (page 1, 72 cards) lub `?p=N` (pagination).
|
||||||
URL-e w listing mają randomized suffix per request (`/videos/94528971225` vs
|
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.
|
używamy dla external_id zamiast całego URL.
|
||||||
- Detail: `/videos/<id_with_suffix>`.
|
- Detail: `/videos/<id_with_suffix>`.
|
||||||
- Tags: `/tags/<URL-encoded-name>`. Trzy kategorie wnioskowane heurystyką
|
- 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)
|
- Performers (z tags w `<div class="tags">`, Capital + space + Capital)
|
||||||
- Release year (regex `Released:` na detail page bodyText)
|
- Release year (regex `Released:` na detail page bodyText)
|
||||||
- Duration (`<div class="item_dur">MM:SS</div>` listing card)
|
- 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"`)
|
- 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.
|
Phash hit-rate niskie ale studio+performer+title fuzzy match wystarczy do canonical.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -40,15 +40,16 @@ from app.connectors.direct_scrapers._browse_base import (
|
||||||
BaseBrowseScraper,
|
BaseBrowseScraper,
|
||||||
compute_thumbnail_phash,
|
compute_thumbnail_phash,
|
||||||
)
|
)
|
||||||
|
from app.normalize.text import unescape_html
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BASE = "https://pornxp.ph"
|
_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 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">`
|
# 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(
|
_LISTING_CARD_RE = re.compile(
|
||||||
r'<div class="item preview"\s+data-id="(?P<id>\d+)"'
|
r'<div class="item preview"\s+data-id="(?P<id>\d+)"'
|
||||||
r'(?:\s+data-preview="(?P<preview>[^"]*)")?[^>]*>'
|
r'(?:\s+data-preview="(?P<preview>[^"]*)")?[^>]*>'
|
||||||
|
|
@ -61,7 +62,7 @@ _LISTING_CARD_RE = re.compile(
|
||||||
_IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
|
_IMG_SRC_RE = re.compile(r'\bsrc="([^"]+)"', re.IGNORECASE)
|
||||||
_IMG_DATASRC_RE = re.compile(r'\bdata-src="([^"]+)"', 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.
|
# Bierzemy do najbliższego </div> bo tagi tej sceny są w jednym divie.
|
||||||
_DETAIL_TAGS_BLOCK_RE = re.compile(
|
_DETAIL_TAGS_BLOCK_RE = re.compile(
|
||||||
r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL,
|
r'<div class="tags">(?P<inner>.*?)</div>', re.IGNORECASE | re.DOTALL,
|
||||||
|
|
@ -71,8 +72,8 @@ _TAG_LINK_RE = re.compile(
|
||||||
)
|
)
|
||||||
_RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE)
|
_RELEASED_RE = re.compile(r'Released:\s*(\d{4})', re.IGNORECASE)
|
||||||
_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE)
|
_H1_RE = re.compile(r'<h1[^>]*>([^<]+)</h1>', re.IGNORECASE)
|
||||||
# Direct mp4/m3u8 sources — preferujemy 720 nad 360. Format często protocol-relative:
|
# 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 src="//sv.porn-xp.com/.../720.mp4">`, normalize do `https://...` w consumerze.
|
||||||
_SOURCE_RE = re.compile(
|
_SOURCE_RE = re.compile(
|
||||||
r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"',
|
r'<source\s+src="(?P<url>(?:https?:)?//[^"]+\.(?:mp4|m3u8))"',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|
@ -99,11 +100,11 @@ def _classify_tag(name: str) -> str:
|
||||||
- Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat
|
- Studio: zawiera `.` (`TheTeenBay.co`, `Clips4sale.tv`) LUB CamelCase concat
|
||||||
bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`)
|
bez spacji (`LegalPorno`, `DirtyWivesClub`, `AnalMom`, `Clips4sale`)
|
||||||
- Performer: dokładnie 2 słowa Capital + Capital (`Alix Lynx`, `Reagan Foxx`)
|
- 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`)
|
(`oral`, `Lesbians`, `Incest`, `BBC`)
|
||||||
|
|
||||||
Edge case: single-word studio jak "Brazzers", "Vixen" → klasyfikowane jako tag.
|
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.
|
więc fallback z 1+ performer match wystarczy.
|
||||||
"""
|
"""
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
|
|
@ -136,7 +137,7 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
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">`
|
# 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
|
# ani thumbnail URL, tylko h1+tags+sources. Cache reset per page (każde
|
||||||
# _extract_scene_urls override'uje).
|
# _extract_scene_urls override'uje).
|
||||||
|
|
@ -182,33 +183,33 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
),
|
),
|
||||||
"thumb": thumb,
|
"thumb": thumb,
|
||||||
"duration_sec": _parse_mmss(m.group("dur") or ""),
|
"duration_sec": _parse_mmss(m.group("dur") or ""),
|
||||||
"title": m.group("title").strip(),
|
"title": unescape_html(m.group("title")),
|
||||||
}
|
}
|
||||||
out.append(url)
|
out.append(url)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
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, {})
|
meta = self._listing_cache.get(scene_url, {})
|
||||||
data_id = meta.get("data_id")
|
data_id = meta.get("data_id")
|
||||||
if not data_id:
|
if not data_id:
|
||||||
# URL nie pasuje do listingu (random suffix mismatch po pagination redo).
|
# 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)
|
id_match = re.search(r"/videos/(\d{6,12})", scene_url)
|
||||||
data_id = id_match.group(1) if id_match else None
|
data_id = id_match.group(1) if id_match else None
|
||||||
|
|
||||||
# Title: prefer h1 over listing card title (detail h1 jest cleaner)
|
# Title: prefer h1 over listing card title (detail h1 jest cleaner)
|
||||||
title = meta.get("title") or ""
|
title = meta.get("title") or ""
|
||||||
if (m := _H1_RE.search(detail_html)):
|
if (m := _H1_RE.search(detail_html)):
|
||||||
title = m.group(1).strip() or title
|
title = unescape_html(m.group(1)) or title
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
duration_sec = meta.get("duration_sec")
|
duration_sec = meta.get("duration_sec")
|
||||||
thumb = meta.get("thumb")
|
thumb = meta.get("thumb")
|
||||||
|
|
||||||
# Release year — `Released: 2016`. RawScene ma `release_date` (typu `date`),
|
# Release year, `Released: 2016`. RawScene ma `release_date` (typu `date`),
|
||||||
# nie samo year — wpisujemy Jan 1 jako placeholder żeby resolver miał year
|
# nie samo year, wpisujemy Jan 1 jako placeholder żeby resolver miał year
|
||||||
# signal (date proximity scoring tylko sprawdza year w composite).
|
# signal (date proximity scoring tylko sprawdza year w composite).
|
||||||
release_date: date | None = None
|
release_date: date | None = None
|
||||||
if (m := _RELEASED_RE.search(detail_html)):
|
if (m := _RELEASED_RE.search(detail_html)):
|
||||||
|
|
@ -251,7 +252,7 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
tags.append(RawTag(external_id=ext_id, name=display, slug=slug))
|
tags.append(RawTag(external_id=ext_id, name=display, slug=slug))
|
||||||
|
|
||||||
# Playback: direct mp4 streams `<source src="//sv.porn-xp.com/.../720.mp4">`.
|
# 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:
|
def _norm(u: str) -> str:
|
||||||
return "https:" + u if u.startswith("//") else u
|
return "https:" + u if u.startswith("//") else u
|
||||||
|
|
||||||
|
|
@ -264,7 +265,7 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
break
|
break
|
||||||
stream_url = stream_url or all_sources[0]
|
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
|
# try). Reseter ścieżek do canonical odbędzie się głównie przez
|
||||||
# studio+performer+year+title scoring.
|
# studio+performer+year+title scoring.
|
||||||
fingerprints: list[RawFingerprint] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
|
|
@ -275,7 +276,7 @@ class PornXPScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
# Normalize page_url: pornxp homepage serwuje random URL suffix per request
|
# Normalize page_url: pornxp homepage serwuje random URL suffix per request
|
||||||
# (`/videos/94528971225` vs `/videos/94528971836` ten sam scene). PlaybackSource
|
# (`/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>`.
|
# na każdym scrape run. Canonical URL = `/videos/<data_id>`.
|
||||||
canonical_url = (
|
canonical_url = (
|
||||||
f"{_BASE}/videos/{data_id}" if data_id else scene_url
|
f"{_BASE}/videos/{data_id}" if data_id else scene_url
|
||||||
|
|
|
||||||
168
app/connectors/direct_scrapers/rule34video.py
Normal file
168
app/connectors/direct_scrapers/rule34video.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -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:
|
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,
|
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
|
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
|
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.
|
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/`.
|
„hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`.
|
||||||
|
|
||||||
**Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po
|
**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.
|
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.
|
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.
|
Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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:
|
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}:')
|
start = detail_html.find(f'player-tags__title">{label}:')
|
||||||
if start < 0:
|
if start < 0:
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
Browse-only (nie search-driven). Sitetag `shyfapnet`. Bogata metadata na detail
|
||||||
page'u (meta tags + body links): title, studio, performers, tags, duration,
|
page'u (meta tags + body links): title, studio, performers, tags, duration,
|
||||||
description, upload_date, embed_url.
|
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,
|
metadata wystarcza do canonical match >5%. Jeśli tak → rozszerzamy o porn00,
|
||||||
fullmovies, pornxp, freshporno, 4k69, hdporn.gg.
|
fullmovies, pornxp, freshporno, 4k69, hdporn.gg.
|
||||||
|
|
||||||
|
|
@ -37,7 +37,7 @@ _PORNSTAR_LINK_RE = re.compile(
|
||||||
_TAG_LINK_RE = re.compile(
|
_TAG_LINK_RE = re.compile(
|
||||||
r'href="/tag/([a-z0-9\-]+)_t(\d+)/"[^>]*>([^<]+)', re.IGNORECASE
|
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.
|
# nie z meta `ya:ovs:id` żeby uniknąć rozjazdu meta vs URL.
|
||||||
_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE)
|
_INTERNAL_ID_RE = re.compile(r"_v(\d+)/?$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
sitetag = "shyfapnet"
|
sitetag = "shyfapnet"
|
||||||
|
|
||||||
def _listing_url(self, page: int) -> str:
|
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)
|
# zawsze, dodatkowy `/N/` dla pagination)
|
||||||
if page <= 1:
|
if page <= 1:
|
||||||
return f"{_BASE}/videos_1/"
|
return f"{_BASE}/videos_1/"
|
||||||
|
|
@ -108,7 +108,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
if not embed_url and internal_id:
|
if not embed_url and internal_id:
|
||||||
embed_url = f"{_BASE}/embed/{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
|
studio: RawStudio | None = None
|
||||||
m_studio = _STUDIO_LINK_RE.search(detail_html)
|
m_studio = _STUDIO_LINK_RE.search(detail_html)
|
||||||
if m_studio:
|
if m_studio:
|
||||||
|
|
@ -119,7 +119,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
slug=slug,
|
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] = []
|
performers: list[RawPerformer] = []
|
||||||
seen_perf: set[str] = set()
|
seen_perf: set[str] = set()
|
||||||
for m_p in _PORNSTAR_LINK_RE.finditer(detail_html):
|
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] = []
|
tags: list[RawTag] = []
|
||||||
seen_tag: set[str] = set()
|
seen_tag: set[str] = set()
|
||||||
for m_t in _TAG_LINK_RE.finditer(detail_html):
|
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)
|
RawTag(external_id=f"shyfapnet:tag:{tid}", name=name, slug=slug)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Playback source — embed_url (mobile WebView fallback). Stream extraction
|
# Playback source, embed_url (mobile WebView fallback). Stream extraction
|
||||||
# przez app/extractors/__init__.py wymaga osobnego registry entry — dla
|
# przez app/extractors/__init__.py wymaga osobnego registry entry, dla
|
||||||
# pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up.
|
# pilot scrapera zostawiamy embed-only (WebView), direct mp4 to follow-up.
|
||||||
playback_sources = [
|
playback_sources = [
|
||||||
RawPlaybackSource(
|
RawPlaybackSource(
|
||||||
|
|
@ -161,7 +161,7 @@ class ShyfapScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
# Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within,
|
# Perceptual hash z thumbnail. Resolver Path 3 (find_by_phash_within,
|
||||||
# Hamming ≤5) auto-merguje gdy TPDB/StashDB ma fingerprint tej samej sceny.
|
# 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] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||||
|
|
|
||||||
|
|
@ -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
|
Historia: dawniej performer-driven search scraper (`?s=<q>`), ale siska zepsuła
|
||||||
wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest
|
wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
przez Bright Data ISP proxy (`settings.brightdata_proxy_url`). Gdy proxy
|
||||||
nieskonfigurowane → scraper no-op (pusty iterator, log warning).
|
nieskonfigurowane → scraper no-op (pusty iterator, log warning).
|
||||||
|
|
||||||
Proxy używamy TYLKO do scrape HTML. Playback NIE idzie przez proxy: `<source>` mp4
|
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
|
(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).
|
(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).
|
Thumbnaile (img*.superporn.com) schodzą z VPS bez proxy (image proxy działa).
|
||||||
|
|
||||||
Sygnały (SSR HTML):
|
Sygnały (SSR HTML):
|
||||||
- JSON-LD VideoObject: name, description, uploadDate, thumbnailUrl, duration
|
- 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>`
|
- Performerzy: chip `<a class="chip-link" href=".../pornstar/<slug>"><span>Name</span>`
|
||||||
- Kategorie: chip `<a class="chip-link" href="/<slug>"><span>Name</span>` (bez
|
- 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).
|
- Duration backup: `<video ... data-video-duration="2328">` (sekundy).
|
||||||
|
|
||||||
Listing: `?page=N` (newest-first; sitemap lastmod jest z 2024, bezużyteczny).
|
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:
|
def _clean(text: str) -> str:
|
||||||
"""superporn dwukrotnie HTML-koduje encje w JSON-LD (`&#39;` zamiast `'`).
|
"""superporn dwukrotnie HTML-koduje encje w JSON-LD (`&#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):
|
for _ in range(2):
|
||||||
new = html.unescape(text)
|
new = html.unescape(text)
|
||||||
if new == text:
|
if new == text:
|
||||||
|
|
@ -95,7 +95,7 @@ class SuperpornScraper(BaseBrowseScraper):
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
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
|
self._proxy = get_settings().brightdata_proxy_url
|
||||||
if not self._proxy:
|
if not self._proxy:
|
||||||
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
|
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
|
||||||
|
|
@ -106,7 +106,7 @@ class SuperpornScraper(BaseBrowseScraper):
|
||||||
return f"{_BASE}/?page={page}"
|
return f"{_BASE}/?page={page}"
|
||||||
|
|
||||||
def crawl_page(self, page: int):
|
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.
|
# "exhausted" (pusta lista), żeby deep-crawl nie retry'ował w kółko.
|
||||||
if not self._proxy:
|
if not self._proxy:
|
||||||
return []
|
return []
|
||||||
|
|
@ -164,7 +164,7 @@ class SuperpornScraper(BaseBrowseScraper):
|
||||||
seen_tag.add(slug)
|
seen_tag.add(slug)
|
||||||
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=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] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||||
|
|
|
||||||
|
|
@ -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
|
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`
|
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"
|
- 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">`.
|
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
|
- 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.
|
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
|
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ą.
|
# 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">.
|
# 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
|
# 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
|
# 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).
|
# `data-original=` (karty poniżej folda: src="data:..." placeholder + realny data-original).
|
||||||
_CARD_RE = re.compile(
|
_CARD_RE = re.compile(
|
||||||
|
|
|
||||||
|
|
@ -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>/`
|
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=`
|
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>/).
|
(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"`)
|
- performerzy: WSZYSTKIE `/actor/<slug>/` linki (z co-starami; `title="Name"`)
|
||||||
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
|
- tagi: `/tag/` + `/category/` (`title="Name"`); część to studia (BangBros/BLACKED/...)
|
||||||
- studio: heurystycznie z tagów-paysite (`_STUDIO_TAGS`); brak match → bez studio
|
- 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] = []
|
tags: list[RawTag] = []
|
||||||
seen_tag: set[str] = set()
|
seen_tag: set[str] = set()
|
||||||
block_m = _TAGS_BLOCK_RE.search(detail)
|
block_m = _TAGS_BLOCK_RE.search(detail)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
`?type=videos&query=<q>` oddawał TRENDING (0 trafień dla performera → strict token
|
||||||
|
|
@ -19,6 +19,14 @@ Metadane z karty (bez +1 fetch detalu):
|
||||||
- thumbnail: `data-src='//...small.jpg'`
|
- thumbnail: `data-src='//...small.jpg'`
|
||||||
|
|
||||||
Playback resolwuje extractor `sxyprncom` (osobno; mp4 z trafficdeposit/lulustream).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -35,31 +43,42 @@ from app.connectors.base import (
|
||||||
)
|
)
|
||||||
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
|
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
|
||||||
from app.extractors import browser_get
|
from app.extractors import browser_get
|
||||||
from app.normalize.text import slugify
|
from app.normalize.text import person_named_in, slugify
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BASE = "https://sxyprn.com"
|
_BASE = "https://sxyprn.com"
|
||||||
|
|
||||||
# js-pop anchor karty: /post/<id>.html(?sk=...&so=...&ss=latest na stronie performera)
|
# 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(
|
_CARD_ANCHOR_RE = re.compile(
|
||||||
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
|
r"<a\s+href='/post/(?P<id>[a-f0-9]+)\.html(?:\?[^']*)?'[^>]*class='js-pop'",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# Uwaga: duration_small ma atrybut `title='s1->c10'` ze znakiem `>` w środku, więc
|
# 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(
|
_DURATION_RE = re.compile(
|
||||||
r"duration_small.*?>\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*<", re.IGNORECASE | re.DOTALL
|
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)
|
_SUBCAT_RE = re.compile(r"post_el_small_subcat[^>]*>([^<]+)<", re.IGNORECASE)
|
||||||
_THUMB_RE = re.compile(r"data-src='(//[^']+?small\.jpg)'", 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(
|
_POSTTIME_RE = re.compile(
|
||||||
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
|
r"post_time'[^>]*href='/post/{id}\.html(?:\?[^']*)?'[^>]*title='([^']*)'",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_HASHTAG_RE = re.compile(r"#(\w[\w-]*)")
|
_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"\{[^}]*\}")
|
_BRACE_RE = re.compile(r"\{[^}]*\}")
|
||||||
_URL_TAIL_RE = re.compile(r"https?://\S+")
|
_URL_TAIL_RE = re.compile(r"https?://\S+")
|
||||||
|
|
||||||
|
|
@ -76,6 +95,16 @@ def _parse_duration(s: str) -> int | None:
|
||||||
return 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]]:
|
def _clean_title(raw: str) -> tuple[str, list[str]]:
|
||||||
"""Zwraca (czysty tytuł, tagi). Usuwa {markery}, hashtagi i końcowy URL."""
|
"""Zwraca (czysty tytuł, tagi). Usuwa {markery}, hashtagi i końcowy URL."""
|
||||||
text = html.unescape(raw)
|
text = html.unescape(raw)
|
||||||
|
|
@ -89,7 +118,7 @@ def _clean_title(raw: str) -> tuple[str, list[str]]:
|
||||||
|
|
||||||
class SxyPrnScraper(BaseSearchScraper):
|
class SxyPrnScraper(BaseSearchScraper):
|
||||||
sitetag = "sxyprncom"
|
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
|
_card_window = 2600 # od js-pop anchora do post_control z tytułem
|
||||||
|
|
||||||
def _performer_path(self, query: str) -> str:
|
def _performer_path(self, query: str) -> str:
|
||||||
|
|
@ -112,9 +141,6 @@ class SxyPrnScraper(BaseSearchScraper):
|
||||||
return
|
return
|
||||||
|
|
||||||
text = r.text
|
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))
|
anchors = list(_CARD_ANCHOR_RE.finditer(text))
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
yielded = 0
|
yielded = 0
|
||||||
|
|
@ -130,10 +156,13 @@ class SxyPrnScraper(BaseSearchScraper):
|
||||||
if not tm:
|
if not tm:
|
||||||
continue
|
continue
|
||||||
title, tags = _clean_title(tm.group(1))
|
title, tags = _clean_title(tm.group(1))
|
||||||
title_l = title.lower()
|
# Scena musi realnie dotyczyć performera. Sprawdzamy CAŁE nazwisko jako ciąg
|
||||||
# Strict: scena musi realnie dotyczyć performera (oba tokeny w tytule) —
|
# sąsiadujących członów, bo poprzedni wariant (każdy człon od trzech znaków
|
||||||
# chroni przed śmieciem na stronie i przed over-attribution.
|
# gdziekolwiek w tytule) degenerował się do samego imienia, gdy nazwisko było
|
||||||
if query_tokens and not all(tok in title_l for tok in query_tokens):
|
# 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):
|
||||||
continue
|
continue
|
||||||
if not title:
|
if not title:
|
||||||
continue
|
continue
|
||||||
|
|
@ -146,6 +175,14 @@ class SxyPrnScraper(BaseSearchScraper):
|
||||||
if thumb and thumb.startswith("//"):
|
if thumb and thumb.startswith("//"):
|
||||||
thumb = "https:" + thumb
|
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
|
studio = None
|
||||||
sub_m = _SUBCAT_RE.search(window)
|
sub_m = _SUBCAT_RE.search(window)
|
||||||
if sub_m:
|
if sub_m:
|
||||||
|
|
|
||||||
239
app/connectors/direct_scrapers/tubepornclassic.py
Normal file
239
app/connectors/direct_scrapers/tubepornclassic.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""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
|
||||||
|
|
@ -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`.
|
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:
|
nie ma linków do scen. Zamiast tego:
|
||||||
- **browse**: `sitemap.xml` -> `sitemap_vids_N.xml` (id-e wszystkich wideo). Newest
|
- **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 =
|
= najwyższe id (auto-increment). Cache posortowanej listy id malejąco, page N =
|
||||||
kolejny wycinek PER_PAGE.
|
kolejny wycinek PER_PAGE.
|
||||||
- **metadata**: bogaty JSON API `GET /api/json/video/1/<floor>/<id>/<id>.json`
|
- **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{}.
|
(studio), models{} (performerki), categories{}+tags{}.
|
||||||
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
|
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
|
||||||
get_file HLS). RE 2026-07-10.
|
get_file HLS). RE 2026-07-10.
|
||||||
|
|
@ -62,7 +62,7 @@ def _parse_duration(value: str | None) -> int | None:
|
||||||
# --- bramka jakości tytułu ----------------------------------------------------------
|
# --- bramka jakości tytułu ----------------------------------------------------------
|
||||||
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
|
# 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ą
|
# 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,
|
# 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).
|
# bo sprawdzała tylko jeden z czterech wzorców).
|
||||||
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
|
_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
|
"""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
|
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
|
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."""
|
tytułów CJK z próbki."""
|
||||||
return sum(1 for c in text if " " <= c <= "鿿" or "" <= c <= "") >= 3
|
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,
|
# 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
|
# /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
|
# 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
|
self._proxy = get_settings().brightdata_proxy_url
|
||||||
if not self._proxy:
|
if not self._proxy:
|
||||||
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
|
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
|
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
|
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
|
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."""
|
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
|
||||||
try:
|
try:
|
||||||
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
|
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
|
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć „koniec
|
||||||
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
|
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
|
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`.
|
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.
|
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
|
||||||
|
|
@ -268,7 +268,7 @@ class VjavScraper(BaseBrowseScraper):
|
||||||
out: list[RawScene] = []
|
out: list[RawScene] = []
|
||||||
for vid in chunk:
|
for vid in chunk:
|
||||||
try:
|
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.
|
# obejmuje je ten sam 429 co sitemap.
|
||||||
res = browser_get(
|
res = browser_get(
|
||||||
self._meta_url(vid),
|
self._meta_url(vid),
|
||||||
|
|
|
||||||
|
|
@ -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
|
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).
|
flashvars `get_file` direct mp4 (extractor `watchporn`, VPS-side, token nie IP-bound).
|
||||||
|
|
||||||
Browse `/latest-updates/` → detail page:
|
Browse `/latest-updates/` → detail page:
|
||||||
|
|
|
||||||
172
app/connectors/direct_scrapers/xanimeporn.py
Normal file
172
app/connectors/direct_scrapers/xanimeporn.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -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>`
|
Search: `https://xhamster.com/search/<q>?page=<n>`
|
||||||
Scene URL: `https://xhamster.com/videos/<slug>-<id>`
|
Scene URL: `https://xhamster.com/videos/<slug>-<id>`
|
||||||
|
|
@ -7,6 +7,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
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
|
from app.connectors.direct_scrapers._search_base import BaseSearchScraper
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -17,3 +24,9 @@ class XHamsterScraper(BaseSearchScraper):
|
||||||
r'href="(?P<url>https://xhamster\.com/videos/(?P<slug>[a-z0-9_\-]+))"',
|
r'href="(?P<url>https://xhamster\.com/videos/(?P<slug>[a-z0-9_\-]+))"',
|
||||||
re.IGNORECASE,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
Search: WordPress `?s=<q>` (lub `/page/<n>/?s=<q>` dla pagination).
|
||||||
Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment).
|
Scene URL: `https://xmoviesforyou.com/<slug>/` (single segment).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
Search: `https://www.xnxx.com/search/<q>/<page-1>` (xnxx 0-indexed)
|
||||||
Scene URL: `https://www.xnxx.com/video-<id>/<slug>`
|
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):
|
class XnxxScraper(BaseSearchScraper):
|
||||||
sitetag = "xnxxcom"
|
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}"
|
_search_url_template = "https://www.xnxx.com/search/{query}/{page}"
|
||||||
_scene_url_re = re.compile(
|
_scene_url_re = re.compile(
|
||||||
r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"',
|
r'href="(?P<url>/video-[a-z0-9]+/(?P<slug>[a-z0-9_\-]+))"',
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
`/models/` ani `/tags/` (0 wystąpień, ładowane JS-em) → performerów/tagi dorabia
|
||||||
canonical-merge + istniejący performer-search. XnxxScraper (search) zostaje; browse
|
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 (`,`/`&e
|
||||||
html.unescape.
|
html.unescape.
|
||||||
|
|
||||||
Listing: xnxx NIE ma czystego SSR `/new/` (404), ale `/best/<YYYY-MM>/<page>` jest
|
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
|
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
|
ś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/.
|
xnxx jest JS-renderowany (0 linków w surowym HTML), stąd /best/.
|
||||||
|
|
||||||
Playback bez zmian (extractor `xnxxcom`). Phash pominięty (xnxx crop-thumbnaile,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@ class XnxxBrowseScraper(BaseBrowseScraper):
|
||||||
if isinstance(thumbnail_url, list):
|
if isinstance(thumbnail_url, list):
|
||||||
thumbnail_url = thumbnail_url[0] if thumbnail_url else None
|
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(
|
return RawScene(
|
||||||
external_id=f"{self.sitetag}:{scene_url}",
|
external_id=f"{self.sitetag}:{scene_url}",
|
||||||
title=title,
|
title=title,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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>`
|
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):
|
class XVideosScraper(BaseSearchScraper):
|
||||||
sitetag = "xvideoscom"
|
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}"
|
_search_url_template = "https://www.xvideos.com/?k={query}&p={page}"
|
||||||
_scene_url_re = re.compile(
|
_scene_url_re = re.compile(
|
||||||
r'href="(?P<url>/video[a-z0-9.\-]+/(?P<slug>[a-z0-9_\-]+))"',
|
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):
|
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).
|
# Override żeby base class fetch'nął zewnętrzny URL z (page-1).
|
||||||
# Najprościej: dostosujmy URL w override przed wywołaniem super().search().
|
# 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
|
original = self._search_url_template
|
||||||
self._search_url_template = original.replace("{page}", str(page - 1))
|
self._search_url_template = original.replace("{page}", str(page - 1))
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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),
|
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).
|
ż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
|
Scene: /video.<hash>/<slug>. Playback: page_url + origin tube:xvideoscom (istniejący
|
||||||
extractor `xvideoscom` resolvuje stream mobile-side). Phash pominięty (xvideos robi
|
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
|
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.base import RawPerformer, RawPlaybackSource, RawScene, RawTag
|
||||||
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper, meta_content
|
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper, meta_content
|
||||||
from app.normalize.text import slugify
|
from app.normalize.text import slugify, unescape_html
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ _JSONLD_RE = re.compile(
|
||||||
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL
|
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>...`.
|
# 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
|
# 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
|
# 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
|
# (bug-report 2026-06-07 "czemu nie ma aktorów, są na stronie"). `(?:(?!</a>).)*?` trzyma match
|
||||||
|
|
@ -99,10 +99,11 @@ class XVideosBrowseScraper(BaseBrowseScraper):
|
||||||
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
||||||
video = _video_object(detail_html) or {}
|
video = _video_object(detail_html) or {}
|
||||||
|
|
||||||
title = (video.get("name") or "").strip()
|
# JSON-LD `name` niesie encje HTML5 (`we're`, `[AI]`), og:title tez.
|
||||||
|
title = unescape_html(video.get("name"))
|
||||||
if not title:
|
if not title:
|
||||||
m = _SETTITLE_RE.search(detail_html)
|
m = _SETTITLE_RE.search(detail_html)
|
||||||
title = m.group(1).strip() if m else (meta_content(detail_html, property="og:title") or "").strip()
|
title = unescape_html(m.group(1) if m else meta_content(detail_html, property="og:title"))
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
Domain: `xxxfree.watch` (sitetag `xxxfreewatch` is legacy from porn-app DEFAULT_SITETAGS).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
Dołączony 2026-05-27. Identyfikowany przez user audit jako "scraper-of-paysites"
|
||||||
(DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude
|
(DogFart / HardX / TeamSkeet / Vixen / Brazzers content). Wcześniejszy theporndude
|
||||||
audit pomylił domeny: `yespornvip.com` (z theporndude rankingu) redirectuje przez
|
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):
|
Czemu wart (parity z porndoe):
|
||||||
- **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate
|
- **JSON-LD VideoObject** w każdym scene page: name, description, uploadDate
|
||||||
(ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl
|
(ISO `YYYY-MM-DDTHH:MM:SS`), duration (ISO `PT0H39M00S`), thumbnailUrl
|
||||||
(BunnyCDN: `yesnn.b-cdn.net/contents/videos_screenshots/...`).
|
(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).
|
(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.
|
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`
|
"Big Ass", "Threesome"). Główne źródło tagów; alternatywnie DOM ma `btn gold`
|
||||||
linki ale te miksują performerów/studio z tagami.
|
linki ale te miksują performerów/studio z tagami.
|
||||||
- **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">`
|
- **Studio + Performers**: oba w sekcji `<a class="btn gold" href="/channels/<slug>/">`
|
||||||
(studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy,
|
(studio, singular) i `<a class="btn gold" href="/models/<slug>/">` (performerzy,
|
||||||
multiple). Slugi mają stable per-type salt (`*-i459s7` dla modeli, `*-7p72tp`
|
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.
|
sesje.
|
||||||
|
|
||||||
External_id strategia: `yespornvip:<numeric_video_id>` (`/video/69841/...` → `69841`).
|
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)
|
- Scene: `/video/<id>/<slug>/` (id numeryczny, slug = title slug + 6-char salt)
|
||||||
- Studio: `/channels/<slug>/`
|
- Studio: `/channels/<slug>/`
|
||||||
- Performer: `/models/<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)
|
osobny tryb dla performer-driven backfill jeśli będzie potrzeba)
|
||||||
|
|
||||||
Playback: download endpoint `/view_video_download.php?id=<id>&format=<480|720|1080>`
|
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 →
|
z server-side. Plus jest `embedUrl: /embed/<id>` w JSON-LD. Extractor →
|
||||||
`_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy):
|
`_vps_blocked_fallback.extract` (zgodne z pre-public bandwidth/anonymity policy):
|
||||||
mobile WebView fetcha embed z phone IP, INJECTED_JS scrape'uje `<video>.src`.
|
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"
|
_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).
|
# Slug `<slug>` zawiera stable per-type salt (`*-npu57w` dla videos).
|
||||||
_SCENE_URL_RE = re.compile(
|
_SCENE_URL_RE = re.compile(
|
||||||
r'href="(https://yesporn\.vip/video/(\d+)/[a-z0-9\-]+/)"',
|
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"<[^>]+>")
|
_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(
|
_JSONLD_RE = re.compile(
|
||||||
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
|
||||||
re.IGNORECASE | re.DOTALL,
|
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(
|
_META_TAG_RE = re.compile(
|
||||||
r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']',
|
r'<meta\s+property=["\']video:tag["\']\s+content=["\']([^"\']+)["\']',
|
||||||
re.IGNORECASE,
|
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."""
|
"""`2026-05-26T19:23:29Z` / `2026-05-26T19:23:29.EDT` → date."""
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
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`.
|
# `fromisoformat` nie crash'ował. video:release_date meta ma czysty `Z`.
|
||||||
cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip())
|
cleaned = re.sub(r"\.[A-Z]{2,4}$", "", value.strip())
|
||||||
try:
|
try:
|
||||||
|
|
@ -265,7 +265,7 @@ class YesPornVipScraper(BaseBrowseScraper):
|
||||||
# lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep
|
# lowercase. Legit kategorie są zawsze Title Case ("Big Ass", "Deep
|
||||||
# Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone
|
# Throat", "Blonde", "Gangbang") lub UPPER ("MILF", "BBW"). Potwierdzone
|
||||||
# w 20-scene dry-run 2026-05-27. Trade-off: stracimy hipotetyczne legit
|
# 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.
|
# akceptowalne bo tags mają wagę tylko 0.05 w composite scoring resolvera.
|
||||||
tags: list[RawTag] = []
|
tags: list[RawTag] = []
|
||||||
seen_tag: set[str] = set()
|
seen_tag: set[str] = set()
|
||||||
|
|
@ -283,17 +283,17 @@ class YesPornVipScraper(BaseBrowseScraper):
|
||||||
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
|
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
|
# Hit-rate vs canonical TPDB/StashDB nieznany do pilot run; graceful: brak
|
||||||
# phash → resolver spada do composite scoring (studio + performer + date +
|
# 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] = []
|
fingerprints: list[RawFingerprint] = []
|
||||||
if thumbnail_url:
|
if thumbnail_url:
|
||||||
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
||||||
if ph:
|
if ph:
|
||||||
fingerprints.append(RawFingerprint(kind="phash", value=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
|
# wymaga PHPSESSID cookie (data-attach-session attribute), więc nie usable
|
||||||
# server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`:
|
# server-side. Extractor `yespornvip` → `_vps_blocked_fallback.extract`:
|
||||||
# mobile WebView z phone IP łapie session natively, INJECTED_JS scrape.
|
# 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
Loading…
Add table
Reference in a new issue