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:
parent
a872fc3a88
commit
45fbe0412e
9 changed files with 439 additions and 66 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 `&#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
|
||||||
|
|
|
||||||
|
|
@ -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] = []
|
||||||
|
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(
|
StreamSource(
|
||||||
link=f"https://videos.xanimeporn.com/download.php?id={post_id}&quality={q}",
|
link=link,
|
||||||
type="mp4",
|
type="mp4",
|
||||||
quality=q,
|
quality=q,
|
||||||
referer=_BASE + "/",
|
referer=_BASE + "/",
|
||||||
# Zweryfikowane 206 + faststart z VPS-a; telefon sam podąża za 302 i
|
# Zweryfikowane 206 + faststart, podpis nie jest zwiazany z IP.
|
||||||
# dostaje własny, świeży token.
|
|
||||||
raw={"mobile_direct_ok": True},
|
raw={"mobile_direct_ok": True},
|
||||||
)
|
)
|
||||||
for q in _QUALITIES
|
)
|
||||||
]
|
return out
|
||||||
|
|
|
||||||
98
scripts/fix_dooplay_junk_titles.py
Normal file
98
scripts/fix_dooplay_junk_titles.py
Normal 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())
|
||||||
110
scripts/fix_hentaimama_titles_thumbs.py
Normal file
110
scripts/fix_hentaimama_titles_thumbs.py
Normal 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())
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue