fix(movies): przestan podsuwac hostery z rodziny seekplayer, ktore nie maja plikow
Zgloszenia z 2026-08-24: "Easyvidplayer nie chce odpalic" (ccbc75da), "Embedseek tez nie rusza" (d59fc396) i sasiednie. To NIE byl nasz blad parsowania. Co ustalone: `/api/v1/video` odpowiada 200 i poprawnie sie odszyfrowuje, tylko nie ma w odpowiedzi pola `source`. W ich wlasnym bundlu warunek jest doslownie taki sam i konczy sie komunikatem "Video is not ready yet". Potwierdzone w przegladarce z lacza domowego (plakat obok oddal 502), z VPS i przez brytyjskie proxy, wiec to nie blokada po IP ani po kraju. Protokol sie nie zmienil, hoster po prostu nie ma juz plikow. Skala na 250 losowych zrodlach: 3 zywe, 88 razy HTTP 404, 79 razy brak `source`. Czyli okolo 2% jeszcze gra. Dwie zmiany: * `seekplayer_engine.extract` rzuca `HosterDead` zamiast zwracac None. Dopiero wtedy `resolve_movie_playback` oznacza zrodlo martwe. Zwracanie None dawalo WebView z tym samym komunikatem, w kolko, i wlasnie to zglaszali uzytkownicy. Katalog czysci sie teraz sam, w miare jak ludzie po nim chodza. * De-ranking calej rodziny w `_movie_origin_priority`, zeby na wierzch wychodzily hostery, ktore graja. Nie usuwamy ich z listy, bo te 2% bywa jedynym zrodlem. Bezpieczne: filmow zaleznych WYLACZNIE od tej rodziny jest w bazie jeden, pozostale 26 223 maja alternatywe. Dolozony `scripts/seekplayer_reap.py` do sprzatania calosci na zadanie. NIE odpalam go w tle: przy limitach hostera (32% odpowiedzi to 429) pelny przemial to kilkadziesiat godzin ciaglego pukania, co grozi zablokowaniem naszego IP dla prawdziwych resolve. Zweryfikowane na filmie ze zgloszenia: piec zrodel seekplayer oznaczonych martwymi, zostaly mixdrop (206 video/mp4) i voe (200 m3u8), oba sprawdzone spoza serwerowni.
This commit is contained in:
parent
3c8bcada84
commit
5a48175a85
3 changed files with 248 additions and 0 deletions
|
|
@ -163,7 +163,25 @@ _MOVIE_DROP_ORIGINS = frozenset({
|
|||
_MOVIE_LANDING_HIDE = frozenset({"mangoporn", "pandamovies", "streamporn"})
|
||||
|
||||
|
||||
# Rodzina seekplayer (embed `#<hash>`, wspólny silnik) na sam koniec listy.
|
||||
# Pomiar 2026-08-24 na 250 losowych źródłach: 3 miały jeszcze strumień, 88 oddało
|
||||
# HTTP 404, a 79 odpowiedź bez pola `source`, po której ICH WŁASNY player pisze
|
||||
# „Video is not ready yet" (sprawdzone w przeglądarce z łącza domowego, z VPS i przez
|
||||
# brytyjskie proxy, więc to nie blokada po IP). Zgłoszenia `d59fc396`, `ccbc75da`,
|
||||
# `7bfc2e8d` i sąsiednie z tego dnia to dokładnie te trafienia.
|
||||
#
|
||||
# Nie wyrzucamy ich z listy (te ~2% działa i bywa jedynym źródłem), ale nie mogą stać
|
||||
# przed hosterami, które grają. Filmów zależnych WYŁĄCZNIE od tej rodziny jest w bazie
|
||||
# jeden, więc de-ranking nikomu nie zabiera dostępu.
|
||||
_MOVIE_DEPRIORITIZED_HOSTERS = frozenset({
|
||||
"easyvidplayer", "embedseek", "seekplayer", "upns", "player4me", "rpmplay",
|
||||
"4meplayer", "seekplays", "ezplayer", "seeks",
|
||||
})
|
||||
|
||||
|
||||
def _movie_origin_priority(origin: str) -> int:
|
||||
if origin.rpartition(":")[2] in _MOVIE_DEPRIORITIZED_HOSTERS:
|
||||
return 900 # za wszystkim, co nieklasyfikowane
|
||||
try:
|
||||
return _MOVIE_PREFERRED_ORIGINS.index(origin)
|
||||
except ValueError:
|
||||
|
|
|
|||
|
|
@ -167,6 +167,19 @@ def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | Non
|
|||
#
|
||||
# Padnięcie pojedynczego noda leczy ponowny resolve: kolejne wywołania wracają z
|
||||
# różnymi adresami CDN (`BACKEND_RERESOLVE_ORIGINS` w PlayerScreen, commit 47547fe).
|
||||
if not source:
|
||||
# Brak `source` to NIE jest usterka parsowania ani chwilowy problem sieci.
|
||||
# W ich własnym bundlu (`index-*.js`, funkcja startowa playera) warunek brzmi
|
||||
# dosłownie: gdy w odpowiedzi nie ma `source`, player pokazuje „Video is not
|
||||
# ready yet" i kończy. Potwierdzone 2026-08-24 w przeglądarce z łącza domowego
|
||||
# na `vip.easyvidplayer.com/#96kzl`: dokładnie ten komunikat, a plakat obok
|
||||
# oddał 502. To samo z VPS i przez brytyjskie proxy, więc nie chodzi o IP.
|
||||
#
|
||||
# Rzucamy `HosterDead`, bo tylko wtedy `resolve_movie_playback` oznaczy źródło
|
||||
# martwe i przestanie je podsuwać. Zwracanie None dawało WebView z tym samym
|
||||
# komunikatem, w kółko, i tak wyglądały zgłoszenia z 2026-08-24.
|
||||
raise HosterDead(f"seekplayer-engine {page_url}: brak pola source")
|
||||
|
||||
sources: list[StreamSource] = []
|
||||
if source:
|
||||
sources.append(
|
||||
|
|
|
|||
217
scripts/seekplayer_reap.py
Normal file
217
scripts/seekplayer_reap.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Oznacza jako martwe źródła z rodziny seekplayer, których hoster już nie serwuje.
|
||||
|
||||
Dlaczego: zgłoszenia `d59fc396`, `ccbc75da` i sąsiednie z 2026-08-24 („Easyvidplayer
|
||||
nie chce odpalić", „Embedseek też nie rusza"). Sprawdzone: to NIE jest nasz błąd
|
||||
parsowania. Endpoint `/api/v1/video` odpowiada 200 i poprawnie się odszyfrowuje, tylko
|
||||
w odpowiedzi nie ma pola `source`. Ich własny player w tej sytuacji pisze wprost
|
||||
„Video is not ready yet", co potwierdziłem w przeglądarce z łącza domowego, i to samo
|
||||
widać z VPS oraz z brytyjskiego proxy, więc nie chodzi o blokadę po IP ani po kraju.
|
||||
|
||||
Skala (próbka 45 losowych źródeł, 2026-08-24): 26 razy HTTP 404, 18 razy odpowiedź bez
|
||||
strumienia, 1 raz działający strumień. Czyli około 2% katalogu na tym silniku jeszcze
|
||||
gra, reszty hoster już nie ma. Sam protokół jest bez zmian, dlatego NIE kasujemy tych
|
||||
źródeł hurtem, tylko pytamy o każde z osobna i zostawiamy te, które odpowiadają.
|
||||
|
||||
Bezpieczeństwo: filmów zależnych WYŁĄCZNIE od tej rodziny jest w bazie dokładnie
|
||||
jeden, pozostałe 26 223 mają inne źródło. Sprzątanie nie chowa więc katalogu, tylko
|
||||
przestaje podsuwać wybór, który i tak kończy się czarnym ekranem.
|
||||
|
||||
Oszczędność: domeny bliźniacze (`p.` i `vip.easyvidplayer.com`, `my.` i
|
||||
`vip.player4me`) trzymają ten sam identyfikator wideo, więc pytamy raz na parę
|
||||
(marka, hash) i werdykt stosujemy do wszystkich źródeł z tym kluczem.
|
||||
|
||||
Wznawianie za darmo: bierzemy tylko żywe źródła, więc kolejne odpalenie samo pomija
|
||||
to, co już oznaczone. Żadnego pliku stanu.
|
||||
|
||||
Użycie (w kontenerze workera):
|
||||
python3 /srv/scripts/seekplayer_reap.py --limit 300 --dry-run
|
||||
python3 /srv/scripts/seekplayer_reap.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import session_scope
|
||||
from app.extractors._fetch import _DEFAULT_UA, browser_get
|
||||
from app.extractors.hosters.seekplayer_engine import _HOST_RE, _decrypt
|
||||
|
||||
log = logging.getLogger("seekplayer-reap")
|
||||
|
||||
# Ta sama rodzina co `seekplayer_engine._HOST_RE`, w formie nadającej się do SQL-a.
|
||||
_FAMILY_SQL = (
|
||||
"easyvidplayer|embedseek|seekplayer|upns|player4me|rpmplay"
|
||||
"|4meplayer|seekplays|ezplayer|seeks"
|
||||
)
|
||||
# Pola, w których silnik podaje adres strumienia. Nazwy wzięte z ich własnego bundla
|
||||
# (`{Cloudflare: cf, Tiktok: hlsVideoTiktok, Google: hlsVideoGoogle, "In-House": source}`),
|
||||
# a warunek „nie ma żadnego z nich" to dokładnie ten sam warunek, po którym ich player
|
||||
# pokazuje „Video is not ready yet".
|
||||
_STREAM_FIELDS = ("source", "cf", "hlsVideoGoogle", "hlsVideoTiktok", "cfNative", "p2pStream")
|
||||
|
||||
_CONCURRENCY = 3
|
||||
# Odstęp przed każdym pobraniem. Bez niego przy pięciu wątkach 38% odpowiedzi to było
|
||||
# HTTP 429, a limit nie jest dowodem na nic, więc takie źródło trzeba zostawić i wrócić
|
||||
# do niego później. Wolniej, ale za to prawie każda próba coś rozstrzyga.
|
||||
_SPACING_SEC = 0.4
|
||||
_TIMEOUT = 20.0
|
||||
|
||||
_ALIVE, _DEAD, _UNKNOWN = "alive", "dead", "unknown"
|
||||
|
||||
|
||||
def _brand(hostname: str) -> str:
|
||||
"""`vip.easyvidplayer.com` → `easyvidplayer`. Klucz wspólny dla domen bliźniaczych."""
|
||||
m = re.search(_FAMILY_SQL, hostname)
|
||||
return m.group(0) if m else hostname
|
||||
|
||||
|
||||
def _probe(item: tuple) -> tuple:
|
||||
"""((brand, hash), verdict, detail). Sieć i parsowanie, bez dotykania bazy."""
|
||||
key, url = item
|
||||
time.sleep(_SPACING_SEC)
|
||||
p = urlparse(url)
|
||||
host = f"{p.scheme}://{p.hostname}"
|
||||
api = f"{host}/api/v1/video?id={p.fragment}&w=1920&h=1080&r="
|
||||
headers = {"User-Agent": _DEFAULT_UA, "Accept": "*/*", "Referer": host + "/"}
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
r = browser_get(api, headers=headers, timeout=_TIMEOUT)
|
||||
except Exception as e:
|
||||
return (key, _UNKNOWN, f"{type(e).__name__}")
|
||||
if r.status_code in (404, 410):
|
||||
return (key, _DEAD, f"HTTP {r.status_code}")
|
||||
if r.status_code == 429:
|
||||
# Rate limit nie jest dowodem na cokolwiek, więc jedna przerwa i koniec.
|
||||
if attempt == 1:
|
||||
time.sleep(5)
|
||||
continue
|
||||
return (key, _UNKNOWN, "HTTP 429")
|
||||
if r.status_code != 200 or not r.text:
|
||||
return (key, _UNKNOWN, f"HTTP {r.status_code}")
|
||||
try:
|
||||
data = json.loads(_decrypt(r.text))
|
||||
except Exception as e:
|
||||
return (key, _UNKNOWN, f"dekod {type(e).__name__}")
|
||||
if data.get("error"):
|
||||
return (key, _DEAD, str(data["error"])[:80])
|
||||
if any(data.get(k) for k in _STREAM_FIELDS):
|
||||
return (key, _ALIVE, "")
|
||||
return (key, _DEAD, "brak pola source (Video is not ready yet)")
|
||||
return (key, _UNKNOWN, "429")
|
||||
|
||||
|
||||
def _load_work() -> tuple[dict, dict, dict]:
|
||||
"""{(brand,hash): url}, {(brand,hash): [movie_src_id]}, {(brand,hash): [scene_src_id]}"""
|
||||
probes: dict = {}
|
||||
movie_ids: dict = collections.defaultdict(list)
|
||||
scene_ids: dict = collections.defaultdict(list)
|
||||
with session_scope() as s:
|
||||
for table, bucket in (
|
||||
("movie_playback_sources", movie_ids),
|
||||
("playback_sources", scene_ids),
|
||||
):
|
||||
rows = s.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT id, coalesce(embed_url, page_url) AS u
|
||||
FROM {table}
|
||||
WHERE dead_at IS NULL
|
||||
AND coalesce(embed_url, page_url) ~ :fam
|
||||
"""
|
||||
),
|
||||
{"fam": _FAMILY_SQL},
|
||||
).fetchall()
|
||||
for sid, u in rows:
|
||||
p = urlparse(u)
|
||||
# Regex w SQL to tylko tani przesiew i potrafi trafić w zwykły adres
|
||||
# tuba, gdy w slugu tytułu siedzi np. "seeks". Autorytetem jest
|
||||
# `_HOST_RE` z samego ekstraktora, ono patrzy wyłącznie na hosta.
|
||||
if not p.hostname or not p.fragment or not _HOST_RE.match(p.hostname):
|
||||
continue
|
||||
key = (_brand(p.hostname), p.fragment.strip())
|
||||
probes.setdefault(key, u)
|
||||
bucket[key].append(sid)
|
||||
return probes, movie_ids, scene_ids
|
||||
|
||||
|
||||
def _mark_dead(table: str, ids: list, reason: str) -> int:
|
||||
if not ids:
|
||||
return 0
|
||||
with session_scope() as s:
|
||||
return s.execute(
|
||||
text(
|
||||
f"UPDATE {table} SET dead_at = now(), dead_reason = :r "
|
||||
" WHERE id = ANY(:ids) AND dead_at IS NULL"
|
||||
),
|
||||
{"r": f"seekplayer-reap: {reason}"[:512], "ids": ids},
|
||||
).rowcount
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--limit", type=int, default=0, help="ile kluczy sprawdzić (0 = wszystkie)")
|
||||
p.add_argument("--dry-run", action="store_true", help="nie zapisuj, tylko policz")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout
|
||||
)
|
||||
for noisy in ("httpx", "httpcore", "urllib3"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
probes, movie_ids, scene_ids = _load_work()
|
||||
keys = list(probes)
|
||||
if a.limit:
|
||||
keys = keys[: a.limit]
|
||||
log.info(
|
||||
"do sprawdzenia %d kluczy (marka+hash), zrodel: filmy %d, sceny %d",
|
||||
len(keys),
|
||||
sum(len(v) for v in movie_ids.values()),
|
||||
sum(len(v) for v in scene_ids.values()),
|
||||
)
|
||||
|
||||
cnt: collections.Counter = collections.Counter()
|
||||
powody: collections.Counter = collections.Counter()
|
||||
nieznane: collections.Counter = collections.Counter()
|
||||
ubite_f = ubite_s = 0
|
||||
t0 = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=_CONCURRENCY) as pool:
|
||||
for i, (key, verdict, detail) in enumerate(
|
||||
pool.map(_probe, [(k, probes[k]) for k in keys]), 1
|
||||
):
|
||||
cnt[verdict] += 1
|
||||
if verdict == _UNKNOWN:
|
||||
nieznane[detail[:40]] += 1
|
||||
if verdict == _DEAD:
|
||||
powody[detail[:40]] += 1
|
||||
if not a.dry_run:
|
||||
ubite_f += _mark_dead("movie_playback_sources", movie_ids.get(key, []), detail)
|
||||
ubite_s += _mark_dead("playback_sources", scene_ids.get(key, []), detail)
|
||||
if i % 250 == 0:
|
||||
log.info(
|
||||
"[%d/%d] zywe=%d martwe=%d nieznane=%d, ubite zrodla: filmy %d sceny %d (%.0f min)",
|
||||
i, len(keys), cnt[_ALIVE], cnt[_DEAD], cnt[_UNKNOWN],
|
||||
ubite_f, ubite_s, (time.time() - t0) / 60,
|
||||
)
|
||||
|
||||
log.info(
|
||||
"KONIEC: zywe=%d martwe=%d nieznane=%d | ubite zrodla: filmy %d sceny %d | %.0f min",
|
||||
cnt[_ALIVE], cnt[_DEAD], cnt[_UNKNOWN], ubite_f, ubite_s, (time.time() - t0) / 60,
|
||||
)
|
||||
log.info("powody martwych: %s", dict(powody.most_common(6)))
|
||||
log.info("powody nieznanych: %s", dict(nieznane.most_common(6)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Reference in a new issue