import type { Media } from '../types.ts'; /** * Instagram and Threads are the same product underneath and serve the same * media schema, so both adapters normalize through here. */ type Candidate = { url?: string; width?: number; height?: number }; export type MetaMediaNode = { image_versions2?: { candidates?: Candidate[] }; video_versions?: Candidate[]; carousel_media?: MetaMediaNode[]; accessibility_caption?: string; original_width?: number; original_height?: number; video_duration?: number; }; /** These CDNs 403 anything that arrives without a matching referrer. */ export function metaHeaders(referer: string): Record { return { Referer: referer, Origin: new URL(referer).origin }; } function largest(candidates: Candidate[] | undefined): Candidate | undefined { return (candidates ?? []) .filter((c) => c.url) .sort((a, b) => (b.width ?? 0) - (a.width ?? 0))[0]; } export function mediaFromMetaNode(node: MetaMediaNode, referer: string): Media[] { if (node.carousel_media?.length) { return node.carousel_media.flatMap((child) => mediaFromMetaNode(child, referer)); } const fetchHeaders = metaHeaders(referer); const poster = largest(node.image_versions2?.candidates); const size = { ...(node.original_width ? { width: node.original_width } : {}), ...(node.original_height ? { height: node.original_height } : {}), }; const video = largest(node.video_versions); if (video?.url) { return [{ kind: 'video', url: video.url, fetchHeaders, ...(poster?.url ? { poster: { url: poster.url, fetchHeaders } } : {}), ...(node.video_duration ? { durationSec: Math.round(node.video_duration) } : {}), ...size, }]; } if (poster?.url) { return [{ kind: 'image', url: poster.url, fetchHeaders, ...(node.accessibility_caption ? { alt: node.accessibility_caption } : {}), ...(poster.width ? { width: poster.width } : size), ...(poster.height ? { height: poster.height } : {}), }]; } return []; } function nonEmptyArray(value: unknown): boolean { return Array.isArray(value) && value.length > 0; } /** * Carrying the key is not the same as carrying media: these payloads are * full of stub nodes whose `image_versions2.candidates` is an empty array. * Only a node with something in it counts. */ function looksLikeMedia(value: Record): boolean { if (nonEmptyArray(value['carousel_media'])) return true; if (nonEmptyArray(value['video_versions'])) return true; const images = value['image_versions2'] as { candidates?: unknown } | undefined; return nonEmptyArray(images?.candidates); } /** * Threads buries the post inside a pile of Relay payloads whose shape moves * around. Rather than chase a path, walk the tree for the first node that * carries media and, separately, the first caption. */ export function findMetaMedia(root: unknown): MetaMediaNode | undefined { return walk(root, (record) => (looksLikeMedia(record) ? (record as MetaMediaNode) : undefined)); } /** Same walk, looking for `caption.text`. */ export function findCaption(root: unknown): string | undefined { return walk(root, (record) => { const caption = record['caption']; if (!caption || typeof caption !== 'object') return undefined; const text = (caption as Record)['text']; return typeof text === 'string' && text.length > 0 ? text : undefined; }); } /** * Breadth-first so the node nearest the root — the post itself — wins over * anything quoted or recommended below it. */ function walk(root: unknown, visit: (record: Record) => T | undefined): T | undefined { const seen = new Set(); const queue: unknown[] = [root]; for (let i = 0; i < queue.length; i += 1) { const value = queue[i]; if (value === null || typeof value !== 'object' || seen.has(value)) continue; seen.add(value); if (Array.isArray(value)) { queue.push(...value); continue; } const record = value as Record; const hit = visit(record); if (hit !== undefined) return hit; queue.push(...Object.values(record)); } return undefined; }