thread-master/apps/backend/scripts/threads-profile/scrape.mjs

178 lines
5.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* 抓取 Threads 公開個人頁貼文文字。
* Usage:
* node scrape.mjs --username zuck [--storage /path/state.json] [--limit 12]
* stdout: JSON { "ok": true, "username": "...", "posts": ["..."] }
*/
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
function arg(name, fallback = "") {
const i = process.argv.indexOf(name);
if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1];
return fallback;
}
function fail(msg) {
process.stdout.write(JSON.stringify({ ok: false, error: msg, posts: [] }) + "\n");
process.exit(0);
}
function walkJson(data, visit) {
if (!data || typeof data !== "object") return;
if (Array.isArray(data)) {
for (const item of data) walkJson(item, visit);
return;
}
visit(data);
for (const v of Object.values(data)) {
if (v && typeof v === "object") walkJson(v, visit);
}
}
function postText(obj) {
return (
obj?.caption?.text ??
obj?.text_post_app_info?.text ??
(typeof obj?.text === "string" ? obj.text : undefined)
);
}
function collectFromJson(data, out, seen) {
walkJson(data, (obj) => {
const t = postText(obj);
if (!t || typeof t !== "string") return;
const text = t.trim();
if (text.length < 8 || text.length > 2500) return;
if (text.startsWith("http://") || text.startsWith("https://")) return;
if (seen.has(text)) return;
seen.add(text);
out.push(text);
});
}
async function main() {
const username = arg("--username", "").replace(/^@/, "").trim();
const storagePath = arg("--storage", "");
const limit = Math.max(2, Math.min(30, Number(arg("--limit", "12")) || 12));
if (!username || /[\s/]/.test(username) || username.includes("threads.")) {
fail("username 無效:請只填帳號名,例如 kupi.cat.31");
}
let storageState;
if (storagePath) {
try {
const raw = fs.readFileSync(path.resolve(storagePath), "utf8");
storageState = JSON.parse(raw);
// Playwright expects cookies/origins shape
if (!storageState.cookies) storageState = undefined;
} catch {
storageState = undefined;
}
}
const browser = await chromium.launch({
headless: true,
args: ["--disable-blink-features=AutomationControlled", "--no-sandbox"],
});
try {
const context = await browser.newContext({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
locale: "zh-TW",
viewport: { width: 1280, height: 900 },
storageState: storageState || undefined,
});
await context.addInitScript(() => {
Object.defineProperty(navigator, "webdriver", { get: () => false });
});
const page = await context.newPage();
const collected = [];
const seen = new Set();
page.on("response", async (response) => {
try {
const url = response.url();
if (!/graphql|threads|instagram/i.test(url)) return;
const ct = response.headers()["content-type"] || "";
if (!ct.includes("json")) return;
const json = await response.json();
collectFromJson(json, collected, seen);
} catch {
// ignore
}
});
const profileUrl = `https://www.threads.com/@${encodeURIComponent(username)}`;
await page.goto(profileUrl, { waitUntil: "domcontentloaded", timeout: 45_000 });
await page.waitForTimeout(1200);
// not found?
const bodyText = await page.locator("body").innerText().catch(() => "");
if (/走丟|找不到|Page not found|isn't available|無法使用/i.test(bodyText) && collected.length === 0) {
fail(`找不到 @${username}(帳號可能不存在或頁面不可用)`);
}
await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined);
// scroll to trigger more network
for (let i = 0; i < 4; i++) {
await page.mouse.wheel(0, 1400);
await page.waitForTimeout(700);
if (collected.length >= limit) break;
}
// embedded JSON scripts
if (collected.length < 2) {
const scripts = await page.locator('script[type="application/json"]').allTextContents();
for (const raw of scripts) {
if (!raw || raw.length < 20) continue;
try {
collectFromJson(JSON.parse(raw), collected, seen);
} catch {
// ignore
}
}
}
// DOM fallback: post link cards text
if (collected.length < 2) {
const links = page.locator('a[href*="/post/"]');
const n = await links.count();
for (let i = 0; i < Math.min(n, 25); i++) {
try {
const t = (await links.nth(i).innerText()).trim();
if (t.length >= 8 && !seen.has(t)) {
seen.add(t);
collected.push(t);
}
} catch {
// ignore
}
if (collected.length >= limit) break;
}
}
const posts = collected.slice(0, limit);
process.stdout.write(
JSON.stringify({
ok: posts.length >= 2,
username,
posts,
error:
posts.length >= 2
? ""
: `可讀取貼文不足 2 篇(目前 ${posts.length})。可先在設定同步 Chrome Session或改貼參考文字分析。`,
}) + "\n",
);
} finally {
await browser.close();
}
}
main().catch((e) => fail(e instanceof Error ? e.message : String(e)));