// fetchPosts({ older: true }) over the gallery-dl X fetcher, end to end // against a fake binary that answers X SEARCH URLs from a fixed set of posts: // the walk steps back a window at a time, writes through the same shard // writer, records its own position apart from the timeline cursor, stops at a // floor / the account's creation date / a run of empty windows, and a normal // fetch never resumes backwards from it. An account that shows no posts is // refused a walk unless forced, and a forced one ends after two empty // windows; a drain stops the walk at a resume point and keeps it. // // Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test controller/fetchOlderPosts.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; const ROOT = await mkdtemp(path.join(os.tmpdir(), "fetcholder-")); const BIN = path.join(ROOT, "fake-gallery-dl.mjs"); const ARGS_LOG = path.join(ROOT, "argv.jsonl"); process.env.TRANSCRIPTS_DIR = path.join(ROOT, "transcripts"); process.env.GALLERY_DL_BIN = BIN; process.env.FAKE_ARGS_LOG = ARGS_LOG; // The account's posts, by date. An id is X's: the creation time in its high // bits, so ids order as dates do. const POST_DATES = [ "2021-03-10 12:00:00", // the oldest post already archived "2021-02-01 09:00:00", "2020-12-15 18:30:00", "2020-06-01 08:00:00", "2019-11-20 22:15:00", ]; // The fake: a timeline URL reads nothing (the timeline is exhausted); a search // URL answers its query's from:/since:/until:/max_id: from POST_DATES, newest // first, honouring --post-range. FAKE_ACCOUNT_DATE is the account's creation date // carried on each record's `user`. FAKE_MODE=stall prints the window's first // post, then sleeps on a "rate limit" until killed. await writeFile( BIN, `#!/usr/bin/env node import { appendFileSync } from "node:fs"; const args = process.argv.slice(2); appendFileSync(process.env.FAKE_ARGS_LOG, JSON.stringify(args) + "\\n"); const url = new URL(args[args.length - 1]); if (!url.pathname.startsWith("/search")) process.exit(0); const q = url.searchParams.get("q"); const term = (k) => (q.split(" ").find((t) => t.startsWith(k + ":")) || "").slice(k.length + 1) || undefined; const from = term("from"), since = term("since"), until = term("until"), maxId = term("max_id"); const range = args[args.indexOf("--post-range") + 1]; const cap = args.includes("--post-range") ? Number(range.split("-")[1]) : Infinity; const dates = ${JSON.stringify(POST_DATES)}; const idOf = (d) => (BigInt(Date.parse(d.replace(" ", "T") + "Z") - 1288834974657) << 22n); const hits = dates .filter((d) => d.slice(0, 10) >= since && d.slice(0, 10) < until) .filter((d) => !maxId || idOf(d) <= BigInt(maxId)) .sort().reverse(); const user = { name: from, nick: "Example" }; if (process.env.FAKE_ACCOUNT_DATE) user.date = process.env.FAKE_ACCOUNT_DATE; const line = (d) => '[2, {"tweet_id": ' + idOf(d) + ', "date": "' + d + '", "content": "post at ' + d + '", "author": ' + JSON.stringify(user) + ', "user": ' + JSON.stringify(user) + '}]\\n'; if (process.env.FAKE_MODE === "stall" && hits.length) { process.stdout.write(line(hits[0])); process.stderr.write("[twitter][info] Waiting for 14 minutes until 15:11:22 (rate limit)\\n"); setTimeout(() => process.exit(0), 60_000); } else { hits.slice(0, cap).forEach((d) => process.stdout.write(line(d))); process.exit(0); } `, ); await chmod(BIN, 0o755); const HANDLE = "example_user"; const channelsDir = path.join(ROOT, "transcripts", "channels"); async function makeChannel(slug: string): Promise { const channelRoot = path.join(channelsDir, slug); await mkdir(channelRoot, { recursive: true }); await writeFile( path.join(channelRoot, "config.json"), JSON.stringify({ handling: "transcribe", sourceKind: "social", postFetcher: "x-gallery-dl", socialHandle: HANDLE, platform: "twitter", name: "Example (X)", url: `https://x.com/${HANDLE}`, }), ); return channelRoot; } const { fetchPosts, FULL_AND_OLDER_REFUSAL, EMPTY_ACCOUNT_OLDER_REFUSAL, emptyAccountOlderProblem, olderPostsProblem, } = await import("./fetchPosts"); const { readPostFetchState, readSeenPostIds, writePostFetchState, writePosts, readAllPosts, } = await import("../lib/posts-server"); const { normalizeXTweet } = await import("../social/xNormalize"); const { getPaths } = await import("../lib/paths"); const LOGIN = { cookiesFromBrowser: "firefox" }; function idOf(d: string): string { return String( BigInt(Date.parse(d.replace(" ", "T") + "Z") - 1288834974657) << 22n, ); } // The archive as a normal fetch would have left it: the oldest post, plus a // timeline resume point. async function seed(channelRoot: string, slug: string): Promise { const post = normalizeXTweet( { tweet_id: idOf(POST_DATES[0]), date: POST_DATES[0], content: "archived", author: { name: HANDLE }, user: { name: HANDLE }, }, slug, ); assert.ok(post); await writePosts(channelRoot, [post]); await writePostFetchState(channelRoot, { lastFetchedAt: "2026-01-01T00:00:00.000Z", cursor: "1/TIMELINE-RESUME", }); } async function argvLines(): Promise { try { return (await readFile(ARGS_LOG, "utf8")) .trim() .split("\n") .filter(Boolean) .map((l) => JSON.parse(l) as string[]); } catch { return []; } } const queryOf = (argv: string[]) => new URL(argv[argv.length - 1]).searchParams.get("q") ?? ""; test("an older fetch walks back window by window and stops after four empty windows", async () => { const slug = "older-walk"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); const before = (await argvLines()).length; const log: string[] = []; const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 0, onLog: (l) => log.push(l), }); assert.equal(result.ok, true, log.join("\n")); assert.equal(result.complete, true); assert.equal(result.written, 4); assert.equal((await readSeenPostIds(channelRoot)).size, 5); const runs = (await argvLines()).slice(before); const queries = runs.map(queryOf); // Windows tile backwards from the day after the oldest archived post. assert.deepEqual(queries.slice(0, 3), [ `from:${HANDLE} since:2020-12-11 until:2021-03-11 include:nativeretweets`, `from:${HANDLE} since:2020-09-11 until:2020-12-11 include:nativeretweets`, `from:${HANDLE} since:2020-06-11 until:2020-09-11 include:nativeretweets`, ]); // Ten windows: three with posts, the run of four empty ones at the end, and // the empty ones between that a later post reset. assert.equal(runs.length, 10, queries.join("\n")); assert.match(queries[9], /since:2018-09-11 until:2018-12-11/); const state = await readPostFetchState(channelRoot); assert.equal(state?.older?.complete, true); assert.match(state?.older?.completeReason ?? "", /4 windows of 3 months in a row held no posts/); // The timeline's own state is exactly as the last normal fetch left it. assert.equal(state?.cursor, "1/TIMELINE-RESUME"); assert.equal(state?.lastFetchedAt, "2026-01-01T00:00:00.000Z"); // Not a sync: the channel's sync stamp is not touched. const config = JSON.parse(await readFile(path.join(channelRoot, "config.json"), "utf8")); assert.equal(config.lastSyncedAt, undefined); // Re-running says so, and searches nothing. const again: string[] = []; const rerun = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 0, onLog: (l) => again.push(l), }); assert.equal(rerun.ok, true); assert.equal(rerun.written, 0); assert.equal((await argvLines()).length, before + 10); assert.ok(again.some((l) => /already complete/.test(l)), again.join("\n")); }); test("a normal fetch ignores the older walk's state, resumes the TIMELINE, and keeps the older state", async () => { const slug = "older-walk"; const channelRoot = path.join(channelsDir, slug); const olderBefore = (await readPostFetchState(channelRoot))?.older; assert.ok(olderBefore); const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN }); assert.equal(result.ok, true); const argv = (await argvLines()).at(-1)!; assert.equal(argv[argv.length - 1], `https://x.com/${HANDLE}/timeline`); assert.ok(argv.includes("extractor.twitter.cursor=1/TIMELINE-RESUME")); assert.equal(argv.some((a) => a.includes("/search")), false); const state = await readPostFetchState(channelRoot); assert.deepEqual(state?.older, olderBefore); }); test("a normal fetch with a half-walked older state still reads new posts from the top", async () => { const slug = "older-half"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); await writePostFetchState(channelRoot, { older: { since: "2019-01-01", until: "2019-04-01", maxId: "123", emptyWindows: 1 }, }); await fetchPosts({ paths: getPaths(), slug, settings: LOGIN }); const argv = (await argvLines()).at(-1)!; assert.equal(argv[argv.length - 1], `https://x.com/${HANDLE}/timeline`); assert.equal(argv.some((a) => a.startsWith("extractor.twitter.cursor=")), false); assert.deepEqual((await readPostFetchState(channelRoot))?.older, { since: "2019-01-01", until: "2019-04-01", maxId: "123", emptyWindows: 1, }); }); test("a floor date ends the walk at that date, and posts below it are not fetched", async () => { const slug = "older-floor"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, floor: "2020-07-01", olderWindowPauseMs: 0, }); assert.equal(result.ok, true); assert.equal(result.complete, true); assert.equal(result.written, 2); const queries = (await argvLines()).map(queryOf); assert.match(queries.at(-1)!, /since:2020-07-01 until:2020-09-11/); const state = await readPostFetchState(channelRoot); assert.equal(state?.older?.floor, "2020-07-01"); assert.match(state?.older?.completeReason ?? "", /reached 2020-07-01/); const dates = (await readAllPosts(channelRoot)).map((p) => p.createdAt.slice(0, 10)); assert.equal(dates.includes("2020-06-01"), false); }); test("the account's creation date, once a post carries it, is the floor", async () => { const slug = "older-created"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); process.env.FAKE_ACCOUNT_DATE = "2019-10-05 10:00:00"; try { const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 0, }); assert.equal(result.complete, true); assert.equal(result.written, 4); } finally { delete process.env.FAKE_ACCOUNT_DATE; } const state = await readPostFetchState(channelRoot); assert.equal(state?.older?.accountCreatedAt, "2019-10-05T10:00:00.000Z"); assert.match(state?.older?.completeReason ?? "", /reached 2019-10-05/); // It stopped at the window holding the creation date, not four windows on. assert.match((await argvLines()).map(queryOf).at(-1)!, /since:2019-10-05 until:2019-12-11/); }); test("a cancelled window keeps what it read and resumes INSIDE the window, below the oldest post read", async () => { const slug = "older-cancel"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); const controller = new AbortController(); process.env.FAKE_MODE = "stall"; try { const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 0, signal: controller.signal, onLog: (l) => { if (/rate limit/.test(l)) controller.abort(); }, }); assert.equal(result.ok, true); assert.equal(result.complete, false); } finally { delete process.env.FAKE_MODE; } // The stall printed the window's newest post: the archived one. const state = await readPostFetchState(channelRoot); assert.deepEqual( { since: state?.older?.since, until: state?.older?.until, maxId: state?.older?.maxId }, { since: "2020-12-11", until: "2021-03-11", maxId: idOf(POST_DATES[0]) }, ); assert.equal(state?.cursor, "1/TIMELINE-RESUME"); const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 0, }); assert.equal(result.complete, true); assert.equal(result.written, 4); const resumed = (await argvLines()).map(queryOf).find((q) => q.includes("max_id:")); assert.equal( resumed, `from:${HANDLE} since:2020-12-11 until:2021-03-11 include:nativeretweets max_id:${idOf(POST_DATES[0])}`, ); }); test("a limit stops the walk part-way with a resume point", async () => { const slug = "older-limit"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, limit: 2, olderWindowPauseMs: 0, }); assert.equal(result.complete, false); assert.equal(result.written, 1); const argv = (await argvLines()).at(-1)!; assert.equal(argv[argv.indexOf("--post-range") + 1], "1-2"); const older = (await readPostFetchState(channelRoot))?.older; assert.equal(older?.maxId, idOf(POST_DATES[1])); assert.equal(older?.complete, undefined); }); test("with no login an older fetch needs cookies and spawns nothing", async () => { const slug = "older-guest"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); const before = (await argvLines()).length; const result = await fetchPosts({ paths: getPaths(), slug, settings: {}, older: true }); assert.equal(result.ok, false); assert.equal(result.needsCookies, true); assert.match(result.error ?? "", /X search needs a logged-in session/); assert.equal((await argvLines()).length, before); const state = await readPostFetchState(channelRoot); assert.equal(state?.needsCookies, true); assert.equal(state?.cursor, "1/TIMELINE-RESUME"); assert.equal(state?.older?.complete, undefined); }); test("full and older together are refused, and so is a fetcher with no older walk", async () => { const both = await fetchPosts({ paths: getPaths(), slug: "older-walk", settings: LOGIN, full: true, older: true, }); assert.equal(both.ok, false); assert.equal(both.error, FULL_AND_OLDER_REFUSAL); const badFloor = await fetchPosts({ paths: getPaths(), slug: "older-walk", settings: LOGIN, older: true, floor: "2020-02-30", }); assert.equal(badFloor.ok, false); assert.match(badFloor.error ?? "", /not a date/); assert.equal(olderPostsProblem({ label: "X", fetchOlder: async () => ({ posts: [], complete: true, position: { since: "", until: "", emptyWindows: 0 } }) }), null); assert.equal(olderPostsProblem({ label: "Bluesky" }), "Bluesky cannot fetch older posts."); assert.equal(olderPostsProblem(undefined), "This channel has no post fetcher."); }); // An account that shows no posts: nothing archived, and the last timeline // fetch finished having read none. async function emptyAccount(slug: string): Promise { const channelRoot = await makeChannel(slug); await writePostFetchState(channelRoot, { lastFetchedAt: "2026-01-01T00:00:00.000Z", lastFetchedCount: 0, }); return channelRoot; } test("an older walk over an account that shows no posts is refused, and searches nothing", async () => { const slug = "older-empty"; const channelRoot = await emptyAccount(slug); const before = (await argvLines()).length; const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 0, }); assert.equal(result.ok, false); assert.equal(result.error, EMPTY_ACCOUNT_OLDER_REFUSAL); assert.equal((await argvLines()).length, before); // Nothing is recorded: the refusal is not a walk. assert.equal((await readPostFetchState(channelRoot))?.older, undefined); }); test("forced, the walk over an empty account ends after two empty windows, not a year of them", async () => { const slug = "older-empty-forced"; const channelRoot = await emptyAccount(slug); const before = (await argvLines()).length; const log: string[] = []; const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, force: true, olderWindowPauseMs: 0, onLog: (l) => log.push(l), }); assert.equal(result.ok, true, log.join("\n")); assert.equal(result.complete, true); assert.equal(result.written, 0); // From today back: two quiet windows, then it stops. assert.equal((await argvLines()).length - before, 2); const older = (await readPostFetchState(channelRoot))?.older; assert.equal(older?.complete, true); assert.equal(older?.emptyWindows, 2); assert.match(older?.completeReason ?? "", /2 windows of 3 months in a row held no posts/); assert.match(older?.completeReason ?? "", /nothing archived and none found/); assert.ok(log.some((l) => /walking anyway, as forced/.test(l)), log.join("\n")); assert.ok(log.some((l) => /backfill complete: .*nothing archived/.test(l)), log.join("\n")); }); test("the refusal reads only a clean timeline fetch: a stopped or failed one, or none yet, does not refuse", async () => { const state = { lastFetchedAt: "2026-01-01T00:00:00.000Z", lastFetchedCount: 0 }; assert.equal(emptyAccountOlderProblem(0, state), EMPTY_ACCOUNT_OLDER_REFUSAL); assert.equal(emptyAccountOlderProblem(1, state), null); assert.equal(emptyAccountOlderProblem(0, null), null); assert.equal(emptyAccountOlderProblem(0, { ...state, lastFetchedCount: 3 }), null); assert.equal(emptyAccountOlderProblem(0, { ...state, cursor: "1/X" }), null); assert.equal(emptyAccountOlderProblem(0, { ...state, lastError: "boom" }), null); assert.equal(emptyAccountOlderProblem(0, { ...state, needsCookies: true }), null); }); test("with nothing archived but posts found this run, the year-of-empties rule still ends the walk", async () => { const slug = "older-found"; const channelRoot = await makeChannel(slug); // A walk already under way at the posts, with nothing yet on disk. await writePostFetchState(channelRoot, { older: { since: "2020-12-11", until: "2021-03-11", emptyWindows: 0 }, }); const before = (await argvLines()).length; const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 0, }); assert.equal(result.ok, true); assert.equal(result.complete, true); // Every post is new, the archived one's included. assert.equal(result.written, 5); assert.equal((await argvLines()).length - before, 10); const older = (await readPostFetchState(channelRoot))?.older; assert.match(older?.completeReason ?? "", /^4 windows of 3 months in a row held no posts, back to [\d-]+$/); }); test("a drain mid-window stops the search at once and keeps the resume point inside the window", async () => { const slug = "older-drain-window"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); const drain = new AbortController(); const log: string[] = []; const started = Date.now(); process.env.FAKE_MODE = "stall"; let result; try { result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 0, drain: drain.signal, onLog: (l) => { log.push(l); if (/rate limit/.test(l)) drain.abort(); }, }); } finally { delete process.env.FAKE_MODE; } assert.equal(result.ok, true, log.join("\n")); assert.equal(result.drained, true); assert.equal(result.complete, false); assert.ok(Date.now() - started < 15_000, `took ${Date.now() - started} ms`); const older = (await readPostFetchState(channelRoot))?.older; assert.deepEqual( { since: older?.since, until: older?.until, maxId: older?.maxId, lastError: older?.lastError }, { since: "2020-12-11", until: "2021-03-11", maxId: idOf(POST_DATES[0]), lastError: undefined }, ); assert.ok(log.some((l) => /Drained; the next run resumes at 2020-12-11 – 2021-03-11 below/.test(l)), log.join("\n")); }); test("a drain during the pause between windows ends it at once, at the next window, before another search", async () => { const slug = "older-drain-gap"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); const before = (await argvLines()).length; const drain = new AbortController(); const log: string[] = []; const started = Date.now(); const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, // A gap the test would notice. olderWindowPauseMs: 60_000, drain: drain.signal, onLog: (l) => { log.push(l); // The first window's tally: its boundary is saved next, then the pause. if (/^2020-12-11 – 2021-03-11: read/.test(l)) setTimeout(() => drain.abort(), 500); }, }); assert.equal(result.ok, true, log.join("\n")); assert.equal(result.drained, true); assert.equal(result.written, 2); assert.ok(Date.now() - started < 15_000, `took ${Date.now() - started} ms`); assert.equal((await argvLines()).length - before, 1); const older = (await readPostFetchState(channelRoot))?.older; assert.deepEqual( { since: older?.since, until: older?.until, maxId: older?.maxId }, { since: "2020-09-11", until: "2020-12-11", maxId: undefined }, ); assert.ok(log.some((l) => /Drained; the next run resumes at 2020-09-11 – 2020-12-11\.$/.test(l)), log.join("\n")); }); test("a drain already set when a window ends stops before the pause, never waiting it out", async () => { const slug = "older-drain-boundary"; const channelRoot = await makeChannel(slug); await seed(channelRoot, slug); const before = (await argvLines()).length; const drain = new AbortController(); const started = Date.now(); const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 60_000, drain: drain.signal, onLog: (l) => { if (/^2020-12-11 – 2021-03-11: read/.test(l)) drain.abort(); }, }); assert.equal(result.drained, true); assert.ok(Date.now() - started < 15_000, `took ${Date.now() - started} ms`); assert.equal((await argvLines()).length - before, 1); assert.equal((await readPostFetchState(channelRoot))?.older?.since, "2020-09-11"); }); test("from starts a completed walk afresh above a sparse old post, and covers the gap", async () => { const slug = "older-from"; const channelRoot = await makeChannel(slug); // Only the OLDEST post survives in the archive (a timeline that returned // one 2019 post), and the walk below it is already complete. const oldest = POST_DATES[POST_DATES.length - 1]; const post = normalizeXTweet( { tweet_id: idOf(oldest), date: oldest, content: "old", author: { name: HANDLE }, user: { name: HANDLE } }, slug, ); assert.ok(post); await writePosts(channelRoot, [post]); await writePostFetchState(channelRoot, { lastFetchedAt: "2026-01-01T00:00:00.000Z", cursor: "1/TIMELINE-RESUME", older: { since: "2019-08-20", until: "2019-11-21", emptyWindows: 4, complete: true, completeReason: "done" }, }); // Without a start date there is nothing to walk. const idle = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, olderWindowPauseMs: 0 }); assert.equal(idle.written, 0); const before = (await argvLines()).length; const log: string[] = []; const result = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, from: "2021-03-10", olderWindowPauseMs: 0, onLog: (l) => log.push(l), }); assert.equal(result.ok, true, log.join("\n")); assert.equal(result.written, 4); assert.equal((await readSeenPostIds(channelRoot)).size, 5); const queries = (await argvLines()).slice(before).map(queryOf); assert.equal(queries[0], `from:${HANDLE} since:2020-12-11 until:2021-03-11 include:nativeretweets`); assert.ok(log.some((l) => /starting afresh from 2021-03-10/.test(l)), log.join("\n")); const state = await readPostFetchState(channelRoot); assert.equal(state?.older?.complete, true); assert.equal(state?.cursor, "1/TIMELINE-RESUME"); }); test("from is refused without older, and when it is not a date", async () => { const slug = "older-from-refusals"; await makeChannel(slug); const a = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, from: "2021-01-01" }); assert.equal(a.ok, false); assert.match(a.error ?? "", /only to an older-posts fetch/); const b = await fetchPosts({ paths: getPaths(), slug, settings: LOGIN, older: true, from: "2021-13-01" }); assert.equal(b.ok, false); assert.match(b.error ?? "", /not a date/); });