sextapes: hls2 na zewnetrznym CDN dawal 502 albo byl blokowany filtrem DNS (Failed to connect ... [::]); resolver sprawdza manifest i przechodzi na hls4 z domeny embedu (segmenty to TS z doklejonym PNG, ExoPlayer je gra; sprawdzone na emulatorze). PIN: na TV w poziomie Cancel/Next byly pod krawedzia ekranu, OK siedzi teraz w pustym polu klawiatury. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
8.4 KiB
TypeScript
211 lines
8.4 KiB
TypeScript
/**
|
|
* Mobile-side P.A.C.K.E.R. hoster resolver.
|
|
*
|
|
* Powód: hostery typu luluvid / streamwish-forki pakują JWPlayer config
|
|
* (`sources:[{file:"https://...m3u8"}]`) w `eval(function(p,a,c,k,e,d){...})`
|
|
* P.A.C.K.E.R. obfuskację. Backend (VPS Hetzner IP) dostaje od nich CAPTCHA /
|
|
* pustą stronę, więc `try_extract` zwraca type='hoster' → WebView fallback,
|
|
* który pokazuje stronę hostera z reklamami.
|
|
*
|
|
* Mobile IP usera (residential / komórka) zwykle NIE triggeruje CAPTCHA,
|
|
* embed page renderuje pełny HTML z packed JWPlayer config. Rozpakowujemy
|
|
* go tutaj i zwracamy direct m3u8/mp4 do natywnego ExoPlayera.
|
|
*
|
|
* Port `unpack_packer` z app/extractors/hoster.py (ten sam regex + base-N).
|
|
* Komplementarny do doodstream.ts (pass_md5 protokół), tamten dla DoodStream,
|
|
* ten dla P.A.C.K.E.R.-JWPlayer hosterów.
|
|
*/
|
|
|
|
const PACKER_HOSTS = new Set([
|
|
'luluvid.com', 'luluvdo.com', 'lulustream.com', 'lulu.st',
|
|
// lulust.com: nowa domena tej samej rodziny (latestpornvideo 2026-10-02). Bez niej szla
|
|
// przez WebView, a w probce wszystkie sceny na lulust konczyly sie 403, na luluvdo gra.
|
|
// Rodzina rotuje domeny, dlatego nizej jest tez regula na przedrostek lulu.
|
|
'lulust.com',
|
|
// luluvid/streamwish forki rotują TLD, `luluvids.top` + cdnstream/cdnvids.top
|
|
// potwierdzone P.A.C.K.E.R.-JWPlayer (mypornerleak embeds, bug-report 2026-06-07:
|
|
// bez tego `isPackerHoster` zwracał false → WebView na luluvids.top → "disable
|
|
// Adblock and enable popup" wall zamiast natywnego playera).
|
|
'luluvids.top', 'cdnstream.top', 'cdnvids.top',
|
|
// StreamWish family, ten sam P.A.C.K.E.R.-JWPlayer wzorzec
|
|
'streamwish.com', 'streamwish.to', 'embedwish.com', 'wishembed.pro',
|
|
'awish.pro', 'dwish.pro', 'mwish.pro', 'swishsrv.com',
|
|
'streamwish.site', 'hlswish.com', 'kswplayer.info',
|
|
// sextapes.to (filmy z xtapes.la, 2026-10-15): ten sam P.A.C.K.E.R.-JWPlayer, HLS na
|
|
// cdn-centaurus zwiazany z IP rozwiazujacego, wiec rozwiazac musi telefon.
|
|
'sextapes.to',
|
|
]);
|
|
|
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
|
|
|
export interface ResolveResult {
|
|
url?: string;
|
|
headers?: Record<string, string>;
|
|
error?: string;
|
|
}
|
|
|
|
export function isPackerHoster(url: string): boolean {
|
|
if (!url) return false;
|
|
try {
|
|
const host = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
|
if (PACKER_HOSTS.has(host)) return true;
|
|
for (const h of PACKER_HOSTS) {
|
|
if (host.endsWith('.' + h)) return true;
|
|
}
|
|
// Kolejne domeny rodziny lulu (luluvid, luluvdo, lulustream, lulust): osadzenie ma
|
|
// zawsze sciezke /e/<id>. Gdy resolver i tak nie znajdzie skryptu, Player spada na WebView.
|
|
if (/^lulu[a-z]*\.[a-z]{2,}$/.test(host) && /^\/e\//.test(new URL(url).pathname)) return true;
|
|
} catch {}
|
|
return false;
|
|
}
|
|
|
|
// Port `_base_n` z hoster.py, token w bazie `base` (max 62, a-zA-Z0-9).
|
|
function baseN(token: string, base: number): number | null {
|
|
let result = 0;
|
|
for (const ch of token) {
|
|
let d: number;
|
|
if (ch >= '0' && ch <= '9') d = ch.charCodeAt(0) - 48;
|
|
else if (ch >= 'a' && ch <= 'z') d = ch.charCodeAt(0) - 97 + 10;
|
|
else if (ch >= 'A' && ch <= 'Z') d = ch.charCodeAt(0) - 65 + 36;
|
|
else return null;
|
|
if (d >= base) return null;
|
|
result = result * base + d;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Port `unpack_packer`, odwraca eval(function(p,a,c,k,e,d){...}('PAYLOAD',BASE,COUNT,'kw'.split('|'))).
|
|
export function unpackPacker(js: string): string | null {
|
|
const m = js.match(
|
|
/\}\s*\(\s*'((?:\\'|[^'])+)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\'|[^'])*)'\s*\.split\('\|'\)/s,
|
|
);
|
|
if (!m) return null;
|
|
let payload = m[1];
|
|
const base = parseInt(m[2], 10);
|
|
const count = parseInt(m[3], 10);
|
|
const keywords = m[4].split('|');
|
|
payload = payload.replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
return payload.replace(/\b\w+\b/g, (token) => {
|
|
const idx = baseN(token, base);
|
|
if (idx === null || idx >= count || idx >= keywords.length) return token;
|
|
return keywords[idx] || token;
|
|
});
|
|
}
|
|
|
|
// JWPlayer `sources:[{file:"URL"}]` lub `file:"URL"`, m3u8/mp4/mpd.
|
|
const FILE_RE = /(?:["']?file["']?|sources?)\s*[:=]\s*["'](https?:\/\/[^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i;
|
|
const AD_RE = /\/(?:preroll|midroll|postroll|preplay|ads?|advert|promo)\d*\.(?:mp4|m3u8|webm)/i;
|
|
|
|
// Nowszy zapis StreamWish (sextapes.to 2026-10): `var links={"hls4":"/stream/.../master.m3u8",
|
|
// "hls2":"https://cdn/.../master.m3u8?t=..."}` i `sources:[{file:links.hls4||links.hls2}]`,
|
|
// wiec FILE_RE nie widzi literalu. Bierzemy pelny adres (hls2), a gdy go nie ma, wzgledny
|
|
// (hls4) doklejony do domeny embedu.
|
|
const LINKS_ENTRY_RE = /["']?(hls\d+)["']?\s*:\s*["']([^"']+\.m3u8[^"']*)["']/gi;
|
|
|
|
/** Kandydaci z obiektu `links`: najpierw pelne adresy (hls2, CDN), potem wzgledne (hls4,
|
|
* sciezka na domenie embedu) zamienione na pelne. */
|
|
function linksObjectCandidates(js: string, embedUrl: string): string[] {
|
|
const absolute: string[] = [];
|
|
const relative: string[] = [];
|
|
let m: RegExpExecArray | null;
|
|
LINKS_ENTRY_RE.lastIndex = 0;
|
|
while ((m = LINKS_ENTRY_RE.exec(js)) !== null) {
|
|
const u = m[2];
|
|
if (AD_RE.test(u)) continue;
|
|
if (/^https?:\/\//i.test(u)) absolute.push(u);
|
|
else if (u.startsWith('/')) {
|
|
try {
|
|
relative.push(new URL(u, embedUrl).toString());
|
|
} catch {
|
|
// pomijamy
|
|
}
|
|
}
|
|
}
|
|
return [...absolute, ...relative];
|
|
}
|
|
|
|
/** Czy manifest pod adresem odpowiada (krotki GET z limitem czasu). */
|
|
async function manifestReachable(url: string, referer: string): Promise<boolean> {
|
|
const ctrl = new AbortController();
|
|
const t = setTimeout(() => ctrl.abort(), 4000);
|
|
try {
|
|
const r = await fetch(url, { headers: { 'User-Agent': UA, Referer: referer }, signal: ctrl.signal });
|
|
if (!r.ok) return false;
|
|
return (await r.text()).trimStart().startsWith('#EXTM3U');
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
clearTimeout(t);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adres z obiektu `links`, sprawdzony z telefonu. sextapes (2026-10-16): hls2 lezy na
|
|
* zewnetrznym CDN (premilkyway, cdn-centaurus), ktory potrafi dac 502 albo byc zablokowany
|
|
* przez filtr reklam w DNS (blad "Failed to connect to ...premilkyway.com/[::]"). Wtedy
|
|
* bierzemy hls4 z domeny embedu (tam strona sama zaczyna). Segmenty hls4 to TS z doklejonym
|
|
* na poczatku 70-bajtowym PNG, ExoPlayer i tak znajduje synchronizacje TS.
|
|
*/
|
|
async function linksObjectUrl(js: string, embedUrl: string, referer: string): Promise<string | null> {
|
|
const candidates = linksObjectCandidates(js, embedUrl);
|
|
if (candidates.length <= 1) return candidates[0] || null;
|
|
for (const c of candidates) {
|
|
if (await manifestReachable(c, referer)) return c;
|
|
}
|
|
return candidates[0];
|
|
}
|
|
|
|
export async function resolvePackerHoster(
|
|
embedUrl: string,
|
|
sourceUrl?: string,
|
|
): Promise<ResolveResult> {
|
|
if (!isPackerHoster(embedUrl)) return { error: 'not_packer_hoster' };
|
|
|
|
// /d/ (download) → /e/ (embed player), embed ma packed JWPlayer config.
|
|
const url = embedUrl.replace('/d/', '/e/');
|
|
const host = (() => { try { return new URL(url).hostname; } catch { return ''; } })();
|
|
const referer = sourceUrl || `https://${host}/`;
|
|
|
|
let html: string;
|
|
try {
|
|
const r = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { 'User-Agent': UA, 'Referer': referer },
|
|
});
|
|
if (!r.ok) return { error: `embed http ${r.status}` };
|
|
html = await r.text();
|
|
} catch (e: any) {
|
|
return { error: `embed fetch fail: ${e?.message || e}` };
|
|
}
|
|
|
|
if (/video not found|file (?:was )?deleted|not found/i.test(html.slice(0, 4000))) {
|
|
return { error: 'video_deleted' };
|
|
}
|
|
|
|
// Najpierw raw HTML (czasem file: jest poza packerem), potem unpacked.
|
|
let videoUrl: string | null = null;
|
|
const rawMatch = html.match(FILE_RE);
|
|
if (rawMatch && !AD_RE.test(rawMatch[1])) {
|
|
videoUrl = rawMatch[1];
|
|
}
|
|
if (!videoUrl) {
|
|
const unpacked = unpackPacker(html);
|
|
if (!unpacked) {
|
|
// Brak packera = CAPTCHA gate (mobile IP też zablokowane?) lub pusta strona.
|
|
if (html.length < 2000 || /challenge-platform|turnstile/i.test(html)) {
|
|
return { error: 'captcha_gate' };
|
|
}
|
|
return { error: 'no_packer_in_html' };
|
|
}
|
|
const um = unpacked.match(FILE_RE);
|
|
if (um && !AD_RE.test(um[1])) videoUrl = um[1];
|
|
if (!videoUrl) videoUrl = await linksObjectUrl(unpacked, url, referer);
|
|
}
|
|
|
|
if (!videoUrl) return { error: 'no_video_url' };
|
|
|
|
return {
|
|
url: videoUrl,
|
|
headers: { 'User-Agent': UA, 'Referer': referer },
|
|
};
|
|
}
|