178 lines
5.3 KiB
JavaScript
178 lines
5.3 KiB
JavaScript
|
|
#!/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)));
|