fix: hentaimama i xanime graja, filmy mangoporn z hosterami, ostatni link filmu chroniony

hentaimama: nowy zapis id posta i 5 playerow w admin-ajax, adresy w JSON-ie;
bierzemy mp4 z gdvid (3 jakosci, bez tokenu), POST najpierw bez proxy.
Ogon "Watch Online Free in HD" w tytulach ucinany.
xanimeporn: przekierowanie download.php rozwiazywane na serwerze, apka
dostawala .php, otwierala WebView i pobierala plik zamiast grac.
Blad resolve (extractor pusty, HTTP strony) trafia do playback_events.

Filmy: tytul z okruszkow (motyw dooplay losuje "Watch"/"Now Playing:"),
linki nitroflare/rapidgator nie sa juz brane za wideo, sam landing idzie
do WebView, a przytrzymanie w apce nie zabija ostatniego linku filmu,
jesli serwer widzi, ze zyje. Skrypty naprawcze: tytuly dooplay,
tytuly i miniatury hentaimama, repair_dooplay_movies dla streampornvip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-10-15 12:12:35 +02:00
parent a872fc3a88
commit 45fbe0412e
9 changed files with 439 additions and 66 deletions

View file

@ -21,9 +21,11 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.schemas import PlaybackSourceOut from app.api.schemas import PlaybackSourceOut
from app.api.device import get_device_id
from app.auth import require_api_key from app.auth import require_api_key
from app.db import get_session from app.db import get_session
from app.extractors import ( from app.extractors import (
@ -304,6 +306,18 @@ def resolve_movie_playback(
raw={"origin": pb.origin}, raw={"origin": pb.origin},
) )
) )
elif not links:
# Sam adres strony serwisu (parser nie znalazl hosterow, np. mangoporn po zmianie
# wygladu 10.2026). Ekstraktor nie ma tu czego szukac, telefon gra w WebView.
links.append(
StreamLink(
stream_url=None,
embed_url=pb.page_url,
quality=pb.quality,
type="hoster",
raw={"origin": pb.origin},
)
)
if not links: if not links:
raise HTTPException(status_code=502, detail="no playable links") raise HTTPException(status_code=502, detail="no playable links")
@ -366,6 +380,43 @@ def mark_playback_dead(
log.info("user marked playback %s dead (origin=%s %s)", pb.id, pb.origin, tag) log.info("user marked playback %s dead (origin=%s %s)", pb.id, pb.origin, tag)
def _movie_source_confirmed_dead(pb: Any) -> bool:
"""Czy serwer sam widzi, ze link filmu nie zyje. Uzywane, gdy przytrzymanie w apce
zabiloby OSTATNI link filmu.
Zgloszenie 284c9522 (2026-10-14): jedyny link "Italian She Male 44" oznaczyl telefon,
ktory nie dochodzil do wezla CDN playmogo, a plik gral (206 z innego IP). Film zostal bez
zadnego linku i apka chowala cala sekcje. Martwy jest tylko przy 404/410 strony embedu albo
gdy ekstraktor mowi wprost, ze film usuniety. Rodzina DoodStream ma blok "Not Found"
zawsze w HTML (ukryty), wiec tam zyje, dopoki strona ma pass_md5. Kazda niepewnosc
(blad sieci, captcha, pusty ekstraktor) zostawia link.
"""
import httpx
target = pb.embed_url or pb.page_url
try:
r = httpx.get(
target,
headers={"User-Agent": "Mozilla/5.0", "Referer": pb.page_url or target},
timeout=15.0,
follow_redirects=True,
)
except Exception as e:
log.info("kontrola linku filmu %s padla: %s", pb.id, e)
return False
if r.status_code in (404, 410):
return True
if r.status_code >= 400 or "pass_md5" in r.text:
return False
try:
extract_stream_from_hoster(target, referer=pb.page_url or target)
except HosterDead:
return True
except Exception:
return False
return False
@movies_router.post( @movies_router.post(
"/{movie_id}/playback/{playback_id}/mark-dead", "/{movie_id}/playback/{playback_id}/mark-dead",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@ -384,17 +435,62 @@ def mark_movie_playback_dead(
raise HTTPException(status_code=404, detail="movie playback source not found") raise HTTPException(status_code=404, detail="movie playback source not found")
if pb.dead_at is None: if pb.dead_at is None:
tag = _requester_tag(request) tag = _requester_tag(request)
others_alive = session.scalar(
select(func.count())
.select_from(MoviePlaybackSource)
.where(
MoviePlaybackSource.movie_id == movie_id,
MoviePlaybackSource.id != pb.id,
MoviePlaybackSource.dead_at.is_(None),
)
)
if not others_alive and not _movie_source_confirmed_dead(pb):
log.info(
"user mark-dead na ostatnim zrodle filmu %s odrzucone, serwer go nie potwierdzil (%s)",
pb.id, tag,
)
return
pb.dead_at = datetime.now(UTC) pb.dead_at = datetime.now(UTC)
pb.dead_reason = f"user-marked dead (mobile long-press) {tag}"[:512] pb.dead_reason = f"user-marked dead (mobile long-press) {tag}"[:512]
session.commit() session.commit()
log.info("user marked movie playback %s dead (origin=%s %s)", pb.id, pb.origin, tag) log.info("user marked movie playback %s dead (origin=%s %s)", pb.id, pb.origin, tag)
def _record_resolve_failure(
session: Session, pb: PlaybackSource, kind: str, detail: str, device_id: str
) -> None:
"""Blad rozwiazania po stronie serwera do playback_events.
Telefon po 503/502 z resolve pokazuje alert i nie dochodzi do odtwarzacza, wiec nic nie
zglaszal. Hentaimama padala tak od zmiany strony i miala zero wierszy w telemetrii (ani
sukcesow, ani bledow), wiec zaden alarm jej nie widzial (2026-10-15).
"""
from app.models.playback_event import PlaybackEvent
try:
session.add(
PlaybackEvent(
origin=pb.origin[:64],
scene_id=pb.scene_id,
status="error",
error_kind=kind,
error_detail=detail[:512],
via="backend",
device_id=device_id,
)
)
session.commit()
except Exception as e:
session.rollback()
log.info("zapis bledu resolve do playback_events padl: %s", e)
@router.post("/{scene_id}/playback/{playback_id}/resolve", response_model=ResolveOut) @router.post("/{scene_id}/playback/{playback_id}/resolve", response_model=ResolveOut)
def resolve_playback( def resolve_playback(
scene_id: uuid.UUID, scene_id: uuid.UUID,
playback_id: uuid.UUID, playback_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)], session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> ResolveOut: ) -> ResolveOut:
pb = session.get(PlaybackSource, playback_id) pb = session.get(PlaybackSource, playback_id)
if pb is None or pb.scene_id != scene_id: if pb is None or pb.scene_id != scene_id:
@ -438,6 +534,9 @@ def resolve_playback(
log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, reason) log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, reason)
raise HTTPException(status_code=410, detail=f"playback dead: {reason}") from e raise HTTPException(status_code=410, detail=f"playback dead: {reason}") from e
log.warning("tube fetch http error %s for %s", e.status_code, pb.page_url) log.warning("tube fetch http error %s for %s", e.status_code, pb.page_url)
_record_resolve_failure(
session, pb, "resolve_http", f"tube fetch HTTP {e.status_code}", device_id
)
raise HTTPException( raise HTTPException(
status_code=502, status_code=502,
detail=f"tube fetch failed: HTTP {e.status_code}", detail=f"tube fetch failed: HTTP {e.status_code}",
@ -457,6 +556,7 @@ def resolve_playback(
"extractor None for playback %s (origin=%s) — transient, not marking dead", "extractor None for playback %s (origin=%s) — transient, not marking dead",
pb.id, pb.origin, pb.id, pb.origin,
) )
_record_resolve_failure(session, pb, "resolve_none", "extractor returned nothing", device_id)
# 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film", ten kod # 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film", ten kod
# jest dla permanent removal. 503 = transient, user może retry. # jest dla permanent removal. 503 = transient, user może retry.
# Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py), # Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py),

View file

@ -1,19 +1,19 @@
"""hentaimama.io — browse scraper. Dodany 2026-08-04. Pierwsze źródło wertykału hentai. """hentaimama.io \u2014 browse scraper. Dodany 2026-08-04. Pierwsze źródło wertykału hentai.
**Ten wertykał rządzi się inaczej niż reszta i to jest celowe.** Dla hentai NIE ISTNIEJE **Ten wertykał rządzi się inaczej niż reszta i to jest celowe.** Dla hentai NIE ISTNIEJE
kanon: TPDB i StashDB nie mają animacji ani postaci rysowanych, więc każda scena stąd kanon: TPDB i StashDB nie mają animacji ani postaci rysowanych, więc każda scena stąd
jest technicznie orphanem i to jest POPRAWNE, nie błędne. Kryterium „fabryka orphanów", jest technicznie orphanem i to jest POPRAWNE, nie błędne. Kryterium „fabryka orphanów",
którym oceniamy tuby z żywymi aktorami, tutaj po prostu nie ma zastosowania — nie ma też którym oceniamy tuby z żywymi aktorami, tutaj po prostu nie ma zastosowania \u2014 nie ma też
obsady, więc żadnej bramki na performera. Miarą jakości jest komplet seria + numer obsady, więc żadnej bramki na performera. Miarą jakości jest komplet seria + numer
odcinka + gatunki, a docelowo dedup MIĘDZY stronami hentai (re-hostują się nawzajem). odcinka + gatunki, a docelowo dedup MIĘDZY stronami hentai (re-hostują się nawzajem).
Jednostka treści to ODCINEK (`/episodes/<seria>-episode-N/`), nie seria. Każdy jest Jednostka treści to ODCINEK (`/episodes/<seria>-episode-N/`), nie seria. Każdy jest
samodzielną pozycją z własnym plakatem i playbackiem, więc mapuje się na scenę tak samo samodzielną pozycją z własnym plakatem i playbackiem, więc mapuje się na scenę tak samo
jak w wertykale JAV — bez zmian w schemacie. jak w wertykale JAV \u2014 bez zmian w schemacie.
Motyw jest z rodziny Dooplay, ale `DooplayConnector` się NIE nadaje: nie ma tu Motyw jest z rodziny Dooplay, ale `DooplayConnector` się NIE nadaje: nie ma tu
`data-fl-source` ani `data-post`, na których stoi tamten parser. Player idzie przez `data-fl-source` ani `data-post`, na których stoi tamten parser. Player idzie przez
`POST /wp-admin/admin-ajax.php` i jest za Cloudflare — obsługuje to extractor `POST /wp-admin/admin-ajax.php` i jest za Cloudflare \u2014 obsługuje to extractor
`hentaimama`, patrz jego docstring. `hentaimama`, patrz jego docstring.
Pułapka: `/genre/` w całym HTML to 118 linków (chmura w sidebarze, ten sam wzorzec co Pułapka: `/genre/` w całym HTML to 118 linków (chmura w sidebarze, ten sam wzorzec co
@ -38,14 +38,14 @@ log = logging.getLogger(__name__)
_BASE = "https://hentaimama.io" _BASE = "https://hentaimama.io"
_EPISODE_HREF_RE = re.compile(r'href="(https://hentaimama\.io/episodes/[^"]+)"') _EPISODE_HREF_RE = re.compile(r'href="(https://hentaimama\.io/episodes/[^"]+)"')
# Sam listing `/episodes/` i jego paginacja też pasują do wzorca wyżej — bez tego # Sam listing `/episodes/` i jego paginacja też pasują do wzorca wyżej \u2014 bez tego
# filtra wchodziły jako „odcinek" o tytule „Recent Episodes". # filtra wchodziły jako „odcinek" o tytule „Recent Episodes".
# `/episodes/feed/` (RSS) tez wpada w href-regex i konczyl jako pusty detail z None. # `/episodes/feed/` (RSS) tez wpada w href-regex i konczyl jako pusty detail z None.
_NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/|feed/)?$") _NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/|feed/)?$")
# `og:title` zamiast <h1>: na stronie są trzy <h1>, dwa to logo serwisu, a trzeci bywa # `og:title` zamiast <h1>: na stronie są trzy <h1>, dwa to logo serwisu, a trzeci bywa
# nagłówkiem sekcji. og:title daje czysty „Shoujo Ramune Episode 7". # nagłówkiem sekcji. og:title daje czysty „Shoujo Ramune Episode 7".
_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,150})") _OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,150})")
# Gatunki TYLKO stąd — patrz pułapka w docstringu. # Gatunki TYLKO stąd \u2014 patrz pułapka w docstringu.
_GENRES_BLOCK_RE = re.compile(r'<div class="sgeneros">(.*?)</div>', re.DOTALL) _GENRES_BLOCK_RE = re.compile(r'<div class="sgeneros">(.*?)</div>', re.DOTALL)
_ANCHOR_TEXT_RE = re.compile(r">\s*([^<]{2,40}?)\s*</a>") _ANCHOR_TEXT_RE = re.compile(r">\s*([^<]{2,40}?)\s*</a>")
_DATE_RE = re.compile(r'<span class="date">\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{4})') _DATE_RE = re.compile(r'<span class="date">\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{4})')
@ -55,7 +55,11 @@ _DATE_RE = re.compile(r'<span class="date">\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{
_DT_DURATION_RE = re.compile(r'"duration"\s*:\s*"(\d{2,6})"') _DT_DURATION_RE = re.compile(r'"duration"\s*:\s*"(\d{2,6})"')
_SERIES_RE = re.compile(r'href="https://hentaimama\.io/tvshows/([a-z0-9-]+)/"') _SERIES_RE = re.compile(r'href="https://hentaimama\.io/tvshows/([a-z0-9-]+)/"')
# Uwaga: atrybuty bywają w innej kolejności niż `<meta property=... content=...>`, # Uwaga: atrybuty bywają w innej kolejności niż `<meta property=... content=...>`,
# a wartość ma na końcu \r — stąd luźny wzorzec i strip() przy użyciu. # a wartość ma na końcu \r \u2014 stąd luźny wzorzec i strip() przy użyciu.
_TITLE_TAIL_RE = re.compile(
r"(?:\s*[\u2013\u2014|-]\s*Watch\s+Online\s+Free\s+in\s+HD)?\s*[\u2013\u2014|-]\s*Hentaimama\s*$",
re.IGNORECASE,
)
_OG_IMAGE_RE = re.compile(r"property=['\"]og:image['\"][^>]*content=['\"]([^'\"]+)") _OG_IMAGE_RE = re.compile(r"property=['\"]og:image['\"][^>]*content=['\"]([^'\"]+)")
@ -86,6 +90,9 @@ class HentaiMamaScraper(BaseBrowseScraper):
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
tm = _OG_TITLE_RE.search(detail_html) tm = _OG_TITLE_RE.search(detail_html)
title = html_mod.unescape(tm.group(1)).strip() if tm else "" title = html_mod.unescape(tm.group(1)).strip() if tm else ""
# Od ~31.08.2026 og:title ma ogon "TYTUL - Watch Online Free in HD - Hentaimama"
# (z polpauzami). Ucinamy go, inaczej tytul nie laczy sie z ta sama scena z innych stron.
title = _TITLE_TAIL_RE.sub("", title).strip()
if not title or title.lower() == "hentaimama": if not title or title.lower() == "hentaimama":
return None return None
@ -101,7 +108,7 @@ class HentaiMamaScraper(BaseBrowseScraper):
tags.append( tags.append(
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug) RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
) )
# Seria jako tag — pozwala grupować odcinki bez zmian w schemacie. # Seria jako tag \u2014 pozwala grupować odcinki bez zmian w schemacie.
sm = _SERIES_RE.search(detail_html) sm = _SERIES_RE.search(detail_html)
if sm and sm.group(1) not in seen: if sm and sm.group(1) not in seen:
series = sm.group(1).replace("-", " ").title() series = sm.group(1).replace("-", " ").title()

View file

@ -171,8 +171,15 @@ _VID_TITLE_RE = re.compile(
r'<h1[^>]*\bclass="vid__t[^"]*"[^>]*>\s*(?:<span[^>]*>)?\s*(.+?)\s*(?:</span>)?\s*</h1>', r'<h1[^>]*\bclass="vid__t[^"]*"[^>]*>\s*(?:<span[^>]*>)?\s*(.+?)\s*(?:</span>)?\s*</h1>',
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
# Motyw 2026-10 losuje przedrostek w h1 ("Watch ...", "Now Playing: ...", "Play ...", na
# pandamovies "... in the Panda Database"). Czysty tytul stoi w okruszkach, bierzemy go
# stamtad, a h1 zostaje do roku i studia (i jako zapas bez okruszkow). "Play" nie jest
# zdejmowane z h1, bo bywa czescia tytulu ("Play Time!", "Play With Me").
_BC_CURRENT_RE = re.compile(
r'<span[^>]*\bclass="bc__c"[^>]*\baria-current="page"[^>]*>([^<]+)</span>', re.IGNORECASE
)
_VID_TITLE_PARTS_RE = re.compile( _VID_TITLE_PARTS_RE = re.compile(
r"^(?:Watch\s+)?(?P<title>.+?)" r"^(?:Watch\s+|Now\s+Playing:\s*)?(?P<title>.+?)"
r"(?:\s+(?P<year>(?:19|20)\d{2})\s+by\s+(?P<studio>.+?))?" r"(?:\s+(?P<year>(?:19|20)\d{2})\s+by\s+(?P<studio>.+?))?"
r"(?:\s+Porn\s+Movie\s+Online\s+Free)?\s*$", r"(?:\s+Porn\s+Movie\s+Online\s+Free)?\s*$",
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
@ -359,6 +366,10 @@ def _parse_dooplay_detail(
raw_title = _decode_html(_strip_tags(m_vid.group(1))).strip() raw_title = _decode_html(_strip_tags(m_vid.group(1))).strip()
m_parts = _VID_TITLE_PARTS_RE.match(raw_title) m_parts = _VID_TITLE_PARTS_RE.match(raw_title)
title = (m_parts.group("title") if m_parts else raw_title).strip() title = (m_parts.group("title") if m_parts else raw_title).strip()
title = re.sub(r"\s+in\s+the\s+Panda\s+Database$", "", title, flags=re.IGNORECASE)
m_bc = _BC_CURRENT_RE.search(html)
if m_bc and _decode_html(m_bc.group(1)).strip():
title = _decode_html(m_bc.group(1)).strip()
if m_parts and m_parts.group("year"): if m_parts and m_parts.group("year"):
vid_year = int(m_parts.group("year")) vid_year = int(m_parts.group("year"))
if m_parts and m_parts.group("studio"): if m_parts and m_parts.group("studio"):

View file

@ -273,7 +273,9 @@ def extract_stream_from_hoster(
"""Iterate matches, pomiń preroll/ad URLs. Zwraca pierwszy clean lub None.""" """Iterate matches, pomiń preroll/ad URLs. Zwraca pierwszy clean lub None."""
for m in pattern.finditer(text): for m in pattern.finditer(text):
url = m.group(group) url = m.group(group)
if not _looks_like_ad(url): # Link "Download" do nitroflare/rapidgator konczy sie na .mp4, a to strona
# hostingu plikow (mangoporn 2026-10: 95 bledow ExoPlayera przez proxy).
if not _looks_like_ad(url) and not _FILE_HOSTER_RE.search(url):
return url return url
return None return None
@ -356,7 +358,7 @@ def _try_ytdlp_hoster(iframe_url: str, *, timeout: float) -> str | None:
def _looks_like_video_url(u: str | None) -> bool: def _looks_like_video_url(u: str | None) -> bool:
if not u: if not u:
return False return False
if _looks_like_ad(u): if _looks_like_ad(u) or _FILE_HOSTER_RE.search(u):
return False return False
low = u.lower() low = u.lower()
# Standardowe formaty video. yt-dlp generic czasem zwraca page URL jako # Standardowe formaty video. yt-dlp generic czasem zwraca page URL jako

View file

@ -14,6 +14,13 @@ ale nie trzeba go składać ręcznie — bierzemy iframe taki, jaki przyszedł.
`admin-ajax.php` dostaje challenge Cloudflare (403, „Just a moment..."), dokładnie jak `admin-ajax.php` dostaje challenge Cloudflare (403, „Just a moment..."), dokładnie jak
`POST /api/video-info` przy sexu. Bez klucza proxy extractor zwraca None. `POST /api/video-info` przy sexu. Bez klucza proxy extractor zwraca None.
**2026-10-15 (zgloszenie 6ab3a973, zadna scena nie grala):** strona zmienila zapis na
`{action: 'get_player_contents',a: '79669'}` (spacje), a odpowiedz admin-ajax ma 5 playerow
(`?dt_embed=rtmp|hls|hls-mp4|r2-mp4` i `newjav.php`) z `&amp;#038;` w src. Strony playerow
trzymaja adresy w JSON-ie (ukosniki zapisane jako backslash i slash). javprovider daje 404 dla nowych odcinkow, gdvid
(`/hls/output/.../mp4/<jakosc>/...mp4`, bez tokenu) gra wszedzie. POST przechodzi dzis z VPS-a
bez proxy, Bright Data zostaje tylko na wypadek 403/Cloudflare.
**Preferujemy `gdvid.info` nad `javprovider.com`**: pierwszy oddaje plik BEZ tokenu, **Preferujemy `gdvid.info` nad `javprovider.com`**: pierwszy oddaje plik BEZ tokenu,
więc URL nigdy nie wygasa; drugi ma `?st=...&e=<epoch>`. Oba zweryfikowane z VPS-a: więc URL nigdy nie wygasa; drugi ma `?st=...&e=<epoch>`. Oba zweryfikowane z VPS-a:
206 na Range, `ftypisom` w pierwszym KB (faststart), działają BEZ Referera — stąd 206 na Range, `ftypisom` w pierwszym KB (faststart), działają BEZ Referera — stąd
@ -21,6 +28,7 @@ więc URL nigdy nie wygasa; drugi ma `?st=...&e=<epoch>`. Oba zweryfikowane z VP
""" """
from __future__ import annotations from __future__ import annotations
import html
import json import json
import logging import logging
import re import re
@ -32,11 +40,13 @@ from app.extractors._models import StreamSource
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_BASE = "https://hentaimama.io" _BASE = "https://hentaimama.io"
_POST_ID_RE = re.compile(r"action:'get_player_contents',\s*a:'(\d+)'") _POST_ID_RE = re.compile(r"""action:\s*['"]get_player_contents['"],\s*a:\s*['"](\d+)['"]""")
_BODY_POST_ID_RE = re.compile(r"\bpostid-(\d+)\b")
_IFRAME_SRC_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE) _IFRAME_SRC_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
_MP4_RE = re.compile(r'https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*') _VIDEO_RE = re.compile(r"""https?://[^\s"'<>]+\.(?:mp4|m3u8)[^\s"'<>]*""")
#: Host bez tokenu idzie pierwszy — jego URL nie wygasa. _QUALITY_RE = re.compile(r"/mp4/(\d{3,4})p/")
_HOST_RANK = ("gdvid", "javprovider") #: Host bez tokenu idzie pierwszy, jego URL nie wygasa. javprovider na koniec (404 na nowych).
_HOST_RANK = ("gdvid.info/hls/", "gdvid", "hentaidoge", "javprovider")
def _rank(url: str) -> int: def _rank(url: str) -> int:
@ -46,42 +56,55 @@ def _rank(url: str) -> int:
return len(_HOST_RANK) return len(_HOST_RANK)
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: def _quality(url: str) -> int:
proxy = get_settings().brightdata_proxy_url m = _QUALITY_RE.search(url)
if not proxy: return int(m.group(1)) if m else 0
log.info("hentaimama: brak BRIGHTDATA_PROXY_URL — CF zablokuje POST admin-ajax")
return None
def _player_contents(page_url: str, post_id: str, timeout: float):
"""POST get_player_contents: najpierw bez proxy, Bright Data tylko przy blokadzie."""
from curl_cffi import requests as cr
proxy = get_settings().brightdata_proxy_url
attempts = [None] + ([proxy] if proxy else [])
for px in attempts:
session = cr.Session(
impersonate="chrome120",
proxies={"http": px, "https": px} if px else None,
verify=False,
)
try:
# Strona odcinka najpierw, bez jej ciasteczek CF potrafi odrzucic POST-a.
session.get(page_url, timeout=timeout)
res = session.post(
f"{_BASE}/wp-admin/admin-ajax.php",
data={"action": "get_player_contents", "a": post_id},
headers={"Referer": page_url, "X-Requested-With": "XMLHttpRequest"},
timeout=timeout,
)
if res.status_code == 200:
return session, json.loads(res.text)
log.info("hentaimama: admin-ajax %s (proxy=%s) dla %s", res.status_code, bool(px), page_url)
except Exception as e:
log.info("hentaimama: get_player_contents (proxy=%s) %s: %s", bool(px), page_url, e)
return None, None
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
html_text = fetch_tube_html(page_url, timeout=timeout) html_text = fetch_tube_html(page_url, timeout=timeout)
pm = _POST_ID_RE.search(html_text) pm = _POST_ID_RE.search(html_text) or _BODY_POST_ID_RE.search(html_text)
if not pm: if not pm:
log.info("hentaimama: brak id posta na %s", page_url) log.info("hentaimama: brak id posta na %s", page_url)
return None return None
from curl_cffi import requests as cr session, fields = _player_contents(page_url, pm.group(1), timeout)
if session is None:
try:
session = cr.Session(
impersonate="chrome120",
proxies={"http": proxy, "https": proxy},
verify=False,
)
# Strona odcinka najpierw — bez jej ciasteczek CF odrzuca POST-a.
session.get(page_url, timeout=timeout)
res = session.post(
f"{_BASE}/wp-admin/admin-ajax.php",
data={"action": "get_player_contents", "a": pm.group(1)},
headers={"Referer": page_url, "X-Requested-With": "XMLHttpRequest"},
timeout=timeout,
)
fields = json.loads(res.text)
except Exception as e:
log.info("hentaimama: get_player_contents failed %s: %s", page_url, e)
return None return None
players: list[str] = [] players: list[str] = []
for field in fields if isinstance(fields, list) else []: for field in fields if isinstance(fields, list) else []:
for src in _IFRAME_SRC_RE.findall(str(field)): for src in _IFRAME_SRC_RE.findall(str(field)):
src = html.unescape(html.unescape(src))
if src.startswith("//"): if src.startswith("//"):
src = "https:" + src src = "https:" + src
if src.startswith("http") and src not in players: if src.startswith("http") and src not in players:
@ -91,29 +114,29 @@ def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | Non
seen: set[str] = set() seen: set[str] = set()
for player_url in players: for player_url in players:
try: try:
body = session.get( body = session.get(player_url, headers={"Referer": page_url}, timeout=timeout).text
player_url, headers={"Referer": page_url}, timeout=timeout
).text
except Exception as e: except Exception as e:
log.info("hentaimama: player fetch failed %s: %s", player_url, e) log.info("hentaimama: player fetch failed %s: %s", player_url, e)
continue continue
for link in _MP4_RE.findall(body): body = body.replace(chr(92) + "/", "/")
if link in seen: for link in _VIDEO_RE.findall(body):
if link in seen or "thumbnail" in link or link.endswith(".vtt"):
continue continue
seen.add(link) seen.add(link)
q = _quality(link)
out.append( out.append(
StreamSource( StreamSource(
link=link, link=link,
type="mp4", type="m3u8" if ".m3u8" in link else "mp4",
quality=None, # serwis nie podaje rozdzielczości quality=f"{q}p" if q else None,
referer=_BASE + "/", referer=_BASE + "/",
# Zweryfikowane: 206 bez Referera, faststart → telefon gra direct. # Zweryfikowane: 206 bez Referera, faststart, telefon gra direct.
raw={"mobile_direct_ok": True}, raw={"mobile_direct_ok": True},
) )
) )
if not out: if not out:
log.info("hentaimama: nie znalazłem mp4 dla %s", page_url) log.info("hentaimama: nie znalazlem wideo dla %s", page_url)
return None return None
out.sort(key=lambda s: _rank(s.link)) out.sort(key=lambda s: (_rank(s.link), s.type != "mp4", -_quality(s.link)))
return out return out

View file

@ -12,11 +12,14 @@ Wyjściem jest zakładka „Downloads", która niesie:
`postId` siedzi w klasie `<body>` (`postid-69951`), więc nie trzeba parsować zakładki — `postId` siedzi w klasie `<body>` (`postid-69951`), więc nie trzeba parsować zakładki —
składamy URL sami dla obu jakości. składamy URL sami dla obu jakości.
**Oddajemy `download.php`, a NIE docelowego pliku.** Endpoint przekierowuje na **Oddajemy docelowy plik, nie `download.php` (zmiana 2026-10-15).** Endpoint przekierowuje
`download.xanimeporn.com/...?s=<sig>&e=<epoch>`, czyli URL z tokenem i wygasaniem. na `download.xanimeporn.com/videos/<q>/...mp4?s=<sig>&e=<epoch>` z `Content-Disposition:
Zwracając `download.php` zostawiamy generowanie tokenu na moment odtwarzania: telefon attachment`. Wczesniej oddawalismy `download.php`, zeby telefon sam bral swiezy podpis, ale
idzie za przekierowaniem i dostaje świeży podpis, więc nic nie wietrzeje. To ta sama apka po `.php` w adresie uznawala link za strone playera i otwierala go w WebView, ktory
lekcja co przy watchporn, gdzie zapisany statyczny URL po czasie padał. POBIERAL plik do Pobranych zamiast grac (zgloszenie 54b17aed; od dodania serwisu ani jedno
odtworzenie). Podpis zyje ok. 2 h i nie jest zwiazany z IP (adres z VPS-a gra z domowego),
a resolve dzieje sie przy kliknieciu, wiec rozwiazujemy przekierowanie tutaj. Gdy sie nie
uda, zostaje `download.php` jako zapas.
Zweryfikowane z VPS-a: 1080p 222 MB i 720p 84 MB, oba 206 na Range z `ftypisom`/`ftypmp42` Zweryfikowane z VPS-a: 1080p 222 MB i 720p 84 MB, oba 206 na Range z `ftypisom`/`ftypmp42`
w pierwszym KB (faststart). Wymaga Referera i UA, oba dokłada `playback.py`. Bez w pierwszym KB (faststart). Wymaga Referera i UA, oba dokłada `playback.py`. Bez
@ -27,6 +30,8 @@ from __future__ import annotations
import logging import logging
import re import re
import httpx
from app.extractors._fetch import fetch_tube_html from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource from app.extractors._models import StreamSource
@ -36,6 +41,7 @@ _BASE = "https://xanimeporn.com"
_POST_ID_RE = re.compile(r"\bpostid-(\d+)\b") _POST_ID_RE = re.compile(r"\bpostid-(\d+)\b")
#: Serwis publikuje dokładnie te dwie jakości; kolejność = preferencja. #: Serwis publikuje dokładnie te dwie jakości; kolejność = preferencja.
_QUALITIES = ("1080p", "720p") _QUALITIES = ("1080p", "720p")
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
@ -46,15 +52,29 @@ def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | Non
return None return None
post_id = m.group(1) post_id = m.group(1)
return [ out: list[StreamSource] = []
StreamSource( for q in _QUALITIES:
link=f"https://videos.xanimeporn.com/download.php?id={post_id}&quality={q}", link = f"https://videos.xanimeporn.com/download.php?id={post_id}&quality={q}"
type="mp4", try:
quality=q, res = httpx.get(
referer=_BASE + "/", link,
# Zweryfikowane 206 + faststart z VPS-a; telefon sam podąża za 302 i headers={"User-Agent": _UA, "Referer": _BASE + "/"},
# dostaje własny, świeży token. follow_redirects=False,
raw={"mobile_direct_ok": True}, timeout=15.0,
)
location = res.headers.get("location") or ""
if res.status_code in (301, 302, 303, 307, 308) and location.startswith("http"):
link = location
except Exception as e:
log.info("xanimeporn: przekierowanie %s %s nie wyszlo: %s", post_id, q, e)
out.append(
StreamSource(
link=link,
type="mp4",
quality=q,
referer=_BASE + "/",
# Zweryfikowane 206 + faststart, podpis nie jest zwiazany z IP.
raw={"mobile_direct_ok": True},
)
) )
for q in _QUALITIES return out
]

View file

@ -0,0 +1,98 @@
"""Czysci tytuly filmow z naglowka nowego motywu dooplay (10.2026) i scala duplikaty.
Motyw mangoporn/streamporn.li wstawia do h1 "Watch TYTUL 2026 by STUDIO Porn Movie Online
Free" albo "Now Playing: TYTUL". Parser od 2026-10-15 bierze tytul z okruszkow, ten skrypt
poprawia to, co weszlo wczesniej:
- "Now Playing: X" -> "X" (zawsze naglowek, nigdy prawdziwy tytul),
- "Watch X RRRR by S Porn Movie Online Free" -> "X" (tylko z tym ogonem; "Watch Me Eat My
Creampie 5" to prawdziwy tytul i zostaje),
- "Play X" zostaje, bo bywa tytulem ("Play Time!").
Jesli po poprawce istnieje juz inny film o tym samym title_normalized i zgodnym roku
(rowny albo jeden pusty), poprawiany film jest do niego dolaczany (merge_movies, starszy
zostaje). Wiecej niz jedno trafienie = tylko poprawka tytulu, bez scalania.
python scripts/fix_dooplay_junk_titles.py # podglad
python scripts/fix_dooplay_junk_titles.py --apply
"""
from __future__ import annotations
import argparse
import logging
import re
import sqlalchemy as sa
from app.db import session_scope
from app.models.movie import Movie
from app.normalize.text import normalize
from app.resolve.movie_merge import merge_movies
log = logging.getLogger(__name__)
_NOW_PLAYING_RE = re.compile(r"^Now\s+Playing:\s*(?P<title>.+?)\s*$", re.IGNORECASE | re.DOTALL)
_WATCH_TAIL_RE = re.compile(
r"^Watch\s+(?P<title>.+?)(?:\s+(?:19|20)\d{2}\s+by\s+.+?)?\s+Porn\s+Movie\s+Online\s+Free\s*$",
re.IGNORECASE | re.DOTALL,
)
def clean_title(title: str) -> str | None:
for rx in (_NOW_PLAYING_RE, _WATCH_TAIL_RE):
m = rx.match(title)
if m and m.group("title").strip():
return m.group("title").strip()
return None
def run(apply: bool) -> dict[str, int]:
counters = {"candidates": 0, "retitled": 0, "merged": 0, "ambiguous": 0}
with session_scope() as session:
rows = session.execute(
sa.select(Movie.id, Movie.title, Movie.release_year).where(
sa.or_(
Movie.title.op("~*")(r"^now playing:"),
Movie.title.op("~*")(r"^watch .+ porn movie online free\s*$"),
)
)
).all()
for movie_id, title, year in rows:
new_title = clean_title(title)
if not new_title:
continue
counters["candidates"] += 1
norm = normalize(new_title)
twins = session.execute(
sa.select(Movie.id, Movie.release_year, Movie.created_at)
.where(Movie.title_normalized == norm, Movie.id != movie_id)
.order_by(Movie.created_at)
).all()
twins = [t for t in twins if t.release_year is None or year is None or t.release_year == year]
print(f"{title[:70]!r} -> {new_title[:60]!r} twins={len(twins)}")
if not apply:
continue
movie = session.get(Movie, movie_id)
movie.title = new_title[:512]
movie.title_normalized = norm[:512]
counters["retitled"] += 1
if len(twins) == 1:
merge_movies(session, keep_id=twins[0].id, drop_id=movie_id, resolved_by="fix_dooplay_junk_titles")
counters["merged"] += 1
elif len(twins) > 1:
counters["ambiguous"] += 1
session.commit()
return counters
def main() -> int:
logging.basicConfig(level=logging.INFO)
p = argparse.ArgumentParser()
p.add_argument("--apply", action="store_true")
args = p.parse_args()
print(run(args.apply))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,110 @@
"""Hentaimama: ogon w tytulach i brakujace miniatury (2026-10-15).
1. Tytuly: od ~31.08.2026 og:title mial ogon "TYTUL - Watch Online Free in HD - Hentaimama"
i tak weszlo ok. 100 scen. Scraper juz go ucina, tu poprawiamy istniejace (title i
title_normalized).
2. Miniatury: ok. 2100 zrodel z pierwszego przebiegu (12.08) nie ma thumbnail_url, a te sceny
nie maja innego zrodla z obrazkiem. Strony maja og:image, pobieramy je po kolei.
python scripts/fix_hentaimama_titles_thumbs.py # podglad
python scripts/fix_hentaimama_titles_thumbs.py --apply [--limit N] [--sleep 0.5]
Dlugie, wiec na prodzie przez `docker compose run -d --rm worker python3 scripts/...`
(z pliku, nie przez stdin).
"""
from __future__ import annotations
import argparse
import html as html_mod
import logging
import time
import sqlalchemy as sa
from app.connectors.direct_scrapers.hentaimama import _OG_IMAGE_RE, _TITLE_TAIL_RE
from app.db import session_scope
from app.extractors._fetch import fetch_tube_html
from app.normalize.text import normalize
log = logging.getLogger(__name__)
def fix_titles(apply: bool) -> int:
n = 0
with session_scope() as session:
rows = session.execute(
sa.text("SELECT id, title FROM scenes WHERE title ~* 'hentaimama\\s*$'")
).all()
for scene_id, title in rows:
new = _TITLE_TAIL_RE.sub("", title).strip()
if not new or new == title:
continue
n += 1
if n <= 5:
print(f"{title!r} -> {new!r}")
if apply:
session.execute(
sa.text("UPDATE scenes SET title=:t, title_normalized=:n WHERE id=:id"),
{"t": new[:512], "n": normalize(new)[:512], "id": scene_id},
)
if apply:
session.commit()
return n
def fix_thumbs(apply: bool, limit: int, sleep: float) -> dict[str, int]:
c = {"checked": 0, "filled": 0, "no_image": 0, "fetch_fail": 0}
with session_scope() as session:
rows = session.execute(
sa.text(
"SELECT id, page_url FROM playback_sources WHERE origin='tube:hentaimama' "
"AND thumbnail_url IS NULL AND dead_at IS NULL ORDER BY id LIMIT :lim"
),
{"lim": limit},
).all()
for pb_id, page_url in rows:
c["checked"] += 1
try:
page = fetch_tube_html(page_url, timeout=30.0)
except Exception as e:
c["fetch_fail"] += 1
log.info("fetch %s: %s", page_url, e)
time.sleep(sleep)
continue
m = _OG_IMAGE_RE.search(page)
if not m:
c["no_image"] += 1
else:
img = html_mod.unescape(m.group(1)).strip()
if apply:
with session_scope() as session:
session.execute(
sa.text("UPDATE playback_sources SET thumbnail_url=:u WHERE id=:id"),
{"u": img[:2048], "id": pb_id},
)
session.commit()
c["filled"] += 1
if c["filled"] <= 3:
print(page_url, "->", img)
if c["checked"] % 100 == 0:
log.info("thumbs %s", c)
time.sleep(sleep)
return c
def main() -> int:
logging.basicConfig(level=logging.INFO)
p = argparse.ArgumentParser()
p.add_argument("--apply", action="store_true")
p.add_argument("--limit", type=int, default=5)
p.add_argument("--sleep", type=float, default=0.5)
p.add_argument("--skip-titles", action="store_true")
args = p.parse_args()
if not args.skip_titles:
print("titles:", fix_titles(args.apply))
print("thumbs:", fix_thumbs(args.apply, args.limit, args.sleep))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -25,6 +25,7 @@ from app.connectors.dooplay import (
MangopornConnector, MangopornConnector,
PandamoviesConnector, PandamoviesConnector,
StreampornConnector, StreampornConnector,
StreampornVipConnector,
) )
from app.db import session_scope from app.db import session_scope
from app.models.movie import Movie from app.models.movie import Movie
@ -38,6 +39,7 @@ CONNECTORS = {
"pandamovies": PandamoviesConnector, "pandamovies": PandamoviesConnector,
"mangoporn": MangopornConnector, "mangoporn": MangopornConnector,
"streamporn": StreampornConnector, "streamporn": StreampornConnector,
"streampornvip": StreampornVipConnector,
} }