goon/app/connectors/freeomovie.py
goon-foss 0c18472a15 fix(feedback): freeomovie posters + dead hoster + myvidplay; watchporn _embed_iframe guard
From user bug reports (8f8c10c0 freeomovie, c156e4b7/8db71220 watchporn scene):

freeomovie (app/connectors/freeomovie.py):
- Parse the detail-page poster (<img class="rmbd">); RawMovie was built with no
  poster_url, so title-only orphans (no canonical match) rendered blank cards. Re-ingest
  backfilled existing blanks 11 -> 1.
- _host_label now returns the registrable domain (parts[-2]) not the subdomain, so
  video.player4me.xyz labels as 'player4me' (was garbage 'freeomovie:video').
- Skip player4me (JS-SPA, no known resolver); pruned 28 dead freeomovie:video sources.
- myvidplay.com added to mobile DOOD_HOSTS (doodstream.ts): it 301s to playmogo.com
  (doodcdn clone), so it now routes to the native dood resolver instead of a dead WebView.

watchporn: the reported 4 duplicate/broken quality rows were the API process serving a
STALE extractor registry (never restarted after the re-enable deploy) -> _embed_iframe
scraped 2 tokenless get_file (403) + 2 preview trailers (404). The running api now serves
the native extractor (2 clean playing links, verified). Hardened _embed_iframe to skip
preview/videos_screenshots clips and tokenless get_file so this class can't recur.

Also: DonateScreen em-dashes -> commas/semicolon (no-em-dash rule).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:02:47 +02:00

201 lines
7.6 KiB
Python

"""freeomovie.to — movie source (WordPress "bestia" theme, NIE dooplay).
Dodany 2026-07-02 (ocena). Pełnometrażowe filmy, świeże (auto-poster, dziś), tytuły
DVD → title-trigram mirror-attach do canonical (paradisehill/TPDB movies), dokładając
playback_sources. Scope: TYLKO `/category/full-movie/` (homepage miesza scen-klipy,
które orphanowałyby jako filmy).
Struktura:
- listing `<li class="thumi"><a href title>` → URL filmu + czysty tytuł
- detail: `var TABS=[{label,url},...]` (hostery), `"articleSection"` (gatunki + obsada;
gatunki PRZED "XXX Movies" → tagi), `"datePublished"` (data POSTA, NIE rok produkcji)
Świadomie NIE bierzemy:
- release_year: datePublished to data uploadu (2026), nie rok filmu → fałszywy rok
psułby year-scoring przy matchu do canonical (film z 2010 dostałby 2026). Lepiej None.
- performerów/studia z articleSection: format miesza performer/alias/studio bez czystego
delimitera → ryzyko junk-performera ("Deeveeous" studio jako performer). Mirror i tak
doczepia się do canonical po tytule, a TPDB enrichment dokłada obsadę/studio autorytatywnie.
Playback: TABS hostery → MoviePlaybackSource per host (voe/luluvid/vidhide resolvują się
VPS-side; myvidplay = DoodStream clone, phone-side — wymaga myvidplay.com w DOOD_HOSTS).
Pomijamy streamtape (martwy malware) + mxdrop (flaky).
"""
from __future__ import annotations
import html
import json
import logging
import re
from collections.abc import Iterator
from datetime import datetime
from urllib.parse import urlparse
from app.connectors.base import (
BaseMovieConnector,
RawMovie,
RawPlaybackSource,
RawTag,
)
from app.extractors import browser_get
from app.models.source import SourceKind
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://www.freeomovie.to"
_LIST_ITEM_RE = re.compile(
r'<li[^>]*class="thumi"[^>]*>\s*<a[^>]+href="(?P<url>https://www\.freeomovie\.to/[a-z0-9\-]+/)"[^>]*title="(?P<title>[^"]+)"',
re.IGNORECASE,
)
_TABS_RE = re.compile(r"var\s+TABS\s*=\s*(\[.*?\])\s*;", re.DOTALL)
_ARTICLE_SECTION_RE = re.compile(r'"articleSection"\s*:\s*"([^"]+)"')
# Poster: <img class="... rmbd ..." src="..."> (fastpic.org itp.). Bez tego film-orphan
# (tytuł bez matchu do canonical) miał poster_url=None → pusty kafelek (report 8f8c10c0).
_POSTER_RE = re.compile(
r'<img[^>]+class="[^"]*\brmbd\b[^"]*"[^>]+src="(https?://[^"]+)"',
re.IGNORECASE,
)
# Hostery pomijane (martwe/flaky). player4me = JS-SPA bez znanego resolvera (28 martwych
# źródeł freeomovie:video, report 8f8c10c0).
_SKIP_HOSTS = ("streamtape", "mxdrop", "mixdrop", "streamsb", "player4me")
def _host_label(embed_url: str) -> str | None:
host = (urlparse(embed_url).hostname or "").lower()
if not host:
return None
parts = host.replace("www.", "").split(".")
# Rejestrowalna domena (SLD), nie subdomena: video.player4me.xyz → 'player4me' (nie
# 'video'), myvidplay.com → 'myvidplay'. Inaczej origin był śmieciowy (freeomovie:video).
return parts[-2] if len(parts) >= 2 else parts[0]
class FreeoMovieConnector(BaseMovieConnector):
kind = SourceKind.scraper
name = "freeomovie"
base_url = _BASE
_MAX_PAGES_DELTA = 3
_MAX_PAGES_FULL = 40
def __init__(self, *, timeout: float = 30.0) -> None:
self._timeout = timeout
def close(self) -> None:
pass
def _fetch(self, url: str) -> str:
if not url.startswith("http"):
url = _BASE + url
r = browser_get(
url,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/125.0",
"Accept": "text/html,application/xhtml+xml",
"Referer": _BASE + "/",
},
timeout=self._timeout,
follow_redirects=True,
)
if r.status_code >= 400:
raise RuntimeError(f"{r.status_code} for {url}")
return r.text
def fetch_movies(
self, *, since: datetime | None = None, limit: int | None = None
) -> Iterator[RawMovie]:
seen = 0
seen_urls: set[str] = set()
max_pages = self._MAX_PAGES_DELTA if since is not None else self._MAX_PAGES_FULL
for page in range(1, max_pages + 1):
path = "/category/full-movie/" if page == 1 else f"/category/full-movie/page/{page}/"
try:
listing = self._fetch(path)
except Exception as e:
log.warning("freeomovie listing page=%d failed: %s", page, e)
return
items = [(m.group("url"), html.unescape(m.group("title")).strip())
for m in _LIST_ITEM_RE.finditer(listing)]
if not items:
log.info("freeomovie: empty page=%d, stop", page)
return
for url, title in items:
if url in seen_urls:
continue
seen_urls.add(url)
try:
movie = self._parse_detail(url, title)
except Exception as e:
log.warning("freeomovie detail %s failed: %s", url, e)
continue
if movie is None:
continue
yield movie
seen += 1
if limit is not None and seen >= limit:
return
def _parse_detail(self, url: str, title: str) -> RawMovie | None:
detail = self._fetch(url)
if not title:
return None
# Tagi: gatunki z articleSection PRZED "XXX Movies" (reszta = obsada/studio, skip).
tags: list[RawTag] = []
seen_tag: set[str] = set()
sm = _ARTICLE_SECTION_RE.search(detail)
if sm:
for part in sm.group(1).split(","):
name = part.strip()
if not name or name.lower() == "xxx movies":
if name.lower() == "xxx movies":
break # dalej idzie obsada/studio — nie tagi
continue
sl = slugify(name)
if not sl or sl in seen_tag:
continue
seen_tag.add(sl)
tags.append(RawTag(external_id=f"{self.name}:tag:{sl}", name=name, slug=sl))
# Playback: TABS array hosterów.
playback: list[RawPlaybackSource] = []
seen_host: set[str] = set()
tm = _TABS_RE.search(detail)
if tm:
try:
arr = json.loads(tm.group(1))
except (json.JSONDecodeError, ValueError):
arr = []
for entry in arr:
embed = (entry.get("url") or "").strip() if isinstance(entry, dict) else ""
if not embed.startswith("http"):
continue
host = _host_label(embed)
if not host or host in _SKIP_HOSTS or host in seen_host:
continue
seen_host.add(host)
playback.append(
RawPlaybackSource(
origin=f"{self.name}:{host}",
page_url=embed,
embed_url=embed,
)
)
if not playback:
return None # bez playbacku film jest bezużyteczny (nie tworzymy orphana)
pm = _POSTER_RE.search(detail)
poster_url = html.unescape(pm.group(1)) if pm else None
slug = urlparse(url).path.strip("/").split("/")[-1]
return RawMovie(
external_id=slug,
title=title,
url=url,
poster_url=poster_url,
tags=tags,
playback_sources=playback,
raw={"source": "freeomovie", "url": url},
)