OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed / node_helper / source_probe.mjs

123 lines · javascript ref:
#!/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,
}));