import { chromium, type Page } from "playwright"; import { createServer } from "node:http"; type SearchRequest = { storage_state?: string; terms?: string[]; limit?: number }; type ResolveRequest = { storage_state?: string; permalink?: string }; type Post = { permalink: string; author: string; text: string }; const port = Number(process.env.SCOUT_CRAWLER_PORT || 8891); const token = process.env.SCOUT_CRAWLER_TOKEN || ""; function isThreadsURL(value: string): boolean { try { const host = new URL(value).hostname.toLowerCase(); return host === "threads.com" || host.endsWith(".threads.com") || host === "threads.net" || host.endsWith(".threads.net"); } catch { return false; } } async function readPosts(page: Page, limit: number): Promise { const posts = new Map(); const links = page.locator('a[href*="/post/"]'); const count = Math.min(await links.count(), 50); for (let i = 0; i < count && posts.size < limit; i++) { const link = links.nth(i); const href = await link.getAttribute("href").catch(() => null); if (!href) continue; const permalink = href.startsWith("http") ? href : `https://www.threads.com${href}`; if (!isThreadsURL(permalink)) continue; const author = href.match(/@([^/]+)\/post/)?.[1] || ""; const scope = link.locator("xpath=ancestor::div[position()<=6]").first(); const text = (await scope.innerText().catch(() => "")).trim(); if (text.length < 5) continue; posts.set(permalink, { permalink, author, text: text.slice(0, 2000) }); } return [...posts.values()]; } async function search(storageState: string, terms: string[], limit: number): Promise { const state = JSON.parse(storageState) as { cookies?: unknown[] }; if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid"); const browser = await chromium.launch({ headless: true }); try { const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" }); const page = await context.newPage(); const query = terms.filter(Boolean).join(" ").slice(0, 180); await page.goto(`https://www.threads.com/search?q=${encodeURIComponent(query)}&serp_type=default`, { waitUntil: "domcontentloaded", timeout: 45_000 }); const body = await page.locator("body").innerText().catch(() => ""); if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired"); await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined); await page.mouse.wheel(0, 900); await page.waitForTimeout(1000); const posts = await readPosts(page, limit); await context.close(); return posts; } finally { await browser.close(); } } function shortcodeFromPermalink(permalink: string): string { return permalink.match(/\/post\/([^/?]+)/)?.[1] || ""; } function findMediaID(value: unknown, shortcode: string): string { if (!value || typeof value !== "object") return ""; if (Array.isArray(value)) { for (const item of value) { const id = findMediaID(item, shortcode); if (id) return id; } return ""; } const record = value as Record; const code = String(record.code || record.shortcode || ""); const id = String(record.id || record.pk || record.media_id || ""); if (code === shortcode && /^\d{10,}$/.test(id)) return id; for (const child of Object.values(record)) { const found = findMediaID(child, shortcode); if (found) return found; } return ""; } function findMediaIDInText(raw: string, shortcode: string): string { const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const patterns = [ new RegExp(`(?:code|shortcode)["']?\\s*[:=]\\s*["']${escaped}["'][\\s\\S]{0,800}?(?:id|pk|media_id)["']?\\s*[:=]\\s*["']?(\\d{10,})`, "i"), new RegExp(`(?:id|pk|media_id)["']?\\s*[:=]\\s*["']?(\\d{10,})[\\s\\S]{0,800}?(?:code|shortcode)["']?\\s*[:=]\\s*["']${escaped}["']`, "i"), ]; for (const pattern of patterns) { const match = raw.match(pattern); if (match?.[1]) return match[1]; } return ""; } async function resolve(storageState: string, permalink: string): Promise { if (!isThreadsURL(permalink)) throw new Error("invalid Threads permalink"); const state = JSON.parse(storageState) as { cookies?: unknown[] }; if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid"); const shortcode = shortcodeFromPermalink(permalink); if (!shortcode) throw new Error("permalink has no Threads shortcode"); const browser = await chromium.launch({ headless: true }); try { const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" }); const page = await context.newPage(); let mediaID = ""; const responseReads: Promise[] = []; page.on("response", async (response) => { if (mediaID || !/graphql|threads|instagram/.test(response.url())) return; responseReads.push((async () => { try { const raw = await response.text(); if (!mediaID) { try { mediaID = findMediaID(JSON.parse(raw), shortcode); } catch { /* non-JSON response */ } } if (!mediaID) mediaID = findMediaIDInText(raw, shortcode); } catch { /* ignored */ } })()); }); await page.goto(permalink, { waitUntil: "domcontentloaded", timeout: 45_000 }); const body = await page.locator("body").innerText().catch(() => ""); if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired"); await page.waitForTimeout(2500); await Promise.allSettled(responseReads); if (!mediaID) { mediaID = findMediaIDInText(await page.content(), shortcode); } await context.close(); if (!mediaID) throw new Error("Threads media ID could not be resolved") return mediaID; } finally { await browser.close(); } } if (!token) throw new Error("SCOUT_CRAWLER_TOKEN is required"); createServer(async (req, res) => { const path = new URL(req.url || "/", "http://127.0.0.1").pathname; if (req.method !== "POST" || (path !== "/v1/threads/search" && path !== "/v1/threads/resolve")) { res.writeHead(404).end(); return; } if (req.headers.authorization !== `Bearer ${token}`) { res.writeHead(401).end(); return; } const body = await new Promise((resolve, reject) => { let raw = ""; req.setEncoding("utf8"); req.on("data", (chunk) => { raw += chunk; if (raw.length > 300_000) req.destroy(); }); req.on("end", () => resolve(raw)); req.on("error", reject); }); try { if (path === "/v1/threads/resolve") { const input = JSON.parse(body) as ResolveRequest; const mediaID = await resolve(String(input.storage_state || ""), String(input.permalink || "")); res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ media_id: mediaID })); } else { const input = JSON.parse(body) as SearchRequest; const posts = await search(String(input.storage_state || ""), Array.isArray(input.terms) ? input.terms.slice(0, 12) : [], Math.min(Math.max(Number(input.limit) || 10, 1), 30)); res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ posts })); } } catch (error) { res.writeHead(422, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "crawler failed" })); } }).listen(port, "127.0.0.1");