Compare commits
No commits in common. "90a63b1e77489aa6fb66d2da6796a642fb473a49" and "55da508f9ca74f890d817578e6bd7122adc66abf" have entirely different histories.
90a63b1e77
...
55da508f9c
50 changed files with 394 additions and 4143 deletions
|
|
@ -1,39 +0,0 @@
|
||||||
"""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")
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
"""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")
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""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 fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import exists, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.device import get_device_id
|
from app.api.device import get_device_id
|
||||||
|
|
@ -128,63 +128,6 @@ def _new_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
||||||
return {gid_val: int(n) for gid_val, n in rows}
|
return {gid_val: int(n) for gid_val, n in rows}
|
||||||
|
|
||||||
|
|
||||||
def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
|
||||||
"""To samo co `_new_counts`, ale dla FILMÓW (user-request 2026-07-28: "+N" ma się
|
|
||||||
pojawiać też gdy ulubiony performer/studio dostanie nowy film, nie tylko scenę).
|
|
||||||
|
|
||||||
Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest
|
|
||||||
o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna
|
|
||||||
top-N ani odsiewu stub/backfill — te pojęcia dotyczą scen.
|
|
||||||
"""
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
from app.models.movie import Movie, MoviePerformer
|
|
||||||
from app.models.movie_playback_source import MoviePlaybackSource
|
|
||||||
|
|
||||||
live_movie = exists(
|
|
||||||
select(1).where(
|
|
||||||
MoviePlaybackSource.movie_id == Movie.id,
|
|
||||||
MoviePlaybackSource.dead_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if kind == "performer":
|
|
||||||
base = (
|
|
||||||
select(
|
|
||||||
MoviePerformer.performer_id.label("gid"),
|
|
||||||
func.count()
|
|
||||||
.filter(Movie.created_at > FavoritePerformer.last_seen_at)
|
|
||||||
.label("n"),
|
|
||||||
)
|
|
||||||
.select_from(FavoritePerformer)
|
|
||||||
.join(MoviePerformer, MoviePerformer.performer_id == FavoritePerformer.performer_id)
|
|
||||||
.join(Movie, Movie.id == MoviePerformer.movie_id)
|
|
||||||
.where(FavoritePerformer.device_id == device_id, live_movie)
|
|
||||||
.group_by(MoviePerformer.performer_id)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
base = (
|
|
||||||
select(
|
|
||||||
Movie.studio_id.label("gid"),
|
|
||||||
func.count().filter(Movie.created_at > FavoriteStudio.last_seen_at).label("n"),
|
|
||||||
)
|
|
||||||
.select_from(FavoriteStudio)
|
|
||||||
.join(Movie, Movie.studio_id == FavoriteStudio.studio_id)
|
|
||||||
.where(FavoriteStudio.device_id == device_id, live_movie)
|
|
||||||
.group_by(Movie.studio_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {gid: int(n) for gid, n in session.execute(base).all() if n}
|
|
||||||
|
|
||||||
|
|
||||||
def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
|
|
||||||
"""Sceny + filmy w jednym liczniku "+N" (badge nie rozróżnia typu treści)."""
|
|
||||||
counts = _new_counts(session, device_id, kind=kind)
|
|
||||||
for gid, n in _new_movie_counts(session, device_id, kind=kind).items():
|
|
||||||
counts[gid] = counts.get(gid, 0) + n
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
class FavoriteOut(BaseModel):
|
class FavoriteOut(BaseModel):
|
||||||
performer_id: uuid.UUID
|
performer_id: uuid.UUID
|
||||||
canonical_name: str
|
canonical_name: str
|
||||||
|
|
@ -219,7 +162,7 @@ def list_favorites(
|
||||||
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
|
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
|
||||||
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
|
||||||
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
|
||||||
new_counts = _merged_new_counts(session, device_id, kind="performer")
|
new_counts = _new_counts(session, device_id, kind="performer")
|
||||||
|
|
||||||
items: list[FavoriteOut] = []
|
items: list[FavoriteOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
|
|
@ -334,7 +277,7 @@ def list_favorite_studios(
|
||||||
|
|
||||||
# scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
|
# scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
|
||||||
scene_counts: dict = {st.id: st.scene_count for _, st in rows}
|
scene_counts: dict = {st.id: st.scene_count for _, st in rows}
|
||||||
new_counts = _merged_new_counts(session, device_id, kind="studio")
|
new_counts = _new_counts(session, device_id, kind="studio")
|
||||||
|
|
||||||
items: list[FavoriteStudioOut] = []
|
items: list[FavoriteStudioOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
|
|
|
||||||
|
|
@ -476,13 +476,9 @@ def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
|
||||||
mobile_direct_ok = True
|
mobile_direct_ok = True
|
||||||
refetch_url = (link.raw or {}).get("refetch_url")
|
refetch_url = (link.raw or {}).get("refetch_url")
|
||||||
refetch_hoster = (link.raw or {}).get("refetch_hoster")
|
refetch_hoster = (link.raw or {}).get("refetch_hoster")
|
||||||
# manifest_producer: hoster oddaje TREŚĆ manifestu (POST), nie URL — `/proxy/hls`
|
|
||||||
# zawoła producenta zamiast GET-a. Patrz extractors/hosters/loadvid.py.
|
|
||||||
manifest_producer = (link.raw or {}).get("manifest_producer")
|
|
||||||
token = make_token(
|
token = make_token(
|
||||||
raw_url, referer, impersonate=use_impersonate,
|
raw_url, referer, impersonate=use_impersonate,
|
||||||
refresh=refetch_url, refresh_hoster=refetch_hoster,
|
refresh=refetch_url, refresh_hoster=refetch_hoster,
|
||||||
producer=manifest_producer,
|
|
||||||
)
|
)
|
||||||
# Decyzja na BASIE link.type (zaufanie do extractora), z fallback path-hint.
|
# Decyzja na BASIE link.type (zaufanie do extractora), z fallback path-hint.
|
||||||
# Pornhat: raw URL `.../get_file/.../<id>.mp4/` ale CDN 302 → HLS manifest.
|
# Pornhat: raw URL `.../get_file/.../<id>.mp4/` ale CDN 302 → HLS manifest.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ oglądać dłuższe sceny + pause/seek bez ryzyka expired token.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import functools
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
|
@ -164,7 +163,6 @@ def make_token(
|
||||||
refresh_hoster: str | None = None,
|
refresh_hoster: str | None = None,
|
||||||
impersonate: bool = False,
|
impersonate: bool = False,
|
||||||
stable_bucket_sec: int | None = None,
|
stable_bucket_sec: int | None = None,
|
||||||
producer: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build proxy token.
|
"""Build proxy token.
|
||||||
|
|
||||||
|
|
@ -189,10 +187,6 @@ def make_token(
|
||||||
payload["rh"] = refresh_hoster
|
payload["rh"] = refresh_hoster
|
||||||
if impersonate:
|
if impersonate:
|
||||||
payload["i"] = 1
|
payload["i"] = 1
|
||||||
if producer:
|
|
||||||
# Hoster oddający TREŚĆ manifestu (nie URL) — `/proxy/hls` zawoła producenta
|
|
||||||
# zamiast GET-a. Patrz app/extractors/hosters/loadvid.py.
|
|
||||||
payload["mp"] = producer
|
|
||||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||||
body = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
body = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||||
sig = base64.urlsafe_b64encode(
|
sig = base64.urlsafe_b64encode(
|
||||||
|
|
@ -592,24 +586,6 @@ async def _curl_cffi_stream(
|
||||||
raise HTTPException(status_code=502, detail=f"proxy error: {e}") from e
|
raise HTTPException(status_code=502, detail=f"proxy error: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
async def _produce_manifest(producer: str, embed_url: str) -> str | None:
|
|
||||||
"""Zawołaj hoster-producenta manifestu w threadpoolu (curl_cffi jest sync)."""
|
|
||||||
import anyio
|
|
||||||
|
|
||||||
if producer == "loadvid":
|
|
||||||
from app.extractors.hosters import loadvid
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await anyio.to_thread.run_sync(
|
|
||||||
functools.partial(loadvid.fetch_manifest, embed_url, timeout=25.0)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.info("proxy-hls producer loadvid failed %s: %s", embed_url, e)
|
|
||||||
return None
|
|
||||||
log.warning("proxy-hls: unknown manifest producer %r", producer)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hls/{token}/{_basename:path}")
|
@router.get("/hls/{token}/{_basename:path}")
|
||||||
async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
||||||
"""Direct-HLS manifest passthrough dla time-bound (mobile_direct_ok) m3u8 hosterów.
|
"""Direct-HLS manifest passthrough dla time-bound (mobile_direct_ok) m3u8 hosterów.
|
||||||
|
|
@ -629,20 +605,6 @@ async def proxy_hls_manifest(token: str, _basename: str) -> Response:
|
||||||
payload = parse_token(token)
|
payload = parse_token(token)
|
||||||
target = payload["u"]
|
target = payload["u"]
|
||||||
referer = payload.get("r") or None
|
referer = payload.get("r") or None
|
||||||
|
|
||||||
# Hoster-producent: manifest nie ma własnego URL-a (loadvid oddaje jego TREŚĆ
|
|
||||||
# dopiero na POST /videos/resolve-token), więc zamiast GET-a wołamy producenta.
|
|
||||||
producer = payload.get("mp")
|
|
||||||
if producer:
|
|
||||||
manifest = await _produce_manifest(producer, target)
|
|
||||||
if manifest is None:
|
|
||||||
raise HTTPException(status_code=502, detail="manifest producer failed")
|
|
||||||
return Response(
|
|
||||||
content=_absolutize_m3u8(manifest, base_url=target),
|
|
||||||
media_type="application/vnd.apple.mpegurl",
|
|
||||||
headers={"Cache-Control": "no-store"},
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = _build_headers(referer)
|
headers = _build_headers(referer)
|
||||||
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
|
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -116,19 +116,6 @@ class Settings(BaseSettings):
|
||||||
sched_title_dedup_hours: int = Field(
|
sched_title_dedup_hours: int = Field(
|
||||||
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
|
default=12, validation_alias="GOON_SCHED_TITLE_DEDUP_HOURS"
|
||||||
)
|
)
|
||||||
# Blacklista zdegenerowanych phashy — wartości dzielonych przez ≥10 scen (zaślepka,
|
|
||||||
# czarna klatka, intro studia). Audyt 2026-07-27: 73 takie wartości przy 4375
|
|
||||||
# scenach, rekordzistka przy 892 scenach o 892 różnych tytułach. Tube może zacząć
|
|
||||||
# serwować zaślepkę w dowolnym momencie, stąd cyklicznie. 24h. 0 = off.
|
|
||||||
sched_phash_blacklist_hours: int = Field(
|
|
||||||
default=24, validation_alias="GOON_SCHED_PHASH_BLACKLIST_HOURS"
|
|
||||||
)
|
|
||||||
# Śmieciowi performerzy — kategorie i studia podszywające się pod osoby („Creampie",
|
|
||||||
# „Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
|
|
||||||
# Odrastają, bo tube-search dokleja performera po jednym tokenie. 24h. 0 = off.
|
|
||||||
sched_junk_performers_hours: int = Field(
|
|
||||||
default=24, validation_alias="GOON_SCHED_JUNK_PERFORMERS_HOURS"
|
|
||||||
)
|
|
||||||
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin
|
# Ingest freshness watchdog — alert do Sentry gdy aktywny tube (origin
|
||||||
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
|
# tube:<sitetag>) przestał dawać nowe sceny > próg. Łapie zamrożenie
|
||||||
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
|
# pojedynczego origin, którego globalny monitor (jeden Source "tube-scraper") nie
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,7 @@ from app.connectors.direct_scrapers.youporn_browse import YouPornBrowseScraper
|
||||||
from app.connectors.direct_scrapers.siska import SiskaScraper
|
from app.connectors.direct_scrapers.siska import SiskaScraper
|
||||||
from app.connectors.direct_scrapers.sxyland import SxyLandScraper
|
from app.connectors.direct_scrapers.sxyland import SxyLandScraper
|
||||||
from app.connectors.direct_scrapers.sxyprn import SxyPrnScraper
|
from app.connectors.direct_scrapers.sxyprn import SxyPrnScraper
|
||||||
from app.connectors.direct_scrapers.fullvideosporn import FullVideosPornScraper
|
|
||||||
from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper
|
|
||||||
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
|
|
||||||
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
|
|
||||||
from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper
|
|
||||||
from app.connectors.direct_scrapers.fullporner import FullPornerScraper
|
|
||||||
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
|
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
|
||||||
from app.connectors.direct_scrapers.youperv import YoupervScraper
|
|
||||||
from app.connectors.direct_scrapers.xhamster import XHamsterScraper
|
from app.connectors.direct_scrapers.xhamster import XHamsterScraper
|
||||||
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
|
from app.connectors.direct_scrapers.xmoviesforyou import XMoviesForYouScraper
|
||||||
from app.connectors.direct_scrapers.xnxx import XnxxScraper
|
from app.connectors.direct_scrapers.xnxx import XnxxScraper
|
||||||
|
|
@ -159,62 +152,6 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
||||||
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
|
# uploadDate, /models/ performerzy, /tags/ tagi, /categories/ studio. Playback KVS
|
||||||
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
|
# get_file direct mp4 (extractor watchporn, VPS-side, token nie IP-bound).
|
||||||
WatchPornScraper,
|
WatchPornScraper,
|
||||||
# YoupervScraper — dodany 2026-07-26 (ocena: orphan-risk LOW, najlepszy kandydat od
|
|
||||||
# hqporner). Ripy paysite, tytuły `Studio - Performer - Title` (71% próbki), performerzy
|
|
||||||
# z linków, duration + release_date ISO, ~60-70 scen/dzień. Playback: direct mp4 bez
|
|
||||||
# tokena (extractor youpervcom, VPS-side, CDN pilnuje tylko Referera).
|
|
||||||
YoupervScraper,
|
|
||||||
# GalaxyPornScraper — dodany 2026-07-26 (ocena 5/5). Ripy paysite, obsada w
|
|
||||||
# wydzielonym `<div id="video-actors">` (zero pollution), studio+data z prefiksu
|
|
||||||
# tytułu, paginacja zdrowa. Duration liczone z thumbnail.vtt playera (nie ma go
|
|
||||||
# w HTML, a NULL ukryłby sceny pod filtrem min_duration_sec). Playback: iframe
|
|
||||||
# seekplayer → istniejący silnik, HLS przez /proxy/hls.
|
|
||||||
GalaxyPornScraper,
|
|
||||||
# PornBusyScraper — dodany 2026-07-27 (ocena 4.5/5). Metadane w `itemprop` (duration/
|
|
||||||
# uploadDate/thumb/desc) + obsada w `<div id="video-actors">` bez pollution. Homepage
|
|
||||||
# pagination ZEPSUTA (str. 1/2/3 identyczne) → listing z sitemapy, newest-first.
|
|
||||||
# Brak pola studio. Playback: loadvid/seekplayer/zpi ≈ 69% katalogu.
|
|
||||||
PornBusyScraper,
|
|
||||||
# PornMikeScraper — dodany 2026-07-27 (ocena 4/5). Metadane w CAŁOŚCI z JSON-LD
|
|
||||||
# VideoObject, obsada bez pollution (1 link = realna obsada), 82% performerów z
|
|
||||||
# refem tpdb/stashdb, 19-20/20 kanałów znamy jako studia. Bramka na obsadę (23%
|
|
||||||
# katalogu bez actor[]). Tagi TYLKO z JSON-LD (HTML ma 38+22 linków nav/sidebar).
|
|
||||||
# UWAGA: to klipy 5-12 min, nie pełne sceny — ~80% katalogu to jednak studia bez
|
|
||||||
# żadnego pokrycia u nas, czyli nowa podaż. Playback: gołe mp4, zero proxy.
|
|
||||||
PornMikeScraper,
|
|
||||||
# KPorner8Scraper — dodany 2026-07-27. Mały, ale wysokiej jakości: ORYGINALNE
|
|
||||||
# tytuły studyjne (nie SEO-rewrite) + realne 4K + przenośny stream, więc sceny
|
|
||||||
# dobrze siadają na kanon. Katalog jest ZAKORKOWANY: sitemapa i `/watch/*.html`
|
|
||||||
# są za Cloudflare, paginacja zwraca stronę 1, load-more chce logowania → widać
|
|
||||||
# tylko ~115 scen z homepage. Detale pobieramy przez `?link1=watch&id=`. Bramka
|
|
||||||
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
|
|
||||||
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy.
|
|
||||||
KPorner8Scraper,
|
|
||||||
# FullPornerScraper — dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
|
|
||||||
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
|
|
||||||
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
|
|
||||||
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO — za to
|
|
||||||
# data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120
|
|
||||||
# scenach: 31% dopina się do tego, co mamy (composite waży performera + długość),
|
|
||||||
# przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny
|
|
||||||
# bez performera scaliły się 0/23. phash liczony celowo — to jedyny sygnał dla
|
|
||||||
# bulk_dedup na te duplikaty, po tytule nie pójdzie.
|
|
||||||
FullPornerScraper,
|
|
||||||
# SexuScraper (`sexu.py`) — ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
|
|
||||||
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
|
|
||||||
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
|
|
||||||
# minut**, nie sceny. Mediana 571 s na stronie 30 i 459 s na 200, ZERO filmów ≥20 min
|
|
||||||
# w całym katalogu. Pilot na 240 scenach: 1 (jeden) canonical match, bo długość i
|
|
||||||
# tytuł, czyli sygnały resolvera, są przepisane pod tube. Przy ~340 uploadach dziennie
|
|
||||||
# to ~10k nierozwiązywalnych wierszy miesięcznie i klip obok pełnej sceny na stronie
|
|
||||||
# performera. Scraper i extractor zostają w repo — gdyby serwis kiedyś zaczął wrzucać
|
|
||||||
# pełne sceny, wystarczy dopisać klasę z powrotem tutaj.
|
|
||||||
# FullVideosPornScraper — dodany 2026-07-27 (ocena 3/5, ADD Z ZASTRZEŻENIAMI).
|
|
||||||
# = sextu.com pod nową marką (NIE klon fullmovies.xxx). BRAMKA NA OBSADĘ: 65-75%
|
|
||||||
# katalogu nie ma performera i weszłoby jako puste orphany, więc ingestujemy tylko
|
|
||||||
# sceny z ≥1 performerem (~25-35% katalogu, ale 88-89% nazwisk canonical u nas).
|
|
||||||
# Tytuł z `vit` playera (og:title to AI-spin SEO), bramka cookie 429 na fetchach.
|
|
||||||
FullVideosPornScraper,
|
|
||||||
# Browse równolegle do istniejącego search scrapera (wzorzec xvideos/eporner):
|
# Browse równolegle do istniejącego search scrapera (wzorzec xvideos/eporner):
|
||||||
# search zostaje (pokrycie back-catalogu performerów), browse gwarantuje świeżość
|
# search zostaje (pokrycie back-catalogu performerów), browse gwarantuje świeżość
|
||||||
# wprost z feedu (watchdog 48h zamiast 168h). Konwersja 2026-06-24 (user request).
|
# wprost z feedu (watchdog 48h zamiast 168h). Konwersja 2026-06-24 (user request).
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
"""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,21 +30,14 @@ from app.extractors.tubes import (
|
||||||
eporner,
|
eporner,
|
||||||
freshporno,
|
freshporno,
|
||||||
fullmovies,
|
fullmovies,
|
||||||
fullvideosporn,
|
|
||||||
galaxyporn,
|
|
||||||
hdporngg,
|
hdporngg,
|
||||||
hqfap,
|
hqfap,
|
||||||
hqporner,
|
hqporner,
|
||||||
fullporner,
|
|
||||||
kporner8,
|
|
||||||
sexu,
|
|
||||||
javflix,
|
javflix,
|
||||||
neporn,
|
neporn,
|
||||||
latestpornvideo,
|
latestpornvideo,
|
||||||
paradisehill,
|
paradisehill,
|
||||||
porn00,
|
porn00,
|
||||||
pornbusy,
|
|
||||||
pornmike,
|
|
||||||
porntrex,
|
porntrex,
|
||||||
supjav,
|
supjav,
|
||||||
sxyprn,
|
sxyprn,
|
||||||
|
|
@ -52,7 +45,6 @@ from app.extractors.tubes import (
|
||||||
watchporn,
|
watchporn,
|
||||||
xhamster,
|
xhamster,
|
||||||
yespornvip,
|
yespornvip,
|
||||||
youperv,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -112,36 +104,6 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
||||||
# watchporn — 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął).
|
# watchporn — 2026-07-02 przebudowany na KVS (DoodStream-CAPTCHA zniknął).
|
||||||
# flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound.
|
# flashvars get_file direct mp4, same-session 302 resolve, token nie IP-bound.
|
||||||
"watchporn": watchporn.extract,
|
"watchporn": watchporn.extract,
|
||||||
# youperv — fluidplayer, zwykły <source> mp4 na files.klubnichka-hd.com. Bez tokena
|
|
||||||
# i bez IP-bindingu; CDN pilnuje tylko Referera (cross-IP 206 z VPS) → mobile direct.
|
|
||||||
"youpervcom": youperv.extract,
|
|
||||||
# galaxyporn — iframe rodziny seekplayer (upns/4meplayer/seekplays); silnik już mamy,
|
|
||||||
# extractor tylko wyłuskuje iframe. HLS Referer+IP-bound → /proxy/hls.
|
|
||||||
"galaxyporncom": galaxyporn.extract,
|
|
||||||
# pornbusy — dispatch po embedURL: loadvid (POST-manifest przez /proxy/hls),
|
|
||||||
# rodzina seekplayer (istniejący silnik), zpi.cx (embedURL to gotowy plik).
|
|
||||||
# upload18 + ogon → None (token IP-bound, resolve wymusiłby proxy całego wideo).
|
|
||||||
"pornbusycom": pornbusy.extract,
|
|
||||||
# pornmike — gołe <source> mp4 na twincdn: bez tokenu, bez Referera, bez expiry.
|
|
||||||
# Cross-IP 206 z VPS i z innego kraju → mobile gra direct, zero proxy/WebView.
|
|
||||||
"pornmike": pornmike.extract,
|
|
||||||
# 8kporner — JWPlayer `sources` (NIE JSON-LD contentUrl, ten jest zawsze 144p!).
|
|
||||||
# okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN
|
|
||||||
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
|
|
||||||
"8kpornercom": kporner8.extract,
|
|
||||||
# fullporner — iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
|
|
||||||
# Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez
|
|
||||||
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
|
|
||||||
"fullporner": fullporner.extract,
|
|
||||||
# sexu — POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS).
|
|
||||||
# Bierzemy HLS `playerData.src`, NIE mp4 `sources` — te mają w tokenie `ip=`
|
|
||||||
# requestera, więc na telefonie dałyby 403. Master kończy się `.mp4` → wchodzi
|
|
||||||
# hls_needs_passthrough i manifest idzie przez /proxy/hls, segmenty direct.
|
|
||||||
"sexu": sexu.extract,
|
|
||||||
# fullvideosporn (= sextu) — TXXX: videofile.php → dekod → get_file → 302 → znvcdn.
|
|
||||||
# Token CDN portable cross-IP → mobile direct. UWAGA: VPS dostaje od CDN 429
|
|
||||||
# (reputacja IP datacenter), więc health-check playbacku z VPS jest ślepy.
|
|
||||||
"fullvideosporn": fullvideosporn.extract,
|
|
||||||
"siskavideo": _embed_iframe.extract,
|
"siskavideo": _embed_iframe.extract,
|
||||||
"porn4dayspw": _embed_iframe.extract,
|
"porn4dayspw": _embed_iframe.extract,
|
||||||
"porndishcom": _embed_iframe.extract,
|
"porndishcom": _embed_iframe.extract,
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
"""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,23 +54,13 @@ log = logging.getLogger(__name__)
|
||||||
_KEY = b"kiemtienmua911ca"
|
_KEY = b"kiemtienmua911ca"
|
||||||
_IV = b"1234567890oiuytr"
|
_IV = b"1234567890oiuytr"
|
||||||
|
|
||||||
# Hostname matching: base hosts × subdomains × TLD variants.
|
# Hostname matching: 6 base hosts × subdomains × TLD variants.
|
||||||
# Examples:
|
# Examples:
|
||||||
# my.embedseek.online, vip.seekplayer.vip, my.rpmplay.online,
|
# my.embedseek.online, vip.seekplayer.vip, my.rpmplay.online,
|
||||||
# my.upns.online, vip.player4me.vip, p.easyvidplayer.com
|
# my.upns.online, vip.player4me.vip, p.easyvidplayer.com
|
||||||
#
|
|
||||||
# 2026-07-26: dołożone `seekplays|4meplayer|ezplayer|seeks` + TLD `pro|cloud|one`.
|
|
||||||
# Ta sama rodzina silnika (identyczny _KEY/_IV + `/api/v1/video?id=`), tylko inne
|
|
||||||
# domeny — bez tego regexu `matches()` odrzucał je zanim doszło do dekodowania.
|
|
||||||
# Zweryfikowane: galaxy.4meplayer.pro / sport.seekplays.com / news.upns.pro
|
|
||||||
# (galaxyporn) oraz av.ezplayer.me / av.seeks.cloud / 4k.upn.one (pornbusy)
|
|
||||||
# dekodują się tym samym silnikiem. Whitelist jest addytywna — istniejące hosty
|
|
||||||
# matchują się dalej tak samo.
|
|
||||||
_HOST_RE = re.compile(
|
_HOST_RE = re.compile(
|
||||||
r"^(?:[a-z0-9]+\.)?"
|
r"^(?:[a-z0-9]+\.)?(?:embedseek|seekplayer|rpmplay|upns|player4me|easyvidplayer)\."
|
||||||
r"(?:embedseek|seekplayer|seekplays|seeks|rpmplay|upns|upn|player4me|4meplayer"
|
r"(?:online|vip|com|net|io|me|tv)$",
|
||||||
r"|ezplayer|easyvidplayer)\."
|
|
||||||
r"(?:online|vip|com|net|io|me|tv|pro|cloud|one)$",
|
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
"""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,23 +27,43 @@ direct z telefonu; patrz stream_proxy.proxy_hls_manifest). Media id w get_file (
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from app.extractors import browser_get
|
from app.extractors import browser_get
|
||||||
from app.extractors._models import StreamSource
|
from app.extractors._models import StreamSource
|
||||||
from app.extractors.tubes import _txxx
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BASE = "https://vjav.com"
|
_BASE = "https://vjav.com"
|
||||||
_VIDEO_ID_RE = re.compile(r"/videos/(\d+)/")
|
_VIDEO_ID_RE = re.compile(r"/videos/(\d+)/")
|
||||||
|
|
||||||
# Dekoder video_url wyniesiony do `_txxx.py` — ten sam silnik TXXX obsługuje też
|
# KVS video_url: base64 z cyrylica-homoglifami zamiast łacińskich liter (wielkie +
|
||||||
# fullvideosporn/sextu, więc trzymamy jedną implementację. Alias zachowany dla
|
# część małych). Odkręcamy je z powrotem na łacinę przed b64decode.
|
||||||
# czytelności modułu i ewentualnych importów.
|
_HOMOGLYPHS = str.maketrans(
|
||||||
_decode_video_url = _txxx.decode_video_url
|
{
|
||||||
|
"А": "A", "В": "B", "С": "C", "Е": "E", "Н": "H", "К": "K", "М": "M",
|
||||||
|
"О": "O", "Р": "P", "Т": "T", "Х": "X", "У": "Y",
|
||||||
|
"а": "a", "с": "c", "е": "e", "о": "o", "р": "p", "х": "x", "у": "y",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_video_url(obfuscated: str) -> str | None:
|
||||||
|
# (1) cyrylica-homoglif -> łacina, (2) custom alfabet base64 -> standardowy.
|
||||||
|
clean = (
|
||||||
|
obfuscated.translate(_HOMOGLYPHS)
|
||||||
|
.replace(",", "/")
|
||||||
|
.replace("~", "=")
|
||||||
|
.replace("-", "+")
|
||||||
|
)
|
||||||
|
clean += "=" * (-len(clean) % 4) # padding do wielokrotności 4
|
||||||
|
try:
|
||||||
|
return base64.b64decode(clean).decode("utf-8", "ignore")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _quality_label(fmt: str | None) -> str | None:
|
def _quality_label(fmt: str | None) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
"""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,7 +20,6 @@ from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
|
||||||
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
|
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
|
||||||
from app.models.playback_event import PlaybackEvent
|
from app.models.playback_event import PlaybackEvent
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
from app.models.phash_blacklist import PhashBlacklist
|
|
||||||
from app.models.saved_search import SavedSearch
|
from app.models.saved_search import SavedSearch
|
||||||
from app.models.source_stats import SourceStats
|
from app.models.source_stats import SourceStats
|
||||||
from app.models.scene import (
|
from app.models.scene import (
|
||||||
|
|
@ -59,7 +58,6 @@ __all__ = [
|
||||||
"MoviePlayProgress",
|
"MoviePlayProgress",
|
||||||
"ScenePlayProgress",
|
"ScenePlayProgress",
|
||||||
"PlaybackSource",
|
"PlaybackSource",
|
||||||
"PhashBlacklist",
|
|
||||||
"SavedSearch",
|
"SavedSearch",
|
||||||
"Scene",
|
"Scene",
|
||||||
"SceneExternalRef",
|
"SceneExternalRef",
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
"""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,16 +102,6 @@ class ScenePerformer(Base):
|
||||||
role: Mapped[str | None] = mapped_column(String(64))
|
role: Mapped[str | None] = mapped_column(String(64))
|
||||||
position: Mapped[int | None] = mapped_column(Integer)
|
position: Mapped[int | None] = mapped_column(Integer)
|
||||||
as_alias: Mapped[str | None] = mapped_column(String(256))
|
as_alias: Mapped[str | None] = mapped_column(String(256))
|
||||||
#: Skąd wzięło się TO przypisanie. Bez tego nie da się odróżnić obsady z kanonu od
|
|
||||||
#: performera doklejonego przez luźny tube-search (ten dopasowuje wynik po JEDNYM
|
|
||||||
#: tokenie zapytania, więc „Rapture" łapała każdą scenę ze słowem „rapture" —
|
|
||||||
#: 30 z 72 jej scen było fałszywych i trzeba je było przeglądać ręcznie).
|
|
||||||
#: Trzymamy NAJBARDZIEJ wiarygodne źródło, jakie przypisało: kanoniczne
|
|
||||||
#: (tpdb/stashdb) nadpisuje scraperowe, nigdy odwrotnie. NULL = wiersz sprzed
|
|
||||||
#: migracji 0026, źródła nie da się odtworzyć wstecz.
|
|
||||||
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("sources.id", ondelete="SET NULL"), index=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SceneTag(Base):
|
class SceneTag(Base):
|
||||||
|
|
|
||||||
|
|
@ -95,23 +95,13 @@ def find_by_phash_within(
|
||||||
if max_hamming is None:
|
if max_hamming is None:
|
||||||
max_hamming = get_settings().fingerprint_hamming_max
|
max_hamming = get_settings().fingerprint_hamming_max
|
||||||
|
|
||||||
# Zdegenerowane wartości (zaślepka / czarna klatka / intro studia) odpadają po
|
|
||||||
# obu stronach: nie szukamy PO nich i nie matchujemy DO nich. Bez tego jedna
|
|
||||||
# zaślepka dzielona przez 892 sceny jest zawsze najbliższym trafieniem (dist 0)
|
|
||||||
# i wygrywa z prawdziwym duplikatem. Tabela ma ~70 wierszy, więc koszt zerowy.
|
|
||||||
if session.execute(
|
|
||||||
text("SELECT 1 FROM phash_blacklist WHERE value = :phash"), {"phash": phash}
|
|
||||||
).first():
|
|
||||||
return None
|
|
||||||
|
|
||||||
if len(phash) == 16:
|
if len(phash) == 16:
|
||||||
row = session.execute(
|
row = session.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT scene_id, "
|
"SELECT scene_id, "
|
||||||
"bit_count(('x'||value)::bit(64) # ('x'||:phash)::bit(64)) AS dist "
|
"bit_count(('x'||value)::bit(64) # ('x'||:phash)::bit(64)) AS dist "
|
||||||
"FROM scene_fingerprints f "
|
"FROM scene_fingerprints "
|
||||||
"WHERE kind = 'phash' AND length(value) = 16 "
|
"WHERE kind = 'phash' AND length(value) = 16 "
|
||||||
"AND NOT EXISTS (SELECT 1 FROM phash_blacklist b WHERE b.value = f.value) "
|
|
||||||
"ORDER BY dist ASC LIMIT 1"
|
"ORDER BY dist ASC LIMIT 1"
|
||||||
),
|
),
|
||||||
{"phash": phash},
|
{"phash": phash},
|
||||||
|
|
|
||||||
|
|
@ -29,18 +29,10 @@ from app.models.scene import (
|
||||||
ScenePerformer,
|
ScenePerformer,
|
||||||
SceneTag,
|
SceneTag,
|
||||||
)
|
)
|
||||||
from app.models.source import Source, SourceKind
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _is_canonical(session: Session, source_id: uuid.UUID | None) -> bool:
|
|
||||||
if source_id is None:
|
|
||||||
return False
|
|
||||||
src = session.get(Source, source_id)
|
|
||||||
return src is not None and src.kind in (SourceKind.tpdb, SourceKind.stashdb)
|
|
||||||
|
|
||||||
|
|
||||||
class MergeError(Exception):
|
class MergeError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -153,17 +145,6 @@ def _move_performers(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID
|
||||||
if clash is not None:
|
if clash is not None:
|
||||||
if link.as_alias and not clash.as_alias:
|
if link.as_alias and not clash.as_alias:
|
||||||
clash.as_alias = link.as_alias
|
clash.as_alias = link.as_alias
|
||||||
# Kasujemy wiersz drop-a, więc jego provenance przepadłaby razem z nim.
|
|
||||||
# Przenosimy ją, gdy jest lepsza: kanon > scraper > NULL. Inaczej merge
|
|
||||||
# potrafiłby zdegradować obsadę potwierdzoną przez TPDB do „nie wiadomo skąd".
|
|
||||||
if clash.source_id is None and link.source_id is not None:
|
|
||||||
clash.source_id = link.source_id
|
|
||||||
elif (
|
|
||||||
link.source_id is not None
|
|
||||||
and _is_canonical(session, link.source_id)
|
|
||||||
and not _is_canonical(session, clash.source_id)
|
|
||||||
):
|
|
||||||
clash.source_id = link.source_id
|
|
||||||
session.delete(link)
|
session.delete(link)
|
||||||
else:
|
else:
|
||||||
link.scene_id = keep_id
|
link.scene_id = keep_id
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,7 @@ def resolve_scene(
|
||||||
if decision == "auto":
|
if decision == "auto":
|
||||||
_update_scene_fields(best_scene, norm, studio_id=studio_id, source_kind=source_kind, session=session)
|
_update_scene_fields(best_scene, norm, studio_id=studio_id, source_kind=source_kind, session=session)
|
||||||
_attach_external_ref(session, scene_id=best_scene.id, source_id=source_id, norm=norm)
|
_attach_external_ref(session, scene_id=best_scene.id, source_id=source_id, norm=norm)
|
||||||
_sync_performers(session, scene_id=best_scene.id, resolved=resolved_performers, source_id=source_id)
|
_sync_performers(session, scene_id=best_scene.id, resolved=resolved_performers)
|
||||||
_sync_tags(session, scene_id=best_scene.id, norm=norm, source_id=source_id)
|
_sync_tags(session, scene_id=best_scene.id, norm=norm, source_id=source_id)
|
||||||
_sync_fingerprints(
|
_sync_fingerprints(
|
||||||
session, scene_id=best_scene.id, norm=norm, source_id=source_id
|
session, scene_id=best_scene.id, norm=norm, source_id=source_id
|
||||||
|
|
@ -349,7 +349,7 @@ def resolve_scene(
|
||||||
if decision == "review":
|
if decision == "review":
|
||||||
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
|
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
|
||||||
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
|
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
|
||||||
_sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers, source_id=source_id)
|
_sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers)
|
||||||
_sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
|
_sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
|
||||||
_sync_fingerprints(
|
_sync_fingerprints(
|
||||||
session, scene_id=new_scene.id, norm=norm, source_id=source_id
|
session, scene_id=new_scene.id, norm=norm, source_id=source_id
|
||||||
|
|
@ -376,7 +376,7 @@ def resolve_scene(
|
||||||
# Brak żadnego sensownego dopasowania → nowa kanoniczna
|
# Brak żadnego sensownego dopasowania → nowa kanoniczna
|
||||||
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
|
new_scene = _create_canonical(session, norm=norm, studio_id=studio_id, backfill=backfill)
|
||||||
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
|
_attach_external_ref(session, scene_id=new_scene.id, source_id=source_id, norm=norm)
|
||||||
_sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers, source_id=source_id)
|
_sync_performers(session, scene_id=new_scene.id, resolved=resolved_performers)
|
||||||
_sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
|
_sync_tags(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
|
||||||
_sync_fingerprints(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
|
_sync_fingerprints(session, scene_id=new_scene.id, norm=norm, source_id=source_id)
|
||||||
_sync_playback_sources(session, scene_id=new_scene.id, norm=norm)
|
_sync_playback_sources(session, scene_id=new_scene.id, norm=norm)
|
||||||
|
|
@ -542,25 +542,17 @@ def _sync_attached_entities(
|
||||||
for p_norm in norm.performers:
|
for p_norm in norm.performers:
|
||||||
performer = resolve_performer(session, norm=p_norm, source_id=source_id)
|
performer = resolve_performer(session, norm=p_norm, source_id=source_id)
|
||||||
resolved.append((performer.id, p_norm.as_alias_in_scene))
|
resolved.append((performer.id, p_norm.as_alias_in_scene))
|
||||||
_sync_performers(session, scene_id=scene.id, resolved=resolved, source_id=source_id)
|
_sync_performers(session, scene_id=scene.id, resolved=resolved)
|
||||||
_sync_tags(session, scene_id=scene.id, norm=norm, source_id=source_id)
|
_sync_tags(session, scene_id=scene.id, norm=norm, source_id=source_id)
|
||||||
_sync_fingerprints(session, scene_id=scene.id, norm=norm, source_id=source_id)
|
_sync_fingerprints(session, scene_id=scene.id, norm=norm, source_id=source_id)
|
||||||
_sync_playback_sources(session, scene_id=scene.id, norm=norm)
|
_sync_playback_sources(session, scene_id=scene.id, norm=norm)
|
||||||
|
|
||||||
|
|
||||||
def _is_canonical_source(session: Session, source_id: uuid.UUID | None) -> bool:
|
|
||||||
if source_id is None:
|
|
||||||
return False
|
|
||||||
src = session.get(Source, source_id)
|
|
||||||
return src is not None and src.kind in (SourceKind.tpdb, SourceKind.stashdb)
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_performers(
|
def _sync_performers(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
scene_id: uuid.UUID,
|
scene_id: uuid.UUID,
|
||||||
resolved: list[tuple[uuid.UUID, str | None]],
|
resolved: list[tuple[uuid.UUID, str | None]],
|
||||||
source_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# Deduplikuj — dwa różne aliasy tej samej osoby (np. "Aj Applegate" + "AJ Applegate")
|
# Deduplikuj — dwa różne aliasy tej samej osoby (np. "Aj Applegate" + "AJ Applegate")
|
||||||
# przejdą przez resolve_performer zwracając ten sam Performer.id. Bez tej dedup
|
# przejdą przez resolve_performer zwracając ten sam Performer.id. Bez tej dedup
|
||||||
|
|
@ -589,21 +581,10 @@ def _sync_performers(
|
||||||
performer_id=performer_id,
|
performer_id=performer_id,
|
||||||
position=position,
|
position=position,
|
||||||
as_alias=as_alias,
|
as_alias=as_alias,
|
||||||
source_id=source_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
elif as_alias and not existing.as_alias:
|
||||||
if as_alias and not existing.as_alias:
|
|
||||||
existing.as_alias = as_alias
|
existing.as_alias = as_alias
|
||||||
# Provenance idzie tylko w górę: NULL wypełnia cokolwiek, kanon nadpisuje
|
|
||||||
# scraper, ale scraper NIGDY nie zamazuje kanonu. Inaczej dowolny późniejszy
|
|
||||||
# tube-search zatarłby informację, że obsadę potwierdziło TPDB.
|
|
||||||
if source_id is not None and existing.source_id != source_id:
|
|
||||||
if existing.source_id is None or (
|
|
||||||
_is_canonical_source(session, source_id)
|
|
||||||
and not _is_canonical_source(session, existing.source_id)
|
|
||||||
):
|
|
||||||
existing.source_id = source_id
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_tags(
|
def _sync_tags(
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,6 @@ _DEFAULT_STATE = Path(__file__).resolve().parent.parent / "_state" / "deepcrawl_
|
||||||
# capu (None) → naturalny koniec katalogu. xvideos /new/ ~27 scen/stronę → 1800 ≈ ~50k.
|
# capu (None) → naturalny koniec katalogu. xvideos /new/ ~27 scen/stronę → 1800 ≈ ~50k.
|
||||||
_PAGE_CAP: dict[str, int] = {
|
_PAGE_CAP: dict[str, int] = {
|
||||||
"xvideoscom": 1800,
|
"xvideoscom": 1800,
|
||||||
# youperv: strony ~2100+ (≤09.2023) to stara generyczna amatorka bez performerów,
|
|
||||||
# do tego martwe pliki na CDN (HTTP 500). Świeży korpus (Studio-Performer-Title)
|
|
||||||
# kończy się ~str. 2000 ≈ 38k scen — dalej crawl tylko generuje orphany.
|
|
||||||
"youpervcom": 2000,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Miękki budżet czasu na run (s). Detail-fetch scrapery (per-scena fetch strony, np.
|
# Miękki budżet czasu na run (s). Detail-fetch scrapery (per-scena fetch strony, np.
|
||||||
|
|
|
||||||
|
|
@ -276,37 +276,6 @@ def _job_thumb_asset_dedup() -> None:
|
||||||
log.exception("[scheduler] thumb-asset dedup failed")
|
log.exception("[scheduler] thumb-asset dedup failed")
|
||||||
|
|
||||||
|
|
||||||
def _job_phash_blacklist() -> None:
|
|
||||||
"""Wyłapuje phashe, które przestały cokolwiek znaczyć (zaślepka / czarna klatka /
|
|
||||||
intro studia) i kasuje je z fingerprintów. Audyt 2026-07-27: 73 takie wartości przy
|
|
||||||
4375 scenach, rekordzistka dzielona przez 892 sceny o 892 różnych tytułach.
|
|
||||||
Cyklicznie, bo tube potrafi zacząć serwować zaślepkę w dowolnym momencie, a wartość
|
|
||||||
raz wpisana na blacklistę zostaje tam na stałe (inaczej odrastałaby przy ingeście)."""
|
|
||||||
log.info("[scheduler] phash blacklist starting")
|
|
||||||
try:
|
|
||||||
from app.scheduler.phash_blacklist import run_phash_blacklist
|
|
||||||
|
|
||||||
_run_with_timeout(run_phash_blacklist, label="phash-blacklist")
|
|
||||||
log.info("[scheduler] phash blacklist done")
|
|
||||||
except Exception:
|
|
||||||
log.exception("[scheduler] phash blacklist failed")
|
|
||||||
|
|
||||||
|
|
||||||
def _job_junk_performers() -> None:
|
|
||||||
"""Kasuje kategorie i studia podszywające się pod performerów („Creampie",
|
|
||||||
„Natural tits", „Brazzers"). Audyt 2026-07-27: 277 rekordów, 33 784 przypisania.
|
|
||||||
Odrastają, bo tube-search dokleja performera po jednym tokenie z zapytania, a
|
|
||||||
resolve_performer tworzy go z dowolnej nazwy podanej przez tube."""
|
|
||||||
log.info("[scheduler] junk performers starting")
|
|
||||||
try:
|
|
||||||
from app.scheduler.junk_performers import run_junk_performer_cleanup
|
|
||||||
|
|
||||||
_run_with_timeout(run_junk_performer_cleanup, label="junk-performers")
|
|
||||||
log.info("[scheduler] junk performers done")
|
|
||||||
except Exception:
|
|
||||||
log.exception("[scheduler] junk performers failed")
|
|
||||||
|
|
||||||
|
|
||||||
def _job_title_duration_dedup() -> None:
|
def _job_title_duration_dedup() -> None:
|
||||||
"""Scal missing-merge dupy (ten sam performer + identyczny tytuł + długość) których
|
"""Scal missing-merge dupy (ten sam performer + identyczny tytuł + długość) których
|
||||||
bulk_dedup nie łapie (tube re-scrape / cross-tube np. porn00 vs xnxx, reports
|
bulk_dedup nie łapie (tube re-scrape / cross-tube np. porn00 vs xnxx, reports
|
||||||
|
|
@ -502,28 +471,6 @@ def build_scheduler(cfg: dict[str, Any]) -> BlockingScheduler:
|
||||||
)
|
)
|
||||||
log.info("scheduler: thumb-asset dedup every %dh", cfg["thumb_dedup_hours"])
|
log.info("scheduler: thumb-asset dedup every %dh", cfg["thumb_dedup_hours"])
|
||||||
|
|
||||||
if cfg.get("phash_blacklist_hours"):
|
|
||||||
sched.add_job(
|
|
||||||
_job_phash_blacklist,
|
|
||||||
IntervalTrigger(hours=cfg["phash_blacklist_hours"], start_date=INTERVAL_ANCHOR),
|
|
||||||
id="phash_blacklist",
|
|
||||||
replace_existing=True,
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
log.info("scheduler: phash blacklist every %dh", cfg["phash_blacklist_hours"])
|
|
||||||
|
|
||||||
if cfg.get("junk_performers_hours"):
|
|
||||||
sched.add_job(
|
|
||||||
_job_junk_performers,
|
|
||||||
IntervalTrigger(hours=cfg["junk_performers_hours"], start_date=INTERVAL_ANCHOR),
|
|
||||||
id="junk_performers",
|
|
||||||
replace_existing=True,
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
log.info("scheduler: junk performers every %dh", cfg["junk_performers_hours"])
|
|
||||||
|
|
||||||
if cfg.get("title_dedup_hours"):
|
if cfg.get("title_dedup_hours"):
|
||||||
sched.add_job(
|
sched.add_job(
|
||||||
_job_title_duration_dedup,
|
_job_title_duration_dedup,
|
||||||
|
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
"""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}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
"""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,12 +216,6 @@ def run_forever() -> int:
|
||||||
"thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None,
|
"thumb_dedup_hours": getattr(settings, "sched_thumb_dedup_hours", 12) or None,
|
||||||
# Title+duration dedup — missing-merge tube-dupy (reports 28fe8181/32df33b1).
|
# Title+duration dedup — missing-merge tube-dupy (reports 28fe8181/32df33b1).
|
||||||
"title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None,
|
"title_dedup_hours": getattr(settings, "sched_title_dedup_hours", 12) or None,
|
||||||
# Blacklista zdegenerowanych phashy — zaślepki dzielone przez setki scen (audyt
|
|
||||||
# 2026-07-27). Bez tego zaślepka jest zawsze najbliższym trafieniem w Path 3.
|
|
||||||
"phash_blacklist_hours": getattr(settings, "sched_phash_blacklist_hours", 24) or None,
|
|
||||||
# Śmieciowi performerzy — kategorie/studia udające osoby (audyt 2026-07-27).
|
|
||||||
# Kryterium wąskie celowo: szerokie kasuje realnych ludzi bez refa w TPDB.
|
|
||||||
"junk_performers_hours": getattr(settings, "sched_junk_performers_hours", 24) or None,
|
|
||||||
# Taxonomy scene_count refresh — denormalizacja liczników (perf fix 0019).
|
# Taxonomy scene_count refresh — denormalizacja liczników (perf fix 0019).
|
||||||
"taxonomy_counts_hours": getattr(settings, "sched_taxonomy_counts_hours", 3) or None,
|
"taxonomy_counts_hours": getattr(settings, "sched_taxonomy_counts_hours", 3) or None,
|
||||||
# Ingest freshness watchdog — per-origin alert do Sentry (report 14f3a655).
|
# Ingest freshness watchdog — per-origin alert do Sentry (report 14f3a655).
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DefaultQuality,
|
DefaultQuality,
|
||||||
getAutoRotate,
|
|
||||||
getDefaultQuality,
|
getDefaultQuality,
|
||||||
getGridColumns,
|
getGridColumns,
|
||||||
setAutoRotate as persistAutoRotate,
|
|
||||||
setDefaultQuality as persistDefaultQuality,
|
setDefaultQuality as persistDefaultQuality,
|
||||||
setGridColumns as persistGridColumns,
|
setGridColumns as persistGridColumns,
|
||||||
} from './storage';
|
} from './storage';
|
||||||
|
|
@ -19,8 +17,6 @@ interface Prefs {
|
||||||
setGridColumns: (n: number) => void;
|
setGridColumns: (n: number) => void;
|
||||||
defaultQuality: DefaultQuality;
|
defaultQuality: DefaultQuality;
|
||||||
setDefaultQuality: (q: DefaultQuality) => void;
|
setDefaultQuality: (q: DefaultQuality) => void;
|
||||||
autoRotate: boolean;
|
|
||||||
setAutoRotate: (on: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Ctx = createContext<Prefs | null>(null);
|
const Ctx = createContext<Prefs | null>(null);
|
||||||
|
|
@ -28,7 +24,6 @@ const Ctx = createContext<Prefs | null>(null);
|
||||||
export function PreferencesProvider({ children }: { children: React.ReactNode }) {
|
export function PreferencesProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [gridColumns, setCols] = useState(2);
|
const [gridColumns, setCols] = useState(2);
|
||||||
const [defaultQuality, setQuality] = useState<DefaultQuality>('auto');
|
const [defaultQuality, setQuality] = useState<DefaultQuality>('auto');
|
||||||
const [autoRotate, setRotate] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getGridColumns()
|
getGridColumns()
|
||||||
|
|
@ -37,9 +32,6 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
|
||||||
getDefaultQuality()
|
getDefaultQuality()
|
||||||
.then(setQuality)
|
.then(setQuality)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
getAutoRotate()
|
|
||||||
.then(setRotate)
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setGridColumns = (n: number) => {
|
const setGridColumns = (n: number) => {
|
||||||
|
|
@ -52,22 +44,8 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
|
||||||
persistDefaultQuality(q).catch(() => {});
|
persistDefaultQuality(q).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setAutoRotate = (on: boolean) => {
|
|
||||||
setRotate(on);
|
|
||||||
persistAutoRotate(on).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ctx.Provider
|
<Ctx.Provider value={{ gridColumns, setGridColumns, defaultQuality, setDefaultQuality }}>
|
||||||
value={{
|
|
||||||
gridColumns,
|
|
||||||
setGridColumns,
|
|
||||||
defaultQuality,
|
|
||||||
setDefaultQuality,
|
|
||||||
autoRotate,
|
|
||||||
setAutoRotate,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Ctx.Provider>
|
</Ctx.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,6 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
{
|
|
||||||
id: '2026-07-28',
|
|
||||||
date: 'July 2026',
|
|
||||||
items: [
|
|
||||||
'Auto-rotate can now be turned off (Settings -> Auto-rotate), so the video stops flipping when the phone lies flat on a table. The fullscreen button still switches to landscape.',
|
|
||||||
'The "+N new" badge on a favourite performer or studio now also counts new movies, not just scenes.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: '2026-07-26b',
|
id: '2026-07-26b',
|
||||||
date: 'July 2026',
|
date: 'July 2026',
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export function AppLockSettingsScreen() {
|
||||||
const [stage, setStage] = useState<Stage>('menu');
|
const [stage, setStage] = useState<Stage>('menu');
|
||||||
const [newPin, setNewPin] = useState('');
|
const [newPin, setNewPin] = useState('');
|
||||||
const [errorText, setErrorText] = useState<string | null>(null);
|
const [errorText, setErrorText] = useState<string | null>(null);
|
||||||
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality, autoRotate, setAutoRotate } = usePreferences();
|
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences();
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -376,24 +376,6 @@ export function AppLockSettingsScreen() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Auto-rotate</Text>
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
Let the video follow your phone's rotation. Turn this off if the picture keeps
|
|
||||||
flipping when the phone lies flat on a table. The fullscreen button still switches
|
|
||||||
to landscape.
|
|
||||||
</Text>
|
|
||||||
<View style={styles.row}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={styles.label}>Rotate with the phone</Text>
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
{autoRotate ? 'Video follows the phone' : 'Video stays upright'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch value={autoRotate} onValueChange={setAutoRotate} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Backup & sync</Text>
|
<Text style={styles.sectionTitle}>Backup & sync</Text>
|
||||||
<Text style={styles.hint}>
|
<Text style={styles.hint}>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
|
||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
import { setLastPlayback } from '../lib/lastPlayback';
|
import { setLastPlayback } from '../lib/lastPlayback';
|
||||||
import { allowScreenOff, keepScreenOn } from '../lib/keepAwake';
|
import { allowScreenOff, keepScreenOn } from '../lib/keepAwake';
|
||||||
import { usePreferences } from '../PreferencesContext';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
|
@ -136,20 +135,12 @@ export function PlayerScreen() {
|
||||||
const params = route.params as RouteParams;
|
const params = route.params as RouteParams;
|
||||||
const mode = params.mode ?? detectMode(params.url);
|
const mode = params.mode ?? detectMode(params.url);
|
||||||
|
|
||||||
// Auto-obrót: domyślnie odblokowany (oglądanie w poziomie). Gdy user go wyłączy
|
|
||||||
// (Settings → Playback), player zostaje w pionie — telefon leżący płasko na stole
|
|
||||||
// nie skacze między orientacjami (bug-report 55da1c3f). Przycisk pełnego ekranu dalej
|
|
||||||
// pozwala wejść w poziom ręcznie, więc wyłączenie nie odbiera funkcji.
|
|
||||||
const { autoRotate } = usePreferences();
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (autoRotate) ScreenOrientation.unlockAsync().catch(() => {});
|
ScreenOrientation.unlockAsync().catch(() => {});
|
||||||
else {
|
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
|
||||||
}
|
|
||||||
return () => {
|
return () => {
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
||||||
};
|
};
|
||||||
}, [autoRotate]);
|
}, []);
|
||||||
|
|
||||||
if (mode === 'webview') return <EmbedWebViewPlayer params={params} />;
|
if (mode === 'webview') return <EmbedWebViewPlayer params={params} />;
|
||||||
return <NativeVideoPlayer params={params} />;
|
return <NativeVideoPlayer params={params} />;
|
||||||
|
|
|
||||||
|
|
@ -104,23 +104,6 @@ export async function setDefaultQuality(v: DefaultQuality): Promise<void> {
|
||||||
await SecureStore.setItemAsync(DEFAULT_QUALITY_KEY, String(v));
|
await SecureStore.setItemAsync(DEFAULT_QUALITY_KEY, String(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-obrót w playerze. Player domyślnie odblokowuje orientację (unlockAsync), żeby
|
|
||||||
// dało się oglądać poziomo — ale wtedy telefon położony płasko na stole sam skacze
|
|
||||||
// między orientacjami (bug-report 55da1c3f: "I can't turn off auto rotate. Which is a
|
|
||||||
// problem when I put the phone on a flat surface"). Off = player zostaje w pionie,
|
|
||||||
// a przełącznik pełnego ekranu dalej pozwala wejść w poziom RĘCZNIE.
|
|
||||||
// Default true = dotychczasowe zachowanie.
|
|
||||||
const AUTO_ROTATE_KEY = 'goon.player_auto_rotate';
|
|
||||||
|
|
||||||
export async function getAutoRotate(): Promise<boolean> {
|
|
||||||
const v = await SecureStore.getItemAsync(AUTO_ROTATE_KEY);
|
|
||||||
return v === null ? true : v === '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setAutoRotate(on: boolean): Promise<void> {
|
|
||||||
await SecureStore.setItemAsync(AUTO_ROTATE_KEY, on ? '1' : '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Credentials {
|
export interface Credentials {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
"""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,18 +14,6 @@ UWAGA: merge KASUJE zdublowaną scenę (refs/sources/tags łączone do keepera)
|
||||||
NIEODWRACALNE. Domyślnie dry-run; --yes wykonuje. Per-para re-fetch → obsługuje klastry
|
NIEODWRACALNE. Domyślnie dry-run; --yes wykonuje. Per-para re-fetch → obsługuje klastry
|
||||||
(scena znika po wcześniejszym merge'u w tym samym klastrze).
|
(scena znika po wcześniejszym merge'u w tym samym klastrze).
|
||||||
|
|
||||||
**Bezpiecznik na pary drugiego rzędu (dodany 2026-07-27).** Merge PRZENOSI odciski na
|
|
||||||
keepera, więc scalona scena zbiera phashe wszystkich wchłoniętych — jedna miała ich 14.
|
|
||||||
Taki konglomerat zaczyna potem kolidować z obcymi scenami przez odziedziczony phash,
|
|
||||||
który nie przedstawia nawet jego treści. Po przebiegu na 4647 parach zostało 7 nowych,
|
|
||||||
POWSTAŁYCH przez ten przebieg, i 5 z nich (71%) było fałszywkami: Brazzers vs Reality
|
|
||||||
Kings, AdultTime vs Teen Curves. W parach pierwszego rzędu było 0 fałszywek na 18.
|
|
||||||
|
|
||||||
Rozdziela je podobieństwo tytułu, i to z dużym marginesem — zmierzone na tych 7 parach:
|
|
||||||
prawdziwe duplikaty 92,1 i 84,8, wszystkie fałszywki 23,0-52,4. Stąd próg 0,70, który
|
|
||||||
leży w środku luki. Prefiks studia progu nie rusza, bo `token_set_ratio` traktuje
|
|
||||||
„Bursting The Bag" jako podzbiór „FakehubOriginals – Sophia Locke – Bursting The Bag".
|
|
||||||
|
|
||||||
Uruchomienie:
|
Uruchomienie:
|
||||||
python -m scripts.merge_phash_exact_dupes # dry-run
|
python -m scripts.merge_phash_exact_dupes # dry-run
|
||||||
python -m scripts.merge_phash_exact_dupes --yes # wykonaj
|
python -m scripts.merge_phash_exact_dupes --yes # wykonaj
|
||||||
|
|
@ -34,7 +22,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from rapidfuzz import fuzz
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.db import session_scope
|
from app.db import session_scope
|
||||||
|
|
@ -42,15 +29,6 @@ from app.models.scene import Scene
|
||||||
from app.resolve.scene_merge import merge_scenes
|
from app.resolve.scene_merge import merge_scenes
|
||||||
from app.scheduler.bulk_dedup import _pick_keep_drop
|
from app.scheduler.bulk_dedup import _pick_keep_drop
|
||||||
|
|
||||||
# Patrz docstring: zmierzona luka to 52,4 (najgorsza fałszywka) ↔ 84,8 (najsłabszy
|
|
||||||
# prawdziwy duplikat). 0.70 siedzi w jej środku.
|
|
||||||
_MIN_TITLE_SIM = 0.70
|
|
||||||
|
|
||||||
# UWAGA (2026-07-27): bez wykluczenia `phash_blacklist` ten self-join jest NIEBEZPIECZNY.
|
|
||||||
# Jedna zaślepka była dzielona przez 892 sceny → sam ten klaster daje ~397 tys. par, a
|
|
||||||
# każde dwie z nich o zbliżonej długości i wspólnym performerze zostałyby scalone, mimo
|
|
||||||
# że to zupełnie różne sceny. Blacklista musi być odświeżona PRZED uruchomieniem:
|
|
||||||
# python -c "from app.scheduler.phash_blacklist import run_phash_blacklist; run_phash_blacklist()"
|
|
||||||
_SAFE_PAIRS_SQL = """
|
_SAFE_PAIRS_SQL = """
|
||||||
SELECT DISTINCT a.scene_id AS sa, b.scene_id AS sb
|
SELECT DISTINCT a.scene_id AS sa, b.scene_id AS sb
|
||||||
FROM scene_fingerprints a
|
FROM scene_fingerprints a
|
||||||
|
|
@ -58,7 +36,6 @@ JOIN scene_fingerprints b ON a.value = b.value AND a.scene_id < b.scene_id
|
||||||
JOIN scenes sca ON sca.id = a.scene_id
|
JOIN scenes sca ON sca.id = a.scene_id
|
||||||
JOIN scenes scb ON scb.id = b.scene_id
|
JOIN scenes scb ON scb.id = b.scene_id
|
||||||
WHERE a.kind = 'phash' AND b.kind = 'phash'
|
WHERE a.kind = 'phash' AND b.kind = 'phash'
|
||||||
AND NOT EXISTS (SELECT 1 FROM phash_blacklist bl WHERE bl.value = a.value)
|
|
||||||
AND sca.duration_sec IS NOT NULL AND scb.duration_sec IS NOT NULL
|
AND sca.duration_sec IS NOT NULL AND scb.duration_sec IS NOT NULL
|
||||||
AND abs(sca.duration_sec - scb.duration_sec) <= 3
|
AND abs(sca.duration_sec - scb.duration_sec) <= 3
|
||||||
AND (
|
AND (
|
||||||
|
|
@ -81,7 +58,7 @@ def main() -> None:
|
||||||
pairs = [(r[0], r[1]) for r in s.execute(text(_SAFE_PAIRS_SQL))]
|
pairs = [(r[0], r[1]) for r in s.execute(text(_SAFE_PAIRS_SQL))]
|
||||||
print(f"safe high-confidence duplicate pairs: {len(pairs)}")
|
print(f"safe high-confidence duplicate pairs: {len(pairs)}")
|
||||||
|
|
||||||
merged = skipped = rejected = 0
|
merged = skipped = 0
|
||||||
for sa, sb in pairs:
|
for sa, sb in pairs:
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
a = s.get(Scene, sa)
|
a = s.get(Scene, sa)
|
||||||
|
|
@ -89,14 +66,6 @@ def main() -> None:
|
||||||
if a is None or b is None or a.id == b.id:
|
if a is None or b is None or a.id == b.id:
|
||||||
skipped += 1 # już zmergowane w tym klastrze
|
skipped += 1 # już zmergowane w tym klastrze
|
||||||
continue
|
continue
|
||||||
# Bezpiecznik na pary drugiego rzędu (patrz docstring): sam wspólny phash
|
|
||||||
# nie wystarcza, gdy któraś strona jest konglomeratem po wcześniejszym
|
|
||||||
# merge'u i niesie odziedziczone odciski obcych scen.
|
|
||||||
sim = fuzz.token_set_ratio(a.title_normalized or "", b.title_normalized or "") / 100.0
|
|
||||||
if sim < _MIN_TITLE_SIM:
|
|
||||||
rejected += 1
|
|
||||||
print(f" SKIP (tytuł {sim:.2f}) '{(a.title or '')[:34]}' vs '{(b.title or '')[:34]}'")
|
|
||||||
continue
|
|
||||||
keep, drop = _pick_keep_drop(s, a, b)
|
keep, drop = _pick_keep_drop(s, a, b)
|
||||||
print(f" {'MERGE' if args.yes else '[dry] MERGE'} keep={str(keep.id)[:8]} "
|
print(f" {'MERGE' if args.yes else '[dry] MERGE'} keep={str(keep.id)[:8]} "
|
||||||
f"'{(keep.title or '')[:32]}' ({keep.duration_sec}s) <- drop={str(drop.id)[:8]} "
|
f"'{(keep.title or '')[:32]}' ({keep.duration_sec}s) <- drop={str(drop.id)[:8]} "
|
||||||
|
|
@ -107,11 +76,9 @@ def main() -> None:
|
||||||
merged += 1
|
merged += 1
|
||||||
|
|
||||||
if args.yes:
|
if args.yes:
|
||||||
print(f"\nAPPLIED: merged={merged} skipped(already-gone)={skipped} "
|
print(f"\nAPPLIED: merged={merged} skipped(already-gone)={skipped}")
|
||||||
f"rejected(tytuł<{_MIN_TITLE_SIM})={rejected}")
|
|
||||||
else:
|
else:
|
||||||
print(f"\n(dry-run — {len(pairs)} par, z tego {rejected} odrzuconych na tytule; "
|
print(f"\n(dry-run — {len(pairs)} par; uruchom z --yes aby scalić. NIEODWRACALNE)")
|
||||||
f"uruchom z --yes aby scalić. NIEODWRACALNE)")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
"""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,43 +41,16 @@ META_MARKERS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Slug w feedzie to slug RECENZJI ("youperv-website-review"), nie domena. Bez odcięcia
|
|
||||||
# sufiksu fallback budował "youperv-website-review.com" → conn_refused → wpis lądował
|
|
||||||
# jako "dead/skip". Tak wypadło 15 ze 144 kandydatów, w tym youperv i pornbusy, które
|
|
||||||
# potem niezależnie oceniliśmy na 4.5-5/5 i dodaliśmy. Fix 2026-07-27.
|
|
||||||
_REVIEW_SUFFIX_RE = re.compile(r"-(?:website-)?review$", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _slug_to_domain(slug: str) -> str:
|
|
||||||
return _REVIEW_SUFFIX_RE.sub("", slug.lower().strip())
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_one(cli: httpx.AsyncClient, url: str) -> tuple[int, str]:
|
async def fetch_one(cli: httpx.AsyncClient, url: str) -> tuple[int, str]:
|
||||||
"""Fetch z Chrome TLS (curl_cffi) — NIE gołym httpx.
|
|
||||||
|
|
||||||
FIX 2026-07-27: pierwotna wersja szła czystym httpx, którego JA3 fingerprint
|
|
||||||
większość tubów za Cloudflare odrzuca. Efekt: 8 timeoutów + 4× 403 w wynikach,
|
|
||||||
czytane potem jako "dead/skip", choć strony działają w przeglądarce. `browser_get`
|
|
||||||
(curl_cffi, chrome120) przechodzi — to ta sama ścieżka, której używają nasze
|
|
||||||
scrapery, więc triage mierzy wreszcie to samo co produkcja.
|
|
||||||
"""
|
|
||||||
import asyncio as _asyncio
|
|
||||||
|
|
||||||
from app.extractors._fetch import browser_get
|
|
||||||
|
|
||||||
def _sync():
|
|
||||||
return browser_get(url, timeout=20.0, headers={"User-Agent": UA})
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = await _asyncio.to_thread(_sync)
|
r = await cli.get(url, headers={"User-Agent": UA}, follow_redirects=True)
|
||||||
return r.status_code, (r.text or "")[:200_000]
|
return r.status_code, r.text[:200_000] # cap response
|
||||||
except Exception as e:
|
except httpx.ConnectError:
|
||||||
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 -1, "conn_refused"
|
||||||
return -9, f"{name}: {e}"[:120]
|
except httpx.TimeoutException:
|
||||||
|
return -2, "timeout"
|
||||||
|
except Exception as e:
|
||||||
|
return -9, str(e)[:120]
|
||||||
|
|
||||||
|
|
||||||
def analyze_html(html: str) -> dict:
|
def analyze_html(html: str) -> dict:
|
||||||
|
|
@ -179,7 +152,7 @@ async def main():
|
||||||
domain = r.get("domain") or ""
|
domain = r.get("domain") or ""
|
||||||
# Jeśli pdude.link daje porndudecams.com (interstitial), użyj <slug>.com
|
# Jeśli pdude.link daje porndudecams.com (interstitial), użyj <slug>.com
|
||||||
if not domain or "porndudecams" in domain:
|
if not domain or "porndudecams" in domain:
|
||||||
domain = f"{_slug_to_domain(r['slug'])}.com"
|
domain = f"{r['slug'].lower()}.com"
|
||||||
return {**r, **(await audit_one(cli, r["slug"], domain))}
|
return {**r, **(await audit_one(cli, r["slug"], domain))}
|
||||||
|
|
||||||
results = await asyncio.gather(*[worker(r) for r in new_candidates])
|
results = await asyncio.gather(*[worker(r) for r in new_candidates])
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue