← back to Axel Bitblaze Scout

scripts/hydrate-tweet.js

133 lines

#!/usr/bin/env node
/**
 * hydrate-tweet.js — resolve a single X/Twitter post to plain text WITHOUT auth.
 *
 * X blocks unauthenticated page reads (WebFetch → HTTP 402), so this uses the
 * public syndication endpoint that powers embedded tweets. The endpoint needs a
 * `token` that is a deterministic function of the tweet id — we compute it
 * locally ($0), so no API key and no login are ever required.
 *
 * Usage:
 *   node hydrate-tweet.js <tweet-id-or-url>
 *
 * Examples:
 *   node hydrate-tweet.js 2055557527153332556
 *   node hydrate-tweet.js https://x.com/heygurisingh/status/2055557527153332556
 *
 * Exit codes:
 *   0  ok — printed tweet JSON summary
 *   3  tombstone — tweet/account is suspended, deleted, or age-gated (unreadable)
 *   1  error — bad input or network/parse failure
 *
 * Output: a JSON object on stdout:
 *   { id, url, author, created_at, text, urls[], github[] }
 * On tombstone: { id, tombstone: "<reason text>" } and exit 3.
 */

'use strict';

function extractId(arg) {
  if (!arg) return null;
  // trailing run of digits handles both bare ids and .../status/<id>[?query]
  const m = String(arg).match(/(\d{5,25})/g);
  return m ? m[m.length - 1] : null;
}

// react-tweet's documented token derivation: (id / 1e15 * PI) in base-36,
// with zeros and the dot stripped. Verified against live tweets 2026-07.
function syndicationToken(id) {
  return ((Number(id) / 1e15) * Math.PI)
    .toString(36)
    .replace(/(0+|\.)/g, '');
}

function findGithub(text, urls) {
  const found = new Set();
  const scan = (s) => {
    if (!s) return;
    const re = /https?:\/\/(?:www\.)?github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+/g;
    let m;
    while ((m = re.exec(s))) found.add(m[0].replace(/[.,)]+$/, ''));
  };
  scan(text);
  (urls || []).forEach(scan);
  return [...found];
}

async function main() {
  const id = extractId(process.argv[2]);
  if (!id) {
    console.error('usage: node hydrate-tweet.js <tweet-id-or-url>');
    process.exit(1);
  }

  const token = syndicationToken(id);
  const endpoint =
    `https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=en&token=${token}`;

  let res;
  try {
    res = await fetch(endpoint, {
      headers: {
        // a browser-ish UA avoids the occasional bot 403 on this endpoint
        'User-Agent':
          'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
          '(KHTML, like Gecko) Chrome/125.0 Safari/537.36',
        Accept: 'application/json',
      },
    });
  } catch (e) {
    console.error(`network error: ${e.message}`);
    process.exit(1);
  }

  if (res.status === 404) {
    console.log(JSON.stringify({ id, tombstone: 'not found (404)' }, null, 2));
    process.exit(3);
  }
  if (!res.ok) {
    console.error(`http ${res.status} from syndication endpoint`);
    process.exit(1);
  }

  let data;
  try {
    data = await res.json();
  } catch (e) {
    console.error(`parse error: ${e.message}`);
    process.exit(1);
  }

  // Tombstone shapes: suspended/deleted/age-gated tweets come back as a
  // __typename of TweetTombstone (or an object with a `tombstone` field).
  const tomb =
    data.__typename === 'TweetTombstone' ||
    data.tombstone ||
    (data.text && /suspended account|Post is from a suspended|isn.?t available/i.test(data.text) && !data.user);
  if (tomb) {
    const reason =
      (data.tombstone && (data.tombstone.text?.text || data.tombstone.text)) ||
      data.text ||
      'unavailable (suspended / deleted / age-gated)';
    console.log(JSON.stringify({ id, tombstone: reason }, null, 2));
    process.exit(3);
  }

  const urls = (data.entities?.urls || []).map((u) => u.expanded_url || u.url);
  const out = {
    id,
    url: `https://x.com/${data.user?.screen_name || 'i'}/status/${id}`,
    author: data.user
      ? `@${data.user.screen_name} (${data.user.name})`
      : null,
    created_at: data.created_at || null,
    text: data.text || '',
    urls,
    github: findGithub(data.text || '', urls),
  };
  console.log(JSON.stringify(out, null, 2));
  process.exit(0);
}

main();