160 lines
7.4 KiB
TypeScript
160 lines
7.4 KiB
TypeScript
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<Post[]> {
|
|
const posts = new Map<string, Post>();
|
|
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<Post[]> {
|
|
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<string, unknown>;
|
|
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<string> {
|
|
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<void>[] = [];
|
|
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<string>((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");
|