Compare commits
19 commits
55da508f9c
...
90a63b1e77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a63b1e77 | ||
|
|
9af5fd4eb4 | ||
|
|
ef8c255e1f | ||
|
|
470d70c11e | ||
|
|
8d92ecc51b | ||
|
|
034ddd644c | ||
|
|
7ad94448a4 | ||
|
|
02ff9c7ff1 | ||
|
|
121e6aa3f5 | ||
|
|
3de8489ffa | ||
|
|
b485511f3f | ||
|
|
ea489454c0 | ||
|
|
f5b7a45ac0 | ||
|
|
b3e1092175 | ||
|
|
2ef54ad6ea | ||
|
|
d7442187c9 | ||
|
|
d716b0c75d | ||
|
|
2bf9179467 | ||
|
|
5a0b62c3e4 |
50 changed files with 4143 additions and 394 deletions
39
alembic/versions/20260727_0025_phash_blacklist.py
Normal file
39
alembic/versions/20260727_0025_phash_blacklist.py
Normal 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")
|
||||
49
alembic/versions/20260727_0026_scene_performer_source.py
Normal file
49
alembic/versions/20260727_0026_scene_performer_source.py
Normal 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")
|
||||
46
alembic/versions/20260727_0027_scenes_title_trgm.py
Normal file
46
alembic/versions/20260727_0027_scenes_title_trgm.py
Normal 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 tę 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")
|
||||
|
|
@ -27,7 +27,7 @@ from typing import Annotated
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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}
|
||||
|
||||
|
||||
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):
|
||||
performer_id: uuid.UUID
|
||||
canonical_name: str
|
||||
|
|
@ -162,7 +219,7 @@ def list_favorites(
|
|||
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
|
||||
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
||||
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
||||
new_counts = _new_counts(session, device_id, kind="performer")
|
||||
new_counts = _merged_new_counts(session, device_id, kind="performer")
|
||||
|
||||
items: list[FavoriteOut] = []
|
||||
new_total = 0
|
||||
|
|
@ -277,7 +334,7 @@ def list_favorite_studios(
|
|||
|
||||
# scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
|
||||
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] = []
|
||||
new_total = 0
|
||||
|
|
|
|||
|
|
@ -476,9 +476,13 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
|||
mobile_direct_ok = True
|
||||
refetch_url = (link.raw or {}).get("refetch_url")
|
||||
refetch_hoster = (link.raw or {}).get("refetch_hoster")
|
||||
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL — `/proxy/hls`
|
||||
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
|
||||
manifest_producer = (link.raw or {}).get("manifest_producer")
|
||||
token = make_token(
|
||||
raw_url, referer, impersonate=use_impersonate,
|
||||
refresh=refetch_url, refresh_hoster=refetch_hoster,
|
||||
producer=manifest_producer,
|
||||
)
|
||||
# Decyzja na BASIE link.type (zaufanie do extractora), z fallback path-hint.
|
||||
# Pornhat: raw URL `.../get_file/.../<id>.mp4/` ale CDN 302 → HLS manifest.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ oglądać dłuższe sceny + pause/seek bez ryzyka expired token.
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
|
@ -163,6 +164,7 @@ def make_token(
|
|||
refresh_hoster: str | None = None,
|
||||
impersonate: bool = False,
|
||||
stable_bucket_sec: int | None = None,
|
||||
producer: str | None = None,
|
||||
) -> str:
|
||||
"""Build proxy token.
|
||||
|
||||
|
|
@ -187,6 +189,10 @@ def make_token(
|
|||
payload["rh"] = refresh_hoster
|
||||
if impersonate:
|
||||
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")
|
||||
body = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
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
|
||||
|
||||
|
||||
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}")
|
||||
async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
||||
"""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)
|
||||
target = payload["u"]
|
||||
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)
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@ class Settings(BaseSettings):
|
|||
sched_title_dedup_hours: int = Field(
|
||||
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
|
||||
)
|
||||
# Blacklista zdegenerowanych phashy — wartości dzielonych przez ≥10 scen (zaślepka,
|
||||
# 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
|
||||
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
|
||||
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
|
||||
|
|
|
|||
|
|
@ -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.sxyland import SxyLandScraper
|
||||
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.youperv import YoupervScraper
|
||||
from app.connectors.direct_scrapers.xhamster import XHamsterScraper
|
||||
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
|
||||
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
|
||||
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
|
||||
WatchPornScraper,
|
||||
# YoupervScraper — dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
|
||||
# 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):
|
||||
# search zostaje (pokrycie back-catalogu performerów), browse gwarantuje świeżość
|
||||
# wprost z feedu (watchdog 48h zamiast 168h). Konwersja 2026-06-24 (user request).
|
||||
|
|
|
|||
167
app/connectors/direct_scrapers/fullporner.py
Normal file
167
app/connectors/direct_scrapers/fullporner.py
Normal 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 są cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest
|
||||
mediana 3. Tytuły są w 100% przepisane pod SEO, więc nie niosą nic z tytułu
|
||||
studyjnego („POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked
|
||||
– Dylan Moore – Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
|
||||
(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,
|
||||
)
|
||||
],
|
||||
)
|
||||
204
app/connectors/direct_scrapers/fullvideosporn.py
Normal file
204
app/connectors/direct_scrapers/fullvideosporn.py
Normal 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,
|
||||
)
|
||||
],
|
||||
)
|
||||
228
app/connectors/direct_scrapers/galaxyporn.py
Normal file
228
app/connectors/direct_scrapers/galaxyporn.py
Normal 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
|
||||
są dokładnie te 2-3 linki `/actor/`, nie ma sekcji Related z obcymi performerami)
|
||||
- studio + data: prefiks tytułu `Studio YY MM DD Performer – Title` albo `[Studio]`
|
||||
- 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 ją z `thumbnail.vtt` playera (ostatni cue), 1 dodatkowy request na
|
||||
scenę po odpytaniu API seekplayer. Wartość jest ~1 interwał miniatur krótsza od
|
||||
realnej (2226 s vs ~2276 s na próbce, ~2%) — akceptowalne, bo alternatywą jest brak.
|
||||
|
||||
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,
|
||||
)
|
||||
],
|
||||
)
|
||||
187
app/connectors/direct_scrapers/kporner8.py
Normal file
187
app/connectors/direct_scrapers/kporner8.py
Normal 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 są realnymi studiami.
|
||||
Bierzemy więc pierwszą kategorię obecną na tej liście. Kilkanaście pozycji z listy
|
||||
to jednak zwykłe słowa („Babes", „Premium", „Swallowed") i te pomijamy, bo inaczej
|
||||
powtórzylibyśmy błąd z sxyland, gdzie gatunek wylądował jako studio.
|
||||
Trafień jest mało (1 na 62 w pilocie) — większość scen po prostu nie ma pilla studia,
|
||||
ale kosztuje to jeden fetch na run, więc zostaje.
|
||||
|
||||
Miniaturki są re-hostowane i zaciemnione base64, więc phash raczej NIE trafi w
|
||||
canonical — dopasowanie musi opierać się na tytule, obsadzie i długości.
|
||||
"""
|
||||
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
|
||||
238
app/connectors/direct_scrapers/pornbusy.py
Normal file
238
app/connectors/direct_scrapers/pornbusy.py
Normal 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,
|
||||
)
|
||||
],
|
||||
)
|
||||
186
app/connectors/direct_scrapers/pornmike.py
Normal file
186
app/connectors/direct_scrapers/pornmike.py
Normal 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,
|
||||
)
|
||||
],
|
||||
)
|
||||
188
app/connectors/direct_scrapers/sexu.py
Normal file
188
app/connectors/direct_scrapers/sexu.py
Normal 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 ją wstecz od teraz.
|
||||
To i tak data uploadu na tube, nie premiery studia.
|
||||
|
||||
Playback: `POST /api/video-info` (extractor `sexu`) — wymaga Bright Data, bo
|
||||
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,
|
||||
)
|
||||
],
|
||||
)
|
||||
173
app/connectors/direct_scrapers/youperv.py
Normal file
173
app/connectors/direct_scrapers/youperv.py
Normal 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,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
@ -30,14 +30,21 @@ from app.extractors.tubes import (
|
|||
eporner,
|
||||
freshporno,
|
||||
fullmovies,
|
||||
fullvideosporn,
|
||||
galaxyporn,
|
||||
hdporngg,
|
||||
hqfap,
|
||||
hqporner,
|
||||
fullporner,
|
||||
kporner8,
|
||||
sexu,
|
||||
javflix,
|
||||
neporn,
|
||||
latestpornvideo,
|
||||
paradisehill,
|
||||
porn00,
|
||||
pornbusy,
|
||||
pornmike,
|
||||
porntrex,
|
||||
supjav,
|
||||
sxyprn,
|
||||
|
|
@ -45,6 +52,7 @@ from app.extractors.tubes import (
|
|||
watchporn,
|
||||
xhamster,
|
||||
yespornvip,
|
||||
youperv,
|
||||
)
|
||||
|
||||
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ął).
|
||||
# flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound.
|
||||
"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,
|
||||
"porn4dayspw": _embed_iframe.extract,
|
||||
"porndishcom": _embed_iframe.extract,
|
||||
|
|
|
|||
128
app/extractors/hosters/loadvid.py
Normal file
128
app/extractors/hosters/loadvid.py
Normal 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 są
|
||||
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",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
|
@ -54,13 +54,23 @@ log = logging.getLogger(__name__)
|
|||
_KEY = b"kiemtienmua911ca"
|
||||
_IV = b"1234567890oiuytr"
|
||||
|
||||
# Hostname matching: 6 base hosts × subdomains × TLD variants.
|
||||
# Hostname matching: base hosts × subdomains × TLD variants.
|
||||
# Examples:
|
||||
# my.embedseek.online, vip.seekplayer.vip, my.rpmplay.online,
|
||||
# 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(
|
||||
r"^(?:[a-z0-9]+\.)?(?:embedseek|seekplayer|rpmplay|upns|player4me|easyvidplayer)\."
|
||||
r"(?:online|vip|com|net|io|me|tv)$",
|
||||
r"^(?:[a-z0-9]+\.)?"
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
|
|||
39
app/extractors/tubes/_txxx.py
Normal file
39
app/extractors/tubes/_txxx.py
Normal 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
|
||||
77
app/extractors/tubes/fullporner.py
Normal file
77
app/extractors/tubes/fullporner.py
Normal 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) { 1→360, 2→480, 4→720, 8→1080 } // /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 ją
|
||||
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
|
||||
144
app/extractors/tubes/fullvideosporn.py
Normal file
144
app/extractors/tubes/fullvideosporn.py
Normal 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
|
||||
69
app/extractors/tubes/galaxyporn.py
Normal file
69
app/extractors/tubes/galaxyporn.py
Normal 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
|
||||
80
app/extractors/tubes/kporner8.py
Normal file
80
app/extractors/tubes/kporner8.py
Normal 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("&", "&")
|
||||
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
|
||||
82
app/extractors/tubes/pornbusy.py
Normal file
82
app/extractors/tubes/pornbusy.py
Normal 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
|
||||
55
app/extractors/tubes/pornmike.py
Normal file
55
app/extractors/tubes/pornmike.py
Normal 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
|
||||
95
app/extractors/tubes/sexu.py
Normal file
95
app/extractors/tubes/sexu.py
Normal 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 są 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},
|
||||
)
|
||||
]
|
||||
|
|
@ -27,43 +27,23 @@ direct z telefonu; patrz stream_proxy.proxy_hls_manifest). Media id w get_file (
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.extractors import browser_get
|
||||
from app.extractors._models import StreamSource
|
||||
from app.extractors.tubes import _txxx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://vjav.com"
|
||||
_VIDEO_ID_RE = re.compile(r"/videos/(\d+)/")
|
||||
|
||||
# KVS video_url: base64 z cyrylica-homoglifami zamiast łacińskich liter (wielkie +
|
||||
# część małych). Odkręcamy je z powrotem na łacinę przed b64decode.
|
||||
_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:
|
||||
# (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
|
||||
# Dekoder video_url wyniesiony do `_txxx.py` — ten sam silnik TXXX obsługuje też
|
||||
# fullvideosporn/sextu, więc trzymamy jedną implementację. Alias zachowany dla
|
||||
# czytelności modułu i ewentualnych importów.
|
||||
_decode_video_url = _txxx.decode_video_url
|
||||
|
||||
|
||||
def _quality_label(fmt: str | None) -> str | None:
|
||||
|
|
|
|||
52
app/extractors/tubes/youperv.py
Normal file
52
app/extractors/tubes/youperv.py
Normal 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},
|
||||
)
|
||||
]
|
||||
|
|
@ -20,6 +20,7 @@ from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
|
|||
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
|
||||
from app.models.playback_event import PlaybackEvent
|
||||
from app.models.playback_source import PlaybackSource
|
||||
from app.models.phash_blacklist import PhashBlacklist
|
||||
from app.models.saved_search import SavedSearch
|
||||
from app.models.source_stats import SourceStats
|
||||
from app.models.scene import (
|
||||
|
|
@ -58,6 +59,7 @@ __all__ = [
|
|||
"MoviePlayProgress",
|
||||
"ScenePlayProgress",
|
||||
"PlaybackSource",
|
||||
"PhashBlacklist",
|
||||
"SavedSearch",
|
||||
"Scene",
|
||||
"SceneExternalRef",
|
||||
|
|
|
|||
32
app/models/phash_blacklist.py
Normal file
32
app/models/phash_blacklist.py
Normal 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 tę 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
|
||||
)
|
||||
|
|
@ -102,6 +102,16 @@ class ScenePerformer(Base):
|
|||
role: Mapped[str | None] = mapped_column(String(64))
|
||||
position: Mapped[int | None] = mapped_column(Integer)
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -95,13 +95,23 @@ def find_by_phash_within(
|
|||
if max_hamming is None:
|
||||
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:
|
||||
row = session.execute(
|
||||
text(
|
||||
"SELECT scene_id, "
|
||||
"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 "
|
||||
"AND NOT EXISTS (SELECT 1 FROM phash_blacklist b WHERE b.value = f.value) "
|
||||
"ORDER BY dist ASC LIMIT 1"
|
||||
),
|
||||
{"phash": phash},
|
||||
|
|
|
|||
|
|
@ -29,10 +29,18 @@ from app.models.scene import (
|
|||
ScenePerformer,
|
||||
SceneTag,
|
||||
)
|
||||
from app.models.source import Source, SourceKind
|
||||
|
||||
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):
|
||||
pass
|
||||
|
||||
|
|
@ -145,6 +153,17 @@ def _move_performers(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID
|
|||
if clash is not None:
|
||||
if link.as_alias and not clash.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)
|
||||
else:
|
||||
link.scene_id = keep_id
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ def resolve_scene(
|
|||
if decision == "auto":
|
||||
_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)
|
||||
_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_fingerprints(
|
||||
session, scene_id=best_scene.id, norm=norm, source_id=source_id
|
||||
|
|
@ -349,7 +349,7 @@ def resolve_scene(
|
|||
if decision == "review":
|
||||
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)
|
||||
_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_fingerprints(
|
||||
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
|
||||
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)
|
||||
_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_fingerprints(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
|
||||
_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:
|
||||
performer = resolve_performer(session, norm=p_norm, source_id=source_id)
|
||||
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_fingerprints(session, scene_id=scene.id, norm=norm, source_id=source_id)
|
||||
_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(
|
||||
session: Session,
|
||||
*,
|
||||
scene_id: uuid.UUID,
|
||||
resolved: list[tuple[uuid.UUID, str | None]],
|
||||
source_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
# 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
|
||||
|
|
@ -581,10 +589,21 @@ def _sync_performers(
|
|||
performer_id=performer_id,
|
||||
position=position,
|
||||
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
|
||||
# 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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
_PAGE_CAP: dict[str, int] = {
|
||||
"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.
|
||||
|
|
|
|||
|
|
@ -276,6 +276,37 @@ def _job_thumb_asset_dedup() -> None:
|
|||
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:
|
||||
"""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
|
||||
|
|
@ -471,6 +502,28 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
|
|||
)
|
||||
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"):
|
||||
sched.add_job(
|
||||
_job_title_duration_dedup,
|
||||
|
|
|
|||
232
app/scheduler/junk_performers.py
Normal file
232
app/scheduler/junk_performers.py
Normal 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 są
|
||||
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 są 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 są 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}
|
||||
65
app/scheduler/phash_blacklist.py
Normal file
65
app/scheduler/phash_blacklist.py
Normal 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}
|
||||
|
|
@ -216,6 +216,12 @@ def run_forever() -> int:
|
|||
"thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None,
|
||||
# Title+duration dedup — missing-merge tube-dupy (reports 28fe8181/32df33b1).
|
||||
"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_counts_hours": getattr(settings, "sched_taxonomy_counts_hours", 3) or None,
|
||||
# Ingest freshness watchdog — per-origin alert do Sentry (report 14f3a655).
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
|
|||
|
||||
import {
|
||||
DefaultQuality,
|
||||
getAutoRotate,
|
||||
getDefaultQuality,
|
||||
getGridColumns,
|
||||
setAutoRotate as persistAutoRotate,
|
||||
setDefaultQuality as persistDefaultQuality,
|
||||
setGridColumns as persistGridColumns,
|
||||
} from './storage';
|
||||
|
|
@ -17,6 +19,8 @@ interface Prefs {
|
|||
setGridColumns: (n: number) => void;
|
||||
defaultQuality: DefaultQuality;
|
||||
setDefaultQuality: (q: DefaultQuality) => void;
|
||||
autoRotate: boolean;
|
||||
setAutoRotate: (on: boolean) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<Prefs | null>(null);
|
||||
|
|
@ -24,6 +28,7 @@ const Ctx = createContext<Prefs | null>(null);
|
|||
export function PreferencesProvider({ children }: { children: React.ReactNode }) {
|
||||
const [gridColumns, setCols] = useState(2);
|
||||
const [defaultQuality, setQuality] = useState<DefaultQuality>('auto');
|
||||
const [autoRotate, setRotate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getGridColumns()
|
||||
|
|
@ -32,6 +37,9 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
|
|||
getDefaultQuality()
|
||||
.then(setQuality)
|
||||
.catch(() => {});
|
||||
getAutoRotate()
|
||||
.then(setRotate)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const setGridColumns = (n: number) => {
|
||||
|
|
@ -44,8 +52,22 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
|
|||
persistDefaultQuality(q).catch(() => {});
|
||||
};
|
||||
|
||||
const setAutoRotate = (on: boolean) => {
|
||||
setRotate(on);
|
||||
persistAutoRotate(on).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ gridColumns, setGridColumns, defaultQuality, setDefaultQuality }}>
|
||||
<Ctx.Provider
|
||||
value={{
|
||||
gridColumns,
|
||||
setGridColumns,
|
||||
defaultQuality,
|
||||
setDefaultQuality,
|
||||
autoRotate,
|
||||
setAutoRotate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ export type 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',
|
||||
date: 'July 2026',
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export function AppLockSettingsScreen() {
|
|||
const [stage, setStage] = useState<Stage>('menu');
|
||||
const [newPin, setNewPin] = useState('');
|
||||
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 client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -376,6 +376,24 @@ export function AppLockSettingsScreen() {
|
|||
</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}>
|
||||
<Text style={styles.sectionTitle}>Backup & sync</Text>
|
||||
<Text style={styles.hint}>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
|
|||
import * as Clipboard from 'expo-clipboard';
|
||||
import { setLastPlayback } from '../lib/lastPlayback';
|
||||
import { allowScreenOff, keepScreenOn } from '../lib/keepAwake';
|
||||
import { usePreferences } from '../PreferencesContext';
|
||||
import React from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
|
|
@ -135,12 +136,20 @@ export function PlayerScreen() {
|
|||
const params = route.params as RouteParams;
|
||||
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(() => {
|
||||
ScreenOrientation.unlockAsync().catch(() => {});
|
||||
if (autoRotate) ScreenOrientation.unlockAsync().catch(() => {});
|
||||
else {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
||||
}
|
||||
return () => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
}, [autoRotate]);
|
||||
|
||||
if (mode === 'webview') return <EmbedWebViewPlayer params={params} />;
|
||||
return <NativeVideoPlayer params={params} />;
|
||||
|
|
|
|||
|
|
@ -104,6 +104,23 @@ export async function setDefaultQuality(v: DefaultQuality): Promise<void> {
|
|||
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 {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
|
|
|
|||
88
scripts/backfill_scene_performer_source.py
Normal file
88
scripts/backfill_scene_performer_source.py
Normal 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 tę 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 tę obsadę potwierdził kanon, czy dokleił ją
|
||||
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()
|
||||
|
|
@ -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
|
||||
(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:
|
||||
python -m scripts.merge_phash_exact_dupes # dry-run
|
||||
python -m scripts.merge_phash_exact_dupes --yes # wykonaj
|
||||
|
|
@ -22,6 +34,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
|
||||
from rapidfuzz import fuzz
|
||||
from sqlalchemy import text
|
||||
|
||||
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.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 = """
|
||||
SELECT DISTINCT a.scene_id AS sa, b.scene_id AS sb
|
||||
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 scb ON scb.id = b.scene_id
|
||||
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 abs(sca.duration_sec - scb.duration_sec) <= 3
|
||||
AND (
|
||||
|
|
@ -58,7 +81,7 @@ def main() -> None:
|
|||
pairs = [(r[0], r[1]) for r in s.execute(text(_SAFE_PAIRS_SQL))]
|
||||
print(f"safe high-confidence duplicate pairs: {len(pairs)}")
|
||||
|
||||
merged = skipped = 0
|
||||
merged = skipped = rejected = 0
|
||||
for sa, sb in pairs:
|
||||
with session_scope() as s:
|
||||
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:
|
||||
skipped += 1 # już zmergowane w tym klastrze
|
||||
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)
|
||||
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]} "
|
||||
|
|
@ -76,9 +107,11 @@ def main() -> None:
|
|||
merged += 1
|
||||
|
||||
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:
|
||||
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__":
|
||||
|
|
|
|||
201
scripts/review_performer_attributions.py
Normal file
201
scripts/review_performer_attributions.py
Normal 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 tę 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 są
|
||||
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()
|
||||
|
|
@ -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]:
|
||||
"""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:
|
||||
r = await cli.get(url, headers={"User-Agent": UA}, follow_redirects=True)
|
||||
return r.status_code, r.text[:200_000] # cap response
|
||||
except httpx.ConnectError:
|
||||
return -1, "conn_refused"
|
||||
except httpx.TimeoutException:
|
||||
return -2, "timeout"
|
||||
r = await _asyncio.to_thread(_sync)
|
||||
return r.status_code, (r.text or "")[:200_000]
|
||||
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:
|
||||
|
|
@ -152,7 +179,7 @@ async def main():
|
|||
domain = r.get("domain") or ""
|
||||
# Jeśli pdude.link daje porndudecams.com (interstitial), użyj <slug>.com
|
||||
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))}
|
||||
|
||||
results = await asyncio.gather(*[worker(r) for r in new_candidates])
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue