// Capturing specific X posts: a screenshot of each post as X renders it, // through the logged-in session profile, and its attached media, downloaded by // gallery-dl (xGalleryDlFetcher.ts owns that half and wires both into // `captureByIds`). // // PACED LIKE EVERY OTHER X READ. A capture is a page load and a gallery-dl run // per post, and each is a contact with X: the run waits a random 4–10 s before // every contact after its first (gallery-dl then paces its own requests the // same way). A page that says X is refusing — "Something went wrong" — or a // login wall stops the run there: the next post would meet the same answer, // and asking again at once is exactly the burst the pacing exists to avoid. // The ids not reached are left for a later run. // // What a page can say instead of the post, each recorded, none retried in the // run that met it: // - a login wall (a redirect to the login flow, or a logged-out page with no // post): the session's state, not the post's — the run stops, needsCookies; // - a sensitive-media interstitial: opened ("Show"), then shot, and the // record says it was there; // - "this post was deleted" / "this page doesn't exist": deleted; // - a protected, suspended or vanished account, or a withheld post: // unavailable. // // A post that is an X Article (long-form) links to it — in its archived text, // or on its rendered card — and the run then opens the article too, one more // paced contact (xArticleCapture.ts), unless `articles` is off. // // NOT VERIFIED AGAINST LIVE X. The page markers below are X's as of this // writing and are tested against recorded snapshots, never x.com; the first // real run is the check. import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import type { PageLike } from "./playwrightRuntime"; import type { PostCaptureInput, PostCaptureOutcome, PostCaptureResult, } from "./fetchers"; import { captureAvailability, captureWork, describeCapturedFile, postCaptureDir, readPostCapture, SHOT_FILENAME, writePostCapture, type ArticleCaptureRecord, type CapturedFile, type CaptureMediaState, type PostCaptureRecord, type PostCaptureState, } from "./postCapture"; import { xArticleLinkFromArchive, type XArticleLink } from "./xArticle"; import { captureXArticle, xArticleLinkFromCard } from "./xArticleCapture"; export function xStatusUrl(id: string): string { return `https://x.com/i/status/${id}`; } // What the page showed, read in one evaluate. `article` is the post itself // (its box in document coordinates, for the clip), when it rendered. export type XPostSnapshot = { path: string; text: string; article: null | { rect: { x: number; y: number; width: number; height: number }; // A "Show" / "View" button inside the post: a sensitive-media cover. sensitive: boolean; // The hrefs inside the post that look like an X Article's: its card. articleHrefs?: string[]; }; }; // The post's own article: the one whose timestamp links to this id (a reply's // parents render above it as articles too), else X's focal article. const SNAPSHOT_SCRIPT = (id: string) => `(() => { const id = ${JSON.stringify(id)}; const articles = Array.from(document.querySelectorAll('article[data-testid="tweet"]')); const own = articles.find((a) => Array.from(a.querySelectorAll('a[href*="/status/"]')).some((l) => { const m = /\\/status\\/(\\d+)/.exec(l.getAttribute("href") || ""); return m && m[1] === id && l.querySelector("time"); }), ) || articles.find((a) => a.getAttribute("tabindex") === "-1") || null; const main = document.querySelector('[data-testid="primaryColumn"]') || document.body; const text = ((main && main.innerText) || "").slice(0, 4000); let article = null; if (own) { own.setAttribute("data-archilyzer-capture", ""); const r = own.getBoundingClientRect(); const sensitive = Array.from(own.querySelectorAll('button, [role="button"]')).some( (b) => /^(show|view)$/i.test((b.innerText || "").trim()), ); const articleHrefs = Array.from(own.querySelectorAll('a[href*="/article/"]')) .map((l) => l.getAttribute("href") || "") .filter(Boolean) .slice(0, 10); article = { rect: { x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height }, sensitive, articleHrefs, }; } return { path: location.pathname, text, article }; })()`; // Open a sensitive-media cover inside the post. const OPEN_SENSITIVE_SCRIPT = `(() => { const own = document.querySelector('[data-archilyzer-capture]'); if (!own) return 0; let n = 0; for (const b of own.querySelectorAll('button, [role="button"]')) { if (/^(show|view)$/i.test((b.innerText || "").trim())) { b.click(); n++; } } return n; })()`; // Let the post's images finish loading (each capped), so the shot is not of // grey boxes. const IMAGES_LOADED_SCRIPT = `(() => { const own = document.querySelector('[data-archilyzer-capture]') || document; const imgs = Array.from(own.querySelectorAll("img")).filter((i) => !i.complete); return Promise.all(imgs.map((i) => new Promise((r) => { i.addEventListener("load", r, { once: true }); i.addEventListener("error", r, { once: true }); setTimeout(r, 5000); }))).then(() => imgs.length); })()`; const DELETED_TEXT = [ /this (post|tweet) was deleted/i, /this page doesn.t exist/i, ]; const UNAVAILABLE_TEXT = [ /account (that )?no longer exists/i, /suspended account/i, /account (is )?suspended/i, /these (posts|tweets) are protected/i, /limits who can view their (posts|tweets)/i, /withheld in/i, /this (post|tweet) is unavailable/i, ]; const AGE_WALL_TEXT = /age-restricted/i; const REFUSED_TEXT = /something went wrong\. try reloading|rate limit exceeded/i; const LOGGED_OUT_TEXT = /(log in|sign in|sign up)/i; export type XPostVerdict = { state: PostCaptureState; sensitive?: boolean; // Set when the run must stop here: the next post would meet the same page. stop?: string; error?: string; }; // What a snapshot means. Pure, so every marker is testable without a browser. // `noun` names what the page should have shown (an article page is read the // same way, xArticleCapture.ts). export function classifyXPostSnapshot( s: Pick, noun = "post", ): XPostVerdict { if (/^\/(i\/flow\/login|login|i\/flow\/signup)\b/.test(s.path)) { return { state: "login-wall", stop: "X asked to log in — the session profile is not logged in.", }; } if (s.article) { return { state: "captured", ...(s.article.sensitive ? { sensitive: true } : {}) }; } if (REFUSED_TEXT.test(s.text)) { return { state: "error", error: `X answered “Something went wrong” instead of the ${noun}.`, stop: "X is refusing pages right now; stopping rather than asking again.", }; } if (DELETED_TEXT.some((re) => re.test(s.text))) return { state: "deleted" }; if (UNAVAILABLE_TEXT.some((re) => re.test(s.text))) return { state: "unavailable" }; if (AGE_WALL_TEXT.test(s.text)) { return { state: "error", error: `X shows this ${noun} only to an age-verified session.`, }; } if (LOGGED_OUT_TEXT.test(s.text)) { return { state: "login-wall", stop: `X showed its logged-out page instead of the ${noun}.`, }; } return { state: "error", error: `The ${noun} did not render.` }; } export type XShotResult = XPostVerdict & { shot?: CapturedFile; // The X Article the post's card links to, when it does. articleLink?: XArticleLink; }; // One post's screenshot: load, read the page, open a sensitive cover, shoot // the post's own article. Writes `shot.png` into `dir` only for a post that // rendered. export async function shootXPost( page: PageLike, id: string, dir: string, onLog?: (line: string) => void, ): Promise { const url = xStatusUrl(id); try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }); } catch (err) { return { state: "error", error: `Could not load ${url}: ${firstLine(err)}` }; } // The post, or whatever X shows instead — which has no stable marker, so a // missing article is waited out and the page read anyway. await page .waitForSelector('article[data-testid="tweet"]', { timeout: 20_000 }) .catch(() => {}); await page.waitForTimeout(1_500); let snap = (await page.evaluate(SNAPSHOT_SCRIPT(id))) as XPostSnapshot; const verdict = classifyXPostSnapshot(snap); if (verdict.state !== "captured") return verdict; if (verdict.sensitive) { onLog?.(`${id}: a sensitive-media cover — opening it before the shot.`); await page.evaluate(OPEN_SENSITIVE_SCRIPT); await page.waitForTimeout(1_500); snap = (await page.evaluate(SNAPSHOT_SCRIPT(id))) as XPostSnapshot; if (!snap.article) { return { state: "error", error: "The post vanished after its sensitive-media cover was opened." }; } } await page.evaluate(IMAGES_LOADED_SCRIPT).catch(() => {}); // Re-read the box: images that loaded may have grown it. snap = (await page.evaluate(SNAPSHOT_SCRIPT(id))) as XPostSnapshot; const rect = snap.article?.rect; if (!rect || rect.width < 1 || rect.height < 1) { return { state: "error", error: "The post rendered with no size to shoot." }; } const clip = { x: Math.max(0, Math.floor(rect.x)), y: Math.max(0, Math.floor(rect.y)), width: Math.ceil(rect.width), height: Math.ceil(rect.height), }; let png: Uint8Array; try { // fullPage, so a post taller than the window is shot whole; the clip is in // document coordinates, which is what the snapshot measured. png = await page.screenshot({ type: "png", fullPage: true, clip }); } catch (err) { return { state: "error", error: `The screenshot failed: ${firstLine(err)}` }; } await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, SHOT_FILENAME), png); const shot = await describeCapturedFile(dir, SHOT_FILENAME, url); const articleLink = xArticleLinkFromCard(snap.article?.articleHrefs); return { ...verdict, shot, ...(articleLink ? { articleLink } : {}) }; } export type MediaDownloadResult = | { ok: true; files: { name: string; url?: string }[] } | { ok: false; error: string; needsCookies?: boolean }; export type XCaptureDeps = { // A page in the logged-in profile. Opened on the first shot, closed at the // end of the run. openPage: () => Promise<{ page: PageLike; close: () => Promise }>; // One post's media into `dir` (gallery-dl in production). downloadMedia: (args: { id: string; dir: string; signal: AbortSignal; onLog?: (line: string) => void; }) => Promise; // The gap before each contact with X after the first. pauseMs: () => number; pause: (ms: number, signal: AbortSignal) => Promise; // The gap between one article image and the next (none when absent). articleImagePauseMs?: () => number; now?: () => Date; }; // A run's errors in a row that stop it: a host whose network or browser is // failing every post should not page through the rest of the list. const STOP_AFTER_ERRORS = 3; // The capture loop: per id, what is owed (captureWork), the shot, the media, // the record. Every post's record is written as soon as that post is done, so // a cancel or a crash loses at most the post in hand. export async function captureXPosts( input: PostCaptureInput, deps: XCaptureDeps, ): Promise { const { signal, onLog } = input; const wanted = { shots: input.shots ?? true, media: input.media ?? true, force: input.force ?? false, articles: input.articles ?? true, }; const now = deps.now ?? (() => new Date()); const outcomes: PostCaptureOutcome[] = []; let contacts = 0; let errorsInARow = 0; let browser: { page: PageLike; close: () => Promise } | undefined; const contact = async () => { if (contacts++ > 0) await deps.pause(deps.pauseMs(), signal); }; const stopped = (why: string, extra: Partial = {}) => { onLog?.(why); return { outcomes, stoppedEarly: why, ...extra }; }; try { for (const [i, id] of input.ids.entries()) { if (signal.aborted) return stopped("Cancelled; the rest are left for a later run."); if (input.drain?.aborted) return stopped("Drained; the rest are left for a later run."); const dir = postCaptureDir(input.outDir, id); const existing = await readPostCapture(dir); const work = captureWork(existing, wanted); // The article the post links to, as far as is known before any page: // the archived text, or the last capture's record. let articleLink: XArticleLink | null = wanted.articles ? (xArticleLinkFromArchive(input.archived?.get(id)) ?? (existing?.article ? { articleId: existing.article.articleId, url: existing.article.url } : null)) : null; const articleOwedNow = work.article && !!articleLink && (!existing || work.shot || existing.state === "captured"); if (!work.shot && !work.media && !articleOwedNow) { onLog?.(`${id}: already captured (${existing?.state ?? "nothing asked for"}) — skipped.`); continue; } onLog?.(`[${i + 1}/${input.ids.length}] ${id}`); let state: PostCaptureState | undefined = work.shot ? undefined : existing?.state; let sensitive = work.shot ? undefined : existing?.sensitive; let shot = work.shot ? undefined : existing?.shot; let error: string | undefined; let stop: string | undefined; if (work.shot) { await contact(); if (signal.aborted) return stopped("Cancelled; the rest are left for a later run."); browser ??= await deps.openPage(); const res = await shootXPost(browser.page, id, dir, onLog); state = res.state; sensitive = res.sensitive; shot = res.shot; error = res.error; stop = res.stop; if (wanted.articles && !articleLink && res.articleLink) articleLink = res.articleLink; } let mediaState: CaptureMediaState = work.media ? "skipped" : (existing?.mediaState ?? "skipped"); let media: CapturedFile[] = work.media ? [] : (existing?.media ?? []); let needsCookies = false; // Only a post that rendered (or was not shot this run) is worth a // download: X has nothing to give for a deleted or walled one. const postIsThere = state === undefined || state === "captured"; if (work.media && postIsThere && !stop) { await contact(); if (signal.aborted) return stopped("Cancelled; the rest are left for a later run."); await mkdir(dir, { recursive: true }); const got = await deps.downloadMedia({ id, dir, signal, onLog }); if (got.ok) { media = []; for (const f of got.files) media.push(await describeCapturedFile(dir, f.name, f.url)); mediaState = media.length > 0 ? "ok" : "none"; state ??= "captured"; } else { mediaState = "error"; error = error ? `${error}; ${got.error}` : got.error; state ??= "error"; if (got.needsCookies) { needsCookies = true; stop = "gallery-dl could not log in to X for the media."; } } } // The article: after the post and its media, one more paced load in the // same page. Only for a post that is there, in a run not already // stopping. let article: ArticleCaptureRecord | undefined = existing?.article; let articleFailed = false; if (work.article && articleLink && (state === undefined || state === "captured") && !stop) { await contact(); if (signal.aborted) return stopped("Cancelled; the rest are left for a later run."); browser ??= await deps.openPage(); const got = await captureXArticle(browser.page, articleLink, dir, { now, onLog, signal, imageGap: deps.articleImagePauseMs ? () => deps.pause(deps.articleImagePauseMs!(), signal) : undefined, }); article = got.record; articleFailed = article.state === "error"; // A run that only read the article learns of the post only that it // linked to a readable article. state ??= article.state === "captured" ? "captured" : "error"; if (got.stop) { stop = got.stop; if (article.state === "login-wall") needsCookies = true; } } const finalState: PostCaptureState = state ?? "error"; const record: PostCaptureRecord = { version: 1, id, url: xStatusUrl(id), capturedAt: now().toISOString(), state: finalState, ...(sensitive ? { sensitive: true } : {}), ...(shot ? { shot } : {}), mediaState, media, ...(article ? { article } : {}), ...(error ? { error } : {}), }; await writePostCapture(dir, record); outcomes.push({ id, state: finalState, // Only the page says whether the post is up: a media-only run has no // verdict to record. ...(work.shot && captureAvailability(finalState) ? { availability: captureAvailability(finalState) } : {}), files: (shot ? 1 : 0) + media.length + (article?.files.length ?? 0), ...(error ? { error } : {}), }); onLog?.( `${id}: ${finalState}${sensitive ? " (behind a sensitive-media cover)" : ""}` + (shot ? ", shot" : "") + (mediaState === "ok" ? `, ${media.length} media file(s)` : mediaState === "none" ? ", no media" : "") + (article && article !== existing?.article ? `, ${describeArticle(article)}` : "") + (error ? ` — ${error}` : ""), ); if (stop) { return stopped(`${stop} Stopped at ${id}; the rest are left for a later run.`, { needsCookies: needsCookies || finalState === "login-wall", }); } errorsInARow = finalState === "error" || articleFailed ? errorsInARow + 1 : 0; if (errorsInARow >= STOP_AFTER_ERRORS) { return stopped( `${STOP_AFTER_ERRORS} posts in a row failed; stopping rather than paging through the rest.`, ); } } return { outcomes }; } finally { await browser?.close().catch(() => {}); } } function describeArticle(a: ArticleCaptureRecord): string { if (a.state !== "captured") { return `article ${a.state}` + (a.error ? ` (${a.error})` : ""); } return ( `article${a.title ? ` “${a.title}”` : ""} (${a.blocks} block(s), ${a.files.length} file(s)` + (a.extraction === "fallback" ? ", read by the fallback" : "") + (a.trimmed ? ", shot trimmed" : "") + ")" + (a.error ? ` — ${a.error}` : "") ); } function firstLine(err: unknown): string { return ((err as Error)?.message ?? String(err)).split("\n")[0]; }