Compare commits

...

19 commits

Author SHA1 Message Date
goon-foss
90a63b1e77 feat: wylaczalny auto-rotate w playerze + '+N' liczy takze filmy
Some checks are pending
Backend tests / test (push) Waiting to run
1) Auto-rotate (bug-report 55da1c3f: 'I can't turn off auto rotate. Which is a
problem when I put the phone on a flat surface'). Player robil bezwarunkowy
unlockAsync, wiec telefon lezacy plasko skakal miedzy orientacjami. Nowa
preferencja goon.player_auto_rotate (default true = dotychczasowe zachowanie),
przelacznik w Settings. Off = player zostaje w pionie, ale przycisk pelnego
ekranu dalej wchodzi w poziom recznie, wiec nic nie ginie.

2) '+N nowych' na ulubionym performerze/studiu liczylo TYLKO sceny (user-request
2026-07-28). Dochodzi _new_movie_counts: filmy z zywym playbackiem, created_at >
last_seen_at, po MoviePerformer / Movie.studio_id. Filmow jest o rzedy wielkosci
mniej niz scen (brak tube-spamu), wiec bez okna top-N i odsiewu stub/backfill -
te pojecia dotycza scen. Zweryfikowane: performer 1633 scen + 436 filmow = 2069,
studio 648 + 95 = 743, /favorites 200 OK.
2026-07-28 09:34:37 +02:00
goon-foss
9af5fd4eb4 fix(performers): zbedne przypisania jednotokenowe obok osoby pelnoimiennej
Ranking z poprzedniego commita ujawnil klase, ktorej zaden wczesniejszy sygnal nie
widzial: wpisy bedace SAMYM NAZWISKIEM albo samym imieniem, powstale z rozbicia peleno
nazwy przez tube-search. "Devine" obok "Shalina Devine", "Kitana" obok "Kitana A",
"Rayne" obok "Tiffany Rayne". Taki wpis lapie potem kazda scene z tym slowem,
nalezaca do zupelnie innej osoby.

Regula jest DOWODLIWIE BEZSTRATNA: kasujemy przypisanie jednotokenowe tylko wtedy, gdy
ta sama scena ma juz przypisana osobe pelnoimienna zawierajaca to slowo. Informacja o
obsadzie zostaje, znika tylko niejednoznaczny duplikat. Kontrola potwierdzila, ze zadna
scena nie zostala przez to bez obsady (0).

Prowenancja z migracji 0026 dala zabezpieczenie: z 62 783 zbednych przypisan 62 120 to
scraper (skasowane), 73 kanon (chronione), 590 nieznane (zostawione). Bez niej nie dalo
by sie tego rozdzielic.

Wynik: 62 120 przypisan zdjetych, 2156 wpisow odchudzonych (Devine 170 na 60), 1740
pustych rekordow usunietych. Regula dopieta do joba junk_performers, drugi przebieg
daje zera.

Zostaje residuum: sceny, na ktorych osoba pelnoimienna NIE byla przypisana, wiec
regula nie miala sie o co oprzec (James, Blue, Moon, Crystal, Cruz, Starr, Knight po
60-160 przypisan). Tam trzeba by DOPISAC wlasciwa osobe, a nie kasowac - to osobne,
bardziej ryzykowne zadanie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 09:23:14 +02:00
goon-foss
ef8c255e1f perf(scenes): indeks trigramowy na title + ranking kandydatow do przegladu
Kazde szukanie slowa w tytulach bylo sekwencyjnym skanem 3,46 mln wierszy. Blokowalo
to ranking over-attribution: metryka "ile scen ma te nazwe w tytule, ale NIE jest do
niej przypisanych" wymaga jednego skanu NA KANDYDATA, a kandydatow jest ~800. Proba
zostala ubita po 10 minutach.

GIN + gin_trgm_ops obsluguje LIKE, ILIKE i regexy, a pg_trgm sam normalizuje wielkosc
liter, wiec indeks na surowym title wystarcza. Efekt: 26 ms zamiast sekund na
zapytanie, ranking 800 kandydatow w 48 sekund zamiast godzin. Indeks 315 MB.

Zbudowany CONCURRENTLY w autocommit_block: zwykle CREATE INDEX trzymaloby lock
blokujacy zapisy na scenes przez cala budowe i zatrzymalo ingest.

Ranking od razu pokazuje dwie rozne rzeczy. Czyste smieci ("Pornhub" 105.9, "Monster"
92, "Pretty" 63, "Nice" 42) oraz SAME NAZWISKA (Devine, Starr, Dior, Cruz, West,
Knight, Johnson, James) - wpisy powstale z rozbicia imienia i nazwiska, ktore potem
lapia kazda scene z tym nazwiskiem, nalezaca do zupelnie innej osoby.

Usuniete przy okazji: "Pornhub" (108 scen, 0 z kanonu, wszystkie to "full video on
pornhub") i "Precious" (119 scen, 0 z kanonu, sam przymiotnik).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:59:52 +02:00
goon-foss
470d70c11e feat(performers): narzedzie do przegladu over-attribution (wzorzec Rapture)
Ostatnia klasa smieci: REALNA osoba o nazwie bedacej pospolitym slowem, do ktorej
tube-search dokleja wszystko, co ma to slowo w tytule. Rapture miala 30 z 72
przypisan falszywych (film "The Rapture" z Mimi Rogers, literowka "hymen raptured",
tytuly scen).

NIE MA na to sygnalu automatycznego. Przetestowalem piec i zaden nie rozstrzyga:
- sama provenance ze scrapera: realni tworcy spoza TPDB (Amouranth, MollyRedWolf)
  tez maja 100 procent przypisan ze scrapera
- obsada z kanonu na scenie: backfill 0026 wypelnil tylko sceny jednozrodlowe, wiec
  sceny mieszane maja NULL - 1 trafienie na 560 tys.
- udzial nazwy w tytule: u niszowych prawdziwych tez ~100 procent (Amouranth 100,
  Clanddi 100)
- obce sceny ze slowem: myli generyczne uzycie z pominieta atrybucja
- stosunek tag/performer >=1: wyklucza 79 pozycji, ale samej Rapture BY NIE ZLAPAL

Zostaje czytanie tytulow. Skrypt robi z tego kilka minut zamiast pol godziny: listuje
komplet z provenance i kontekstem, liczy wskaznik pospolitosci nazwy, przyjmuje liste
prefiksow do odpiecia i DOMYSLNIE nie rusza przypisan potwierdzonych przez kanon.

Bez trybu rankingu wszystkich kandydatow: bez indeksu trigramowego na scenes.title to
skan 3,4 mln tytulow RAZY ~800 kandydatow. Sprobowalem, ubilem po 10 minutach.

Przy okazji przeglad zdemaskowal "Precious" (119 scen, 0 z kanonu) - wszystkie
przypisania to przymiotnik ("precious stepdaughter", "her ass is precious"). Usuniete.

Skala reszty: 1335 kandydatow z >=20 scenami, 539 z >=100. Nie ma sensu przerabiac
wszystkich - narzedzie jest na zglaszane przypadki.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:39:51 +02:00
goon-foss
8d92ecc51b fix(performers): zdjecie scraperowych przypisan z nazw 1-2 znakowych
"A", "L", "Ro", "Be", "Js" mialy po 250-290 scen kazdy. To artefakty parsowania
tytulow przez tube-search, ale NIE wszystkie: "Cj", "PD" i "JD" to realne krotkie
pseudonimy z obsada potwierdzona w TPDB i StashDB, z prawdziwymi partnerami
scenicznymi (Cj z Lucy Belle i Denisem Martim, PD z Victoria Love).

Dlatego nie kasujemy tu performera, tylko przypisania, o ktorych WIEMY ze dokleil je
scraper. Reszta rozstrzyga sie sama: komu nic nie zostanie, ten znika. To pierwsze
uzycie provenance z migracji 0026 - bez niej nie dalo sie odroznic "Cj" z obsady
TPDB od "Cj" wylusknietego z tytulu.

Rozklad byl jednoznaczny: z 9404 przypisan 9232 (98,2 proc.) ze scrapera, 141 z
kanonu, 31 nieznanych. Wiersze z NULL-em zostawiamy, nie zgadujemy.

Wynik: 9232 przypisan zdjetych, 20 performerow zniknelo calkiem, 31 zostalo z sama
prawdziwa obsada (Cj 41, PD 38, JD 20, reszta <=10). Dopiete do joba junk_performers,
zeby nie odroslo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:18:38 +02:00
goon-foss
034ddd644c feat(dedup): provenance przypisan performerow do scen (scene_performers.source_id)
scene_performers nie zapisywalo, KTO przypisal performera do sceny. Przez to nie dalo
sie odroznic obsady z kanonu od performera doklejonego przez tube-search, ktory
dopasowuje wynik po JEDNYM tokenie zapytania. Stad przypadek "Rapture": 30 z 72 jej
przypisan bylo falszywych (film "The Rapture" z Mimi Rogers, literowka "hymen
raptured", tytuly scen) i trzeba bylo je przegladac recznie, bo nie bylo sygnalu.

Kolumna trzyma NAJBARDZIEJ wiarygodne zrodlo, jakie przypisalo. Regula idzie tylko w
gore: NULL wypelnia cokolwiek, kanon nadpisuje scraper, scraper NIGDY nie zamazuje
kanonu. Inaczej dowolny pozniejszy tube-search zatarlby informacje, ze obsade
potwierdzilo TPDB. To samo przy merge_scenes: przy kolizji przenosimy lepsza
provenance na keepera, zeby scalanie nie degradowalo potwierdzonej obsady.

Backfill wypelnil 4 147 128 wierszy (96,3 procent) wnioskowaniem, ktore jest pewne:
scena z refami z DOKLADNIE jednego zrodla ma cala obsade stamtad. Sceny wielozrodlowe
(67 tys.) zostaja z NULL-em, bo tam zgadywanie zepsuloby sens kolumny. Wsadowo, z
commitem na paczke: pojedynczy UPDATE na 4 mln wierszy trzymalby locki na
scene_performers i zablokowal ingest.

Rozklad: tube-scraper 3 219 677, tpdb 747 540, stashdb 180 355.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:04:10 +02:00
goon-foss
7ad94448a4 fix(performers): drugie kryterium smieciowych performerow (tag przytlacza osobe)
Klasa "prawdziwy performer o pospolitej nazwie" okazala sie zawierac 211 rekordow,
ktore w ogole nie sa osobami, tylko kategoriami z refem kanonicznym: "Big Tits"
(931 scen), "Big Ass" (640), "Hardcore" (222), "Blonde" (238), "Masturbation".
TPDB i StashDB SAME zawieraja takie wpisy, wiec ref kanoniczny ich nie oczyszcza.

Kryterium promiskuitycznosci z poprzedniego commita tu NIE dziala i sprawdzenie tego
bylo kluczowe: w tej populacji obcy mierzy popularnosc, nie smieciowosc. Przy
wartosciach 50-59 stoja Adriana Chechik (4201 scen), Lauren Phillips, Jane Wilde i
Jordi El Nino Polla. Moja wczesniejsza walidacja progu 50 byla obciazona, bo grupa
bez refa to sami malo znani.

Rozstrzyga stosunek scen-z-tagiem do scen-performera, i to z uzasadnienia, nie z
probki: nazwisko uzyte jako wlasny tag daje stosunek ~1 Z DEFINICJI, bo tag siedzi
dokladnie na jej scenach. Potwierdzone na markach osobistych: Hotwifevictoria 1,02,
Rossmexicana 0,99, Sexwithmilfstella 0,87. Wielkie gwiazdy jeszcze nizej, maksimum
0,35 wsrod 921 pewnych osob. Kategorie: Hardcore 1720, Masturbation 1570, Blonde 770.
Prog 2 + wymog >=50 scen (ponizej stosunek jest szumem).

Skasowane 211 rekordow / ~32 tys. przypisan w dwoch przebiegach. Job idempotentny.
Ogon klasy jest zdrowy: z 8116 pozostalych 92% miesci sie ponizej 0,35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:23:03 +02:00
goon-foss
02ff9c7ff1 fix(performers): skasowanie 277 smieciowych performerow + job zapobiegajacy odrastaniu
Kategorie i studia podszywajace sie pod osoby: "Creampie" (269 scen), "Natural tits"
(670), "reverse cowgirl" (315), "69", "Brazzers", "Blacked", "Twistys". Razem 277
rekordow i 33 784 przypisan do scen.

Zrodlo: _search_base filtruje wyniki tube-searcha warunkiem "slug zawiera >=1 token
z zapytania", a resolve_performer tworzy performera z dowolnej nazwy podanej przez
tube. Zadnej bramki po drodze.

KRYTERIUM JEST WASKIE CELOWO. To uzywane w app/api/scenes.py do de-rankingu (slug
pokrywa sie z tagiem I brak refa tpdb/stashdb) daje 3111 trafien, ale probka pokazala
~60% falszywek dla nazw wielowyrazowych: wpadaja w nie Hatano Yui, Davina Raines,
Michelle Ferrari, Clanddi, SolaZola, Kate Kuray. Zalozenie "prawdziwy performer ma ref
kanoniczny" nie dziala dla JAV, amatorek i debiutantek. Do de-rankingu wystarcza, do
kasowania nie.

Rozstrzyga promiskuitycznosc taga: ile ROZNYCH innych performerow pojawia sie na
scenach z tym samym slugiem. Zmierzone: prawdziwe osoby maksimum 20 (Julia Reaves),
wiekszosc 0-4, Hatano Yui 0. Kategorie: Deepthroat 39541, Creampie 33661, riding
16284. Prog 50 lezy ponad 2x powyzej najwyzszej prawdziwej osoby. Odrzucilem tez
sygnal "slug rowny studiu", bo wciagal marki osobiste (Katekuray, Julia Reaves).

Tresc nie ginie: te slugi dalej istnieja jako tagi (creampie przy 176 tys. scen),
przestaja tylko udawac osoby. 16 968 scen zostalo bez obsady, co dokłada 1,8 procent
do istniejacych 943 tys. scen bez obsady.

Job co 24h zamiast bramki w resolve_performer: nie dotyka goracej sciezki ingestu,
a smieci odrastaja wolno. Ta sama logika co przy blackliscie phashy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 13:44:11 +02:00
goon-foss
121e6aa3f5 fix(dedup): bezpiecznik na pary drugiego rzedu w merge_phash_exact_dupes
Po scaleniu 4647 par zostalo 7 nowych, POWSTALYCH przez ten przebieg. Merge
przenosi odciski na keepera, wiec scalona scena zbiera phashe wszystkich
wchlonietych (jedna miala ich 14) i zaczyna kolidowac z obcymi scenami przez
odziedziczony phash, ktory nie przedstawia nawet jej tresci.

Ocena recznie po obsadzie i studiach: 5 z 7 (71%) to byly falszywki, m.in.
Brazzers vs Reality Kings i AdultTime vs Teen Curves. W parach pierwszego rzedu
bylo 0 falszywek na 18. Czyli ponowne odpalenie skryptu bez zmian scalaloby
blednie.

Rozdziela je podobienstwo tytulu z duzym marginesem: prawdziwe duplikaty 92,1 i
84,8, wszystkie falszywki 23,0-52,4. Prog 0,70 lezy w srodku luki. Prefiks studia
progu nie rusza, bo token_set_ratio traktuje goly tytul jako podzbior tytulu z
prefiksem.

Po wpieciu: 2 prawdziwe scalone, 5 odrzuconych, kolejny przebieg nie generuje
nowych par. Zbiezne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:53:54 +02:00
goon-foss
3de8489ffa fix(dedup): blacklista zdegenerowanych phashy + scalenie 4647 duplikatow
Audyt duplikatow po phashu miniatur wykazal dwa rozne zjawiska, ktore wygladaly
jak jedno.

1. Zdegenerowane phashe. 73 wartosci wystepowaly przy >=10 scenach kazda, lacznie
przy 4375 scenach; rekordzistka byla dzielona przez 892 sceny o 892 roznych
tytulach i 1886 roznych performerach. To zaslepki i czarne klatki, nie odciski
scen. find_by_phash_within bierze najblizsza wartosc z calej tabeli, wiec taka
zaslepka zawsze wygrywala z prawdziwym duplikatem (dist 0). Do tej pory bronila
nas bramka dur_prox i nic sie nie skleilo, ale to zabezpieczenie drugiej linii.

Blacklista jest tabela, nie jednorazowym DELETE, bo sam DELETE nic nie daje:
zaslepka wraca przy kolejnym ingescie. Trzeba pamietac, ze wartosc jest
bezuzyteczna. Job co 24h dopisuje nowe i czysci odciski. Po czyszczeniu zero
grup >=10, najwieksza pozostala ma 9.

2. Realne duplikaty. 4647 scalonych. Przyczyna byla jedna: 97 procent par ma
perverzije po dokladnie jednej stronie, bo pisze tytuly z prefiksem studia i
performera, a reszta tubow daje goly tytul. Wbrew mojej pierwszej diagnozie NIE
trzeba tu ruszac scoringu tytulu (token_set_ratio i tak radzi sobie z prefiksem,
a sciezka phash idzie przed composite): 80 procent par to dlug sprzed 60+ dni, a
biezacy wyciek to okolo 1 dziennie. Nadmiarowe wiersze: 18452 na 9449.

merge_phash_exact_dupes.py dostal wykluczenie blacklisty. Bez tego byl grozny:
sam klaster 892 scen dawal ~397 tys. par do rozwazenia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:47:20 +02:00
goon-foss
b485511f3f feat(fullporner): browse scraper pelnych scen + extractor bez tokenu
Pelne sceny, nie klipy: mediana ~33 min, zero ponizej 10 min, ~140-240 dziennie,
listing chronologiczny mimo naglowka Featured.

Extractor sklada URL sam, bez tokenu i bez wygasania: id w iframe jest zapisane
wspak, a koncowka to maska bitowa jakosci (1/2/4/8 = 360/480/720/1080). Id bywa
numeryczne albo szesnastkowe, a to drugie to ~25% katalogu. Blokada CDN jest na
naglowku (bez UA 403, bez Referera 404), NIE na fingerprincie TLS, wiec telefon
przechodzi -> mobile_direct_ok. Zweryfikowane 206 + faststart do 1080p.

Metadane cienkie: brak studia, mediana 3 tagi, tytuly przepisane pod SEO. Za to
data jako unix timestamp i dlugosc co do sekundy. Bramka na obsade, bo sceny bez
performera scalily sie 0/23 w dry-runie.

Phash miniatury liczony celowo i to on robi robote: dry-run resolvera bez niego
dawal 31% scalen, pilot z nim dal 65% (seen 62, new 22, updated 40, errors 0).
85 z 89 performerow ma ref tpdb/stashdb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:04:34 +02:00
goon-foss
ea489454c0 feat(sexu): scraper + extractor HLS, ale bez rejestracji w ingescie
Metadane ma najlepsze z tej serii: kanal = realne studio na 97% scen, 96%
performerow z refem tpdb/stashdb, dokladna dlugosc w sekundach, swiezosc w
godzinach, 120 scen na stronie listingu.

Nie wlaczamy go jednak do ALL_BROWSE_SCRAPERS, bo jednostka tresci jest zla:
to klipy 5-10 minut, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200,
zero filmow >=20 min w calym katalogu. Pilot na 240 scenach dal 1 canonical
match, bo dlugosc i tytul, czyli sygnaly resolvera, sa przepisane pod tube.
Przy ~340 uploadach dziennie to ~10k nierozwiazywalnych wierszy miesiecznie.

Kod zostaje kompletny i zweryfikowany: POST /api/video-info przez Bright Data
(CF blokuje sam POST z VPS), HLS playerData.src zamiast mp4 sources (te maja
ip= requestera w tokenie), master konczy sie .mp4 wiec lapie go istniejacy
hls_needs_passthrough. Gdyby serwis zaczal wrzucac pelne sceny, wystarczy
dopisac klase z powrotem do rejestru.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:42:05 +02:00
goon-foss
f5b7a45ac0 feat(8kporner): browse scraper za obejściem CF + extractor JWPlayer sources
Sitemapa i /watch/*.html są za Cloudflare, paginacja zwraca stronę 1, a load-more
chce logowania, więc katalog buduje się z homepage (~115 scen), a detale lecą przez
?link1=watch&id=. Bramka na obsadę przepuszcza 62/115; z tego 97% performerów ma
ref tpdb/stashdb, a 60% scen dopina się do tego, co już mamy.

Extractor czyta tablicę sources JWPlayera, nie JSON-LD contentUrl (ten jest zawsze
144p). Cap 720p, bo okcdn dławi połączenie do ~2,1 Mbps. Stream przenośny cross-IP
(206 bez Referera, faststart) → mobile_direct_ok.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:15:32 +02:00
goon-foss
b3e1092175 feat(pornmike): browse scraper behind a cast gate + direct-mp4 extractor
pornmike.com puts everything in JSON-LD @graph -> ItemPage.mainEntity
(name, duration, uploadDate, actor[], genre[], keywords, description,
thumbnail), so the parser reads one JSON blob instead of scraping markup.
Cast is clean: the whole scene page carries exactly as many /pornstar/
links as there are actors, none of the sidebar pollution that got xxxfiles
rejected. 82% of sampled performers already carry a tpdb/stashdb ref and
19-20 of 20 channels are studios we already know.

Ingest is gated on a non-empty actor[]: 23% of the catalog has no cast and
those scenes could neither be attributed nor deduped. Tags and categories
come only from JSON-LD (keywords + genre), never from the HTML, which
carries 38 /category/ and 22 /tag/ nav and sidebar links per page.
Pagination is ?p=N only: the /N/ form 404s and ?page=N is silently ignored,
returning page 1.

Playback is the simplest in the portfolio: a plain <source> mp4 on twincdn
with no token, no query string and no expiry. Verified 206 on a Range
request from the VPS and from another machine in another country, both
without a Referer, so it is neither hotlink-guarded nor IP-bound and the
phone streams it directly.

Two honest caveats recorded in the module docstring: these are 5-12 minute
clips (median ~487s against 1800-2400s for the tubes we accepted), so for
the ~20% of the catalog from Tushy/Blacked Raw/Milfy/Anilos they will sit
as a short shadow next to full canonical scenes; and uploadDate is the
tube's import date, not the studio release, so pages past the second are
marked backfill.

Pilot ingest of 3 pages: 127 seen, 49 merged into existing scenes, 66 new,
0 errors; on page one the gate passed 42 of 62 links with zero scenes
missing cast, duration or studio.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-07-27 10:42:39 +02:00
goon-foss
2ef54ad6ea fix(triage): theporndude candidate audit had two false-negative bugs
The domain fallback built its guess from the REVIEW slug: when pdude.link
resolved to the porndudecams interstitial, "youperv-website-review" became
youperv-website-review.com, which of course refused the connection and the
candidate was filed as dead. That hit 15 of 144 entries, and among them were
youperv and pornbusy, which we later found independently and shipped at
4.5/5. Now the -website-review / -review suffix is stripped, and 13 of those
15 answer 200.

The audit also fetched with plain httpx, whose TLS fingerprint most
Cloudflare-fronted tubes reject, so timeouts and 403s were being read as
"dead" for sites that work fine in a browser. It now goes through
browser_get (curl_cffi), the same path our scrapers use, so the triage
finally measures what production sees.

Worth knowing before trusting the output: the heuristic score does NOT
predict a candidate's value to us. It matches cosmetic homepage markers,
and on sites we have since evaluated properly it scored youperv 0 and
pornbusy -1.5 (a false auth_wall) while giving 2.5 to hqfap, which we
removed as an orphan factory. Use the refreshed file as a list of live,
unexplored domains (95 of them) and judge each one on canonical match and
metadata quality, not on this number.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-07-27 09:37:51 +02:00
goon-foss
d7442187c9 feat(fullvideosporn): browse scraper behind a cast gate + TXXX extractor
fullvideosporn.com is sextu.com rebranded, not a clone of fullmovies.xxx
(different engine, different catalog, 0/140 title overlap with our
fullmoviesxxx corpus).

The site is only worth ingesting behind a gate: 65-75% of its catalog has
no performer at all, and those scenes also carry SEO-spun titles, so they
would never match canonical and would land as empty orphans. So we ingest
only scenes with at least one performer. That keeps ~25-35% of the catalog
(verified: 19 of 60 on page one) where the signal is good, since 88-89% of
the performer names in the research sample already resolve to a canonical
performer in our DB.

Three site-specific traps, all handled:
- Titles come from the player's vit:"..." field, not og:title/h1, which are
  sometimes an AI SEO rewrite rather than the real scene title.
- Cast is read only from the <h3>Porn-stars:</h3> section; the page carries
  ~22 videos.php?q= links overall but only 1-2 real performers, the same
  pollution that got xxxfiles rejected. Porn Site / Porn Categories are
  separate h3 blocks and are parsed per-section so they don't bleed.
- Every fetch passes a cookie gate: a fresh session gets HTTP 429 plus a
  small JS challenge, so we read the cookie out of it and retry on the same
  session. Hence the custom crawl_page instead of the base browser_get.

The TXXX video_url decoder moved out of vjav into _txxx.py since both tubes
share the engine; vjav keeps an alias and was re-verified after the move.
Playback resolves videofile.php -> decode -> get_file -> 302 -> znvcdn, and
the final CDN URL is portable cross-IP so the phone streams it directly.
Note for future debugging: the VPS itself gets 429 from that CDN because of
datacenter IP reputation, so playback health-checks run from the VPS will
be falsely negative.

Pilot ingest of 2 pages: 39 seen, 11 merged into existing scenes, 28 new,
0 errors; 0/19 without cast or duration on the sampled page.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-07-27 08:56:23 +02:00
goon-foss
d716b0c75d feat(pornbusy): browse scraper + loadvid POST-manifest producer
pornbusy.com exposes every field as a discrete itemprop node (title,
duration, uploadDate, thumbnail, description) plus a dedicated
<div id="video-actors"> cast block with no sidebar pollution. Measured
orphan risk is low: 80% of a 100-scene sample have a performer that
already carries a tpdb/stashdb ref, and 21% of titles strongly match
scenes we already hold. No studio field exists on the site.

Homepage pagination is broken (pages 1/2/3 return an identical set), so
the listing is driven off sitemap_index.xml -> 10 post-sitemaps sorted by
lastmod, chunked in crawl_page the same way the PlayTube base does it.

Playback dispatches on embedURL and covers ~69% of the catalog: the
seekplayer family (already handled by the engine after the earlier host
regex widening) and zpi.cx (the embedURL is the file itself), plus loadvid
at ~41%, which needed new plumbing.

loadvid hands back the CONTENT of an m3u8 over POST /videos/resolve-token
(CSRF + videoToken from the embed page) and has no manifest URL at all:
GET on that endpoint is 405 and the guessable .m3u8 paths are 404. So
make_token grew an optional `producer` marker and /proxy/hls calls the
producer instead of GETting a URL. Segments in the returned manifest are
absolute and fully portable (verified: 206 on a Range request with no
headers, no referer, no token), so the phone still pulls them straight
from the CDN and only the manifest travels through the VPS.

upload18 (~12%) and the tail are deliberately left unresolved: their token
embeds the fetcher's /24, so resolving server-side would force the whole
video through the VPS.

Verified: 19 scenes/page, 19/19 with duration, 17/19 with cast, 19/19 with
tags (names read from the title attribute since an icon element precedes
the anchor text), playback 8/8 via loadvid, and /proxy/hls returning a
116KB manifest with 869 absolute segments. Pilot ingest of 3 pages: 59
seen, 35 merged into existing scenes, 24 new, 0 errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-07-27 08:45:00 +02:00
goon-foss
2bf9179467 feat(galaxyporn): browse scraper + extractor; widen seekplayer host regex
galaxyporn.net carries paysite rips with a dedicated <div id="video-actors">
cast block (no sidebar pollution), studio + date in the title prefix, healthy
/page/N/ pagination. Measured orphan risk is low: 92% of a 75-performer
sample already have a tpdb/stashdb ref in our DB and 79% of titles match
scenes we already hold.

seekplayer_engine host regex widened to cover seekplays|4meplayer|ezplayer|
seeks|upn and the pro|cloud|one TLDs. This is the same engine (identical
AES key/IV and /api/v1/video endpoint), just newer domains; the whitelist is
additive so existing hosts keep matching (verified, including that
upns.evil.com is still rejected). Unlocks all four galaxyporn player hosts
and the same family on other sites.

Two things the extractor had to get right:
- Call seekplayer_engine directly rather than via extract_stream_from_hoster:
  the wrapper verifies the resulting URL and the hotlink-guarded HLS 403s, so
  it discarded correctly decoded streams.
- Referer must be the PLAYER origin, not galaxyporn.net. With the site referer
  the manifest 403s; with the player origin it returns 200. Verified end to
  end: manifest 200 -> variant 200 (570 segments) -> segment 206.

Duration is absent from the HTML, JSON-LD and player payload, and a NULL
duration would silently hide scenes behind the min_duration_sec filter (the
porntrex incident), so it is computed from the player's thumbnail.vtt last
cue. The player API throttles bursts, hence the retry/backoff: that took
missing durations from 9/21 down to 3/21. The value runs ~2% short of the
true length (one thumbnail interval), which beats having none.

Pilot: 21 scenes/page with studio 21/21 and clean cast (max 3), ingest of
2 pages = 42 seen, 34 new, 8 merged, 0 errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-07-26 18:23:13 +02:00
goon-foss
5a0b62c3e4 feat(youperv): browse scraper + direct-mp4 extractor
youperv.com (DataLife Engine) carries paysite rips titled
"Studio - Performer - Title" (71% of a 132-title sample), ~60-70 new
scenes/day. Browse the homepage + /page/N/ (19 scenes/page, no overlap);
scene URL is /<category>/<id>-<slug>.html.

Cast is scoped to the fmeta block (up to the Related section): the whole
page carries 19-26 /xfsearch/pornstar/ links but only 1-2 are the actual
cast, so without scoping this would repeat the page-wide pollution that
got xxxfiles rejected. Studio comes from the title prefix, guarded so a
performer name is never mistaken for a studio. Duration, ISO release date,
per-scene tags and thumbnail all come from the same block.

Playback is a plain <source> mp4 (files.klubnichka-hd.com) with no token
or expiry, but the CDN hotlink-guards on Referer: bare Range gets 403,
Range + Referer + browser UA gets 206 cross-IP from the VPS. So the
extractor returns it with referer + mobile_direct_ok and the phone streams
straight from the CDN, no WebView and no proxy. Path is percent-encoded
because the filenames contain spaces.

Deep-crawl capped at 2000 pages: beyond that (<=09.2023) the catalog turns
into generic amateur uploads with no performers and dead CDN files.

Verified: 19 scenes/page with studio+cast+duration+date, max 2 performers
per scene (pollution guard holds), 0/19 missing duration, playback 206
video/mp4. Pilot ingest 3 pages: 57 seen, 39 attached to existing canonical
scenes, 18 new, 0 errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-07-26 17:57:24 +02:00
50 changed files with 4143 additions and 394 deletions

View file

@ -0,0 +1,39 @@
"""phash blacklist: zdegenerowane wartości phasha
Revision ID: 0025_phash_blacklist
Revises: 0024_saved_searches
Create Date: 2026-07-27
Audyt duplikatów po phashu wykazał 73 wartości występujące przy 10 scenach każda
(łącznie 4375 scen), z rekordzistką dzieloną przez 892 sceny o 892 różnych tytułach.
To zaślepki i czarne klatki, nie odciski scen. Tabela pamięta takie wartości na
stałe, żeby po wyczyszczeniu wierszy nie wracały przy kolejnych ingestach.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0025_phash_blacklist"
down_revision: str | None = "0024_saved_searches"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"phash_blacklist",
sa.Column("value", sa.String(length=128), nullable=False),
sa.Column("scene_count", sa.Integer(), nullable=False),
sa.Column(
"detected_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("value", name="pk_phash_blacklist"),
)
def downgrade() -> None:
op.drop_table("phash_blacklist")

View file

@ -0,0 +1,49 @@
"""provenance przypisań performerów do scen
Revision ID: 0026_scene_performer_source
Revises: 0025_phash_blacklist
Create Date: 2026-07-27
`scene_performers` nie zapisywało, KTO przypisał performera do sceny. Przez to nie da
się odróżnić obsady pochodzącej z kanonu od performera doklejonego przez tube-search
(`_search_base` dopasowuje wynik po JEDNYM tokenie zapytania, więc Rapture" łapała
każdą scenę ze słowem rapture" — 30 z 72 jej przypisań było fałszywych i trzeba je
było przejrzeć ręcznie, bo nie było na to żadnego sygnału).
Kolumna nullable, bo dla 4,3 mln istniejących wierszy źródła nie da się odtworzyć
wypełnia się od teraz, przy kolejnych ingestach.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0026_scene_performer_source"
down_revision: str | None = "0025_phash_blacklist"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"scene_performers",
sa.Column("source_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_scene_performers_source_id_sources",
"scene_performers",
"sources",
["source_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_scene_performers_source_id", "scene_performers", ["source_id"])
def downgrade() -> None:
op.drop_index("ix_scene_performers_source_id", table_name="scene_performers")
op.drop_constraint(
"fk_scene_performers_source_id_sources", "scene_performers", type_="foreignkey"
)
op.drop_column("scene_performers", "source_id")

View file

@ -0,0 +1,46 @@
"""indeks trigramowy na scenes.title
Revision ID: 0027_scenes_title_trgm
Revises: 0026_scene_performer_source
Create Date: 2026-07-27
Bez tego każde szukanie słowa w tytułach to sekwencyjny skan 3,46 mln wierszy. Blokowało
to ranking kandydatów do przeglądu over-attribution
(`scripts/review_performer_attributions.py`): metryka ile scen ma nazwę w tytule, ale
NIE jest do niej przypisanych" wymaga jednego skanu NA KANDYDATA, a kandydatów jest ~800.
Przy skanie sekwencyjnym to godziny obciążania bazy próba została ubita po 10 minutach.
GIN + `gin_trgm_ops` obsługuje `LIKE`, `ILIKE` i regexy (`~`, `~*`), a pg_trgm normalizuje
wielkość liter sam, więc indeks na surowym `title` wystarcza.
**CONCURRENTLY i `autocommit_block`**: zwykłe `CREATE INDEX` bierze lock blokujący zapisy
na `scenes` na cały czas budowy (kilka minut przy tej wielkości), czyli zatrzymałoby
ingest. `CONCURRENTLY` nie może biec w transakcji, a Alembic domyślnie owija migrację w
transakcję stąd `autocommit_block()`.
Uwaga operacyjna: nieudane `CREATE INDEX CONCURRENTLY` zostawia indeks w stanie INVALID.
Sprawdzenie: `SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;` taki
trzeba dropnąć ręcznie i powtórzyć.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0027_scenes_title_trgm"
down_revision: str | None = "0026_scene_performer_source"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scenes_title_trgm "
"ON scenes USING gin (title gin_trgm_ops)"
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scenes_title_trgm")

View file

@ -27,7 +27,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import exists, 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
@ -128,6 +128,63 @@ def _new_counts(session: Session, device_id: str, *, kind: str) -> dict:
return {gid_val: int(n) for gid_val, n in rows} return {gid_val: int(n) for gid_val, n in rows}
def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
"""To samo co `_new_counts`, ale dla FILMÓW (user-request 2026-07-28: "+N" ma się
pojawiać też gdy ulubiony performer/studio dostanie nowy film, nie tylko scenę).
Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest
o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna
top-N ani odsiewu stub/backfill te pojęcia dotyczą scen.
"""
from sqlalchemy import func
from app.models.movie import Movie, MoviePerformer
from app.models.movie_playback_source import MoviePlaybackSource
live_movie = exists(
select(1).where(
MoviePlaybackSource.movie_id == Movie.id,
MoviePlaybackSource.dead_at.is_(None),
)
)
if kind == "performer":
base = (
select(
MoviePerformer.performer_id.label("gid"),
func.count()
.filter(Movie.created_at > FavoritePerformer.last_seen_at)
.label("n"),
)
.select_from(FavoritePerformer)
.join(MoviePerformer, MoviePerformer.performer_id == FavoritePerformer.performer_id)
.join(Movie, Movie.id == MoviePerformer.movie_id)
.where(FavoritePerformer.device_id == device_id, live_movie)
.group_by(MoviePerformer.performer_id)
)
else:
base = (
select(
Movie.studio_id.label("gid"),
func.count().filter(Movie.created_at > FavoriteStudio.last_seen_at).label("n"),
)
.select_from(FavoriteStudio)
.join(Movie, Movie.studio_id == FavoriteStudio.studio_id)
.where(FavoriteStudio.device_id == device_id, live_movie)
.group_by(Movie.studio_id)
)
return {gid: int(n) for gid, n in session.execute(base).all() if n}
def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
"""Sceny + filmy w jednym liczniku "+N" (badge nie rozróżnia typu treści)."""
counts = _new_counts(session, device_id, kind=kind)
for gid, n in _new_movie_counts(session, device_id, kind=kind).items():
counts[gid] = counts.get(gid, 0) + n
return counts
class FavoriteOut(BaseModel): class FavoriteOut(BaseModel):
performer_id: uuid.UUID performer_id: uuid.UUID
canonical_name: str canonical_name: str
@ -162,7 +219,7 @@ def list_favorites(
# _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 = _new_counts(session, device_id, kind="performer") new_counts = _merged_new_counts(session, device_id, kind="performer")
items: list[FavoriteOut] = [] items: list[FavoriteOut] = []
new_total = 0 new_total = 0
@ -277,7 +334,7 @@ def list_favorite_studios(
# 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 = _new_counts(session, device_id, kind="studio") new_counts = _merged_new_counts(session, device_id, kind="studio")
items: list[FavoriteStudioOut] = [] items: list[FavoriteStudioOut] = []
new_total = 0 new_total = 0

View file

@ -476,9 +476,13 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
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`
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
manifest_producer = (link.raw or {}).get("manifest_producer")
token = make_token( token = make_token(
raw_url, referer, impersonate=use_impersonate, raw_url, referer, impersonate=use_impersonate,
refresh=refetch_url, refresh_hoster=refetch_hoster, refresh=refetch_url, refresh_hoster=refetch_hoster,
producer=manifest_producer,
) )
# Decyzja na BASIE link.type (zaufanie do extractora), z fallback path-hint. # Decyzja na BASIE link.type (zaufanie do extractora), z fallback path-hint.
# Pornhat: raw URL `.../get_file/.../<id>.mp4/` ale CDN 302 → HLS manifest. # Pornhat: raw URL `.../get_file/.../<id>.mp4/` ale CDN 302 → HLS manifest.

View file

@ -20,6 +20,7 @@ oglądać dłuższe sceny + pause/seek bez ryzyka expired token.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import functools
import hashlib import hashlib
import hmac import hmac
import json import json
@ -163,6 +164,7 @@ def make_token(
refresh_hoster: str | None = None, refresh_hoster: str | None = None,
impersonate: bool = False, impersonate: bool = False,
stable_bucket_sec: int | None = None, stable_bucket_sec: int | None = None,
producer: str | None = None,
) -> str: ) -> str:
"""Build proxy token. """Build proxy token.
@ -187,6 +189,10 @@ def make_token(
payload["rh"] = refresh_hoster payload["rh"] = refresh_hoster
if impersonate: if impersonate:
payload["i"] = 1 payload["i"] = 1
if producer:
# Hoster oddający TREŚĆ manifestu (nie URL) — `/proxy/hls` zawoła producenta
# zamiast GET-a. Patrz app/extractors/hosters/loadvid.py.
payload["mp"] = producer
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
body = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") body = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
sig = base64.urlsafe_b64encode( sig = base64.urlsafe_b64encode(
@ -586,6 +592,24 @@ async def _curl_cffi_stream(
raise HTTPException(status_code=502, detail=f"proxy error: {e}") from e raise HTTPException(status_code=502, detail=f"proxy error: {e}") from e
async def _produce_manifest(producer: str, embed_url: str) -> str | None:
"""Zawołaj hoster-producenta manifestu w threadpoolu (curl_cffi jest sync)."""
import anyio
if producer == "loadvid":
from app.extractors.hosters import loadvid
try:
return await anyio.to_thread.run_sync(
functools.partial(loadvid.fetch_manifest, embed_url, timeout=25.0)
)
except Exception as e:
log.info("proxy-hls producer loadvid failed %s: %s", embed_url, e)
return None
log.warning("proxy-hls: unknown manifest producer %r", producer)
return 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.
@ -605,6 +629,20 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
payload = parse_token(token) payload = parse_token(token)
target = payload["u"] target = payload["u"]
referer = payload.get("r") or None referer = payload.get("r") or None
# Hoster-producent: manifest nie ma własnego URL-a (loadvid oddaje jego TREŚĆ
# dopiero na POST /videos/resolve-token), więc zamiast GET-a wołamy producenta.
producer = payload.get("mp")
if producer:
manifest = await _produce_manifest(producer, target)
if manifest is None:
raise HTTPException(status_code=502, detail="manifest producer failed")
return Response(
content=_absolutize_m3u8(manifest, base_url=target),
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-store"},
)
headers = _build_headers(referer) headers = _build_headers(referer)
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:

View file

@ -116,6 +116,19 @@ class Settings(BaseSettings):
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,
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
sched_phash_blacklist_hours: int = Field(
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
)
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie",
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
sched_junk_performers_hours: int = Field(
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
)
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin # Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie # 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

View file

@ -43,7 +43,14 @@ from app.connectors.direct_scrapers.youporn_browse import YouPornBrowseScraper
from app.connectors.direct_scrapers.siska import SiskaScraper from app.connectors.direct_scrapers.siska import SiskaScraper
from app.connectors.direct_scrapers.sxyland import SxyLandScraper from app.connectors.direct_scrapers.sxyland import SxyLandScraper
from app.connectors.direct_scrapers.sxyprn import SxyPrnScraper from app.connectors.direct_scrapers.sxyprn import SxyPrnScraper
from app.connectors.direct_scrapers.fullvideosporn import FullVideosPornScraper
from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper
from app.connectors.direct_scrapers.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.xhamster import XHamsterScraper 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
@ -152,6 +159,62 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
# 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
# hqporner). Ripy paysite, tytuły `Studio - Performer - Title` (71% próbki), performerzy
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
YoupervScraper,
# GalaxyPornScraper — dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
# wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu
# tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go
# w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
GalaxyPornScraper,
# PornBusyScraper — dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
# uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
PornBusyScraper,
# PornMikeScraper — dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
# VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z
# refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23%
# katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar).
# UWAGA: to klipy 5-12 min, nie pełne sceny — ~80% katalogu to jednak studia bez
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
PornMikeScraper,
# KPorner8Scraper — dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
# tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
# tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy.
KPorner8Scraper,
# FullPornerScraper — dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO — za to
# data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120
# scenach: 31% dopina się do tego, co mamy (composite waży performera + długość),
# przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny
# bez performera scaliły się 0/23. phash liczony celowo — to jedyny sygnał dla
# bulk_dedup na te duplikaty, po tytule nie pójdzie.
FullPornerScraper,
# SexuScraper (`sexu.py`) — ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
# minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min
# w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i
# tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie
# to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie
# performera. Scraper i extractor zostają w repo — gdyby serwis kiedyś zaczął wrzucać
# pełne sceny, wystarczy dopisać klasę z powrotem tutaj.
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
# Tytuł z `vit` playera (og:title to AI-spin SEO), bramka cookie 429 na fetchach.
FullVideosPornScraper,
# Browse równolegle do istniejącego search scrapera (wzorzec xvideos/eporner): # Browse równolegle do istniejącego search scrapera (wzorzec xvideos/eporner):
# search zostaje (pokrycie back-catalogu performerów), browse gwarantuje świeżość # search zostaje (pokrycie back-catalogu performerów), browse gwarantuje świeżość
# wprost z feedu (watchdog 48h zamiast 168h). Konwersja 2026-06-24 (user request). # wprost z feedu (watchdog 48h zamiast 168h). Konwersja 2026-06-24 (user request).

View file

@ -0,0 +1,167 @@
"""fullporner.com — browse scraper. Dodany 2026-07-27.
Pełne sceny, nie klipy: mediana długości 2067 s na stronie 1, 1911 s na 40 i 1969 s
na 300, zero filmów poniżej 10 minut w próbce. To jest różnica, przez którą sexu
odpadł, a ten wchodzi.
Listing jest ściśle chronologiczny, mimo że nagłówek mówi Featured Videos" (strona
1-3 = dziś, 10 = doba, 50 = 9 dni, 500 = 86 dni), 24 sceny na stronę. Daje to
~140-240 pełnych scen dziennie i katalog co najmniej 12 tys. scen wstecz.
**Metadane cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest
mediana 3. Tytuły w 100% przepisane pod SEO, więc nie niosą nic z tytułu
studyjnego (POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked
Dylan Moore Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
(dokładna, nie względna) i długość co do sekundy.
Dry-run resolvera na 120 scenach (w rollbacku): 37 (31%) dopięło się do scen, które
już mamy, przez `composite_auto` composite radzi sobie mimo złych tytułów, bo waży
performera i długość. Reszta to nowa podaż. Zmierzony przeciek duplikatów, po
odjęciu tła z kontroli na przesuniętej długości: ~15% katalogu. Liczymy phash
miniaturki właśnie po to, żeby późniejszy `bulk_dedup` miał czym te duplikaty łapać.
**Bramka na obsadę.** Sceny bez performera (19%) w dry-runie scaliły się w 0 na 23
przypadków bez performera composite nie ma na czym pracować, a studia tu nie ma
i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą
tylko sceny z obsadą.
Playback: `xiaoshenke.net` (extractor `fullporner`), bezpośredni mp4 do 1080p bez
tokenu i bez wygasania.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import UTC, datetime
from app.connectors.base import (
RawFingerprint,
RawPerformer,
RawPlaybackSource,
RawScene,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import (
BaseBrowseScraper,
compute_thumbnail_phash,
)
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://fullporner.com"
_THUMB = "https://imgs.xiaoshenke.net/thumb/{}.jpg"
_SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"')
_TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>")
# `<span class="create">1785081937</span></div><div>36:33</div>` — data i długość
# siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem.
_INFO_RE = re.compile(
r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>'
)
_PERF_BLOCK_RE = re.compile(r"<b>\s*Pornstars?:\s*</b>(.*?)</p>", re.IGNORECASE | re.DOTALL)
_PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
_TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>')
# `<iframe src="//xiaoshenke.net/video/4458723/14">` — id wspak, /14 = maska jakości.
# Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu).
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
def _duration(text: str) -> int | None:
parts = [int(x) for x in text.split(":")]
if len(parts) == 2:
return parts[0] * 60 + parts[1]
if len(parts) == 3:
return parts[0] * 3600 + parts[1] * 60 + parts[2]
return None
class FullPornerScraper(BaseBrowseScraper):
sitetag = "fullporner"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/" if page <= 1 else f"{_BASE}/home/{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
# BRAMKA: bez obsady scena nie ma żadnego sygnału kanonicznego (patrz docstring).
performers: list[RawPerformer] = []
seen_perf: set[str] = set()
pb = _PERF_BLOCK_RE.search(detail_html)
if pb:
for name in _PERF_RE.findall(pb.group(1)):
# Strona pisze performerów z małej („melissa straton") — do wyświetlania
# podnosimy, dopasowanie i tak idzie po slugu.
name = html_mod.unescape(name).strip().title()
slug = slugify(name)
if slug and slug not in seen_perf:
seen_perf.add(slug)
performers.append(
RawPerformer(external_id=f"{self.sitetag}:performer:{slug}", name=name)
)
if not performers:
return None
im = _INFO_RE.search(detail_html)
release_date = duration_sec = None
if im:
release_date = datetime.fromtimestamp(int(im.group(1)), UTC).date()
duration_sec = _duration(im.group(2))
tags: list[RawTag] = []
seen_tag: set[str] = set()
tb = _TAG_BLOCK_RE.search(detail_html)
if tb:
for name in _TAG_RE.findall(tb.group(1)):
name = html_mod.unescape(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)
)
# Miniaturka: id mediów to id z iframe zapisane WSPAK (patrz extractor).
thumbnail_url = None
fm = _IFRAME_RE.search(detail_html)
if fm:
thumbnail_url = _THUMB.format(fm.group(1)[::-1])
# phash liczymy świadomie, mimo kosztu jednego fetcha na scenę: to jedyny
# sygnał, którym bulk_dedup może potem łapać te ~15% duplikatów, których
# composite nie scalił (tytuły są przepisane, więc po nich nie pójdzie).
fingerprints: list[RawFingerprint] = []
if thumbnail_url:
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
if ph:
fingerprints.append(RawFingerprint(kind="phash", value=ph))
return RawScene(
external_id=f"{self.sitetag}:{scene_url.rsplit('/', 1)[-1]}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
performers=performers,
tags=tags,
fingerprints=fingerprints,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -0,0 +1,204 @@
"""fullvideosporn.com (= sextu.com pod nową marką) — browse scraper z BRAMKĄ NA OBSADĘ.
To NIE jest klon fullmovies.xxx: inny silnik (TXXX vs KVS), inna taksonomia, 0/140
pokrycia tytułów z naszym korpusem `tube:fullmoviesxxx`.
**BRAMKA (warunek konieczny)**: 65-75% katalogu NIE MA żadnego performera. Sceny bez
obsady mają dodatkowo mocno spunowane tytuły ("Fabulous Porn Movie Gothic Newest
Exclusive Version"), więc nie zmatchują canonical i weszłyby jako puste orphany.
Ingestujemy WYŁĄCZNIE sceny z 1 performerem wtedy sygnał jest dobry: 88-89%
nazwisk z próbki ma u nas performera z refem tpdb/stashdb. Zawężenie zostawia
~25-35% katalogu (~150-220k scen), czyli i tak dużo.
Pozostałe pułapki, wszystkie obsłużone niżej:
- **tytuł bierzemy z `vit:""` (JS playera), NIE z `og:title`/`h1`** te bywają
AI-owym spinem SEO ("Redhead MILF Fucks BBC in Courtroom Parody") zamiast realnego
tytułu sceny ("Alexis Fawx In Rise Anal In The Courtroom").
- **obsadę czytamy TYLKO z `<h3 class="item">Porn-stars: </h3>`** na całej stronie
jest ~22 linków `videos.php?q=`, w sekcji obsady 1-2 realne (pułapka xxxfiles).
- sekcje `Porn Site:` / `Porn Categories:` / `Porn-stars:` to ODDZIELNE `<h3>`, więc
parsujemy je per-blok, inaczej kategorie wyciekają do studia i odwrotnie.
- listing wymaga bramki cookie (429 challenge retry), stąd własny `crawl_page`
zamiast domyślnego `browser_get` z bazy.
Playback: extractor `fullvideosporn` (TXXX get_file 302 znvcdn, portable cross-IP).
"""
from __future__ import annotations
import html
import logging
import re
from datetime import UTC, datetime, timedelta
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors.tubes.fullvideosporn import _new_session, gated_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://fullvideosporn.com"
_SCENE_URL_RE = re.compile(r'href="(/en/video/(\d+)/[a-z0-9\-_]+/)"', re.IGNORECASE)
_VIT_RE = re.compile(r'vit:\s*"([^"]+)"')
_OGTITLE_RE = re.compile(r'property="og:title" content="([^"]+)"', re.IGNORECASE)
_DUR_RE = re.compile(r'og:video:duration" content="([0-9]+)"', re.IGNORECASE)
_THUMB_RE = re.compile(r'property="og:image" content="([^"]+)"', re.IGNORECASE)
# Każda sekcja to osobny <h3 class="item">Label: <a>..</a><a>..</a></h3>
_SECTION_RE = re.compile(r'<h3 class="item">(.*?)</h3>', re.IGNORECASE | re.DOTALL)
_ANCHOR_RE = re.compile(r">([^<>]{2,60})</a>")
_SUBMITTED_RE = re.compile(
r"Submitted:\s*<em>\s*(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago",
re.IGNORECASE,
)
_UNIT_DAYS = {
"second": 0, "minute": 0, "hour": 0,
"day": 1, "week": 7, "month": 30, "year": 365,
}
def _submitted_to_date(detail_html: str):
"""`Submitted: <em>3 days ago</em>` → data. Strona nie podaje daty wprost, a
to jest data uploadu na tube (nie premiery studia) traktujemy jak inne tuby."""
m = _SUBMITTED_RE.search(detail_html)
if not m:
return None
n, unit = int(m.group(1)), m.group(2).lower()
days = n * _UNIT_DAYS.get(unit, 0)
return (datetime.now(UTC) - timedelta(days=days)).date()
class FullVideosPornScraper(BaseBrowseScraper):
sitetag = "fullvideosporn"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/en/videos.php?p={page}&s=l"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for m in _SCENE_URL_RE.finditer(listing_html):
url = _BASE + m.group(1)
if url not in seen:
seen.add(url)
out.append(url)
return out
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Własna implementacja, bo listing i detale wymagają bramki cookie (429 →
challenge retry) i wspólnej sesji, czego `browser_get` z bazy nie robi."""
session = _new_session()
r = gated_get(session, self._listing_url(page), timeout=self._timeout)
if r is None or r.status_code != 200:
log.warning(
"fullvideosporn listing page=%d status=%s", page, getattr(r, "status_code", None)
)
return None
urls = self._extract_scene_urls(r.text)
if not urls:
return []
out: list[RawScene] = []
gated = 0
for scene_url in urls:
d = gated_get(session, scene_url, timeout=self._timeout)
if d is None or d.status_code != 200:
continue
try:
raw = self._parse_detail(scene_url, d.text)
except Exception as e:
log.warning("fullvideosporn detail parse failed %s: %s", scene_url, e)
continue
if raw is None:
gated += 1
continue
out.append(raw)
if gated:
log.info(
"fullvideosporn page=%d: %d/%d scen pominietych (brak obsady)",
page, gated, len(urls),
)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
# Sekcje metadanych — każda w swoim <h3 class="item">.
performers: list[RawPerformer] = []
tags: list[RawTag] = []
studio: RawStudio | None = None
seen_p: set[str] = set()
seen_t: set[str] = set()
for sec in _SECTION_RE.findall(detail_html):
label = re.sub(r"<[^>]+>", " ", sec).strip().lower()
names = [html.unescape(x).strip() for x in _ANCHOR_RE.findall(sec)]
if label.startswith("porn-stars"):
for name in names:
sl = slugify(name)
if sl and sl not in seen_p:
seen_p.add(sl)
performers.append(
RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name)
)
elif label.startswith("porn site"):
if names and studio is None:
sname = names[0]
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(sname)}",
name=sname,
slug=slugify(sname),
)
elif label.startswith("porn categories"):
for name in names:
sl = slugify(name)
if sl and sl not in seen_t:
seen_t.add(sl)
tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl)
)
# BRAMKA: bez obsady nie ingestujemy (patrz docstring — 2/3 katalogu to
# spunowane tytuły bez performera, które weszłyby jako puste orphany).
if not performers:
return None
# Tytuł z playera; og:title/h1 bywa AI-owym spinem SEO.
tm = _VIT_RE.search(detail_html)
title = html.unescape(tm.group(1)).strip() if tm else ""
if not title:
om = _OGTITLE_RE.search(detail_html)
title = html.unescape(om.group(1)).strip() if om else ""
if not title:
return None
dm = _DUR_RE.search(detail_html)
duration_sec = int(dm.group(1)) if dm else None
thm = _THUMB_RE.search(detail_html)
thumbnail_url = thm.group(1) if thm else None
# Tag == nazwisko performera (np. „Octavia Red" bywa i kategorią) → wytnij.
tags = [t for t in tags if t.slug not in seen_p]
return RawScene(
external_id=f"{self.sitetag}:{scene_url}",
title=title,
release_date=_submitted_to_date(detail_html),
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -0,0 +1,228 @@
"""galaxyporn.net — browse scraper (WordPress retrotube). Dodany 2026-07-26.
Ripy paysite ze studiami (BrazzersExxtra, EvilAngel, NubilesPorn, RealityKings,
MYLF, BlackedRaw). Ocena: orphan-risk LOW z twardym pomiarem 92% performerów
z próbki 75 ma już u nas ref tpdb/stashdb, 79% tytułów matchuje istniejące sceny.
Listing `/page/N/`, 21 scen/stronę, ~1180 stron, paginacja zdrowa (overlap p1/p2 = 0).
Metadane:
- obsada: `<div id="video-actors">` czysta, BEZ pollution (na całej stronie sceny
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]`
- tagi: `<div class="tags-list">` (wycinamy slug studia, żeby nie robić tag=studio)
- thumbnail: `data-main-thumb` / og:image
**Duration uwaga, nie ma go w HTML.** Ani listing, ani detail, ani JSON-LD, ani
payload playera nie niosą długości. NULL duration jest GROŹNY: `/scenes` filtruje
`duration_sec >= min_duration_sec`, a NULL w SQL odpada z wyniku, więc sceny byłyby
NIEWIDOCZNE w apce mimo "new: N, errors: 0" (dokładnie incydent porntrex, 6479 scen).
Dlatego liczymy z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na
scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od
realnej (2226 s vs ~2276 s na próbce, ~2%) akceptowalne, bo alternatywą jest brak.
Playback: iframe `#hash` rodziny seekplayer (galaxy.upns.online / galaxy.4meplayer.pro /
sport.seekplays.com / news.upns.pro) extractor `galaxyporncom` istniejący
`seekplayer_engine` (ten sam AES key/IV). HLS wymaga Referera `/proxy/hls`.
"""
from __future__ import annotations
import html
import json
import logging
import re
import time
from datetime import date
from urllib.parse import urlparse
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper, meta_content
from app.extractors._fetch import _DEFAULT_UA, browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://galaxyporn.net"
_SCENE_URL_RE = re.compile(r'<a\s+href="(https://galaxyporn\.net/[a-z0-9\-]+/)"[^>]*title=', re.IGNORECASE)
_H1_RE = re.compile(r'<h1[^>]*class="entry-title"[^>]*>(.*?)</h1>', re.IGNORECASE | re.DOTALL)
_ACTORS_BLOCK_RE = re.compile(r'<div id="video-actors">(.*?)</div>', re.IGNORECASE | re.DOTALL)
_ANCHOR_TEXT_RE = re.compile(r">([^<>]+)</a>")
_TAGS_BLOCK_RE = re.compile(r'<div class="tags-list">(.*?)</div>', re.IGNORECASE | re.DOTALL)
_THUMB_RE = re.compile(r'data-main-thumb="([^"]+)"', re.IGNORECASE)
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
# `BrazzersExxtra 26 07 26 Kayley Gunner & Elly Clutch Swapping Off The Pervy Maid`
_TITLE_STUDIO_DATE_RE = re.compile(r"^(?P<studio>[A-Za-z0-9][A-Za-z0-9._-]{1,30})\s+(?P<y>\d{2})\s+(?P<m>\d{2})\s+(?P<d>\d{2})\s+(?P<rest>.+)$")
# `[Onlyfans] Some Title` / `[Darkkotv] …`
_TITLE_BRACKET_RE = re.compile(r"^\[(?P<studio>[^\]]{2,30})\]\s*(?P<rest>.+)$")
# `… [2018-09-28]`
_TITLE_ISO_DATE_RE = re.compile(r"\[(\d{4})-(\d{2})-(\d{2})\]")
_VTT_CUE_RE = re.compile(r"(\d{2}):(\d{2}):(\d{2})[.,]\d+\s*-->\s*(\d{2}):(\d{2}):(\d{2})")
def _clean_text(raw: str) -> str:
text = re.sub(r"<[^>]+>", " ", raw)
return html.unescape(re.sub(r"\s+", " ", text)).strip()
def _resolve_duration(iframe_url: str, *, timeout: float, attempts: int = 3) -> int | None:
"""Długość z `thumbnail.vtt` playera (ostatni cue). Patrz docstring modułu:
duration nie ma w HTML, a NULL ukrywa sceny pod filtrem `min_duration_sec`.
API playera throttluje serie zapytań (przy ciągłym crawlu ~40% wywołań wracało
puste), więc retry z rosnącym backoffem. Każdy błąd None (scena i tak wejdzie,
po prostu bez długości)."""
from app.extractors.hosters.seekplayer_engine import _decrypt, matches
p = urlparse(iframe_url)
if not p.hostname or not matches(iframe_url):
return None
hash_id = p.fragment.strip()
if not hash_id:
return None
host = f"{p.scheme}://{p.hostname}"
headers = {"User-Agent": _DEFAULT_UA, "Accept": "*/*", "Referer": host + "/"}
for attempt in range(attempts):
if attempt:
time.sleep(1.5 * attempt)
try:
r = browser_get(
f"{host}/api/v1/video?id={hash_id}&w=1920&h=1080&r=",
headers=headers,
timeout=timeout,
)
if r.status_code != 200 or not r.text:
continue
data = json.loads(_decrypt(r.text))
thumb = (data.get("thumbnail") or "").strip()
if not thumb:
return None # payload OK, ale brak miniatur — retry nie pomoże
vtt_url = host + thumb if thumb.startswith("/") else thumb
vr = browser_get(vtt_url, headers=headers, timeout=timeout)
if vr.status_code != 200:
continue
cues = _VTT_CUE_RE.findall(vr.text)
if not cues:
return None
h, m, s = cues[-1][3], cues[-1][4], cues[-1][5]
return (int(h) * 3600 + int(m) * 60 + int(s)) or None
except Exception as e: # pragma: no cover - best-effort
log.info("galaxyporn: duration attempt %d failed (%s): %s", attempt + 1, iframe_url[:50], e)
return None
class GalaxyPornScraper(BaseBrowseScraper):
sitetag = "galaxyporncom"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/" if page <= 1 else f"{_BASE}/page/{page}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for m in _SCENE_URL_RE.finditer(listing_html):
url = m.group(1)
if url in seen or url.rstrip("/") == _BASE:
continue
seen.add(url)
out.append(url)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
h1 = _H1_RE.search(detail_html)
title = _clean_text(h1.group(1)) if h1 else ""
if not title:
title = (meta_content(detail_html, property="og:title") or "").strip()
if not title:
return None
# Obsada — blok wydzielony, bez scopingu na siłę (potwierdzone: cała strona
# ma dokładnie tyle linków /actor/ ile realnych aktorów).
performers: list[RawPerformer] = []
seen_p: set[str] = set()
ab = _ACTORS_BLOCK_RE.search(detail_html)
if ab:
for m in _ANCHOR_TEXT_RE.finditer(ab.group(1)):
name = html.unescape(m.group(1)).strip()
sl = slugify(name)
if not sl or sl in seen_p:
continue
seen_p.add(sl)
performers.append(
RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name)
)
# Studio + data z prefiksu tytułu. `Studio YY MM DD …` albo `[Studio] …`.
studio: RawStudio | None = None
release_date: date | None = None
studio_name: str | None = None
if (m := _TITLE_STUDIO_DATE_RE.match(title)):
studio_name = m.group("studio")
try:
release_date = date(2000 + int(m.group("y")), int(m.group("m")), int(m.group("d")))
except ValueError:
release_date = None
elif (m := _TITLE_BRACKET_RE.match(title)):
studio_name = m.group("studio")
if release_date is None and (m := _TITLE_ISO_DATE_RE.search(title)):
try:
release_date = date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
except ValueError:
release_date = None
# Guard: prefiks nie może być nazwiskiem performera (wtedy to nie studio).
if studio_name and slugify(studio_name) not in seen_p:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(studio_name)}",
name=studio_name,
slug=slugify(studio_name),
)
# Tagi — bez slugu studia (retrotube wrzuca studio także jako zwykły tag).
tags: list[RawTag] = []
seen_t: set[str] = set()
studio_slug = slugify(studio_name) if studio_name else ""
tb = _TAGS_BLOCK_RE.search(detail_html)
if tb:
for m in _ANCHOR_TEXT_RE.finditer(tb.group(1)):
name = html.unescape(m.group(1)).strip()
sl = slugify(name)
if not sl or sl in seen_t or sl == studio_slug or sl in seen_p:
continue
seen_t.add(sl)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl))
thumbnail_url = meta_content(detail_html, property="og:image")
if not thumbnail_url and (tm := _THUMB_RE.search(detail_html)):
thumbnail_url = tm.group(1)
duration_sec: int | None = None
if (fm := _IFRAME_RE.search(detail_html)):
duration_sec = _resolve_duration(fm.group(1).strip(), timeout=self._timeout)
return RawScene(
external_id=f"{self.sitetag}:{scene_url}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -0,0 +1,187 @@
"""8kporner.com — browse scraper (PlayTube CMS, jak hqfap/4k69). Dodany 2026-07-27.
Wyróżnik na tle innych kandydatów: **oryginalne tytuły studyjne**, nie przepisane
pod SEO. Do tego realne 4K (zweryfikowane parsowaniem `avc1`: 3840x2160) i przenośny
stream. Dlatego mimo małego wolumenu sceny dobrze matchują się do kanonu.
Trzy odstępstwa od `BasePlayTubeScraper`, wszystkie wymuszone przez stronę:
1. **Sitemap jest za Cloudflare** (`/sitemaps/videos/sitemap-N.xml` 403 również
PRZEZ Bright Data proxy, sprawdzone). Baza buduje katalog właśnie z sitemapy, więc
`_load_catalog` jest nadpisany: bierzemy homepage (~111 unikatów) + `?link1=videos&
page=latest` (20). To jednocześnie SUFIT deep crawl jest niemożliwy.
2. **Paginacja nie działa** `?page=2` i `?page=3` zwracają bajtowo ten sam zestaw
co strona 1, a `POST /aj/load-more/latest` wymaga zalogowania (`{"status":400}`).
3. **Forma `/watch/<slug>_<id>.html` jest CF-blokowana (403)**, ale `?link1=watch&id=
<id>` przechodzi czysto. Dlatego `page_url` trzymamy w formie kanonicznej (ładny
URL, stabilny external_id), a POBIERAMY przez obejście. Extractor `8kpornercom`
robi to samo przepisanie przy resolve.
**Bramka na obsadę.** Przepuszcza **62 ze 115 scen (54%)**, ale bardzo nierówno:
strona 1 (najświeższy blok) tylko 4/20, strony 2-6 od 50 do 75%. Reszta katalogu nie
ma żadnego performera i weszłaby jako puste orphany.
Bramka się broni z 117 performerów wciągniętych pilotem **113 (97%) ma ref
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
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
`/videos/category/`, a taksonomia kategorii (2035 pozycji) miesza gatunki ze studiami.
Rozstrzyga to `/studios`: kuratorowana lista 261 nazw, które realnymi studiami.
Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy
to jednak zwykłe słowa (Babes", „Premium", Swallowed") i te pomijamy, bo inaczej
powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio.
Trafień jest mało (1 na 62 w pilocie) większość scen po prostu nie ma pilla studia,
ale kosztuje to jeden fetch na run, więc zostaje.
Miniaturki re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w
canonical dopasowanie musi opierać się na tytule, obsadzie i długości.
"""
from __future__ import annotations
import logging
import re
import time
from urllib.parse import unquote
from app.connectors.base import RawScene
from app.connectors.direct_scrapers._playtube import BasePlayTubeScraper
from app.extractors import browser_get
log = logging.getLogger(__name__)
_BASE = "https://8kporner.com"
_PAGE_SIZE = 20
_FETCH_DELAY = 1.0 # CF przepuszcza przy ~1 s; szybciej zaczyna rzucać 403
_SCENE_HREF_RE = re.compile(
r'href="(https://8kporner\.com/watch/[a-z0-9_-]+_(\d+)\.html)"', re.IGNORECASE
)
_SCENE_ID_IN_URL_RE = re.compile(r"_(\d+)\.html")
_STUDIO_PILL_RE = re.compile(r'/videos/studio/([^"?]+)"', re.IGNORECASE)
# Pozycje z `/studios`, które są normalnymi gatunkami — jako studio dałyby fałszywkę
# na większości scen (patrz sxyland/"MILF"). Kilka z nich to realne marki
# (Swallowed, Throated), ale przy tym wolumenie lepiej nie mieć studia niż mieć złe.
_AMBIGUOUS_STUDIOS = {
"abc", "babes", "doc", "freeze", "futanari", "harassment", "hunter", "inserted",
"kawaii", "madonna", "milk", "non", "premium", "rabbit", "rookie", "royal",
"ruby", "smashed", "sperma", "squirted", "swallowed", "switch", "test shoots",
"throated", "venus",
}
def _skey(name: str) -> str:
return re.sub(r"[^a-z0-9]", "", name.lower())
def _bypass_url(scene_url: str) -> str:
"""Kanoniczny `/watch/<slug>_<id>.html` → `?link1=watch&id=<id>` (CF-safe)."""
m = _SCENE_ID_IN_URL_RE.search(scene_url)
return f"{_BASE}/?link1=watch&id={m.group(1)}" if m else scene_url
class KPorner8Scraper(BasePlayTubeScraper):
sitetag = "8kpornercom"
base_url = _BASE
def __init__(self) -> None:
super().__init__()
self._studios: dict[str, str] | None = None
def _studio_index(self) -> dict[str, str]:
"""{znormalizowany klucz: nazwa} z `/studios`. Ciągniemy z sieci zamiast
trzymać listę w kodzie, żeby nie gniła przy zmianach na stronie."""
if self._studios is not None:
return self._studios
index: dict[str, str] = {}
try:
r = browser_get(f"{_BASE}/?link1=studios", timeout=self._timeout)
r.raise_for_status()
for raw in set(_STUDIO_PILL_RE.findall(r.text)):
name = unquote(raw).replace("+", " ").strip()
if name and name.lower() not in _AMBIGUOUS_STUDIOS:
index[_skey(name)] = name
except Exception as e:
# Brak listy = brak studia. Reszta metadanych jest ważniejsza.
log.warning("8kporner: studios list fetch failed: %s", e)
self._studios = index
return index
def _pick_studio(self, category_names: list[str]) -> str | None:
index = self._studio_index()
for name in category_names:
hit = index.get(_skey(name))
if hit:
return hit
return None
def _load_catalog(self) -> list[str] | None:
"""Homepage + `?link1=videos&page=latest`. Sitemap jest CF-blokowany (patrz
docstring), więc to jedyne dostępne źródło URL-i i zarazem sufit katalogu."""
if self._catalog is not None:
return self._catalog
urls: list[str] = []
seen: set[str] = set()
for listing in (f"{_BASE}/", f"{_BASE}/?link1=videos&page=latest"):
try:
r = browser_get(listing, timeout=self._timeout)
r.raise_for_status()
except Exception as e:
log.warning("8kporner: listing fetch failed %s: %s", listing, e)
continue
for m in _SCENE_HREF_RE.finditer(r.text):
url = m.group(1)
if url not in seen:
seen.add(url)
urls.append(url)
time.sleep(_FETCH_DELAY)
if not urls:
return None
log.info("8kporner: catalog loaded — %d scenes (homepage+latest, brak sitemapy)", len(urls))
self._catalog = urls
return urls
def crawl_page(self, page: int) -> list[RawScene] | None:
"""Jak w bazie, ale detale pobieramy przez `?link1=watch&id=` (forma `/watch/`
daje 403) i z pauzą CF tnie przy szybszym tempie."""
catalog = self._load_catalog()
if catalog is None:
return None
chunk = catalog[(page - 1) * _PAGE_SIZE: page * _PAGE_SIZE]
if not chunk:
return []
out: list[RawScene] = []
for scene_url in chunk:
try:
res = browser_get(_bypass_url(scene_url), timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.info("8kporner detail fetch failed %s: %s", scene_url, e)
continue
try:
raw = self._parse_detail(scene_url, res.text)
except Exception as e:
log.warning("8kporner detail parse failed %s: %s", scene_url, e)
continue
if raw is not None:
out.append(raw)
time.sleep(_FETCH_DELAY)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
scene = super()._parse_detail(scene_url, detail_html)
if scene is None:
return None
# BRAMKA: 68% scen nie ma obsady (zmierzone 8/25 z obsadą) — bez niej
# większość katalogu wpadłaby jako puste orphany.
if not scene.performers:
return None
# Baza wycina studio z tagów przez porównanie napisów, a nazwa na `/studios`
# bywa inaczej rozstrzelona niż pill kategorii („DDFNetwork" vs „DDF Network")
# → dobijamy po znormalizowanym kluczu, żeby studio nie dublowało się jako tag.
if scene.studio is not None:
skey = _skey(scene.studio.name)
scene.tags = [t for t in scene.tags if _skey(t.name) != skey]
return scene

View file

@ -0,0 +1,238 @@
"""pornbusy.com — browse scraper (WordPress + Rank Math). Dodany 2026-07-27.
Ripy studyjne (~60%) + JAV z kodami (~30%). Ocena orphan-risk LOW z pomiarem:
80% scen z próbki 100 ma performera, który u nas MA już ref tpdb/stashdb, a 21%
tytułów mocno matchuje sceny które już mamy (czyli się scalą, nie zorphanują).
**Paginacja homepage jest ZEPSUTA** `/page/2/` i `/page/3/` zwracają ten sam
zestaw co strona 1 (zweryfikowane). Dlatego listing bierzemy z SITEMAPY:
`sitemap_index.xml` 10× `post-sitemapN.xml` po ~500 URL-i, każdy z `<lastmod>`,
posortowane od najnowszych. `crawl_page` tnie ten katalog na strony po `_PAGE_SIZE`
(wzorzec jak `_playtube.BasePlayTubeScraper`).
Metadane każde pole to osobny węzeł `itemprop`, nic nie trzeba wyłuskiwać z
tytułu-slug:
- title `<h1 class="entry-title">`, duration ISO, uploadDate, thumbnailUrl, description
- obsada: `<div id="video-actors">` CZYSTA, bez pollution (na stronie sceny jest
dokładnie tyle linków `/actor/` ilu realnych aktorów; brak sekcji Related)
- kategorie + tagi: `<div class="tags-list">` (`/category/` i `/tag/`)
- studio: **brak** pornbusy nie ma pola studia ani prefiksu w tytule
Playback: `itemprop="embedURL"` extractor `pornbusycom` (loadvid / seekplayer /
zpi.cx 69% katalogu).
"""
from __future__ import annotations
import html
import logging
import re
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.connectors.direct_scrapers._playtube import _parse_iso_date
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://pornbusy.com"
_PAGE_SIZE = 20
_SITEMAP_INDEX = f"{_BASE}/sitemap_index.xml"
_LOC_RE = re.compile(r"<loc>\s*([^<]+?)\s*</loc>")
_URL_BLOCK_RE = re.compile(r"<url>(.*?)</url>", re.DOTALL | re.IGNORECASE)
_LASTMOD_RE = re.compile(r"<lastmod>\s*([^<]+?)\s*</lastmod>")
_TITLE_RE = re.compile(r'<h1 class="entry-title"[^>]*>(.*?)</h1>', re.IGNORECASE | re.DOTALL)
_DUR_RE = re.compile(r'itemprop="duration"\s+content="([^"]+)"', re.IGNORECASE)
_DATE_RE = re.compile(r'itemprop="uploadDate"\s+content="([^"]+)"', re.IGNORECASE)
_THUMB_RE = re.compile(r'itemprop="thumbnailUrl"\s+content="([^"]+)"', re.IGNORECASE)
_DESC_RE = re.compile(r'itemprop="description"\s+content="([^"]*)"', re.IGNORECASE)
_ACTORS_BLOCK_RE = re.compile(r'<div id="video-actors">(.*?)</div>', re.IGNORECASE | re.DOTALL)
_ACTOR_RE = re.compile(r'/actor/[^/"]+/"[^>]*title="([^"]+)"', re.IGNORECASE)
_ACTOR_TEXT_RE = re.compile(r">([^<>]+)</a>")
_TAGS_BLOCK_RE = re.compile(r'<div class="tags-list">(.*?)</div>', re.IGNORECASE | re.DOTALL)
# Nazwa z atrybutu `title`, NIE z tekstu anchora: tekst poprzedza zagnieżdżona ikona
# (`<a ... title="Blonde"><i class="fa fa-tag"></i>Blonde</a>`), więc `>([^<]+)</a>`
# nie matchuje. `title` jest czysty i zawsze obecny.
_TAXO_RE = re.compile(
r'/(?:category|tag)/([a-z0-9\-]+)/"[^>]*title="([^"]+)"', re.IGNORECASE
)
# `P0DT0H42M52S`
_ISO_DUR_RE = re.compile(
r"P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", re.IGNORECASE
)
# Junk-performer guard: pornbusy wrzuca do /actor/ także ogólniki.
_JUNK_ACTORS = frozenset({"amateur", "unknown", "anonymous", "n-a", "na", "various"})
def _parse_iso_duration(value: str | None) -> int | None:
if not value:
return None
m = _ISO_DUR_RE.match(value.strip())
if not m:
return None
d, h, mn, s = (int(g or 0) for g in m.groups())
total = d * 86400 + h * 3600 + mn * 60 + s
return total or None
def _clean(raw: str) -> str:
return html.unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", raw))).strip()
class PornBusyScraper(BaseBrowseScraper):
sitetag = "pornbusycom"
def __init__(self) -> None:
super().__init__()
# Katalog z sitemapy, newest-first. Lazy raz per instancję (browse_latest i
# deep_crawl tworzą instancję per run, więc 10 fetchy XML amortyzuje się).
self._catalog: list[str] | None = None
# Listing nie jest stronicowalny GET-em (homepage pagination zepsuta) —
# paginację robi sitemap w `crawl_page`. Te dwie metody są abstrakcyjne w bazie.
def _listing_url(self, page: int) -> str: # pragma: no cover - nieużywane
return _SITEMAP_INDEX
def _extract_scene_urls(self, listing_html: str) -> list[str]: # pragma: no cover
return []
def _load_catalog(self) -> list[str] | None:
if self._catalog is not None:
return self._catalog
try:
idx = browser_get(_SITEMAP_INDEX, timeout=self._timeout)
idx.raise_for_status()
except Exception as e:
log.warning("pornbusy: sitemap index fetch failed: %s", e)
return None
maps = [u for u in _LOC_RE.findall(idx.text) if "post-sitemap" in u]
if not maps:
log.warning("pornbusy: no post-sitemaps in index")
return None
entries: list[tuple[str, str]] = []
for sm_url in maps:
try:
sm = browser_get(sm_url, timeout=self._timeout)
sm.raise_for_status()
except Exception as e:
log.warning("pornbusy: sitemap fetch failed %s: %s", sm_url, e)
continue
for block in _URL_BLOCK_RE.findall(sm.text):
loc = _LOC_RE.search(block)
if not loc:
continue
url = loc.group(1)
# Pomijamy strony taksonomii/nav — sceny to `/<slug>/` jednosegmentowe.
if any(x in url for x in ("/actor/", "/category/", "/tag/", "/page/")):
continue
lm = _LASTMOD_RE.search(block)
entries.append((lm.group(1) if lm else "", url))
if not entries:
return None
entries.sort(key=lambda e: e[0], reverse=True)
seen: set[str] = set()
catalog: list[str] = []
for _, url in entries:
if url in seen:
continue
seen.add(url)
catalog.append(url)
log.info("pornbusy: catalog loaded — %d scenes from %d sitemaps", len(catalog), len(maps))
self._catalog = catalog
return catalog
def crawl_page(self, page: int) -> list[RawScene] | None:
catalog = self._load_catalog()
if catalog is None:
return None
chunk = catalog[(page - 1) * _PAGE_SIZE: page * _PAGE_SIZE]
if not chunk:
return []
out: list[RawScene] = []
for scene_url in chunk:
try:
res = browser_get(scene_url, timeout=self._timeout)
res.raise_for_status()
except Exception as e:
log.info("pornbusy detail fetch failed %s: %s", scene_url, e)
continue
try:
raw = self._parse_detail(scene_url, res.text)
except Exception as e:
log.warning("pornbusy detail parse failed %s: %s", scene_url, e)
continue
if raw is not None:
out.append(raw)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _TITLE_RE.search(detail_html)
title = _clean(tm.group(1)) if tm else ""
if not title:
return None
dm = _DUR_RE.search(detail_html)
duration_sec = _parse_iso_duration(dm.group(1)) if dm else None
um = _DATE_RE.search(detail_html)
release_date = _parse_iso_date(um.group(1)) if um else None
thm = _THUMB_RE.search(detail_html)
thumbnail_url = thm.group(1) if thm else None
dsm = _DESC_RE.search(detail_html)
description = html.unescape(dsm.group(1)).strip() if dsm else None
if description and description.strip().lower() == title.strip().lower():
description = None # opis bywa kopią tytułu — nie duplikujemy
performers: list[RawPerformer] = []
seen_p: set[str] = set()
ab = _ACTORS_BLOCK_RE.search(detail_html)
if ab:
names = _ACTOR_RE.findall(ab.group(1)) or _ACTOR_TEXT_RE.findall(ab.group(1))
for name in names:
name = html.unescape(name).strip()
sl = slugify(name)
if not sl or sl in seen_p or sl in _JUNK_ACTORS:
continue
seen_p.add(sl)
performers.append(
RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name)
)
tags: list[RawTag] = []
seen_t: set[str] = set()
tb = _TAGS_BLOCK_RE.search(detail_html)
if tb:
for slug, name in _TAXO_RE.findall(tb.group(1)):
name = html.unescape(name).strip()
if not name or slug in seen_t or slug in seen_p:
continue
seen_t.add(slug)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug))
return RawScene(
external_id=f"{self.sitetag}:{scene_url}",
title=title,
description=description,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
# studio: pornbusy go nie ma (ani pole, ani prefiks tytułu)
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -0,0 +1,186 @@
"""pornmike.com — browse scraper (CodeIgniter, JSON-LD). Dodany 2026-07-27.
Ripy paysite (Tushy, Blacked Raw, Milfy, Deep Lush, Anilos) + nisze bez pokrycia
u nas (Jeffs Models, Golden Slut, Teen Erotica, Red-XXX, Erotik von Nebenan).
Pomiar: 82% performerów ma u nas ref tpdb/stashdb, 19-20/20 kanałów znamy jako studia.
Metadane w CAŁOŚCI z JSON-LD `@graph` `ItemPage.mainEntity` (VideoObject): name,
duration, uploadDate, actor[], genre[], keywords, description, thumbnailUrl. Zero
`og:`, zero itemprop więc nie ma ryzyka AI-spinu w og:title (choć sam tytuł i tak
jest przepisany pod SEO, patrz niżej).
**Świadome decyzje:**
- **Bramka na obsadę** (23% katalogu bez `actor[]`). Te sceny nie mają jak się
zdedupować ani zaatrybuować, weszłyby jako puste orphany. Kosztuje mało, w
przeciwieństwie do fullvideosporn (tam 65-75%).
- **Tagi i kategorie TYLKO z JSON-LD**, nigdy z HTML: strona ma 38 linków
`/category/` i 22 `/tag/` w nawigacji i sidebarze. `genre[]` daje 3-4 scoped,
`keywords` 7-8 scoped.
- **`release_date` = uploadDate, ale to data importu na tubie, nie premiera studia**
(najstarsze sceny mają uploadDate z 2025-10). Dlatego `backfill=True` dla stron > 2
zgodnie z regułą `scenes.backfill`, żeby stary katalog nie udawał nowości.
- Paginacja: WYŁĄCZNIE `?p=N`. Forma `/newest-porn-videos/2/` daje 404, a `?page=2`
jest ignorowane (zwraca stronę 1). Na każdej stronie jest stały 10-elementowy blok
sidebara (stąd 62 linki, realnie 52 sceny) dedup po external_id go pochłania.
- 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,
które przyjęliśmy. Dla ~20% katalogu (Tushy/Blacked Raw/Milfy/Anilos) będą cieniem
obok pełnych scen kanonicznych. Pozostałe ~80% to studia bez żadnego pokrycia u nas,
czyli realnie nowa podaż.
Playback: `<source>` direct mp4 (twincdn) extractor `pornmike`, bez tokenu, bez
Referera, przenośny cross-IP.
"""
from __future__ import annotations
import json
import logging
import re
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.connectors.direct_scrapers._playtube import _parse_iso_date, _parse_iso_duration
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://pornmike.com"
_SCENE_URL_RE = re.compile(r'href="(?:https://pornmike\.com)?(/videos/[a-z0-9-]+/)"', re.IGNORECASE)
_JSONLD_RE = re.compile(
r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.IGNORECASE | re.DOTALL
)
_CHANNEL_RE = re.compile(
r'href="(?:https://pornmike\.com)?/channel/([a-z0-9-]+)/"[^>]*>([^<]*)', re.IGNORECASE
)
_SCENE_ID_RE = re.compile(r"-(\d+)/?$")
def _humanize(slug: str) -> str:
return " ".join(w.capitalize() if w.islower() else w for w in slug.split("-") if w)
def _video_object(html_text: str) -> dict | None:
"""JSON-LD `@graph` → pierwszy VideoObject (zwykle jako `ItemPage.mainEntity`)."""
for m in _JSONLD_RE.finditer(html_text):
try:
data = json.loads(m.group(1).strip())
except (json.JSONDecodeError, ValueError):
continue
nodes = data.get("@graph") if isinstance(data, dict) else data
if isinstance(nodes, dict):
nodes = [nodes]
for node in nodes or []:
if not isinstance(node, dict):
continue
if node.get("@type") == "VideoObject":
return node
main = node.get("mainEntity")
if isinstance(main, dict) and main.get("@type") == "VideoObject":
return main
return None
class PornMikeScraper(BaseBrowseScraper):
sitetag = "pornmike"
def _listing_url(self, page: int) -> str:
# Tylko `?p=N`: `/newest-porn-videos/N/` → 404, `?page=N` → ignorowane.
base = f"{_BASE}/newest-porn-videos/"
return base if page <= 1 else f"{base}?p={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for m in _SCENE_URL_RE.finditer(listing_html):
url = _BASE + m.group(1)
if url in seen:
continue
seen.add(url)
out.append(url)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
vo = _video_object(detail_html)
if not vo:
log.info("pornmike: brak JSON-LD VideoObject na %s", scene_url)
return None
# BRAMKA: bez obsady scena nie ma jak się zaatrybuować ani zdedupować.
performers: list[RawPerformer] = []
seen_p: set[str] = set()
for a in vo.get("actor") or []:
name = (a.get("name") or "").strip() if isinstance(a, dict) else ""
sl = slugify(name)
if not sl or sl in seen_p:
continue
seen_p.add(sl)
performers.append(
RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name)
)
if not performers:
return None
title = (vo.get("name") or "").strip()
if not title:
return None
duration_sec = _parse_iso_duration(vo.get("duration"))
release_date = _parse_iso_date(vo.get("uploadDate"))
description = (vo.get("description") or "").strip() or None
thumbnail_url = (vo.get("thumbnailUrl") or "").strip() or None
# Tagi: keywords + ostatni segment każdego URL-a z genre[]. NIGDY z HTML
# (38 linków /category/ + 22 /tag/ w nav i sidebarze).
tags: list[RawTag] = []
seen_t: set[str] = set()
names: list[str] = [k.strip() for k in (vo.get("keywords") or "").split(",")]
for g in vo.get("genre") or []:
if isinstance(g, str):
names.append(_humanize(g.rstrip("/").rsplit("/", 1)[-1]))
for name in names:
sl = slugify(name)
if not sl or sl in seen_t or sl in seen_p:
continue
seen_t.add(sl)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl))
studio: RawStudio | None = None
cm = _CHANNEL_RE.search(detail_html)
if cm:
sname = (cm.group(2) or "").strip() or _humanize(cm.group(1))
if slugify(sname) not in seen_p:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(sname)}",
name=sname,
slug=slugify(sname),
)
id_m = _SCENE_ID_RE.search(scene_url.rstrip("/"))
scene_id = id_m.group(1) if id_m else scene_url
return RawScene(
external_id=f"{self.sitetag}:{scene_id}",
title=title,
description=description,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -0,0 +1,188 @@
"""sexu.com — browse scraper. Dodany 2026-07-27.
Najlepsze metadane z całej tegorocznej serii kandydatów. Próbka 25 najnowszych scen:
obsada 20/25 (80%), **kanał 25/25 i to realne studia** (New Sensations, Brutal X,
Moms Bang Teens, 2 Girls 1 Camera), dokładna długość w sekundach 25/25, data 25/25.
Świeżość: cała próbka miała 7-8 godzin. Listing daje 120 scen na stronę.
Dlatego **nie ma tu bramki na obsadę** (w odróżnieniu od fullvideosporn czy 8kporner).
Te 20% scen bez performera i tak niesie studio + dokładną długość + oryginalny tytuł,
czyli komplet sygnałów, których resolver potrzebuje do canonical matcha. Bramka
odrzuciłaby dobre dane.
**Dwie pułapki w HTML, obie sprawdzone:**
1. **Tagi trzeba wyciąć z bloku.** Linków `/tag/` jest na stronie 25, ale realnych
tagów sceny tylko 5 (reszta to sidebar popularne wyszukiwania"). Bez zawężenia
do bloku Tags:" wpisalibyśmy każdej scenie te same 20 śmieci.
2. **W bloku tagów siedzą też linki `/search?q=`** to fraz-y pod SEO (pleases",
hole", „indian milf"), nie tagi. Bierzemy wyłącznie `/tag/`.
**Studio.** Kanał to zwykle studio, ale przy uploadach amatorskich bywa nazwany po
performerze (`/channel/priya+rai` przy scenie z Priya Rai). Taki kanał odrzucamy,
bo performer w tabeli studios to zanieczyszczenie, nie dana.
Data jest względna (7 hours ago", „yesterday"), więc liczymy wstecz od teraz.
To i tak data uploadu na tube, nie premiery studia.
Playback: `POST /api/video-info` (extractor `sexu`) wymaga Bright Data, bo
Cloudflare blokuje POST z VPS. Sam scrape listingu i detali idzie bez proxy.
"""
from __future__ import annotations
import html as html_mod
import logging
import re
from datetime import UTC, datetime, timedelta
from app.connectors.base import (
RawPerformer,
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://sexu.com"
_SCENE_HREF_RE = re.compile(r'href="(/\d+/)"')
_TITLE_RE = re.compile(r"<h1[^>]*>\s*([^<]+?)\s*</h1>")
_DURATION_RE = re.compile(r'property="video:duration"\s+content="(\d+)"')
_OG_IMAGE_RE = re.compile(r'property="og:image"\s+content="([^"]+)"')
_DATE_RE = re.compile(r'player-block__date">\s*([^<]+?)\s*<')
_REL_DATE_RE = re.compile(
r"(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago", re.IGNORECASE
)
_UNIT_DAYS = {
"second": 0, "minute": 0, "hour": 0,
"day": 1, "week": 7, "month": 30, "year": 365,
}
# Bloki metadanych: `<div class="player-tags__title">Tags:</div> … <div class="x_container">`.
# Linki sceny są między tytułem a `_container` (tam zaczyna się formularz „Suggest…").
_BLOCK_END_RE = re.compile(r"_container|player-tags__title")
def _block(detail_html: str, label: str) -> str:
"""Fragment HTML należący do bloku `<label>:` — patrz pułapka 1 w docstringu."""
start = detail_html.find(f'player-tags__title">{label}:')
if start < 0:
return ""
rest = detail_html[start + len(label) + 22:]
end = _BLOCK_END_RE.search(rest)
return rest[: end.start()] if end else rest
def _links(block: str, kind: str) -> list[str]:
"""Nazwy z anchorów `/<kind>/<slug>` w obrębie bloku, z zachowaniem kolejności."""
pat = re.compile(rf'href="/{kind}/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
out: list[str] = []
seen: set[str] = set()
for name in pat.findall(block):
name = html_mod.unescape(name).strip()
key = name.lower()
if name and key not in seen:
seen.add(key)
out.append(name)
return out
def _relative_date(detail_html: str):
m = _DATE_RE.search(detail_html)
if not m:
return None
text = m.group(1).strip().lower()
now = datetime.now(UTC)
if text in ("today", "just now"):
return now.date()
if text == "yesterday":
return (now - timedelta(days=1)).date()
rel = _REL_DATE_RE.search(text)
if not rel:
return None
days = int(rel.group(1)) * _UNIT_DAYS.get(rel.group(2).lower(), 0)
return (now - timedelta(days=days)).date()
class SexuScraper(BaseBrowseScraper):
sitetag = "sexu"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/new/" if page <= 1 else f"{_BASE}/new?page={page}"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for m in _SCENE_HREF_RE.finditer(listing_html):
path = m.group(1)
if path not in seen:
seen.add(path)
out.append(_BASE + path)
return out
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
scene_id = scene_url.rstrip("/").rsplit("/", 1)[-1]
dm = _DURATION_RE.search(detail_html)
duration_sec = int(dm.group(1)) if dm else None
thumbnail_url = None
om = _OG_IMAGE_RE.search(detail_html)
if om:
thumbnail_url = om.group(1)
if thumbnail_url.startswith("//"):
thumbnail_url = "https:" + thumbnail_url
performers = [
RawPerformer(external_id=f"{self.sitetag}:performer:{slugify(n)}", name=n)
for n in _links(_block(detail_html, "Pornstars"), "pornstar")
if slugify(n)
]
# Kanał == performer → to upload amatorski nazwany po dziewczynie, nie studio.
studio = None
perf_keys = {p.name.lower() for p in performers}
for name in _links(_block(detail_html, "Channel"), "channel"):
if name.lower() in perf_keys or not slugify(name):
continue
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(name)}",
name=name,
slug=slugify(name),
)
break
tags = [
RawTag(external_id=f"{self.sitetag}:tag:{slugify(n)}", name=n, slug=slugify(n))
for n in _links(_block(detail_html, "Tags"), "tag")
if slugify(n)
]
return RawScene(
external_id=f"{self.sitetag}:{scene_id}",
title=title,
release_date=_relative_date(detail_html),
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -0,0 +1,173 @@
"""youperv.com — browse scraper (DataLife Engine). Dodany 2026-07-26.
Ripy paysite ze studiami (Brazzers Exxtra, Blacked, Evil Angel, Deeper, Private),
tytuły w formacie `Studio - Performer - Title` (71% próbki 132 tytułów), świeże
(~60-70 scen/dzień). Orphan-risk LOW: nazwane studio + nazwany performer + data
co do sekundy + duration = mocny sygnał do canonical match.
Listing: homepage (newest) + `/page/N/`, 19 scen/stronę, zero overlapu między
stronami. Scene URL: `/<kategoria>/<id>-<slug>.html`.
**KRYTYCZNE scoping obsady**: performerzy MUSZĄ być czytani tylko z bloku
`<div class="fmeta">` `Related`. Na całej stronie jest 19-26 linków
`xfsearch/pornstar/` (blok Related), w samym fmeta 1-2 realnych. Bez scopingu
powtórzylibyśmy błąd, przez który odrzuciliśmy xxxfiles (page-wide pollution
zaśmiecająca bazę performerów).
Tytuł zostaje z prefiksem studia (jak hdporngg/porn00) token_set_ratio i tak
złapie canonical, a prefiks niesie dodatkowy sygnał.
Playback: direct mp4 `<source>` na files.klubnichka-hd.com, BEZ tokena/expiry, ale
CDN ma hotlink-guard na Referer (bez nagłówka 403, z nagłówkiem 206 cross-IP).
Rozwiązuje extractor `youpervcom` (VPS-side, mobile gra direct, zero WebView/proxy).
Głębokość: deep-crawl capowany (`_PAGE_CAP` w deep_crawl.py) strony ~2100+ to
stara amatorka bez performerów, z martwymi linkami (HTTP 500 na CDN).
"""
from __future__ import annotations
import html
import re
from urllib.parse import unquote
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper, meta_content
from app.connectors.direct_scrapers._playtube import _parse_iso_date
from app.normalize.text import slugify
_BASE = "https://youperv.com"
_SCENE_URL_RE = re.compile(
r'href="(https://youperv\.com/[a-z0-9\-]+/\d+-[^"]+\.html)"', re.IGNORECASE
)
_H1_RE = re.compile(r'<h1[^>]*class="items-title[^"]*"[^>]*>(.*?)</h1>', re.IGNORECASE | re.DOTALL)
_PERF_RE = re.compile(r'xfsearch/pornstar/([^/"]+)', re.IGNORECASE)
_CAT_XF_RE = re.compile(r'xfsearch/cat/([^/"]+)', re.IGNORECASE)
_TAG_LINK_RE = re.compile(r'<a[^>]+href="[^"]+"[^>]*>([^<]{2,40})</a>', re.IGNORECASE)
_DUR_RE = re.compile(r"fa-clock-o[^>]*></i>\s*(\d{1,2}):(\d{2})(?::(\d{2}))?", re.IGNORECASE)
_DATE_RE = re.compile(r'"datePublished"\s*:\s*"([^"]+)"')
# Sufiks h1: `… Title 07.26.2026 <span class="xd"> HD</span>`
_H1_DATE_SUFFIX_RE = re.compile(r"\s*\d{2}\.\d{2}\.\d{4}\s*$")
def _clean_title(raw_h1: str) -> str:
text = re.sub(r"<[^>]+>", " ", raw_h1) # <span class="xd"> HD</span> itp.
text = html.unescape(re.sub(r"\s+", " ", text)).strip()
text = re.sub(r"\bHD\b\s*$", "", text).strip()
return _H1_DATE_SUFFIX_RE.sub("", text).strip()
def _perf_name(raw_slug: str) -> str:
"""`carolina%20guerrero` → `Carolina Guerrero`."""
name = unquote(raw_slug).replace("-", " ").strip()
return " ".join(w.capitalize() if w.islower() else w for w in name.split())
class YoupervScraper(BaseBrowseScraper):
sitetag = "youpervcom"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/" if page <= 1 else f"{_BASE}/page/{page}/"
def _extract_scene_urls(self, listing_html: str) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for m in _SCENE_URL_RE.finditer(listing_html):
url = m.group(1)
if url not in seen: # każdy link jest 2× w karcie (thumb + tytuł)
seen.add(url)
out.append(url)
return out
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
h1 = _H1_RE.search(detail_html)
title = _clean_title(h1.group(1)) if h1 else ""
if not title:
og = meta_content(detail_html, property="og:title") or ""
title = og.split(" » ")[0].strip()
if not title:
return None
# Blok metadanych TEJ sceny: od `class="fmeta` do sekcji Related (dalej idą
# linki powiązanych scen → performer pollution, patrz docstring).
i = detail_html.find('class="fmeta')
j = detail_html.find("Related", i + 1) if i >= 0 else -1
fmeta = detail_html[i:j] if i >= 0 and j > i else ""
performers: list[RawPerformer] = []
seen_p: set[str] = set()
for m in _PERF_RE.finditer(fmeta):
name = _perf_name(m.group(1))
sl = slugify(name)
if not sl or sl in seen_p:
continue
seen_p.add(sl)
performers.append(
RawPerformer(external_id=f"{self.sitetag}:performer:{sl}", name=name)
)
# Studio z prefiksu `Studio - Performer - Title` (≥3 człony). Guard: prefiks
# nie może być nazwiskiem performera (wtedy to `Performer - Title`, bez studia).
studio: RawStudio | None = None
parts = [p.strip() for p in title.split(" - ")]
if len(parts) >= 3 and 2 <= len(parts[0]) <= 40:
cand = parts[0]
if slugify(cand) not in seen_p:
studio = RawStudio(
external_id=f"{self.sitetag}:studio:{slugify(cand)}",
name=cand,
slug=slugify(cand),
)
tags: list[RawTag] = []
seen_t: set[str] = set()
tag_names = [_perf_name(m.group(1)) for m in _CAT_XF_RE.finditer(fmeta)]
ti = detail_html.find("full-tags")
if ti >= 0:
block = detail_html[ti:ti + 800]
tag_names += [html.unescape(m.group(1)).strip() for m in _TAG_LINK_RE.finditer(block)]
for name in tag_names:
sl = slugify(name)
if not sl or sl in seen_t or sl in seen_p or name.lower() in ("categories", "tags"):
continue
seen_t.add(sl)
tags.append(RawTag(external_id=f"{self.sitetag}:tag:{sl}", name=name, slug=sl))
duration_sec: int | None = None
dm = _DUR_RE.search(fmeta or detail_html)
if dm:
h_or_m, mins, secs = dm.group(1), dm.group(2), dm.group(3)
duration_sec = (
int(h_or_m) * 3600 + int(mins) * 60 + int(secs)
if secs
else int(h_or_m) * 60 + int(mins)
)
rd = _DATE_RE.search(detail_html)
release_date = _parse_iso_date(rd.group(1)) if rd else None
thumbnail_url = meta_content(detail_html, property="og:image")
return RawScene(
external_id=f"{self.sitetag}:{scene_url}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=scene_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=scene_url,
duration_sec=duration_sec,
thumbnail_url=thumbnail_url,
)
],
)

View file

@ -30,14 +30,21 @@ from app.extractors.tubes import (
eporner, eporner,
freshporno, freshporno,
fullmovies, fullmovies,
fullvideosporn,
galaxyporn,
hdporngg, hdporngg,
hqfap, hqfap,
hqporner, hqporner,
fullporner,
kporner8,
sexu,
javflix, javflix,
neporn, neporn,
latestpornvideo, latestpornvideo,
paradisehill, paradisehill,
porn00, porn00,
pornbusy,
pornmike,
porntrex, porntrex,
supjav, supjav,
sxyprn, sxyprn,
@ -45,6 +52,7 @@ from app.extractors.tubes import (
watchporn, watchporn,
xhamster, xhamster,
yespornvip, yespornvip,
youperv,
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -104,6 +112,36 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
# watchporn — 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął). # watchporn — 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął).
# flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound. # flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound.
"watchporn": watchporn.extract, "watchporn": watchporn.extract,
# youperv — fluidplayer, zwykły <source> mp4 na files.klubnichka-hd.com. Bez tokena
# i bez IP-bindingu; CDN pilnuje tylko Referera (cross-IP 206 z VPS) → mobile direct.
"youpervcom": youperv.extract,
# galaxyporn — iframe rodziny seekplayer (upns/4meplayer/seekplays); silnik już mamy,
# extractor tylko wyłuskuje iframe. HLS Referer+IP-bound → /proxy/hls.
"galaxyporncom": galaxyporn.extract,
# pornbusy — dispatch po embedURL: loadvid (POST-manifest przez /proxy/hls),
# rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik).
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
"pornbusycom": pornbusy.extract,
# pornmike — gołe <source> mp4 na twincdn: bez tokenu, bez Referera, bez expiry.
# Cross-IP 206 z VPS i z innego kraju → mobile gra direct, zero proxy/WebView.
"pornmike": pornmike.extract,
# 8kporner — JWPlayer `sources` (NIE JSON-LD contentUrl, ten jest zawsze 144p!).
# okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
"8kpornercom": kporner8.extract,
# fullporner — iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
# Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
"fullporner": fullporner.extract,
# sexu — POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS).
# Bierzemy HLS `playerData.src`, NIE mp4 `sources` — te mają w tokenie `ip=`
# requestera, więc na telefonie dałyby 403. Master kończy się `.mp4` → wchodzi
# hls_needs_passthrough i manifest idzie przez /proxy/hls, segmenty direct.
"sexu": sexu.extract,
# fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
"fullvideosporn": fullvideosporn.extract,
"siskavideo": _embed_iframe.extract, "siskavideo": _embed_iframe.extract,
"porn4dayspw": _embed_iframe.extract, "porn4dayspw": _embed_iframe.extract,
"porndishcom": _embed_iframe.extract, "porndishcom": _embed_iframe.extract,

View file

@ -0,0 +1,128 @@
"""cdn.loadvid.com — HLS hoster oddający manifest przez POST (nie URL).
Protokół (reverse-engineer 2026-07-27, potwierdzony live):
1. GET /videos/play/<hash> HTML z `<meta name="csrf-token">` + inline
`window.LoadVidConfig = { videoHash, videoToken, ... }`
2. POST /videos/resolve-token {token, hash} + nagłówek `X-CSRF-TOKEN`
**treść manifestu m3u8** (200, `application/vnd.apple.mpegurl`, ~116 KB,
~870 segmentów), a NIE URL do manifestu.
Dlatego to nie jest zwykły extractor "URL → URL": nie da się oddać linku do
manifestu, bo taki link nie istnieje (GET na resolve-token 405, zgadywane
`/index.m3u8` 404). Manifest musi wyprodukować backend.
Rozwiązanie: `extract()` zwraca **embed URL** oznaczony `manifest_producer=loadvid`.
`/proxy/hls/<token>/play.m3u8` rozpoznaje ten marker i zamiast GET-a woła
`fetch_manifest()`, po czym serwuje manifest telefonowi. Segmenty w manifeście
ABSOLUTNE i w pełni przenośne (zweryfikowane: 206 `bytes 0-1023` BEZ jakichkolwiek
nagłówków, bez Referera, bez tokena serwowane jako `image/png`, w środku TS),
więc telefon ciągnie je bezpośrednio z CDN. Przez VPS idzie tylko manifest.
"""
from __future__ import annotations
import logging
import re
from urllib.parse import urlparse
from app.extractors._fetch import _DEFAULT_IMPERSONATE, _DEFAULT_UA, _HAS_CURL_CFFI
from app.extractors._models import HosterDead, StreamSource
log = logging.getLogger(__name__)
_HOST_RE = re.compile(r"^(?:[a-z0-9]+\.)?loadvid\.com$", re.IGNORECASE)
_CSRF_RE = re.compile(r'<meta name="csrf-token" content="([^"]+)"', re.IGNORECASE)
_CONFIG_RE = re.compile(r"LoadVidConfig\s*=\s*(\{.*?\})\s*;", re.DOTALL)
_TOKEN_RE = re.compile(r"""videoToken\s*:\s*['"]([^'"]+)""")
_HASH_RE = re.compile(r"""videoHash\s*:\s*['"]([^'"]+)""")
_RESOLVE_PATH = "/videos/resolve-token"
def matches(url: str) -> bool:
try:
return bool(_HOST_RE.match(urlparse(url).hostname or ""))
except Exception:
return False
def fetch_manifest(embed_url: str, *, timeout: float = 30.0) -> str | None:
"""Embed URL → treść manifestu m3u8 (albo None).
CSRF-token i cookie sesji muszą pochodzić z TEGO SAMEGO requestu co POST,
dlatego jedna sesja curl_cffi na całą operację."""
if not _HAS_CURL_CFFI:
log.info("loadvid: curl_cffi unavailable")
return None
from curl_cffi import requests as cf
parsed = urlparse(embed_url)
origin = f"{parsed.scheme}://{parsed.hostname}"
session = cf.Session(impersonate=_DEFAULT_IMPERSONATE)
try:
r = session.get(
embed_url,
headers={"User-Agent": _DEFAULT_UA, "Accept": "text/html,application/xhtml+xml"},
timeout=timeout,
)
except Exception as e:
log.info("loadvid: embed fetch failed %s: %s", embed_url, e)
return None
if r.status_code in (404, 410):
raise HosterDead(f"loadvid {embed_url}: HTTP {r.status_code}")
if r.status_code != 200 or not r.text:
log.info("loadvid: embed status=%s for %s", r.status_code, embed_url)
return None
csrf = _CSRF_RE.search(r.text)
cfg = _CONFIG_RE.search(r.text)
if not csrf or not cfg:
log.info("loadvid: no csrf/LoadVidConfig on %s", embed_url)
return None
tok = _TOKEN_RE.search(cfg.group(1))
hsh = _HASH_RE.search(cfg.group(1))
if not tok or not hsh:
log.info("loadvid: no videoToken/videoHash on %s", embed_url)
return None
try:
pr = session.post(
origin + _RESOLVE_PATH,
headers={
"User-Agent": _DEFAULT_UA,
"X-CSRF-TOKEN": csrf.group(1),
"X-Requested-With": "XMLHttpRequest",
"Accept": "application/vnd.apple.mpegurl",
"Referer": embed_url,
},
json={"token": tok.group(1), "hash": hsh.group(1)},
timeout=timeout,
)
except Exception as e:
log.info("loadvid: resolve-token failed %s: %s", embed_url, e)
return None
if pr.status_code != 200 or "#EXTM3U" not in pr.text:
log.info("loadvid: resolve-token status=%s len=%d", pr.status_code, len(pr.text or ""))
return None
return pr.text
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
"""Zwraca embed URL z markerem producenta — manifest powstaje dopiero w
`/proxy/hls` (patrz docstring modułu). Weryfikujemy tu, że manifest realnie
da się wyprodukować, żeby nie oddawać martwego źródła."""
if not matches(page_url):
return None
manifest = fetch_manifest(page_url, timeout=timeout)
if not manifest:
return None
return [
StreamSource(
link=page_url,
type="m3u8",
raw={
# Segmenty absolutne i portable → telefon ciągnie je direct z CDN,
# przez VPS leci tylko manifest.
"mobile_direct_ok": True,
"manifest_producer": "loadvid",
},
)
]

View file

@ -54,13 +54,23 @@ log = logging.getLogger(__name__)
_KEY = b"kiemtienmua911ca" _KEY = b"kiemtienmua911ca"
_IV = b"1234567890oiuytr" _IV = b"1234567890oiuytr"
# Hostname matching: 6 base hosts × subdomains × TLD variants. # Hostname matching: base hosts × subdomains × TLD variants.
# Examples: # Examples:
# my.embedseek.online, vip.seekplayer.vip, my.rpmplay.online, # my.embedseek.online, vip.seekplayer.vip, my.rpmplay.online,
# my.upns.online, vip.player4me.vip, p.easyvidplayer.com # my.upns.online, vip.player4me.vip, p.easyvidplayer.com
#
# 2026-07-26: dołożone `seekplays|4meplayer|ezplayer|seeks` + TLD `pro|cloud|one`.
# Ta sama rodzina silnika (identyczny _KEY/_IV + `/api/v1/video?id=`), tylko inne
# domeny — bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania.
# Zweryfikowane: galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro
# (galaxyporn) oraz av.ezplayer.me / av.seeks.cloud / 4k.upn.one (pornbusy)
# dekodują się tym samym silnikiem. Whitelist jest addytywna — istniejące hosty
# matchują się dalej tak samo.
_HOST_RE = re.compile( _HOST_RE = re.compile(
r"^(?:[a-z0-9]+\.)?(?:embedseek|seekplayer|rpmplay|upns|player4me|easyvidplayer)\." r"^(?:[a-z0-9]+\.)?"
r"(?:online|vip|com|net|io|me|tv)$", r"(?:embedseek|seekplayer|seekplays|seeks|rpmplay|upns|upn|player4me|4meplayer"
r"|ezplayer|easyvidplayer)\."
r"(?:online|vip|com|net|io|me|tv|pro|cloud|one)$",
re.IGNORECASE, re.IGNORECASE,
) )

View file

@ -0,0 +1,39 @@
"""Wspólne elementy sieci TXXX (vjav, fullvideosporn/sextu, …).
Silnik TXXX oddaje URL pliku przez `GET /api/videofile.php?video_id=<id>&lifetime=N`
w polu `video_url`, zaciemnionym DWIEMA warstwami:
1. wielkie/małe litery łacińskie podmienione na cyrylickie homoglify (МM, СC, ),
2. custom alfabet base64: `,``/`, `~``=`, `-``+`.
Po odkręceniu obu i b64decode dostajemy `/get_file/...` (czasem absolutny URL).
Wyniesione tutaj, żeby vjav i fullvideosporn nie trzymały dwóch kopii dekodera.
"""
from __future__ import annotations
import base64
# Cyrylickie homoglify → łacina. Bez tego b64decode dostaje śmieci.
HOMOGLYPHS = str.maketrans(
{
"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H", "К": "K", "М": "M",
"О": "O", "Р": "P", "Т": "T", "Х": "X", "У": "Y",
"а": "a", "с": "c", "е": "e", "о": "o", "р": "p", "х": "x", "у": "y",
}
)
def decode_video_url(obfuscated: str) -> str | None:
"""Zaciemniony `video_url` → ścieżka/URL `get_file`. None gdy dekod padnie."""
if not obfuscated:
return None
clean = (
obfuscated.translate(HOMOGLYPHS)
.replace(",", "/")
.replace("~", "=")
.replace("-", "+")
)
clean += "=" * (-len(clean) % 4) # padding do wielokrotności 4
try:
return base64.b64decode(clean).decode("utf-8", "ignore")
except Exception:
return None

View file

@ -0,0 +1,77 @@
"""fullporner.com — bezpośredni mp4 z xiaoshenke.net. Dodany 2026-07-27.
Najprostszy i najtrwalszy stream w całym portfolio: URL buduje się deterministycznie,
**bez tokenu, bez podpisu i bez wygasania**. Nie ma tu nic do resolvowania po stronie
hostera, wystarczy przeczytać dwie liczby z iframe'a.
Strona sceny osadza `<iframe src="//xiaoshenke.net/video/4458723/14">`, a player robi
z tego:
var id = "4458723".split('').reverse().join(''); // id mediów zapisane WSPAK
function btq(f) { 1360, 2480, 4720, 81080 } // /14 = 8+4+2 = 1080,720,480
v.media = `//${location.hostname}/vid/${id}/${quality}`
czyli `https://xiaoshenke.net/vid/3278544/1080`. Że id jest odwrócone, potwierdza
miniaturka z listingu: `imgs.xiaoshenke.net/thumb/3278544.jpg`.
**Warunek dostępu to Referer + jakikolwiek realistyczny User-Agent.** Sprawdzone
osobno, bo to przesądza o `mobile_direct_ok`: zwykły httpx (a więc NIE fingerprint
TLS Chrome'a) z Refererem i UA ExoPlayera dostaje 206, a ten sam request bez UA
dostaje 403. Blokada jest więc na nagłówku, nie na fingerprincie, i telefon
przechodzi `playback.py` wysyła oba nagłówki.
Zweryfikowane: 1080p = 1,51 GB, 720p = 754 MB, 206 na Range, `ftypisom` w pierwszym
KB (faststart, seek działa od razu). Przy błędzie player dokleja `/b` (backup CDN)
nie używamy tego, bo podstawowy URL działa, ale gdyby zaczęło sypać 404, to jest
pierwsza rzecz do sprawdzenia.
"""
from __future__ import annotations
import logging
import re
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
_BASE = "https://fullporner.com"
# Id bywa numeryczne (`4458723`) ALBO szesnastkowe (`a6487a97766a6`) — to drugie to
# ~25% katalogu i przy `\d+` wypadało z ingestu. Odwracanie działa dla obu, bo player
# robi zwykły reverse stringa.
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
# Maska bitowa z URL-a iframe'a, 1:1 z `btq()` w playerze.
_QUALITY_BITS = ((1, 360), (2, 480), (4, 720), (8, 1080))
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout)
m = _IFRAME_RE.search(html_text)
if not m:
log.info("fullporner: brak iframe xiaoshenke na %s", page_url)
return None
media_id = m.group(1)[::-1] # id w iframe jest wspak
mask = int(m.group(2))
out: list[StreamSource] = []
for bit, height in _QUALITY_BITS:
if not mask & bit:
continue
out.append(
StreamSource(
link=f"https://xiaoshenke.net/vid/{media_id}/{height}",
type="mp4",
quality=f"{height}p",
# Bez Referera CDN oddaje 404, bez UA 403 — oba dokłada playback.py.
referer=_BASE + "/",
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("fullporner: maska jakości %s nie dała żadnej rendycji na %s", mask, page_url)
return None
out.sort(key=lambda s: int((s.quality or "0p")[:-1]), reverse=True)
return out

View file

@ -0,0 +1,144 @@
"""fullvideosporn.com (= sextu.com pod nową marką) — TXXX network, direct mp4.
To NIE jest klon fullmovies.xxx (inny silnik, inny katalog 0/140 pokrycia tytułów).
**Bramka cookie**: pierwszy request z nowej sesji dostaje HTTP 429 + 342-bajtowy JS
challenge ustawiający ciasteczko (`document.cookie = 'PxeA3f=<num>; max-age=600'`).
Wystarczy wyregexować parę nazwa=wartość i powtórzyć request bez proxy, bez
przeglądarki. Ciasteczko żyje 600 s, więc trzymamy je w sesji modułu.
Stream (identyczny jak vjav, rodzina TXXX):
GET /api/videofile.php?video_id=<id>&lifetime=8640000
[{"format":"_lq.mp4","video_url":"<zaciemniony>",...}]
dekod (`_txxx.decode_video_url`) `/get_file/...` (relatywny, prepend host)
GET get_file bez follow 302 `https://sextuN.znvcdn.com/t=.../....mp4`
`_lq.mp4` w nazwie myli to realnie 1280x720 (~1,29 Mbps), jedyny dostępny format.
**Cross-IP**: finalny URL CDN wybity z IP VPS gra z residential (206, `video/mp4`,
bez Referera) token NIE jest IP-bound, `mobile_direct_ok`. ALE sam VPS dostaje od
CDN 429 (reputacja IP datacenter), więc **health-check playbacku z VPS będzie
fałszywie negatywny** nie traktuj tego jako regresji.
"""
from __future__ import annotations
import json
import logging
import re
from urllib.parse import urlparse
from app.extractors._fetch import _DEFAULT_IMPERSONATE, _DEFAULT_UA, _HAS_CURL_CFFI
from app.extractors._models import StreamSource
from app.extractors.tubes import _txxx
log = logging.getLogger(__name__)
_BASE = "https://fullvideosporn.com"
_VIDEO_ID_RE = re.compile(r"/video/(\d+)/")
# `<script>window.addEventListener('click',()=>{...document.cookie = 'PxeA3f=980886937; max-age=600...`
_COOKIE_CHALLENGE_RE = re.compile(
"document.cookie[^']*'([A-Za-z0-9_]+)=([^;']+)", re.IGNORECASE
)
def _new_session():
from curl_cffi import requests as cf
return cf.Session(impersonate=_DEFAULT_IMPERSONATE)
def gated_get(session, url: str, *, timeout: float = 30.0, headers: dict | None = None,
allow_redirects: bool = True):
"""GET przechodzący bramkę 429 (patrz docstring modułu). Ciasteczko ląduje w sesji,
więc kolejne requesty tej samej sesji idą od razu. Zwraca response (albo None)."""
h = {"User-Agent": _DEFAULT_UA, "Accept": "text/html,application/xhtml+xml"}
if headers:
h.update(headers)
try:
r = session.get(url, headers=h, timeout=timeout, allow_redirects=allow_redirects)
except Exception as e:
log.info("fullvideosporn: fetch failed %s: %s", url, e)
return None
if r.status_code == 429:
m = _COOKIE_CHALLENGE_RE.search(r.text or "")
if not m:
log.info("fullvideosporn: 429 bez challenge-cookie na %s", url)
return r
session.cookies.set(m.group(1), m.group(2))
try:
r = session.get(url, headers=h, timeout=timeout, allow_redirects=allow_redirects)
except Exception as e:
log.info("fullvideosporn: retry po cookie failed %s: %s", url, e)
return None
return r
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
if not _HAS_CURL_CFFI:
log.info("fullvideosporn: curl_cffi unavailable")
return None
m = _VIDEO_ID_RE.search(page_url)
if not m:
log.info("fullvideosporn: brak video id w %s", page_url)
return None
vid = m.group(1)
session = _new_session()
api = f"{_BASE}/api/videofile.php?video_id={vid}&lifetime=8640000"
r = gated_get(
session, api, timeout=timeout,
headers={"Referer": page_url, "X-Requested-With": "XMLHttpRequest",
"Accept": "application/json,text/plain,*/*"},
)
if r is None or r.status_code != 200 or not r.text:
log.info("fullvideosporn: videofile.php status=%s", getattr(r, "status_code", None))
return None
try:
data = json.loads(r.text)
except Exception as e:
log.info("fullvideosporn: videofile.php parse fail: %s", e)
return None
if not isinstance(data, list):
return None
out: list[StreamSource] = []
seen: set[str] = set()
for entry in data:
if not isinstance(entry, dict):
continue
fmt = (entry.get("format") or "").strip()
if fmt.strip("_").replace(".mp4", "").lower() == "tr":
continue # trailer, nie pełne wideo
get_file = _txxx.decode_video_url(entry.get("video_url") or "")
if not get_file or "/get_file/" not in get_file:
continue
if get_file.startswith("/"):
get_file = _BASE + get_file
# get_file 302 → finalny CDN. Rozwiązujemy TU (w sesji z ciasteczkiem), bo
# telefon nie ma tej sesji; finalny URL jest portable cross-IP.
rr = gated_get(session, get_file, timeout=timeout,
headers={"Referer": page_url}, allow_redirects=False)
final = None
if rr is not None and rr.status_code in (301, 302, 303, 307, 308):
final = rr.headers.get("location")
elif rr is not None and rr.status_code == 200:
final = get_file # brak redirectu — get_file sam serwuje plik
if not final or final in seen:
continue
seen.add(final)
out.append(
StreamSource(
link=final,
type="mp4",
quality="720p", # `_lq` w nazwie myli: realnie 1280x720
referer=_BASE + "/",
# Token CDN time-bound, NIE IP-bound (zweryfikowane cross-IP) →
# telefon gra direct, zero proxy.
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("fullvideosporn: brak dekodowalnego video_url dla %s", page_url)
return None
return out

View file

@ -0,0 +1,69 @@
"""galaxyporn.net — iframe → seekplayer engine. Dodany 2026-07-26.
Scene page ma jeden `<iframe src="https://<host>/#<hash>">` rodziny seekplayer
(galaxy.upns.online / galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro
100% próbki 50 scen z całej głębokości archiwum). Silnik mamy już w repo
(`hosters/seekplayer_engine.py`, ten sam AES key/IV + `/api/v1/video?id=`), więc
oddajemy iframe do generycznego `extract_stream_from_hoster` i nic nie dublujemy.
HLS jest hotlink-guarded na Referer (bez niego 403 na master i na segmentach),
a token podpisany pod IP fetchera manifest i segmenty idą przez `/proxy/hls`.
"""
from __future__ import annotations
import logging
import re
from urllib.parse import urlparse
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
from app.extractors.hosters import seekplayer_engine
log = logging.getLogger(__name__)
_BASE = "https://galaxyporn.net"
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout)
m = _IFRAME_RE.search(html_text)
if not m:
log.info("galaxyporn: no iframe on %s", page_url)
return None
iframe_src = m.group(1).strip()
if iframe_src.startswith("//"):
iframe_src = "https:" + iframe_src
# Wołamy silnik BEZPOŚREDNIO, nie przez `extract_stream_from_hoster`: wrapper
# weryfikuje wynikowy URL, a HLS galaxyporn jest hotlink-guarded (403 bez
# Referera) → wrapper kasował poprawnie zdekodowany stream (sprawdzone: engine
# zwracał m3u8, wrapper None dla tych samych scen).
sources = seekplayer_engine.extract(iframe_src, timeout=timeout)
if not sources:
# Nie oddajemy iframe'a jako type='hoster' — seekplayer to SPA, w WebView
# user zobaczy pusty player. Lepiej None (źródło jako nierozwiązane).
log.info("galaxyporn: seekplayer resolve failed for %s", iframe_src)
return None
# Referer MUSI być hostem PLAYERA (np. https://galaxy.4meplayer.pro/), NIE
# galaxyporn.net — zweryfikowane: ten sam manifest z Refererem galaxyporn = 403,
# z Refererem playera = 200 + lista wariantów. Silnik ustawia go poprawnie, więc
# go zachowujemy; fallback liczymy z hosta iframe'a.
player_origin = f"https://{urlparse(iframe_src).hostname}/"
out: list[StreamSource] = []
for s in sources:
raw = dict(s.raw or {})
# Token HLS jest Referer+IP-bound → manifest+segmenty przez /proxy/hls
# (bez mobile_direct_ok, inaczej telefon dostanie 403).
raw["proxy_no_verify"] = True
out.append(
StreamSource(
link=s.link,
type=s.type or ("m3u8" if ".m3u8" in s.link.lower() else "mp4"),
quality=s.quality,
referer=s.referer or player_origin,
raw=raw,
)
)
return out

View file

@ -0,0 +1,80 @@
"""8kporner.com — JWPlayer sources → direct mp4 na OK.ru CDN. Dodany 2026-07-27.
**Pułapka, o którą łatwo się rozbić:** JSON-LD `contentUrl` na tej stronie to ZAWSZE
144p (256x144, sprawdzone). Prawdziwe jakości siedzą wyłącznie w tablicy `sources`
JWPlayera. Skopiowanie wzorca z `hqfap.py` (który czyta `contentUrl`) wysłałoby
każdemu userowi 144p.
Fetch idzie przez `?link1=watch&id=<id>`, bo kanoniczna forma `/watch/<slug>_<id>.html`
jest CF-blokowana (403, również przez proxy).
CDN to `vd*.okcdn.ru` / `ok6-*.vkuser.net` ta sama rodzina co 4k69/hqfap: `srcIp`
jest w URL-u, ale NIE jest egzekwowane, więc token działa cross-IP (mobile_direct).
**Limit przepustowości:** OK.ru dławi pojedyncze połączenie do ~2,1 Mbps. 1080p
potrzebuje 3,8 Mbps, 2K 6,2, a 4K 13,8 czyli nie dociągną się w czasie rzeczywistym
i dałyby loading forever" (ten sam objaw co przy 4K na fullmovies). Dlatego oddajemy
maksymalnie 720p (1,5 Mbps), zgodnie z decyzją podjętą już dla 4k69.
"""
from __future__ import annotations
import logging
import re
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
_BASE = "https://8kporner.com"
_SCENE_ID_RE = re.compile(r"_(\d+)\.html")
# Pary z JWPlayer setupu: "file":"<url>","label":"720p"
_SOURCE_RE = re.compile(
r'"file"\s*:\s*"(https?://[^"]+)"\s*,\s*"label"\s*:\s*"([^"]+)"', re.IGNORECASE
)
# Powyżej 720p CDN nie nadąża (patrz docstring).
_MAX_HEIGHT = 720
def _height(label: str) -> int:
lab = (label or "").strip().lower()
if lab.startswith("4k"):
return 2160
if lab.startswith("2k"):
return 1440
m = re.match(r"(\d{3,4})", lab)
return int(m.group(1)) if m else 0
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
# Kanoniczny URL → forma omijająca CF.
m = _SCENE_ID_RE.search(page_url)
fetch_url = f"{_BASE}/?link1=watch&id={m.group(1)}" if m else page_url
html_text = fetch_tube_html(fetch_url, timeout=timeout)
seen: set[str] = set()
out: list[StreamSource] = []
for sm in _SOURCE_RE.finditer(html_text):
url = sm.group(1).replace("&amp;", "&")
label = sm.group(2).strip()
h = _height(label)
if url in seen or h == 0 or h > _MAX_HEIGHT:
continue
seen.add(url)
out.append(
StreamSource(
link=url,
type="mp4",
quality=label,
referer=_BASE + "/",
# srcIp nieegzekwowane (jak 4k69) → telefon gra direct z CDN.
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("8kporner: brak sources <=720p na %s", page_url)
return None
out.sort(key=lambda s: _height(s.quality or ""), reverse=True)
return out

View file

@ -0,0 +1,82 @@
"""pornbusy.com — dispatch po hosterze z `itemprop="embedURL"`.
Katalog jest rozproszony po hosterach (próbka 120 scen): ~41% loadvid,
~20% rodzina seekplayer (av.ezplayer.me / av.seeks.cloud / 4k.upn.one /
4k.player4me.vip objęte poszerzonym `_HOST_RE` silnika), ~8% zpi.cx,
~12% upload18 (token z wbitym `i=<ip>/24` = IP-bound), reszta ogon.
Obsługujemy trzy pierwsze (~69% katalogu):
- loadvid manifest przez POST, marker `manifest_producer` (patrz hosters/loadvid.py)
- seekplayer istniejący silnik (ten sam AES key/IV)
- zpi.cx `embedURL` JEST plikiem wideo (mimo `.webm` w nazwie to mp4);
zweryfikowane: Range 206 `video/mp4`, bez Referera, bez tokena
upload18 i ogon zwracamy jako None resolve na VPS wymusiłby proxowanie CAŁEGO
wideo (token IP-bound), co łamie zasadę no-video-proxy.
"""
from __future__ import annotations
import logging
import re
from urllib.parse import urlparse
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
from app.extractors.hosters import loadvid, seekplayer_engine
log = logging.getLogger(__name__)
_BASE = "https://pornbusy.com"
_EMBED_RE = re.compile(r'itemprop="embedURL"\s+content="([^"]+)"', re.IGNORECASE)
_IFRAME_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
_ZPI_HOST_RE = re.compile(r"^(?:[a-z0-9]+\.)?zpi\.cx$", re.IGNORECASE)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout)
m = _EMBED_RE.search(html_text) or _IFRAME_RE.search(html_text)
if not m:
log.info("pornbusy: no embedURL/iframe on %s", page_url)
return None
embed = m.group(1).strip()
if embed.startswith("//"):
embed = "https:" + embed
if loadvid.matches(embed):
return loadvid.extract(embed, timeout=timeout)
if seekplayer_engine.matches(embed):
sources = seekplayer_engine.extract(embed, timeout=timeout)
if not sources:
return None
player_origin = f"https://{urlparse(embed).hostname}/"
out: list[StreamSource] = []
for s in sources:
raw = dict(s.raw or {})
raw["proxy_no_verify"] = True
out.append(
StreamSource(
link=s.link,
type=s.type or "m3u8",
quality=s.quality,
# Referer = origin PLAYERA, nie strony (jak przy galaxyporn:
# z Refererem strony CDN zwraca 403).
referer=s.referer or player_origin,
raw=raw,
)
)
return out
host = (urlparse(embed).hostname or "").lower()
if _ZPI_HOST_RE.match(host):
# embedURL to bezpośrednio plik (rozszerzenie `.webm` myli — to mp4).
return [
StreamSource(
link=embed,
type="mp4",
raw={"mobile_direct_ok": True},
)
]
log.info("pornbusy: unsupported hoster %s (%s)", host, page_url)
return None

View file

@ -0,0 +1,55 @@
"""pornmike.com — direct mp4 z `<source>`. Dodany 2026-07-27.
Najprostszy stream w portfolio: zwykły video.js na natywnym `<video>`, dwie rendycje
(720p + 360p) na `*.twincdn.com`, **bez tokenu, bez query stringa, bez expiry**.
Zweryfikowane cross-IP (Range bytes=0-1023): 206 z VPS BEZ Referera, 206 z innej
maszyny w innym kraju również bez Referera ani hotlink-guard, ani IP-binding.
Pierwszy KB zawiera `ftyp` + `moov` (faststart), więc seek działa od razu. Link
nie gnije: scena sprzed 9 miesięcy nadal oddaje 206.
Stąd `mobile_direct_ok` telefon gra prosto z CDN, zero proxy i zero WebView.
Uwaga na fałszywy trop przy diagnozie: strona ma `m3u8` + `hls.js`, ale to widget
reklamowy live-cam (saawsedge/stripchat), nie ma nic wspólnego ze sceną.
"""
from __future__ import annotations
import logging
import re
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
_BASE = "https://pornmike.com"
_SOURCE_RE = re.compile(
r'<source\s+src="([^"]+\.mp4)"[^>]*data-res="(\d+)"', re.IGNORECASE
)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout)
seen: set[str] = set()
out: list[StreamSource] = []
for m in _SOURCE_RE.finditer(html_text):
url, res = m.group(1), m.group(2)
if url in seen:
continue
seen.add(url)
out.append(
StreamSource(
link=url,
type="mp4",
quality=f"{res}p",
# Referer zbędny (CDN go nie sprawdza), ustawiamy dla higieny.
referer=_BASE + "/",
raw={"mobile_direct_ok": True},
)
)
if not out:
log.info("pornmike: brak <source> mp4 na %s", page_url)
return None
out.sort(key=lambda s: int((s.quality or "0p")[:-1]), reverse=True)
return out

View file

@ -0,0 +1,95 @@
"""sexu.com — HLS z `POST /api/video-info`. Dodany 2026-07-27.
Player jest hydratowany po stronie klienta (`/js/vjs/hydrate-source.js`): strona sceny
nie zawiera żadnego URL-a wideo, dopiero `POST /api/video-info` z `videoId=<id>` oddaje
JSON-a z `playerData`.
**Ten POST wymaga Bright Data.** Z gołego IP VPS-a Cloudflare oddaje na niego challenge
(GET-y na listing i detale przechodzą bez problemu blokowany jest sam POST). Idzie tu
JSON, nie wideo, więc mieści się w tym, do czego proxy służy.
**Bierzemy `playerData.src` (HLS), a NIE `playerData.sources` (mp4).** Wygląda to
odwrotnie do intuicji, bo mp4 wygodniejsze, ale:
sources[].src /key=,end=,ip=158.46.155.106/sec=protect/-720p-x.mp4
playerData.src /key=,end=/multi=854x480:480p,1280x720:720p/media=hls4A/
mp4 mają w tokenie `ip=` requestera, czyli IP wyjściowe proxy na telefonie dałyby 403.
Token HLS `ip=` nie ma i jest przenośny: sprawdzone z VPS-a (inne IP niż proxy) master
200, wariant 200, segment 206. Telefon gra go z własnego IP.
Ścieżka mastera kończy się na `.mp4`, nie `.m3u8`, więc ExoPlayer wziąłby go za
progresywny plik i padł. Łapie to istniejąca logika `hls_needs_passthrough` w
`playback.py` (typ `m3u8` + `mobile_direct_ok` + brak `.m3u8` w ścieżce) manifest
(~430 B) leci przez `/proxy/hls`, a segmenty prosto z CDN.
"""
from __future__ import annotations
import json
import logging
import re
from app.config import get_settings
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
_BASE = "https://sexu.com"
_SCENE_ID_RE = re.compile(r"/(\d+)/?$")
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
m = _SCENE_ID_RE.search(page_url.split("?")[0])
if not m:
log.info("sexu: nie wyłuskałem id z %s", page_url)
return None
video_id = m.group(1)
proxy = get_settings().brightdata_proxy_url
if not proxy:
log.info("sexu: brak BRIGHTDATA_PROXY_URL — CF zablokuje POST /api/video-info")
return None
from curl_cffi import requests as cr
try:
session = cr.Session(
impersonate="chrome120",
proxies={"http": proxy, "https": proxy},
verify=False,
)
# Strona sceny najpierw — bez jej ciasteczek CF odrzuca POST-a.
session.get(page_url, timeout=timeout)
res = session.post(
f"{_BASE}/api/video-info",
data={"videoId": video_id},
headers={
"X-Requested-With": "XMLHttpRequest",
"Referer": page_url,
},
timeout=timeout,
)
data = json.loads(res.text)
except Exception as e:
log.info("sexu: video-info failed %s: %s", page_url, e)
return None
if not data.get("success"):
log.info("sexu: video-info success=false na %s", page_url)
return None
src = (data.get("playerData") or {}).get("src")
if not src:
log.info("sexu: brak playerData.src na %s", page_url)
return None
if src.startswith("//"):
src = "https:" + src
return [
StreamSource(
link=src,
type="m3u8",
quality="720p", # master multi=854x480,1280x720 — ExoPlayer wybierze sam
referer=_BASE + "/",
raw={"mobile_direct_ok": True},
)
]

View file

@ -27,43 +27,23 @@ direct z telefonu; patrz stream_proxy.proxy_hls_manifest). Media id w get_file (
""" """
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import logging import logging
import re import re
from app.extractors import browser_get from app.extractors import browser_get
from app.extractors._models import StreamSource from app.extractors._models import StreamSource
from app.extractors.tubes import _txxx
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_BASE = "https://vjav.com" _BASE = "https://vjav.com"
_VIDEO_ID_RE = re.compile(r"/videos/(\d+)/") _VIDEO_ID_RE = re.compile(r"/videos/(\d+)/")
# KVS video_url: base64 z cyrylica-homoglifami zamiast łacińskich liter (wielkie + # Dekoder video_url wyniesiony do `_txxx.py` — ten sam silnik TXXX obsługuje też
# część małych). Odkręcamy je z powrotem na łacinę przed b64decode. # fullvideosporn/sextu, więc trzymamy jedną implementację. Alias zachowany dla
_HOMOGLYPHS = str.maketrans( # czytelności modułu i ewentualnych importów.
{ _decode_video_url = _txxx.decode_video_url
"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H", "К": "K", "М": "M",
"О": "O", "Р": "P", "Т": "T", "Х": "X", "У": "Y",
"а": "a", "с": "c", "е": "e", "о": "o", "р": "p", "х": "x", "у": "y",
}
)
def _decode_video_url(obfuscated: str) -> str | None:
# (1) cyrylica-homoglif -> łacina, (2) custom alfabet base64 -> standardowy.
clean = (
obfuscated.translate(_HOMOGLYPHS)
.replace(",", "/")
.replace("~", "=")
.replace("-", "+")
)
clean += "=" * (-len(clean) % 4) # padding do wielokrotności 4
try:
return base64.b64decode(clean).decode("utf-8", "ignore")
except Exception:
return None
def _quality_label(fmt: str | None) -> str | None: def _quality_label(fmt: str | None) -> str | None:

View file

@ -0,0 +1,52 @@
"""youperv.com — direct mp4 extractor (fluidplayer). Dodany 2026-07-26.
Scene page ma zwykły `<source type="video/mp4" src="https://files.klubnichka-hd.com/...">`
BEZ tokena, bez expiry, bez KVS/DoodStream/iframe. URL zawiera spacje (tytuł w
ścieżce), więc trzeba go za-quote'ować przed podaniem playerowi.
CDN ma hotlink-guard na **Referer**: goły Range 403 nginx, Range + Referer
`https://youperv.com/` + browser UA 206 `video/mp4` (zweryfikowane cross-IP z VPS).
Nie jest IP-bound, więc `mobile_direct_ok` telefon gra prosto z CDN, zero proxy
i zero WebView. playback.py dokleja Referer+UA z `referer=` do nagłówków StreamLink.
"""
from __future__ import annotations
import logging
import re
from urllib.parse import quote, urlsplit, urlunsplit
from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
log = logging.getLogger(__name__)
_BASE = "https://youperv.com"
_SOURCE_RE = re.compile(
r'<source[^>]+src="([^"]+\.(?:mp4|m4v)[^"]*)"', re.IGNORECASE
)
def _encode_url(url: str) -> str:
"""Spacje/znaki specjalne w ścieżce → percent-encoding (ExoPlayer inaczej odpada).
Query zostawiamy nietknięte."""
parts = urlsplit(url)
return urlunsplit(
(parts.scheme, parts.netloc, quote(parts.path, safe="/%"), parts.query, parts.fragment)
)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout)
m = _SOURCE_RE.search(html_text)
if not m:
log.info("youperv: no <source> mp4 on %s", page_url)
return None
return [
StreamSource(
link=_encode_url(m.group(1).strip()),
type="mp4",
referer=_BASE + "/",
# Token brak; CDN pilnuje tylko Referera (cross-IP 206) → mobile direct.
raw={"mobile_direct_ok": True},
)
]

View file

@ -20,6 +20,7 @@ from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
from app.models.playback_event import PlaybackEvent from app.models.playback_event import PlaybackEvent
from app.models.playback_source import PlaybackSource from app.models.playback_source import PlaybackSource
from app.models.phash_blacklist import PhashBlacklist
from app.models.saved_search import SavedSearch from app.models.saved_search import SavedSearch
from app.models.source_stats import SourceStats from app.models.source_stats import SourceStats
from app.models.scene import ( from app.models.scene import (
@ -58,6 +59,7 @@ __all__ = [
"MoviePlayProgress", "MoviePlayProgress",
"ScenePlayProgress", "ScenePlayProgress",
"PlaybackSource", "PlaybackSource",
"PhashBlacklist",
"SavedSearch", "SavedSearch",
"Scene", "Scene",
"SceneExternalRef", "SceneExternalRef",

View file

@ -0,0 +1,32 @@
"""Zdegenerowane phashe — wartości, które NIE identyfikują sceny.
Audyt 2026-07-27: 73 wartości phasha występowały przy 10 scenach każda, łącznie
przy 4375 scenach. Rekordzistka miała **892 sceny o 892 różnych tytułach i 1886
różnych performerach** to placeholder, czarna klatka albo intro, nie odcisk sceny.
Dlaczego to musi być tabela, a nie zwykłe skasowanie wierszy: sam `DELETE` nic nie
załatwia, bo przy kolejnych ingestach ta sama zaślepka wraca. Trzeba PAMIĘTAĆ, że
dana wartość jest bezużyteczna, i już nigdy po niej nie matchować.
Do tej pory obroniła nas bramka `dur_prox` w `scene_resolver` (te 892 sceny nadal
istnieją osobno, więc nic się nie skleiło), ale to zabezpieczenie drugiej linii:
wystarczy, żeby dwie niepowiązane sceny miały samą zaślepkę i zbliżoną długość.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class PhashBlacklist(Base):
__tablename__ = "phash_blacklist"
value: Mapped[str] = mapped_column(String(128), primary_key=True)
scene_count: Mapped[int] = mapped_column(Integer, nullable=False)
detected_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View file

@ -102,6 +102,16 @@ class ScenePerformer(Base):
role: Mapped[str | None] = mapped_column(String(64)) role: Mapped[str | None] = mapped_column(String(64))
position: Mapped[int | None] = mapped_column(Integer) position: Mapped[int | None] = mapped_column(Integer)
as_alias: Mapped[str | None] = mapped_column(String(256)) as_alias: Mapped[str | None] = mapped_column(String(256))
#: Skąd wzięło się TO przypisanie. Bez tego nie da się odróżnić obsady z kanonu od
#: performera doklejonego przez luźny tube-search (ten dopasowuje wynik po JEDNYM
#: tokenie zapytania, więc „Rapture" łapała każdą scenę ze słowem „rapture" —
#: 30 z 72 jej scen było fałszywych i trzeba je było przeglądać ręcznie).
#: Trzymamy NAJBARDZIEJ wiarygodne źródło, jakie przypisało: kanoniczne
#: (tpdb/stashdb) nadpisuje scraperowe, nigdy odwrotnie. NULL = wiersz sprzed
#: migracji 0026, źródła nie da się odtworzyć wstecz.
source_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL"), index=True
)
class SceneTag(Base): class SceneTag(Base):

View file

@ -95,13 +95,23 @@ def find_by_phash_within(
if max_hamming is None: if max_hamming is None:
max_hamming = get_settings().fingerprint_hamming_max max_hamming = get_settings().fingerprint_hamming_max
# Zdegenerowane wartości (zaślepka / czarna klatka / intro studia) odpadają po
# obu stronach: nie szukamy PO nich i nie matchujemy DO nich. Bez tego jedna
# zaślepka dzielona przez 892 sceny jest zawsze najbliższym trafieniem (dist 0)
# i wygrywa z prawdziwym duplikatem. Tabela ma ~70 wierszy, więc koszt zerowy.
if session.execute(
text("SELECT 1 FROM phash_blacklist WHERE value = :phash"), {"phash": phash}
).first():
return None
if len(phash) == 16: if len(phash) == 16:
row = session.execute( row = session.execute(
text( text(
"SELECT scene_id, " "SELECT scene_id, "
"bit_count(('x'||value)::bit(64) # ('x'||:phash)::bit(64)) AS dist " "bit_count(('x'||value)::bit(64) # ('x'||:phash)::bit(64)) AS dist "
"FROM scene_fingerprints " "FROM scene_fingerprints f "
"WHERE kind = 'phash' AND length(value) = 16 " "WHERE kind = 'phash' AND length(value) = 16 "
"AND NOT EXISTS (SELECT 1 FROM phash_blacklist b WHERE b.value = f.value) "
"ORDER BY dist ASC LIMIT 1" "ORDER BY dist ASC LIMIT 1"
), ),
{"phash": phash}, {"phash": phash},

View file

@ -29,10 +29,18 @@ from app.models.scene import (
ScenePerformer, ScenePerformer,
SceneTag, SceneTag,
) )
from app.models.source import Source, SourceKind
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _is_canonical(session: Session, source_id: uuid.UUID | None) -> bool:
if source_id is None:
return False
src = session.get(Source, source_id)
return src is not None and src.kind in (SourceKind.tpdb, SourceKind.stashdb)
class MergeError(Exception): class MergeError(Exception):
pass pass
@ -145,6 +153,17 @@ def _move_performers(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID
if clash is not None: if clash is not None:
if link.as_alias and not clash.as_alias: if link.as_alias and not clash.as_alias:
clash.as_alias = link.as_alias clash.as_alias = link.as_alias
# Kasujemy wiersz drop-a, więc jego provenance przepadłaby razem z nim.
# Przenosimy ją, gdy jest lepsza: kanon > scraper > NULL. Inaczej merge
# potrafiłby zdegradować obsadę potwierdzoną przez TPDB do „nie wiadomo skąd".
if clash.source_id is None and link.source_id is not None:
clash.source_id = link.source_id
elif (
link.source_id is not None
and _is_canonical(session, link.source_id)
and not _is_canonical(session, clash.source_id)
):
clash.source_id = link.source_id
session.delete(link) session.delete(link)
else: else:
link.scene_id = keep_id link.scene_id = keep_id

View file

@ -327,7 +327,7 @@ def resolve_scene(
if decision == "auto": if decision == "auto":
_update_scene_fields(best_scene, norm, studio_id=studio_id, source_kind=source_kind, session=session) _update_scene_fields(best_scene, norm, studio_id=studio_id, source_kind=source_kind, session=session)
_attach_external_ref(session, scene_id=best_scene.id, source_id=source_id, norm=norm) _attach_external_ref(session, scene_id=best_scene.id, source_id=source_id, norm=norm)
_sync_performers(session, scene_id=best_scene.id, resolved=resolved_performers) _sync_performers(session, scene_id=best_scene.id, resolved=resolved_performers, source_id=source_id)
_sync_tags(session, scene_id=best_scene.id, norm=norm, source_id=source_id) _sync_tags(session, scene_id=best_scene.id, norm=norm, source_id=source_id)
_sync_fingerprints( _sync_fingerprints(
session, scene_id=best_scene.id, norm=norm, source_id=source_id session, scene_id=best_scene.id, norm=norm, source_id=source_id
@ -349,7 +349,7 @@ def resolve_scene(
if decision == "review": if decision == "review":
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill) new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
_sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers) _sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers, source_id=source_id)
_sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id) _sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
_sync_fingerprints( _sync_fingerprints(
session, scene_id=new_scene.id, norm=norm, source_id=source_id session, scene_id=new_scene.id, norm=norm, source_id=source_id
@ -376,7 +376,7 @@ def resolve_scene(
# Brak żadnego sensownego dopasowania → nowa kanoniczna # Brak żadnego sensownego dopasowania → nowa kanoniczna
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill) new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm) _attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
_sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers) _sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers, source_id=source_id)
_sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id) _sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
_sync_fingerprints(session, scene_id=new_scene.id, norm=norm, source_id=source_id) _sync_fingerprints(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
_sync_playback_sources(session, scene_id=new_scene.id, norm=norm) _sync_playback_sources(session, scene_id=new_scene.id, norm=norm)
@ -542,17 +542,25 @@ def _sync_attached_entities(
for p_norm in norm.performers: for p_norm in norm.performers:
performer = resolve_performer(session, norm=p_norm, source_id=source_id) performer = resolve_performer(session, norm=p_norm, source_id=source_id)
resolved.append((performer.id, p_norm.as_alias_in_scene)) resolved.append((performer.id, p_norm.as_alias_in_scene))
_sync_performers(session, scene_id=scene.id, resolved=resolved) _sync_performers(session, scene_id=scene.id, resolved=resolved, source_id=source_id)
_sync_tags(session, scene_id=scene.id, norm=norm, source_id=source_id) _sync_tags(session, scene_id=scene.id, norm=norm, source_id=source_id)
_sync_fingerprints(session, scene_id=scene.id, norm=norm, source_id=source_id) _sync_fingerprints(session, scene_id=scene.id, norm=norm, source_id=source_id)
_sync_playback_sources(session, scene_id=scene.id, norm=norm) _sync_playback_sources(session, scene_id=scene.id, norm=norm)
def _is_canonical_source(session: Session, source_id: uuid.UUID | None) -> bool:
if source_id is None:
return False
src = session.get(Source, source_id)
return src is not None and src.kind in (SourceKind.tpdb, SourceKind.stashdb)
def _sync_performers( def _sync_performers(
session: Session, session: Session,
*, *,
scene_id: uuid.UUID, scene_id: uuid.UUID,
resolved: list[tuple[uuid.UUID, str | None]], resolved: list[tuple[uuid.UUID, str | None]],
source_id: uuid.UUID | None = None,
) -> None: ) -> None:
# Deduplikuj — dwa różne aliasy tej samej osoby (np. "Aj Applegate" + "AJ Applegate") # Deduplikuj — dwa różne aliasy tej samej osoby (np. "Aj Applegate" + "AJ Applegate")
# przejdą przez resolve_performer zwracając ten sam Performer.id. Bez tej dedup # przejdą przez resolve_performer zwracając ten sam Performer.id. Bez tej dedup
@ -581,10 +589,21 @@ def _sync_performers(
performer_id=performer_id, performer_id=performer_id,
position=position, position=position,
as_alias=as_alias, as_alias=as_alias,
source_id=source_id,
) )
) )
elif as_alias and not existing.as_alias: else:
if as_alias and not existing.as_alias:
existing.as_alias = as_alias existing.as_alias = as_alias
# Provenance idzie tylko w górę: NULL wypełnia cokolwiek, kanon nadpisuje
# scraper, ale scraper NIGDY nie zamazuje kanonu. Inaczej dowolny późniejszy
# tube-search zatarłby informację, że obsadę potwierdziło TPDB.
if source_id is not None and existing.source_id != source_id:
if existing.source_id is None or (
_is_canonical_source(session, source_id)
and not _is_canonical_source(session, existing.source_id)
):
existing.source_id = source_id
def _sync_tags( def _sync_tags(

View file

@ -37,6 +37,10 @@ _DEFAULT_STATE = Path(__file__).resolve().parent.parent / "_state" / "deepcrawl_
# capu (None) → naturalny koniec katalogu. xvideos /new/ ~27 scen/stronę → 1800 ≈ ~50k. # capu (None) → naturalny koniec katalogu. xvideos /new/ ~27 scen/stronę → 1800 ≈ ~50k.
_PAGE_CAP: dict[str, int] = { _PAGE_CAP: dict[str, int] = {
"xvideoscom": 1800, "xvideoscom": 1800,
# youperv: strony ~2100+ (≤09.2023) to stara generyczna amatorka bez performerów,
# do tego martwe pliki na CDN (HTTP 500). Świeży korpus (Studio-Performer-Title)
# kończy się ~str. 2000 ≈ 38k scen — dalej crawl tylko generuje orphany.
"youpervcom": 2000,
} }
# Miękki budżet czasu na run (s). Detail-fetch scrapery (per-scena fetch strony, np. # Miękki budżet czasu na run (s). Detail-fetch scrapery (per-scena fetch strony, np.

View file

@ -276,6 +276,37 @@ def _job_thumb_asset_dedup() -> None:
log.exception("[scheduler] thumb-asset dedup failed") log.exception("[scheduler] thumb-asset dedup failed")
def _job_phash_blacklist() -> None:
"""Wyłapuje phashe, które przestały cokolwiek znaczyć (zaślepka / czarna klatka /
intro studia) i kasuje je z fingerprintów. Audyt 2026-07-27: 73 takie wartości przy
4375 scenach, rekordzistka dzielona przez 892 sceny o 892 różnych tytułach.
Cyklicznie, bo tube potrafi zacząć serwować zaślepkę w dowolnym momencie, a wartość
raz wpisana na blacklistę zostaje tam na stałe (inaczej odrastałaby przy ingeście)."""
log.info("[scheduler] phash blacklist starting")
try:
from app.scheduler.phash_blacklist import run_phash_blacklist
_run_with_timeout(run_phash_blacklist, label="phash-blacklist")
log.info("[scheduler] phash blacklist done")
except Exception:
log.exception("[scheduler] phash blacklist failed")
def _job_junk_performers() -> None:
"""Kasuje kategorie i studia podszywające się pod performerów („Creampie",
Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
Odrastają, bo tube-search dokleja performera po jednym tokenie z zapytania, a
resolve_performer tworzy go z dowolnej nazwy podanej przez tube."""
log.info("[scheduler] junk performers starting")
try:
from app.scheduler.junk_performers import run_junk_performer_cleanup
_run_with_timeout(run_junk_performer_cleanup, label="junk-performers")
log.info("[scheduler] junk performers done")
except Exception:
log.exception("[scheduler] junk performers failed")
def _job_title_duration_dedup() -> None: def _job_title_duration_dedup() -> None:
"""Scal missing-merge dupy (ten sam performer + identyczny tytuł + długość) których """Scal missing-merge dupy (ten sam performer + identyczny tytuł + długość) których
bulk_dedup nie łapie (tube re-scrape / cross-tube np. porn00 vs xnxx, reports bulk_dedup nie łapie (tube re-scrape / cross-tube np. porn00 vs xnxx, reports
@ -471,6 +502,28 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
) )
log.info("scheduler: thumb-asset dedup every %dh", cfg["thumb_dedup_hours"]) log.info("scheduler: thumb-asset dedup every %dh", cfg["thumb_dedup_hours"])
if cfg.get("phash_blacklist_hours"):
sched.add_job(
_job_phash_blacklist,
IntervalTrigger(hours=cfg["phash_blacklist_hours"], start_date=INTERVAL_ANCHOR),
id="phash_blacklist",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: phash blacklist every %dh", cfg["phash_blacklist_hours"])
if cfg.get("junk_performers_hours"):
sched.add_job(
_job_junk_performers,
IntervalTrigger(hours=cfg["junk_performers_hours"], start_date=INTERVAL_ANCHOR),
id="junk_performers",
replace_existing=True,
max_instances=1,
coalesce=True,
)
log.info("scheduler: junk performers every %dh", cfg["junk_performers_hours"])
if cfg.get("title_dedup_hours"): if cfg.get("title_dedup_hours"):
sched.add_job( sched.add_job(
_job_title_duration_dedup, _job_title_duration_dedup,

View file

@ -0,0 +1,232 @@
"""Kasowanie śmieciowych performerów — kategorii i studiów podszywających się pod osoby.
Skąd się biorą: `_search_base` filtruje wyniki tube-searcha warunkiem slug zawiera
1 token z zapytania", a `resolve_performer` tworzy performera z dowolnej nazwy podanej
przez tube. W efekcie Creampie", „Natural tits", reverse cowgirl", „69", Brazzers"
i Blacked" wylądowały w tabeli `performers`. Audyt 2026-07-27: 277 takich rekordów z
33 784 przypisaniami do scen.
**Kryterium jest wąskie celowo, bo szerokie kasuje ludzi.** Kryterium używane w
`app/api/scenes.py` do de-rankingu (slug pokrywa się z tagiem I brak refa tpdb/stashdb)
daje 3111 trafień, ale przy próbkowaniu okazało się mieć ~60% fałszywek dla nazw
wielowyrazowych: wpadają w nie **Hatano Yui, Davina Raines, Michelle Ferrari, Clanddi,
SolaZola, Kate Kuray**. Powód: założenie prawdziwy performer ma ref kanoniczny" nie
działa dla JAV, amatorek i debiutantek, których nie ma w TPDB. Do de-rankingu to
wystarcza (fałszywka = gorszy ranking), do kasowania nie.
Rozstrzyga dopiero **promiskuityczność taga**: ile RÓŻNYCH innych performerów pojawia
się na scenach otagowanych tym samym slugiem. Nazwisko współwystępuje głównie ze swoim
właścicielem i partnerami ze sceny, kategoria z całym katalogiem. Zmierzone:
prawdziwe osoby Hatano Yui 0, Clanddi 0, Chyna 0, Kwini 2, Katekuray 9,
SolaZola 4, Julia Reaves 20 (maksimum 20)
kategorie Deepthroat 39541, Creampie 33661, riding 16284, Skinny 9443
Próg 50 leży ponad dwukrotnie powyżej najwyższej prawdziwej osoby. Dodatkowo chronimy
nazwy w formacie Imię Nazwisko", bo sygnał studia (odrzucony) wciągał marki osobiste.
**Drugie kryterium: tag przytłacza performera.** Promiskuityczność zawodzi dla śmieci,
które mają ref kanoniczny bo TPDB i StashDB SAME zawierają wpisy Hardcore", „Blonde",
Masturbation", „Amateur". Co gorsza, `obcy` mierzy tam popularność, nie śmieciowość:
przy wartości 50-59 stoją Adriana Chechik (4201 scen), Lauren Phillips i Jane Wilde.
Rozstrzyga stosunek `scen z tagiem / scen performera`. Nazwisko-tag siedzi głównie na
scenach swojej właścicielki, więc stosunek jest ułamkiem; kategoria wisi na całym
katalogu, więc idzie w setki:
Hardcore 1720,6 Masturbation 1570,4 Blonde 770,0 Anal Creampie 31,4
najwyższy wśród 921 PEWNYCH osób (ref kanoniczny + 500 scen): **0,35**
**Próg 2 wynika z zasady, nie z próbki.** Nazwisko użyte jako własny tag daje stosunek
1 Z DEFINICJI, bo tag siedzi dokładnie na scenach swojej właścicielki potwierdzone na
twórcach z własną marką: Hotwifevictoria 1,02, Rossmexicana 0,99, Sexwithmilfstella 0,87.
Wielkie gwiazdy mają jeszcze mniej (tuby tagują tylko część ich scen). Stosunek 2
oznacza więc, że słowo jest doklejane do scen, które do tej osoby nie należą.
Wymóg 50 scen odcina szum przy małych liczbach (przy 11 scenach Denzel" wychodził na
161, ale to za mało danych, żeby przesądzać). Przy progu 2 na 78 trafień dwa
dyskusyjne (Teddy", „Grey" mogą być nazwiskami) ale i one mają tag rozlany na 3×
więcej scen, niż mają przypisań, więc ich atrybucje i tak w większości słowne.
Job, nie bramka w `resolve_performer`: kasowanie cyklem nie dotyka gorącej ścieżki
ingestu, a śmieci i tak odrastają wolno. Dotyczy to zwłaszcza tego drugiego kryterium,
bo tamte rekordy odtwarza sam ingest TPDB/StashDB. Ta sama logika co przy
[[phash_blacklist]].
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
#: Ponad 2× powyżej najwyższej zmierzonej prawdziwej osoby (Julia Reaves, 20).
DEFAULT_MIN_FOREIGN_PERFORMERS = 50
#: 2× powyżej naturalnej wartości dla nazwiska użytego jako własny tag (≈1).
DEFAULT_MIN_TAG_RATIO = 2
#: Poniżej tego stosunek jest szumem — za mało scen, żeby cokolwiek przesądzać.
DEFAULT_MIN_SCENES_FOR_RATIO = 50
#: Nazwy 1-2 znakowe („A", „L", „Ro", „Js") to artefakty parsowania tytułów, ale
#: kilka to realne krótkie pseudonimy (Cj, PD, JD mają obsadę potwierdzoną w TPDB
#: i StashDB, z prawdziwymi partnerami scenicznymi). Dlatego NIE kasujemy tu
#: performera, tylko przypisania, o których WIEMY, że dokleił je scraper — reszta
#: rozstrzyga się sama: komu nic nie zostanie, ten znika.
MAX_ARTIFACT_NAME_LEN = 2
_SELECT_SQL = """
WITH junk AS (
SELECT pf.id, pf.canonical_name, pf.slug
FROM performers pf
WHERE EXISTS (SELECT 1 FROM tags t WHERE t.slug = pf.slug)
AND NOT EXISTS (
SELECT 1 FROM performer_external_refs r JOIN sources s ON s.id = r.source_id
WHERE r.performer_id = pf.id AND s.kind IN ('tpdb', 'stashdb')
)
-- Imię Nazwisko" zostaje: marki osobiste (Kate Kuray, Julia Reaves) wyglądają
-- jak studio, ale osobami.
AND pf.canonical_name !~ '^[A-Z][a-z]+ [A-Z][a-z]+$'
)
SELECT j.id FROM junk j
WHERE (
SELECT count(DISTINCT sp.performer_id)
FROM tags t JOIN scene_tags st ON st.tag_id = t.id
JOIN scene_performers sp ON sp.scene_id = st.scene_id
WHERE t.slug = j.slug AND sp.performer_id <> j.id
) >= :min_foreign
"""
_RATIO_SQL = """
SELECT pf.id
FROM performers pf
WHERE EXISTS (SELECT 1 FROM tags t WHERE t.slug = pf.slug)
AND (SELECT count(*) FROM scene_performers sp WHERE sp.performer_id = pf.id) >= :min_scenes
AND (SELECT count(*) FROM scene_tags st JOIN tags t ON t.id = st.tag_id WHERE t.slug = pf.slug)::numeric
/ NULLIF((SELECT count(*) FROM scene_performers sp WHERE sp.performer_id = pf.id), 0) >= :min_ratio
"""
def find_junk_performers(
session: Session,
*,
min_foreign: int = DEFAULT_MIN_FOREIGN_PERFORMERS,
min_ratio: int = DEFAULT_MIN_TAG_RATIO,
min_scenes: int = DEFAULT_MIN_SCENES_FOR_RATIO,
):
"""Suma dwóch niezależnych kryteriów — pierwsze łapie śmieci bez refa kanonicznego,
drugie te, które ref mają (bo TPDB/StashDB też zawierają Hardcore" czy „Blonde")."""
ids = {r[0] for r in session.execute(text(_SELECT_SQL), {"min_foreign": min_foreign})}
ids |= {
r[0]
for r in session.execute(
text(_RATIO_SQL), {"min_ratio": min_ratio, "min_scenes": min_scenes}
)
}
return list(ids)
# Jednotokenowy performer jest ZBĘDNY na scenie, która ma już osobę pełnoimienną
# zawierającą to samo słowo: „Devine" obok „Shalina Devine", „Kitana" obok „Kitana A".
# To rozbite zapisy tej samej osoby, powstałe z parsowania tytułów przez tube-search —
# jeden wpis nazwiskiem łapie potem wszystkie sceny z tym nazwiskiem, należące do
# zupełnie innych ludzi (audyt 2026-07-27: 62 tys. takich przypisań, 2156 wpisów).
#
# Kasowanie jest DOWODLIWIE BEZSTRATNE: warunek wymaga, żeby konkretna osoba już była
# przypisana do tej sceny, więc informacja o obsadzie zostaje. Żadna scena nie może
# przez to zostać bez obsady (sprawdzone: 0).
#
# Ograniczone do `kind='scraper'` — przypisania z kanonu zostają nietknięte (73 takie).
_PRUNE_SURNAME_SQL = """
DELETE FROM scene_performers sp
USING sources s, performers p
WHERE p.id = sp.performer_id
AND p.canonical_name !~ ' '
AND length(p.canonical_name) >= 4
AND s.id = sp.source_id
AND s.kind = 'scraper'
AND EXISTS (
SELECT 1 FROM scene_performers sp2 JOIN performers q ON q.id = sp2.performer_id
WHERE sp2.scene_id = sp.scene_id AND q.id <> p.id AND q.canonical_name ~ ' '
AND q.canonical_name ~* ('\\m' || p.canonical_name || '\\M')
)
"""
_PRUNE_ARTIFACT_SQL = """
DELETE FROM scene_performers sp
USING sources s, performers p
WHERE p.id = sp.performer_id
AND length(trim(p.canonical_name)) <= :max_len
AND s.id = sp.source_id
AND s.kind = 'scraper'
"""
_DROP_EMPTY_ARTIFACT_SQL = """
DELETE FROM performers p
WHERE length(trim(p.canonical_name)) <= :max_len
AND NOT EXISTS (SELECT 1 FROM scene_performers sp WHERE sp.performer_id = p.id)
"""
def prune_artifact_name_attributions(
session: Session, *, max_len: int = MAX_ARTIFACT_NAME_LEN
) -> tuple[int, int]:
"""Zdejmij scraperowe przypisania w dwóch przypadkach i usuń tych, którym nic nie
zostało: (1) nazwy 1-2 znakowe, (2) jednotokenowy performer na scenie, która ma już
osobę pełnoimienną zawierającą to słowo. Kanoniczne przypisania zostają nietknięte.
Wymaga wypełnionego `scene_performers.source_id` (migracja 0026): bez provenance nie
dałoby się odróżnić Cj" z obsady TPDB od „Cj" wyłuskanego z tytułu przez tube-search.
Wiersze z NULL-em (sceny wieloźródłowe) zostawiamy nie zgadujemy."""
pruned = int(
session.execute(text(_PRUNE_ARTIFACT_SQL), {"max_len": max_len}).rowcount or 0
)
pruned += int(session.execute(text(_PRUNE_SURNAME_SQL)).rowcount or 0)
dropped = int(
session.execute(text(_DROP_EMPTY_ARTIFACT_SQL), {"max_len": max_len}).rowcount or 0
)
dropped += int(
session.execute(
text(
"DELETE FROM performers p WHERE p.canonical_name !~ ' ' "
"AND length(p.canonical_name) >= 4 "
"AND NOT EXISTS (SELECT 1 FROM scene_performers sp "
"WHERE sp.performer_id = p.id) "
"AND NOT EXISTS (SELECT 1 FROM performer_external_refs r "
"JOIN sources s ON s.id = r.source_id WHERE r.performer_id = p.id "
"AND s.kind IN ('tpdb','stashdb'))"
)
).rowcount or 0
)
return pruned, dropped
def run_junk_performer_cleanup(
*, min_foreign: int = DEFAULT_MIN_FOREIGN_PERFORMERS, commit: bool = True
) -> dict[str, int]:
"""Skasuj śmieciowych performerów. `scene_performers` leci kaskadą — sceny i tagi
zostają, więc treść nie ginie: creampie" nadal jest tagiem przy 176 tys. scen,
tylko przestaje udawać osobę."""
from app.db import session_scope
with session_scope() as session:
ids = find_junk_performers(session, min_foreign=min_foreign)
deleted = pruned = dropped = 0
if commit:
if ids:
deleted = int(
session.execute(
text("DELETE FROM performers WHERE id = ANY(:ids)"), {"ids": ids}
).rowcount or 0
)
pruned, dropped = prune_artifact_name_attributions(session)
session.commit()
log.info(
"junk performers: znaleziono %d, skasowano %d; nazwy 1-2 znakowe: zdjęto %d "
"przypisań, usunięto %d pustych",
len(ids), deleted, pruned, dropped,
)
return {"found": len(ids), "deleted": deleted, "pruned": pruned, "dropped": dropped}

View file

@ -0,0 +1,65 @@
"""Wykrywanie i czyszczenie zdegenerowanych phashy.
Phash identyfikuje scenę tylko wtedy, gdy miniaturka pokazuje treść sceny. Gdy tube
serwuje zaślepkę, czarną klatkę albo wspólne intro studia, ta sama wartość ląduje
przy setkach niepowiązanych scen i przestaje cokolwiek znaczyć a `find_by_phash_within`
bierze najbliższą wartość z CAŁEJ tabeli, więc taki wpis jest czystym szumem.
Próg `min_scenes=10` wynika z rozkładu zmierzonego 2026-07-27: 11 000 wartości
występowało przy dokładnie 2 scenach (to realne duplikaty, chcemy je zachować),
1346 przy 3-9, a dopiero od 10 w górę zaczynają się grupy, w których liczba różnych
tytułów równa się liczbie scen czyli nic ich nie łączy poza obrazkiem.
Kolejność ma znaczenie: najpierw `refresh` (zapisz wartości na stałe), potem `purge`
(skasuj wiersze). Odwrotnie skasowalibyśmy dowody i blacklista wyszłaby pusta.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
DEFAULT_MIN_SCENES = 10
_REFRESH_SQL = """
INSERT INTO phash_blacklist (value, scene_count)
SELECT value, count(DISTINCT scene_id)
FROM scene_fingerprints
WHERE kind = 'phash'
GROUP BY value
HAVING count(DISTINCT scene_id) >= :min_scenes
ON CONFLICT (value) DO UPDATE SET scene_count = EXCLUDED.scene_count
"""
_PURGE_SQL = """
DELETE FROM scene_fingerprints f
USING phash_blacklist b
WHERE f.kind = 'phash' AND f.value = b.value
"""
def refresh_phash_blacklist(session: Session, *, min_scenes: int = DEFAULT_MIN_SCENES) -> int:
"""Dopisz do blacklisty wartości dzielone przez ≥min_scenes scen. Zwraca rozmiar
blacklisty po odświeżeniu (wpisy raz dodane NIE znikają o to chodzi)."""
session.execute(text(_REFRESH_SQL), {"min_scenes": min_scenes})
return int(session.execute(text("SELECT count(*) FROM phash_blacklist")).scalar_one())
def purge_blacklisted_fingerprints(session: Session) -> int:
"""Skasuj wiersze `scene_fingerprints` o zablacklistowanej wartości. Zwraca liczbę
skasowanych. Sceny zostają nietknięte znika tylko bezużyteczny odcisk."""
return int(session.execute(text(_PURGE_SQL)).rowcount or 0)
def run_phash_blacklist(*, min_scenes: int = DEFAULT_MIN_SCENES) -> dict[str, int]:
from app.db import session_scope
with session_scope() as session:
size = refresh_phash_blacklist(session, min_scenes=min_scenes)
purged = purge_blacklisted_fingerprints(session)
session.commit()
log.info("phash blacklist: %d wartości na liście, skasowano %d odcisków", size, purged)
return {"blacklist_size": size, "purged": purged}

View file

@ -216,6 +216,12 @@ def run_forever() -> int:
"thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None, "thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None,
# Title+duration dedup — missing-merge tube-dupy (reports 28fe8181/32df33b1). # Title+duration dedup — missing-merge tube-dupy (reports 28fe8181/32df33b1).
"title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None, "title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None,
# Blacklista zdegenerowanych phashy — zaślepki dzielone przez setki scen (audyt
# 2026-07-27). Bez tego zaślepka jest zawsze najbliższym trafieniem w Path 3.
"phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None,
# Śmieciowi performerzy — kategorie/studia udające osoby (audyt 2026-07-27).
# Kryterium wąskie celowo: szerokie kasuje realnych ludzi bez refa w TPDB.
"junk_performers_hours": getattr(settings, "sched_junk_performers_hours", 24) or None,
# Taxonomy scene_count refresh — denormalizacja liczników (perf fix 0019). # Taxonomy scene_count refresh — denormalizacja liczników (perf fix 0019).
"taxonomy_counts_hours": getattr(settings, "sched_taxonomy_counts_hours", 3) or None, "taxonomy_counts_hours": getattr(settings, "sched_taxonomy_counts_hours", 3) or None,
# Ingest freshness watchdog — per-origin alert do Sentry (report 14f3a655). # Ingest freshness watchdog — per-origin alert do Sentry (report 14f3a655).

View file

@ -2,8 +2,10 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
import { import {
DefaultQuality, DefaultQuality,
getAutoRotate,
getDefaultQuality, getDefaultQuality,
getGridColumns, getGridColumns,
setAutoRotate as persistAutoRotate,
setDefaultQuality as persistDefaultQuality, setDefaultQuality as persistDefaultQuality,
setGridColumns as persistGridColumns, setGridColumns as persistGridColumns,
} from './storage'; } from './storage';
@ -17,6 +19,8 @@ interface Prefs {
setGridColumns: (n: number) => void; setGridColumns: (n: number) => void;
defaultQuality: DefaultQuality; defaultQuality: DefaultQuality;
setDefaultQuality: (q: DefaultQuality) => void; setDefaultQuality: (q: DefaultQuality) => void;
autoRotate: boolean;
setAutoRotate: (on: boolean) => void;
} }
const Ctx = createContext<Prefs | null>(null); const Ctx = createContext<Prefs | null>(null);
@ -24,6 +28,7 @@ const Ctx = createContext<Prefs | null>(null);
export function PreferencesProvider({ children }: { children: React.ReactNode }) { export function PreferencesProvider({ children }: { children: React.ReactNode }) {
const [gridColumns, setCols] = useState(2); const [gridColumns, setCols] = useState(2);
const [defaultQuality, setQuality] = useState<DefaultQuality>('auto'); const [defaultQuality, setQuality] = useState<DefaultQuality>('auto');
const [autoRotate, setRotate] = useState(true);
useEffect(() => { useEffect(() => {
getGridColumns() getGridColumns()
@ -32,6 +37,9 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
getDefaultQuality() getDefaultQuality()
.then(setQuality) .then(setQuality)
.catch(() => {}); .catch(() => {});
getAutoRotate()
.then(setRotate)
.catch(() => {});
}, []); }, []);
const setGridColumns = (n: number) => { const setGridColumns = (n: number) => {
@ -44,8 +52,22 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
persistDefaultQuality(q).catch(() => {}); persistDefaultQuality(q).catch(() => {});
}; };
const setAutoRotate = (on: boolean) => {
setRotate(on);
persistAutoRotate(on).catch(() => {});
};
return ( return (
<Ctx.Provider value={{ gridColumns, setGridColumns, defaultQuality, setDefaultQuality }}> <Ctx.Provider
value={{
gridColumns,
setGridColumns,
defaultQuality,
setDefaultQuality,
autoRotate,
setAutoRotate,
}}
>
{children} {children}
</Ctx.Provider> </Ctx.Provider>
); );

View file

@ -16,6 +16,14 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-07-28',
date: 'July 2026',
items: [
'Auto-rotate can now be turned off (Settings -> Auto-rotate), so the video stops flipping when the phone lies flat on a table. The fullscreen button still switches to landscape.',
'The "+N new" badge on a favourite performer or studio now also counts new movies, not just scenes.',
],
},
{ {
id: '2026-07-26b', id: '2026-07-26b',
date: 'July 2026', date: 'July 2026',

View file

@ -62,7 +62,7 @@ export function AppLockSettingsScreen() {
const [stage, setStage] = useState<Stage>('menu'); const [stage, setStage] = useState<Stage>('menu');
const [newPin, setNewPin] = useState(''); const [newPin, setNewPin] = useState('');
const [errorText, setErrorText] = useState<string | null>(null); const [errorText, setErrorText] = useState<string | null>(null);
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences(); const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality, autoRotate, setAutoRotate } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -376,6 +376,24 @@ export function AppLockSettingsScreen() {
</View> </View>
</View> </View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Auto-rotate</Text>
<Text style={styles.hint}>
Let the video follow your phone's rotation. Turn this off if the picture keeps
flipping when the phone lies flat on a table. The fullscreen button still switches
to landscape.
</Text>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Rotate with the phone</Text>
<Text style={styles.hint}>
{autoRotate ? 'Video follows the phone' : 'Video stays upright'}
</Text>
</View>
<Switch value={autoRotate} onValueChange={setAutoRotate} />
</View>
</View>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Backup & sync</Text> <Text style={styles.sectionTitle}>Backup & sync</Text>
<Text style={styles.hint}> <Text style={styles.hint}>

View file

@ -9,6 +9,7 @@ import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { setLastPlayback } from '../lib/lastPlayback'; import { setLastPlayback } from '../lib/lastPlayback';
import { allowScreenOff, keepScreenOn } from '../lib/keepAwake'; import { allowScreenOff, keepScreenOn } from '../lib/keepAwake';
import { usePreferences } from '../PreferencesContext';
import React from 'react'; import React from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
@ -135,12 +136,20 @@ export function PlayerScreen() {
const params = route.params as RouteParams; const params = route.params as RouteParams;
const mode = params.mode ?? detectMode(params.url); const mode = params.mode ?? detectMode(params.url);
// Auto-obrót: domyślnie odblokowany (oglądanie w poziomie). Gdy user go wyłączy
// (Settings → Playback), player zostaje w pionie — telefon leżący płasko na stole
// nie skacze między orientacjami (bug-report 55da1c3f). Przycisk pełnego ekranu dalej
// pozwala wejść w poziom ręcznie, więc wyłączenie nie odbiera funkcji.
const { autoRotate } = usePreferences();
React.useEffect(() => { React.useEffect(() => {
ScreenOrientation.unlockAsync().catch(() => {}); if (autoRotate) ScreenOrientation.unlockAsync().catch(() => {});
else {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
}
return () => { return () => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {}); ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
}; };
}, []); }, [autoRotate]);
if (mode === 'webview') return <EmbedWebViewPlayer params={params} />; if (mode === 'webview') return <EmbedWebViewPlayer params={params} />;
return <NativeVideoPlayer params={params} />; return <NativeVideoPlayer params={params} />;

View file

@ -104,6 +104,23 @@ export async function setDefaultQuality(v: DefaultQuality): Promise<void> {
await SecureStore.setItemAsync(DEFAULT_QUALITY_KEY, String(v)); await SecureStore.setItemAsync(DEFAULT_QUALITY_KEY, String(v));
} }
// Auto-obrót w playerze. Player domyślnie odblokowuje orientację (unlockAsync), żeby
// dało się oglądać poziomo — ale wtedy telefon położony płasko na stole sam skacze
// między orientacjami (bug-report 55da1c3f: "I can't turn off auto rotate. Which is a
// problem when I put the phone on a flat surface"). Off = player zostaje w pionie,
// a przełącznik pełnego ekranu dalej pozwala wejść w poziom RĘCZNIE.
// Default true = dotychczasowe zachowanie.
const AUTO_ROTATE_KEY = 'goon.player_auto_rotate';
export async function getAutoRotate(): Promise<boolean> {
const v = await SecureStore.getItemAsync(AUTO_ROTATE_KEY);
return v === null ? true : v === '1';
}
export async function setAutoRotate(on: boolean): Promise<void> {
await SecureStore.setItemAsync(AUTO_ROTATE_KEY, on ? '1' : '0');
}
export interface Credentials { export interface Credentials {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;

View file

@ -0,0 +1,88 @@
"""Backfill `scene_performers.source_id` dla wierszy sprzed migracji 0026.
Wnioskowanie jest pewne tylko dla scen, które mają `scene_external_refs` z DOKŁADNIE
jednego źródła: skoro tylko ono kiedykolwiek dotknęło scenę, to cała jej obsada
stamtąd pochodzi. Takich scen jest 3,39 mln i pokrywają 4,15 mln przypisań, czyli ~96%
tabeli. Sceny wieloźródłowe (67 tys.) zostawiamy z NULL-em tam nie da się powiedzieć,
które źródło przypisało którego performera, a zgadywanie zepsułoby cały sens kolumny.
Granularność jest na poziomie ŹRÓDŁA, nie pojedynczego tuba: wszystkie direct-scrapery
dzielą jeden rekord `sources` (`SCRAPER_SOURCE_NAME`). To wystarcza do pytania, które
było nie do zadania wcześniej: czy obsadę potwierdził kanon, czy dokleił
tube-search".
Wsadowo i z osobną transakcją na paczkę pojedynczy UPDATE na 4 mln wierszy trzymałby
locki na `scene_performers` przez cały czas i zablokował ingest (zdarzyło się przy
migracji 0026: porzucone zapytanie analityczne wstrzymało ALTER TABLE, a za nim ustawiła
się kolejka).
Uruchomienie (kontener worker):
python -m scripts.backfill_scene_performer_source # dry-run
python -m scripts.backfill_scene_performer_source --yes # wykonaj
"""
from __future__ import annotations
import argparse
import logging
from sqlalchemy import text
from app.db import session_scope
log = logging.getLogger(__name__)
_BATCH = 50_000
_COUNT_SQL = """
SELECT count(*) FROM scene_performers sp
WHERE sp.source_id IS NULL
AND (SELECT count(DISTINCT r.source_id) FROM scene_external_refs r
WHERE r.scene_id = sp.scene_id) = 1
"""
# ctid-based batching: bez sztucznego klucza na (scene_id, performer_id) to najtańszy
# sposób na „weź N dowolnych jeszcze niewypełnionych".
_UPDATE_SQL = """
UPDATE scene_performers sp
SET source_id = (
SELECT (array_agg(DISTINCT r.source_id))[1]
FROM scene_external_refs r WHERE r.scene_id = sp.scene_id
)
WHERE sp.ctid IN (
SELECT sp2.ctid FROM scene_performers sp2
WHERE sp2.source_id IS NULL
AND (SELECT count(DISTINCT r.source_id) FROM scene_external_refs r
WHERE r.scene_id = sp2.scene_id) = 1
LIMIT :batch
)
"""
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--yes", action="store_true", help="wykonaj (bez tego dry-run)")
ap.add_argument("--batch", type=int, default=_BATCH)
args = ap.parse_args()
with session_scope() as s:
total = int(s.execute(text(_COUNT_SQL)).scalar_one())
log.info("do wypełnienia: %d przypisań (sceny jednoźródłowe)", total)
if not args.yes:
log.info("(dry-run — uruchom z --yes)")
return
done = 0
while True:
with session_scope() as s:
n = int(s.execute(text(_UPDATE_SQL), {"batch": args.batch}).rowcount or 0)
s.commit()
if n == 0:
break
done += n
log.info(" wypełnione %d / %d", done, total)
log.info("GOTOWE: %d wierszy", done)
if __name__ == "__main__":
main()

View file

@ -14,6 +14,18 @@ UWAGA: merge KASUJE zdublowaną scenę (refs/sources/tags łączone do keepera)
NIEODWRACALNE. Domyślnie dry-run; --yes wykonuje. Per-para re-fetch obsługuje klastry NIEODWRACALNE. Domyślnie dry-run; --yes wykonuje. Per-para re-fetch obsługuje klastry
(scena znika po wcześniejszym merge'u w tym samym klastrze). (scena znika po wcześniejszym merge'u w tym samym klastrze).
**Bezpiecznik na pary drugiego rzędu (dodany 2026-07-27).** Merge PRZENOSI odciski na
keepera, więc scalona scena zbiera phashe wszystkich wchłoniętych jedna miała ich 14.
Taki konglomerat zaczyna potem kolidować z obcymi scenami przez odziedziczony phash,
który nie przedstawia nawet jego treści. Po przebiegu na 4647 parach zostało 7 nowych,
POWSTAŁYCH przez ten przebieg, i 5 z nich (71%) było fałszywkami: Brazzers vs Reality
Kings, AdultTime vs Teen Curves. W parach pierwszego rzędu było 0 fałszywek na 18.
Rozdziela je podobieństwo tytułu, i to z dużym marginesem zmierzone na tych 7 parach:
prawdziwe duplikaty 92,1 i 84,8, wszystkie fałszywki 23,0-52,4. Stąd próg 0,70, który
leży w środku luki. Prefiks studia progu nie rusza, bo `token_set_ratio` traktuje
Bursting The Bag" jako podzbiór „FakehubOriginals Sophia Locke Bursting The Bag".
Uruchomienie: Uruchomienie:
python -m scripts.merge_phash_exact_dupes # dry-run python -m scripts.merge_phash_exact_dupes # dry-run
python -m scripts.merge_phash_exact_dupes --yes # wykonaj python -m scripts.merge_phash_exact_dupes --yes # wykonaj
@ -22,6 +34,7 @@ from __future__ import annotations
import argparse import argparse
from rapidfuzz import fuzz
from sqlalchemy import text from sqlalchemy import text
from app.db import session_scope from app.db import session_scope
@ -29,6 +42,15 @@ from app.models.scene import Scene
from app.resolve.scene_merge import merge_scenes from app.resolve.scene_merge import merge_scenes
from app.scheduler.bulk_dedup import _pick_keep_drop from app.scheduler.bulk_dedup import _pick_keep_drop
# Patrz docstring: zmierzona luka to 52,4 (najgorsza fałszywka) ↔ 84,8 (najsłabszy
# prawdziwy duplikat). 0.70 siedzi w jej środku.
_MIN_TITLE_SIM = 0.70
# UWAGA (2026-07-27): bez wykluczenia `phash_blacklist` ten self-join jest NIEBEZPIECZNY.
# Jedna zaślepka była dzielona przez 892 sceny → sam ten klaster daje ~397 tys. par, a
# każde dwie z nich o zbliżonej długości i wspólnym performerze zostałyby scalone, mimo
# że to zupełnie różne sceny. Blacklista musi być odświeżona PRZED uruchomieniem:
# python -c "from app.scheduler.phash_blacklist import run_phash_blacklist; run_phash_blacklist()"
_SAFE_PAIRS_SQL = """ _SAFE_PAIRS_SQL = """
SELECT DISTINCT a.scene_id AS sa, b.scene_id AS sb SELECT DISTINCT a.scene_id AS sa, b.scene_id AS sb
FROM scene_fingerprints a FROM scene_fingerprints a
@ -36,6 +58,7 @@ JOIN scene_fingerprints b ON a.value = b.value AND a.scene_id < b.scene_id
JOIN scenes sca ON sca.id = a.scene_id JOIN scenes sca ON sca.id = a.scene_id
JOIN scenes scb ON scb.id = b.scene_id JOIN scenes scb ON scb.id = b.scene_id
WHERE a.kind = 'phash' AND b.kind = 'phash' WHERE a.kind = 'phash' AND b.kind = 'phash'
AND NOT EXISTS (SELECT 1 FROM phash_blacklist bl WHERE bl.value = a.value)
AND sca.duration_sec IS NOT NULL AND scb.duration_sec IS NOT NULL AND sca.duration_sec IS NOT NULL AND scb.duration_sec IS NOT NULL
AND abs(sca.duration_sec - scb.duration_sec) <= 3 AND abs(sca.duration_sec - scb.duration_sec) <= 3
AND ( AND (
@ -58,7 +81,7 @@ def main() -> None:
pairs = [(r[0], r[1]) for r in s.execute(text(_SAFE_PAIRS_SQL))] pairs = [(r[0], r[1]) for r in s.execute(text(_SAFE_PAIRS_SQL))]
print(f"safe high-confidence duplicate pairs: {len(pairs)}") print(f"safe high-confidence duplicate pairs: {len(pairs)}")
merged = skipped = 0 merged = skipped = rejected = 0
for sa, sb in pairs: for sa, sb in pairs:
with session_scope() as s: with session_scope() as s:
a = s.get(Scene, sa) a = s.get(Scene, sa)
@ -66,6 +89,14 @@ def main() -> None:
if a is None or b is None or a.id == b.id: if a is None or b is None or a.id == b.id:
skipped += 1 # już zmergowane w tym klastrze skipped += 1 # już zmergowane w tym klastrze
continue continue
# Bezpiecznik na pary drugiego rzędu (patrz docstring): sam wspólny phash
# nie wystarcza, gdy któraś strona jest konglomeratem po wcześniejszym
# merge'u i niesie odziedziczone odciski obcych scen.
sim = fuzz.token_set_ratio(a.title_normalized or "", b.title_normalized or "") / 100.0
if sim < _MIN_TITLE_SIM:
rejected += 1
print(f" SKIP (tytuł {sim:.2f}) '{(a.title or '')[:34]}' vs '{(b.title or '')[:34]}'")
continue
keep, drop = _pick_keep_drop(s, a, b) keep, drop = _pick_keep_drop(s, a, b)
print(f" {'MERGE' if args.yes else '[dry] MERGE'} keep={str(keep.id)[:8]} " print(f" {'MERGE' if args.yes else '[dry] MERGE'} keep={str(keep.id)[:8]} "
f"'{(keep.title or '')[:32]}' ({keep.duration_sec}s) <- drop={str(drop.id)[:8]} " f"'{(keep.title or '')[:32]}' ({keep.duration_sec}s) <- drop={str(drop.id)[:8]} "
@ -76,9 +107,11 @@ def main() -> None:
merged += 1 merged += 1
if args.yes: if args.yes:
print(f"\nAPPLIED: merged={merged} skipped(already-gone)={skipped}") print(f"\nAPPLIED: merged={merged} skipped(already-gone)={skipped} "
f"rejected(tytuł<{_MIN_TITLE_SIM})={rejected}")
else: else:
print(f"\n(dry-run — {len(pairs)} par; uruchom z --yes aby scalić. NIEODWRACALNE)") print(f"\n(dry-run — {len(pairs)} par, z tego {rejected} odrzuconych na tytule; "
f"uruchom z --yes aby scalić. NIEODWRACALNE)")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -0,0 +1,201 @@
"""Przegląd i przycinanie przypisań JEDNEGO performera (wzorzec „Rapture").
Dotyczy klasy, której NIE DA SIĘ posprzątać hurtowo: realna osoba (jest w TPDB/StashDB)
o nazwie będącej pospolitym słowem, do której tube-search dokleja wszystko, co ma to
słowo w tytule. Rapture" miała 30 z 72 przypisań fałszywych — mainstreamowy film „The
Rapture" z Mimi Rogers, literówkę „hymen raptured", tytuły scen (LucidFlix Tru Kait
Rapture") i zwykłe użycia („complete rapture", moments of rapture").
**Dlaczego ręcznie.** Przetestowałem pięć sygnałów automatycznych i żaden nie rozstrzyga:
- sama provenance ze scrapera realni twórcy spoza TPDB (Amouranth, MollyRedWolf)
też mają 100% przypisań ze scrapera
- obsada z kanonu na scenie backfill 0026 wypełnił tylko sceny jednoźródłowe, więc
sceny mieszane, w których leży problem, mają NULL (1 trafienie na 560 tys.)
- udział nazwy w tytule u prawdziwych niszowych osób też ~100% (Amouranth 100%,
Clanddi 100%)
- obce" sceny ze słowem w tytule — myli generyczne użycie z pominiętą atrybucją
(Amouranth ma 137 takich, a to po prostu jej sceny bez przypisanej obsady)
- stosunek tag/performer 1 wyklucza 79 pozycji, ale samej Rapture BY NIE ZŁAPAŁ
Zostaje przeczytanie tytułów. Ten skrypt robi z tego operację na kilka minut zamiast
pół godziny: wypisuje komplet z kontekstem, przyjmuje listę prefiksów do odpięcia i
domyślnie NIE rusza przypisań potwierdzonych przez kanon.
Skala problemu (2026-07-27): 1335 kandydatów z 20 scenami, 539 z 100 (105 tys.
przypisań). Nie ma sensu przerabiać ich wszystkich używaj, gdy konkretna osoba
zwróci uwagę, tak jak Rapture wyszła przy audycie phashy.
**Wskaźnik pospolitości** liczony przy każdym przeglądzie: ile scen ma nazwę w
tytule, ale NIE jest do niej przypisanych, podzielone przez liczbę jej przypisań.
Addiction 14,3 Rogue 5,0 Mystery 4,3 Gypsy 3,2 Precious 3,1 Rapture 2,5
Kwini 1,8 Chyna 0,8 Amouranth 0,45
Wskaźnik NIE mówi, czy osoba jest prawdziwa mówi tylko, że jej przypisania
niewiarygodne i trzeba je przeczytać. Rapture przy 2,5 okazała się realna (zostały 42
sceny), Precious" przy 3,1 okazała się wyłącznie przymiotnikiem („precious
stepdaughter", „her ass is precious") i poszła w całości.
`--candidates` daje ranking, kogo przejrzeć. Działa dzięki indeksowi trigramowemu
(migracja 0027) przedtem pojedyncze szukanie słowa w tytułach było skanem 3,46 mln
wierszy, więc ranking ~800 kandydatów zajmował godziny i został ubity. Teraz jedno
zapytanie to ~26 ms.
Użycie (kontener worker):
python -m scripts.review_performer_attributions --candidates
python -m scripts.review_performer_attributions "Rapture"
python -m scripts.review_performer_attributions "Rapture" --detach-file /tmp/zle.txt
python -m scripts.review_performer_attributions "Rapture" --detach-file /tmp/zle.txt --yes
`--detach-file` to zwykły plik tekstowy: jedna linia = początek tytułu (bez wielkości
liter). Puste linie i `#` pomijane.
"""
from __future__ import annotations
import argparse
import sys
from sqlalchemy import text
from app.db import session_scope
_LIST_SQL = """
SELECT sc.title,
sc.duration_sec,
coalesce(src.kind::text, '?') AS zrodlo,
(SELECT count(*) FROM scene_performers x WHERE x.scene_id = sc.id) AS ilu_perf,
sc.id
FROM scene_performers sp
JOIN scenes sc ON sc.id = sp.scene_id
LEFT JOIN sources src ON src.id = sp.source_id
WHERE sp.performer_id = :pid
ORDER BY lower(sc.title)
"""
_FOREIGN_SQL = """
SELECT count(*) FROM scenes s
WHERE s.title ~* ('\\m' || :name || '\\M')
AND NOT EXISTS (SELECT 1 FROM scene_performers sp
WHERE sp.scene_id = s.id AND sp.performer_id = :pid)
"""
# UWAGA: `ORDER BY 3::numeric / scen` NIE działa — po rzutowaniu Postgres traktuje
# trójkę jako literał, nie numer kolumny, i sortuje po 3/scen (czyli po liczbie scen
# rosnąco). Wyrażenie trzeba powtórzyć jawnie, stąd osobne CTE.
_CANDIDATES_SQL = """
WITH kand AS (
SELECT pf.id, pf.canonical_name,
(SELECT count(*) FROM scene_performers sp WHERE sp.performer_id = pf.id) AS scen
FROM performers pf
WHERE pf.canonical_name !~ ' ' AND length(pf.canonical_name) >= 4
), z AS (
SELECT k.canonical_name, k.scen,
(SELECT count(*) FROM scenes s
WHERE s.title ~* ('\\m' || k.canonical_name || '\\M')
AND NOT EXISTS (SELECT 1 FROM scene_performers sp
WHERE sp.scene_id = s.id AND sp.performer_id = k.id)) AS obce
FROM kand k WHERE k.scen >= :min_scenes
)
SELECT canonical_name, scen, obce FROM z
ORDER BY obce::numeric / scen DESC
LIMIT :limit
"""
def _find_performer(session, name: str):
row = session.execute(
text(
"SELECT id, canonical_name FROM performers "
"WHERE lower(canonical_name) = lower(:n) ORDER BY id LIMIT 1"
),
{"n": name},
).first()
return row
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("performer", nargs="?", help="canonical_name performera")
ap.add_argument("--candidates", action="store_true", help="ranking: kogo przejrzeć")
ap.add_argument("--min-scenes", type=int, default=60)
ap.add_argument("--limit", type=int, default=25)
ap.add_argument("--detach-file", help="plik z prefiksami tytułów do odpięcia")
ap.add_argument("--yes", action="store_true", help="wykonaj odpięcie (bez tego dry-run)")
ap.add_argument(
"--include-canonical",
action="store_true",
help="pozwól odpiąć też przypisania z tpdb/stashdb (domyślnie chronione)",
)
args = ap.parse_args()
if args.candidates:
with session_scope() as s:
rows = s.execute(
text(_CANDIDATES_SQL),
{"min_scenes": args.min_scenes, "limit": args.limit},
).fetchall()
print("nazwa sceny obce wskaźnik")
for nazwa, scen, obce in rows:
print(f" {nazwa[:20]:22} {scen:5} {obce:7} {obce/scen:7.1f}")
print("\nWskaźnik = obce sceny ze słowem w tytule / przypisania tej osoby.")
print("Wysoki = nazwa jest zwykłym słowem; NIE znaczy, że osoba jest fikcyjna.")
return
if not args.performer:
print("podaj canonical_name performera albo --candidates")
sys.exit(1)
prefixes: list[str] = []
if args.detach_file:
with open(args.detach_file, encoding="utf-8") as fh:
prefixes = [
ln.strip().lower()
for ln in fh
if ln.strip() and not ln.lstrip().startswith("#")
]
with session_scope() as s:
perf = _find_performer(s, args.performer)
if perf is None:
print(f"nie znalazłem performera: {args.performer!r}")
sys.exit(1)
pid, name = perf[0], perf[1]
rows = s.execute(text(_LIST_SQL), {"pid": pid}).fetchall()
obce = int(s.execute(text(_FOREIGN_SQL), {"name": name, "pid": pid}).scalar_one())
wsk = obce / len(rows) if rows else 0.0
print(f"{name}{len(rows)} przypisań, obcych scen ze słowem: {obce}, "
f"wskaźnik pospolitości: {wsk:.1f}")
print("(wysoki = nazwa jest zwykłym słowem, przypisania trzeba przeczytać)\n")
hit_ids: list[str] = []
for title, dur, zrodlo, ilu, sid in rows:
t = (title or "").lower()
matched = any(t.startswith(p) for p in prefixes)
protected = matched and zrodlo in ("tpdb", "stashdb") and not args.include_canonical
if matched and not protected:
hit_ids.append(sid)
mark = "ODPNIJ" if (matched and not protected) else ("CHRONIONE" if protected else " ")
w_tytule = "T" if name.lower() in t else "-"
print(f" {mark:10} [{zrodlo:8}] nazwa-w-tytule={w_tytule} perf={ilu} "
f"{dur or '?'}s {(title or '')[:58]!r}")
if not prefixes:
print("\n(sam przegląd — podaj --detach-file, żeby coś odpiąć)")
return
print(f"\ndo odpięcia: {len(hit_ids)} / {len(rows)}")
if not args.yes:
print("(dry-run — uruchom z --yes)")
return
n = s.execute(
text("DELETE FROM scene_performers WHERE performer_id = :pid AND scene_id = ANY(:ids)"),
{"pid": pid, "ids": hit_ids},
).rowcount
s.commit()
print(f"ODPIĘTE: {n}")
if __name__ == "__main__":
main()

View file

@ -41,16 +41,43 @@ META_MARKERS = [
] ]
# Slug w feedzie to slug RECENZJI ("youperv-website-review"), nie domena. Bez odcięcia
# sufiksu fallback budował "youperv-website-review.com" → conn_refused → wpis lądował
# jako "dead/skip". Tak wypadło 15 ze 144 kandydatów, w tym youperv i pornbusy, które
# potem niezależnie oceniliśmy na 4.5-5/5 i dodaliśmy. Fix 2026-07-27.
_REVIEW_SUFFIX_RE = re.compile(r"-(?:website-)?review$", re.IGNORECASE)
def _slug_to_domain(slug: str) -> str:
return _REVIEW_SUFFIX_RE.sub("", slug.lower().strip())
async def fetch_one(cli: httpx.AsyncClient, url: str) -> tuple[int, str]: async def fetch_one(cli: httpx.AsyncClient, url: str) -> tuple[int, str]:
"""Fetch z Chrome TLS (curl_cffi) — NIE gołym httpx.
FIX 2026-07-27: pierwotna wersja szła czystym httpx, którego JA3 fingerprint
większość tubów za Cloudflare odrzuca. Efekt: 8 timeoutów + 4× 403 w wynikach,
czytane potem jako "dead/skip", choć strony działają w przeglądarce. `browser_get`
(curl_cffi, chrome120) przechodzi to ta sama ścieżka, której używają nasze
scrapery, więc triage mierzy wreszcie to samo co produkcja.
"""
import asyncio as _asyncio
from app.extractors._fetch import browser_get
def _sync():
return browser_get(url, timeout=20.0, headers={"User-Agent": UA})
try: try:
r = await cli.get(url, headers={"User-Agent": UA}, follow_redirects=True) r = await _asyncio.to_thread(_sync)
return r.status_code, r.text[:200_000] # cap response return r.status_code, (r.text or "")[:200_000]
except httpx.ConnectError:
return -1, "conn_refused"
except httpx.TimeoutException:
return -2, "timeout"
except Exception as e: except Exception as e:
return -9, str(e)[:120] name = type(e).__name__
if "Timeout" in name:
return -2, "timeout"
if "Connect" in name or "Resolve" in name or "DNS" in name:
return -1, "conn_refused"
return -9, f"{name}: {e}"[:120]
def analyze_html(html: str) -> dict: def analyze_html(html: str) -> dict:
@ -152,7 +179,7 @@ async def main():
domain = r.get("domain") or "" domain = r.get("domain") or ""
# Jeśli pdude.link daje porndudecams.com (interstitial), użyj <slug>.com # Jeśli pdude.link daje porndudecams.com (interstitial), użyj <slug>.com
if not domain or "porndudecams" in domain: if not domain or "porndudecams" in domain:
domain = f"{r['slug'].lower()}.com" domain = f"{_slug_to_domain(r['slug'])}.com"
return {**r, **(await audit_one(cli, r["slug"], domain))} return {**r, **(await audit_one(cli, r["slug"], domain))}
results = await asyncio.gather(*[worker(r) for r in new_candidates]) results = await asyncio.gather(*[worker(r) for r in new_candidates])

File diff suppressed because it is too large Load diff