miruro / lib /scraper /insertunit.ts
mwask's picture
Upload 45 files
3e8e34c verified
Raw
History Blame Contribute Delete
6.76 kB
// Server-only Insertunit source resolver (api.insertunit.ws). Resolves a TMDB
// id to an IMDB id, scrapes the embed HTML, and extracts the dash/hls streams,
// audio-track names and cc (subtitle) list. Streams live on interkh.com and
// require the insertunit Referer, so they must be proxied.
export interface InsertunitResult {
dash: string | null;
dasha: string | null;
hls: string | null;
audio: { names: string[]; order: number[] } | null;
cc: { url: string; name: string }[] | null;
}
const HEADERS: Record<string, string> = {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"accept-language": "en-GB,en;q=0.9",
"cache-control": "max-age=0",
"sec-ch-ua":
'"Chromium";v="148", "Brave";v="148", "Not/A)Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
};
export const INSERTUNIT_REFERER = "https://api.insertunit.ws/";
function safeJsonLoad<T = any>(s: string): T | null {
try {
return JSON.parse(s) as T;
} catch {
// strip trailing commas from sloppy JS objects, retry
const clean = s.replace(/,\s*}/g, "}").replace(/,\s*\]/g, "]");
try {
return JSON.parse(clean) as T;
} catch {
return null;
}
}
}
export class InsertunitScraper {
private tmdbKey =
process.env.TMDB_API_KEY || "";
private async getImdbId(tmdbId: number, isTv: boolean): Promise<string | null> {
const mt = isTv ? "tv" : "movie";
try {
const r = await fetch(
`https://api.themoviedb.org/3/${mt}/${tmdbId}/external_ids?api_key=${this.tmdbKey}`
);
if (!r.ok) return null;
const d = await r.json();
return d.imdb_id || null;
} catch {
return null;
}
}
// Finds the dynamic token suffix appended to media URLs to bypass 403s.
private extractDynamicToken(html: string): string {
const varMatch = html.match(
/o\[k\]\s*\+=\s*'&'\s*\+\s*([a-zA-Z0-9_]+);/
);
if (!varMatch) return "";
const name = varMatch[1];
const valMatch = html.match(
new RegExp(`${name}\\s*=\\s*"([^"]+)"`)
);
if (!valMatch) return "";
return `&${valMatch[1]}`;
}
async fetchSources(
tmdbId: number,
season = 0,
episode = 0
): Promise<InsertunitResult | null> {
const isTv = !!(season && episode);
const imdb = await this.getImdbId(tmdbId, isTv);
if (!imdb) return null;
let html: string;
try {
const r = await fetch(`https://api.insertunit.ws/embed/imdb/${imdb}`, {
headers: HEADERS,
cache: "no-store", // fresh tokens each resolve
});
if (!r.ok) return null;
html = await r.text();
} catch {
return null;
}
return isTv
? this.parseTv(html, season, episode)
: this.parseMovie(html);
}
private parseMovie(html: string): InsertunitResult | null {
const m = html.match(/source:\s*({[\s\S]*?})/);
if (!m) return null;
const src = m[1];
const tok = this.extractDynamicToken(html);
const dash = src.match(/dash:\s*"(.*?)"/);
const dasha = src.match(/dasha:\s*"(.*?)"/);
const hls = src.match(/hls:\s*"(.*?)"/);
const audio = src.match(/audio:\s*({[\s\S]*?})/);
const cc = src.match(/cc:\s*(\[[\s\S]*?\])/);
return {
dash: dash ? dash[1] + tok : null,
dasha: dasha ? dasha[1] + tok : null,
hls: hls ? hls[1] + tok : null,
audio: audio ? safeJsonLoad(audio[1]) : null,
cc: cc ? safeJsonLoad(cc[1]) : null,
};
}
private parseTv(
html: string,
season: number,
episode: number
): InsertunitResult | null {
// Isolate the season block to avoid cross-season episode mismatches.
const seasonRe = new RegExp(
`"season":\\s*${season}\\s*,\\s*"blocked"[\\s\\S]*?(?="season":\\s*\\d+\\s*,|$)`
);
const sm = html.match(seasonRe);
if (!sm) return null;
const seasonHtml = sm[0];
const epRe = new RegExp(
`"episode":\\s*"${episode}"[\\s\\S]*?(?="episode":\\s*"\\d+"|$)`
);
const em = seasonHtml.match(epRe);
if (!em) return null;
const eh = em[0];
const tok = this.extractDynamicToken(html);
const dash = eh.match(/"dash":\s*"(.*?)"/);
const dasha = eh.match(/"dasha":\s*"(.*?)"/);
const hls = eh.match(/"hls":\s*"(.*?)"/);
const audio = eh.match(/"audio":\s*({[\s\S]*?})/);
const cc = eh.match(/"cc":\s*(\[[\s\S]*?\])/);
return {
dash: dash ? dash[1] + tok : null,
dasha: dasha ? dasha[1] + tok : null,
hls: hls ? hls[1] + tok : null,
audio: audio ? safeJsonLoad(audio[1]) : null,
cc: cc ? safeJsonLoad(cc[1]) : null,
};
}
}
import type { SourceModule } from "./types";
// Best-effort language code from an Insertunit cc track name.
function guessLang(name: string): string {
const n = (name || "").toLowerCase();
if (n.includes("eng") || n.includes("english")) return "en";
if (n.includes("рус") || n.includes("rus")) return "ru";
if (n.includes("укр") || n.includes("ukr")) return "uk";
if (n.includes("span") || n.includes("esp")) return "es";
if (n.includes("fr")) return "fr";
if (n.includes("de") || n.includes("ger")) return "de";
return "";
}
const _insertunit = new InsertunitScraper();
// interkh streams need the insertunit Referer, so both streams and cc subs are
// proxied. Prefer DASH (its <BaseURL>[0] is a real CDN host; the HLS variant
// uses an "x-bc" placeholder host that 410s).
export const insertunit: SourceModule = {
id: "insertunit",
name: "Ari",
label: "Multi-audio · built-in subs",
active: true,
rank: 2,
async fetch(ctx) {
const r = await _insertunit
.fetchSources(ctx.tmdbId, ctx.season, ctx.episode)
.catch(() => null);
if (!r) return null;
const ref = INSERTUNIT_REFERER;
const streams = [];
if (r.dash) {
streams.push({
file: ctx.proxyStream(r.dash, ref),
label: "auto",
type: "dash" as const,
});
} else if (r.hls) {
streams.push({
file: ctx.proxyStream(r.hls, ref),
label: "auto",
type: "hls" as const,
});
}
if (!streams.length) return null;
const subtitles = (r.cc || []).map((c) => ({
url: ctx.proxySub(c.url, ref),
display: c.name,
language: guessLang(c.name),
source: "Ari",
}));
return { streams, subtitles };
},
};