← back to Tiktok Architect

scripts/tt-oembed.js

88 lines

#!/usr/bin/env node
/**
 * tt-oembed.js — resolve ONE TikTok URL to clean metadata WITHOUT auth or JS.
 *
 * TikTok video pages are JS-rendered, so WebFetch/curl on the HTML returns only
 * the generic "Make Your Day" shell. The public oEmbed endpoint
 * (tiktok.com/oembed?url=...) returns the caption (as `title`), author, and
 * thumbnail as plain JSON — no login, no browser, $0. This is the reliable way
 * to turn a dropped link (including a /t/<short> redirect) into a candidate.
 *
 * Usage:
 *   node tt-oembed.js <tiktok-url>            # canonical or /t/<short> link
 *
 * Examples:
 *   node tt-oembed.js https://www.tiktok.com/t/ZP8GY4Rgo/
 *   node tt-oembed.js https://www.tiktok.com/@tiktok_architect/video/7643451753400241421
 *
 * Exit codes: 0 ok, 1 error (bad input / not found / network).
 * Output: a JSON object on stdout:
 *   { id, url, author, caption, hashtags[], claude_relevant, thumbnail }
 * `claude_relevant` is a cheap keyword flag so the scout can skip the creator's
 * off-topic posts (motorcycles, hobbies) without a second call.
 */

'use strict';

const RELEVANCE_RE =
  /\b(claude|claudecode|claude-code|anthropic|vibecod|cursor|codex|copilot|chatgpt|openai|\bllm\b|\bai\b|aitools|aiagent|agentic|mcp|prompt|coding|developer|softwareengineer|programming)\b/i;

function hashtagsFrom(caption) {
  const tags = new Set();
  const re = /#([\p{L}\p{N}_]+)/gu;
  let m;
  while ((m = re.exec(caption || ''))) tags.add(m[1].toLowerCase());
  return [...tags];
}

// Follow redirects (the /t/<short> links are 301s) to the canonical URL, then
// pull the numeric video id out of .../video/<id>.
async function resolve(url) {
  let res;
  try {
    res = await fetch(url, { redirect: 'follow', headers: { 'User-Agent': 'Mozilla/5.0' } });
  } catch (e) {
    // network hiccup on the redirect hop — fall back to the input URL
    return { canonical: url, id: (url.match(/video\/(\d{5,25})/) || [])[1] || null };
  }
  const canonical = res.url || url;
  const id = (canonical.match(/video\/(\d{5,25})/) || [])[1] || null;
  return { canonical, id };
}

async function main() {
  const input = process.argv[2];
  if (!input || !/tiktok\.com/i.test(input)) {
    console.error('usage: node tt-oembed.js <tiktok-url>');
    process.exit(1);
  }

  const { canonical, id } = await resolve(input);

  const oe = `https://www.tiktok.com/oembed?url=${encodeURIComponent(canonical)}`;
  const res = await fetch(oe, { headers: { 'User-Agent': 'Mozilla/5.0' } });
  if (!res.ok) {
    console.error(`oembed http ${res.status} for ${canonical}`);
    process.exit(1);
  }
  const j = await res.json();

  const caption = j.title || '';
  const hashtags = hashtagsFrom(caption);
  const out = {
    id: id || j.embed_product_id || null,
    url: canonical,
    author: j.author_name || j.author_unique_id || null,
    caption,
    hashtags,
    claude_relevant: RELEVANCE_RE.test(caption),
    thumbnail: j.thumbnail_url || null,
  };
  console.log(JSON.stringify(out, null, 2));
}

main().catch((e) => {
  console.error(String((e && e.message) || e));
  process.exit(1);
});