← back to Allnewsdaily

scripts/check-live.js

135 lines

#!/usr/bin/env node
// Polls each outlet's YouTube /channel/<id>/live URL.
// YouTube returns the channel's currently-live broadcast page (200 + watch-page HTML)
// or a "no live stream" placeholder. We detect liveness by looking for the
// hlsManifestUrl / "isLive":true marker in the inline ytInitialPlayerResponse JSON.
//
// Writes data/live-status.json keyed by outlet.id:
//   { "<id>": { isLive: bool, videoId: "...", checkedAt: ISO, error?: "..." } }

const fs = require('fs');
const path = require('path');
const https = require('https');

const OUTLETS_PATH = path.join(__dirname, '..', 'data', 'outlets.json');
const STATUS_PATH = path.join(__dirname, '..', 'data', 'live-status.json');

const UA =
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
  '(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36';

function fetchHtml(url, timeoutMs = 8000) {
  return new Promise((resolve, reject) => {
    const req = https.get(
      url,
      {
        headers: {
          'User-Agent': UA,
          'Accept-Language': 'en-US,en;q=0.9',
          Accept: 'text/html,application/xhtml+xml',
        },
      },
      res => {
        if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
          // Follow one hop
          fetchHtml(new URL(res.headers.location, url).toString(), timeoutMs).then(resolve, reject);
          res.resume();
          return;
        }
        if (res.statusCode !== 200) {
          res.resume();
          return reject(new Error('status ' + res.statusCode));
        }
        let body = '';
        res.setEncoding('utf8');
        res.on('data', d => {
          body += d;
          if (body.length > 4_000_000) {
            req.destroy(new Error('body too big'));
          }
        });
        res.on('end', () => resolve(body));
      }
    );
    req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')));
    req.on('error', reject);
  });
}

function detectLive(html) {
  // Marker 1 — hls manifest is present only when actively streaming
  if (/"hlsManifestUrl":\s*"https:\/\/manifest\.googlevideo\.com/.test(html)) {
    const m = html.match(/"videoId":\s*"([A-Za-z0-9_-]{11})"/);
    return { isLive: true, videoId: m ? m[1] : null };
  }
  // Marker 2 — isLive:true inside videoDetails
  if (/"videoDetails":\s*\{[^}]*"isLive":true/.test(html)) {
    const m = html.match(/"videoId":\s*"([A-Za-z0-9_-]{11})"/);
    return { isLive: true, videoId: m ? m[1] : null };
  }
  // Marker 3 — livestream microformat
  if (/"liveBroadcastDetails":\s*\{[^}]*"isLiveNow":true/.test(html)) {
    const m = html.match(/"videoId":\s*"([A-Za-z0-9_-]{11})"/);
    return { isLive: true, videoId: m ? m[1] : null };
  }
  return { isLive: false, videoId: null };
}

async function checkOne(outlet) {
  if (outlet.liveCheck !== 'youtube' || !outlet.youtube) {
    return { id: outlet.id, isLive: false, videoId: null, skipped: true };
  }
  const url = `https://www.youtube.com/channel/${outlet.youtube}/live`;
  try {
    const html = await fetchHtml(url);
    const { isLive, videoId } = detectLive(html);
    return { id: outlet.id, isLive, videoId };
  } catch (e) {
    return { id: outlet.id, isLive: false, videoId: null, error: e.message };
  }
}

async function runBatch(outlets, concurrency = 6) {
  const results = {};
  let idx = 0;
  async function worker() {
    while (idx < outlets.length) {
      const o = outlets[idx++];
      const r = await checkOne(o);
      results[o.id] = {
        isLive: r.isLive,
        videoId: r.videoId,
        checkedAt: new Date().toISOString(),
        ...(r.error ? { error: r.error } : {}),
      };
    }
  }
  await Promise.all(Array.from({ length: concurrency }, worker));
  return results;
}

async function main() {
  const outlets = JSON.parse(fs.readFileSync(OUTLETS_PATH, 'utf8'));
  const checkable = outlets.filter(o => o.liveCheck === 'youtube' && o.youtube);
  console.log(`[check-live] checking ${checkable.length} of ${outlets.length} outlets`);
  const t0 = Date.now();

  const results = await runBatch(outlets);

  const liveCount = Object.values(results).filter(r => r.isLive).length;
  console.log(`[check-live] done in ${Date.now() - t0}ms — ${liveCount} live`);

  // Merge with prior so we keep history if any outlet errored this tick
  let prior = {};
  try {
    prior = JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8'));
  } catch (_) {}
  const merged = { ...prior, ...results };
  fs.writeFileSync(STATUS_PATH, JSON.stringify(merged, null, 2));
}

main().catch(e => {
  console.error('[check-live] fatal', e);
  process.exit(1);
});