#!/usr/bin/env node
// Generic source probe: open a player page and record HOW it obtains its stream.
//
// vidcore_resolve.mjs only needs the m3u8 URL, so it captures media requests and
// nothing else. That is not enough to evaluate a NEW source: the interesting
// question is where the URL is minted and what that mint depends on. vidlink is
// the case in point -- its stream URL carries `sign` and `t` params, returns 403
// on every referer tried and 427 with its own, so it is session- or IP-bound and
// the m3u8 alone is worthless. What matters is the request that produced it.
//
// So this records three things per run:
// media - stream URLs (m3u8/mp4), with the referer actually sent
// api - XHR/fetch calls, with request headers and a slice of the response
// cookies - what the origin set, since a session-bound token has to live
// somewhere
//
// Usage: node source_probe.mjs <url> [referer] [seconds]
// Output (last stdout line): {media:[...], api:[...], cookies:[...], title, bodyText}
import { chromium } from 'patchright';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const URL_ARG = process.argv[2];
const REFERER = process.argv[3] || '';
const SECONDS = process.argv[4] ? parseInt(process.argv[4], 10) : 30;
const log = (...a) => console.error('[probe]', ...a);
if (!URL_ARG) {
console.log(JSON.stringify({ error: 'usage: <url> [referer] [seconds]' }));
process.exit(1);
}
const MEDIA = /\.m3u8(\?|$)|\.mp4(\?|$)|\/playlist|\/master|\.ts(\?|$)|\.m4s(\?|$)/i;
// Anything that could be minting a URL. Deliberately broad -- a probe that only
// looks where it expects finds only what it expected.
const API = /\/api\/|\/source|\/stream|\/play|\/get|sign=|token=|\.json(\?|$)|graphql/i;
const NOISE = /youtube|googlevideo|ytimg|google-analytics|googletagmanager|doubleclick|facebook|cloudflareinsights|sentry|\.(png|jpg|jpeg|webp|svg|gif|css|woff2?|ico)(\?|$)/i;
const media = new Map(); // url -> referer sent
const api = []; // {url, method, status, reqHeaders, body}
const seen = new Set();
const PROFILE = fs.mkdtempSync(path.join(os.tmpdir(), 'probe_'));
process.on('exit', () => { try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch {} });
const ctx = await chromium.launchPersistentContext(PROFILE, {
headless: false, args: ['--no-sandbox'],
viewport: { width: 1280, height: 720 }, locale: 'en-US',
});
// Popunders: these players open ad tabs that steal focus and can outlive the run.
ctx.on('page', p => setTimeout(() => {
if (!p.isClosed() && p.url() !== URL_ARG) p.close().catch(() => {});
}, 800));
ctx.on('request', r => {
const u = r.url();
if (NOISE.test(u)) return;
// FULL headers, not just the referer. vidlink's proxy returns 428/429/403 to
// curl for a URL the browser fetches fine, so the difference IS the header
// set (or the TLS fingerprint) -- and you cannot replay what you did not
// record. This is the artefact that decides whether a source is usable by
// ffmpeg at all, which is the only thing that matters for ingest.
if (MEDIA.test(u)) media.set(u, r.headers() || {});
});
ctx.on('response', async resp => {
const u = resp.url();
if (NOISE.test(u) || !API.test(u) || seen.has(u)) return;
seen.add(u);
const req = resp.request();
if (!['xhr', 'fetch', 'document'].includes(req.resourceType())) return;
let body = null;
try {
const t = await resp.text();
body = t.slice(0, 700);
} catch { /* opaque or already consumed */ }
api.push({
url: u.slice(0, 300),
method: req.method(),
status: resp.status(),
reqHeaders: Object.fromEntries(Object.entries(req.headers() || {})
.filter(([k]) => /referer|origin|authorization|x-|cookie/i.test(k))),
body,
});
});
const page = ctx.pages()[0] || await ctx.newPage();
if (REFERER) await page.setExtraHTTPHeaders({ Referer: REFERER });
log('goto', URL_ARG, REFERER ? `(referer ${REFERER})` : '');
await page.goto(URL_ARG, { waitUntil: 'domcontentloaded', timeout: 60000 })
.catch(e => log('warn', e.message));
const rounds = Math.max(1, Math.floor(SECONDS / 3));
for (let i = 0; i < rounds; i++) {
await page.waitForTimeout(3000);
// Nudge anything waiting on a gesture. Both a <video> and a generic play
// button are tried -- these players differ and a missed click looks exactly
// like a source that has nothing.
const v = await page.$('video').catch(() => null);
if (v) await v.click({ timeout: 1000, force: true }).catch(() => {});
for (const sel of ['button[aria-label*="lay" i]', '.play-button', '#player']) {
const el = await page.$(sel).catch(() => null);
if (el) await el.click({ timeout: 800, force: true }).catch(() => {});
}
const st = await page.evaluate(() => (document.body?.innerText || '')
.replace(/\s+/g, ' ').slice(0, 60)).catch(() => '');
log(`t=${(i + 1) * 3}s media=${media.size} api=${api.length} "${st}"`);
if (media.size) break;
}
const cookies = (await ctx.cookies().catch(() => []))
.map(c => `${c.domain}${c.path} ${c.name}=${String(c.value).slice(0, 24)}`);
const title = await page.title().catch(() => '');
const bodyText = await page.evaluate(() => (document.body?.innerText || '')
.replace(/\s+/g, ' ').slice(0, 300)).catch(() => '');
await ctx.close();
fs.rmSync(PROFILE, { recursive: true, force: true });
console.log(JSON.stringify({
media: [...media.entries()].map(([url, headers]) => ({ url, headers })),
api, cookies, title, bodyText,
}));