diff --git a/app/api/playback.py b/app/api/playback.py index cb3ad01..a2c29ac 100644 --- a/app/api/playback.py +++ b/app/api/playback.py @@ -21,9 +21,11 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.schemas import PlaybackSourceOut +from app.api.device import get_device_id from app.auth import require_api_key from app.db import get_session from app.extractors import ( @@ -304,6 +306,18 @@ def resolve_movie_playback( 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: 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) +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( "/{movie_id}/playback/{playback_id}/mark-dead", 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") if pb.dead_at is None: 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_reason = f"user-marked dead (mobile long-press) {tag}"[:512] session.commit() 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) def resolve_playback( scene_id: uuid.UUID, playback_id: uuid.UUID, session: Annotated[Session, Depends(get_session)], + device_id: Annotated[str, Depends(get_device_id)], ) -> ResolveOut: pb = session.get(PlaybackSource, playback_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) 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) + _record_resolve_failure( + session, pb, "resolve_http", f"tube fetch HTTP {e.status_code}", device_id + ) raise HTTPException( status_code=502, 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", 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 # jest dla permanent removal. 503 = transient, user może retry. # Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py), diff --git a/app/connectors/direct_scrapers/hentaimama.py b/app/connectors/direct_scrapers/hentaimama.py index 16b9488..992c1e2 100644 --- a/app/connectors/direct_scrapers/hentaimama.py +++ b/app/connectors/direct_scrapers/hentaimama.py @@ -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 kanon: TPDB i StashDB nie mają animacji ani postaci rysowanych, więc każda scena stąd jest technicznie orphanem i to jest POPRAWNE, nie błędne. Kryterium „fabryka orphanów", -którym oceniamy tuby z żywymi aktorami, tutaj po prostu nie ma zastosowania — nie ma też +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 odcinka + gatunki, a docelowo dedup MIĘDZY stronami hentai (re-hostują się nawzajem). Jednostka treści to ODCINEK (`/episodes/-episode-N/`), nie seria. Każdy jest samodzielną pozycją z własnym plakatem i playbackiem, więc mapuje się na scenę tak samo -jak w wertykale JAV — bez zmian w schemacie. +jak w wertykale JAV \u2014 bez zmian w schemacie. Motyw jest z rodziny Dooplay, ale `DooplayConnector` się NIE nadaje: nie ma tu `data-fl-source` ani `data-post`, na których stoi tamten parser. Player idzie przez -`POST /wp-admin/admin-ajax.php` i jest za Cloudflare — obsługuje to extractor +`POST /wp-admin/admin-ajax.php` i jest za Cloudflare \u2014 obsługuje to extractor `hentaimama`, patrz jego docstring. Pułapka: `/genre/` w całym HTML to 118 linków (chmura w sidebarze, ten sam wzorzec co @@ -38,14 +38,14 @@ log = logging.getLogger(__name__) _BASE = "https://hentaimama.io" _EPISODE_HREF_RE = re.compile(r'href="(https://hentaimama\.io/episodes/[^"]+)"') -# Sam listing `/episodes/` i jego paginacja też pasują do wzorca wyżej — bez tego +# Sam listing `/episodes/` i jego paginacja też pasują do wzorca wyżej \u2014 bez tego # filtra wchodziły jako „odcinek" o tytule „Recent Episodes". # `/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/)?$") # `og:title` zamiast

: na stronie są trzy

, dwa to logo serwisu, a trzeci bywa # nagłówkiem sekcji. og:title daje czysty „Shoujo Ramune Episode 7". _OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,150})") -# Gatunki TYLKO stąd — patrz pułapka w docstringu. +# Gatunki TYLKO stąd \u2014 patrz pułapka w docstringu. _GENRES_BLOCK_RE = re.compile(r'
(.*?)
', re.DOTALL) _ANCHOR_TEXT_RE = re.compile(r">\s*([^<]{2,40}?)\s*") _DATE_RE = re.compile(r'\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{4})') @@ -55,7 +55,11 @@ _DATE_RE = re.compile(r'\s*([A-Z][a-z]{2}\.?\s+\d{1,2},\s+\d{ _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-]+)/"') # Uwaga: atrybuty bywają w innej kolejności niż ``, -# 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=['\"]([^'\"]+)") @@ -86,6 +90,9 @@ class HentaiMamaScraper(BaseBrowseScraper): def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: tm = _OG_TITLE_RE.search(detail_html) title = html_mod.unescape(tm.group(1)).strip() if tm else "" + # 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": return None @@ -101,7 +108,7 @@ class HentaiMamaScraper(BaseBrowseScraper): tags.append( 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) if sm and sm.group(1) not in seen: series = sm.group(1).replace("-", " ").title() diff --git a/app/connectors/dooplay.py b/app/connectors/dooplay.py index 69dd3ae..f33a532 100644 --- a/app/connectors/dooplay.py +++ b/app/connectors/dooplay.py @@ -171,8 +171,15 @@ _VID_TITLE_RE = re.compile( r']*\bclass="vid__t[^"]*"[^>]*>\s*(?:]*>)?\s*(.+?)\s*(?:)?\s*

', 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']*\bclass="bc__c"[^>]*\baria-current="page"[^>]*>([^<]+)', re.IGNORECASE +) _VID_TITLE_PARTS_RE = re.compile( - r"^(?:Watch\s+)?(?P.+?)" + r"^(?:Watch\s+|Now\s+Playing:\s*)?(?P<title>.+?)" r"(?:\s+(?P<year>(?:19|20)\d{2})\s+by\s+(?P<studio>.+?))?" r"(?:\s+Porn\s+Movie\s+Online\s+Free)?\s*$", re.IGNORECASE | re.DOTALL, @@ -359,6 +366,10 @@ def _parse_dooplay_detail( raw_title = _decode_html(_strip_tags(m_vid.group(1))).strip() m_parts = _VID_TITLE_PARTS_RE.match(raw_title) 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"): vid_year = int(m_parts.group("year")) if m_parts and m_parts.group("studio"): diff --git a/app/extractors/hoster.py b/app/extractors/hoster.py index 39b9da4..19e2429 100644 --- a/app/extractors/hoster.py +++ b/app/extractors/hoster.py @@ -273,7 +273,9 @@ def extract_stream_from_hoster( """Iterate matches, pomiń preroll/ad URLs. Zwraca pierwszy clean lub None.""" for m in pattern.finditer(text): 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 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: if not u: return False - if _looks_like_ad(u): + if _looks_like_ad(u) or _FILE_HOSTER_RE.search(u): return False low = u.lower() # Standardowe formaty video. yt-dlp generic czasem zwraca page URL jako diff --git a/app/extractors/tubes/hentaimama.py b/app/extractors/tubes/hentaimama.py index 9c1d35b..66209f7 100644 --- a/app/extractors/tubes/hentaimama.py +++ b/app/extractors/tubes/hentaimama.py @@ -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 `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 `&#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, 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 @@ -21,6 +28,7 @@ więc URL nigdy nie wygasa; drugi ma `?st=...&e=<epoch>`. Oba zweryfikowane z VP """ from __future__ import annotations +import html import json import logging import re @@ -32,11 +40,13 @@ from app.extractors._models import StreamSource log = logging.getLogger(__name__) _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) -_MP4_RE = re.compile(r'https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*') -#: Host bez tokenu idzie pierwszy — jego URL nie wygasa. -_HOST_RANK = ("gdvid", "javprovider") +_VIDEO_RE = re.compile(r"""https?://[^\s"'<>]+\.(?:mp4|m3u8)[^\s"'<>]*""") +_QUALITY_RE = re.compile(r"/mp4/(\d{3,4})p/") +#: 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: @@ -46,42 +56,55 @@ def _rank(url: str) -> int: return len(_HOST_RANK) -def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None: - proxy = get_settings().brightdata_proxy_url - if not proxy: - log.info("hentaimama: brak BRIGHTDATA_PROXY_URL — CF zablokuje POST admin-ajax") - return None +def _quality(url: str) -> int: + m = _QUALITY_RE.search(url) + return int(m.group(1)) if m else 0 + +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) - 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: log.info("hentaimama: brak id posta na %s", page_url) return None - from curl_cffi import requests as cr - - 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) + session, fields = _player_contents(page_url, pm.group(1), timeout) + if session is None: return None players: list[str] = [] for field in fields if isinstance(fields, list) else []: for src in _IFRAME_SRC_RE.findall(str(field)): + src = html.unescape(html.unescape(src)) if src.startswith("//"): src = "https:" + src 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() for player_url in players: try: - body = session.get( - player_url, headers={"Referer": page_url}, timeout=timeout - ).text + body = session.get(player_url, headers={"Referer": page_url}, timeout=timeout).text except Exception as e: log.info("hentaimama: player fetch failed %s: %s", player_url, e) continue - for link in _MP4_RE.findall(body): - if link in seen: + body = body.replace(chr(92) + "/", "/") + for link in _VIDEO_RE.findall(body): + if link in seen or "thumbnail" in link or link.endswith(".vtt"): continue seen.add(link) + q = _quality(link) out.append( StreamSource( link=link, - type="mp4", - quality=None, # serwis nie podaje rozdzielczości + type="m3u8" if ".m3u8" in link else "mp4", + quality=f"{q}p" if q else None, referer=_BASE + "/", - # Zweryfikowane: 206 bez Referera, faststart → telefon gra direct. + # Zweryfikowane: 206 bez Referera, faststart, telefon gra direct. raw={"mobile_direct_ok": True}, ) ) 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 - out.sort(key=lambda s: _rank(s.link)) + out.sort(key=lambda s: (_rank(s.link), s.type != "mp4", -_quality(s.link))) return out diff --git a/app/extractors/tubes/xanimeporn.py b/app/extractors/tubes/xanimeporn.py index 4a2f970..309068b 100644 --- a/app/extractors/tubes/xanimeporn.py +++ b/app/extractors/tubes/xanimeporn.py @@ -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 — składamy URL sami dla obu jakości. -**Oddajemy `download.php`, a NIE docelowego pliku.** Endpoint przekierowuje na -`download.xanimeporn.com/...?s=<sig>&e=<epoch>`, czyli URL z tokenem i wygasaniem. -Zwracając `download.php` zostawiamy generowanie tokenu na moment odtwarzania: telefon -idzie za przekierowaniem i dostaje świeży podpis, więc nic nie wietrzeje. To ta sama -lekcja co przy watchporn, gdzie zapisany statyczny URL po czasie padał. +**Oddajemy docelowy plik, nie `download.php` (zmiana 2026-10-15).** Endpoint przekierowuje +na `download.xanimeporn.com/videos/<q>/...mp4?s=<sig>&e=<epoch>` z `Content-Disposition: +attachment`. Wczesniej oddawalismy `download.php`, zeby telefon sam bral swiezy podpis, ale +apka po `.php` w adresie uznawala link za strone playera i otwierala go w WebView, ktory +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` 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 re +import httpx + from app.extractors._fetch import fetch_tube_html from app.extractors._models import StreamSource @@ -36,6 +41,7 @@ _BASE = "https://xanimeporn.com" _POST_ID_RE = re.compile(r"\bpostid-(\d+)\b") #: Serwis publikuje dokładnie te dwie jakości; kolejność = preferencja. _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: @@ -46,15 +52,29 @@ def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | Non return None post_id = m.group(1) - return [ - StreamSource( - link=f"https://videos.xanimeporn.com/download.php?id={post_id}&quality={q}", - type="mp4", - quality=q, - referer=_BASE + "/", - # Zweryfikowane 206 + faststart z VPS-a; telefon sam podąża za 302 i - # dostaje własny, świeży token. - raw={"mobile_direct_ok": True}, + out: list[StreamSource] = [] + for q in _QUALITIES: + link = f"https://videos.xanimeporn.com/download.php?id={post_id}&quality={q}" + try: + res = httpx.get( + link, + headers={"User-Agent": _UA, "Referer": _BASE + "/"}, + follow_redirects=False, + 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 diff --git a/scripts/fix_dooplay_junk_titles.py b/scripts/fix_dooplay_junk_titles.py new file mode 100644 index 0000000..6005986 --- /dev/null +++ b/scripts/fix_dooplay_junk_titles.py @@ -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()) diff --git a/scripts/fix_hentaimama_titles_thumbs.py b/scripts/fix_hentaimama_titles_thumbs.py new file mode 100644 index 0000000..06d8b6f --- /dev/null +++ b/scripts/fix_hentaimama_titles_thumbs.py @@ -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()) diff --git a/scripts/repair_dooplay_movies.py b/scripts/repair_dooplay_movies.py index bcd5090..9ccf2cb 100644 --- a/scripts/repair_dooplay_movies.py +++ b/scripts/repair_dooplay_movies.py @@ -25,6 +25,7 @@ from app.connectors.dooplay import ( MangopornConnector, PandamoviesConnector, StreampornConnector, + StreampornVipConnector, ) from app.db import session_scope from app.models.movie import Movie @@ -38,6 +39,7 @@ CONNECTORS = { "pandamovies": PandamoviesConnector, "mangoporn": MangopornConnector, "streamporn": StreampornConnector, + "streampornvip": StreampornVipConnector, }