Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 46f3a68db8e7ef1da82078db2dc373765dfa6987
parent 7c0081629cdce9c3a87d048dc78016f45aed9179
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat,  3 Oct 2026 13:33:01 -0400

editor: Fetch older posts — button, action param, ops route, replay

fetchPostsAction gains older and floor (carried in the job spec, so the
job replays). It refuses full with older, a floor without older or that
is not a date, and a fetcher with no older walk, before a job exists. The
social channel panel shows a Fetch older posts button and an Older posts
line for a fetcher that has fetchOlder. POST /api/ops/fetch-posts
{ slug, full?, older?, floor?, limit?, queueKey? } is the adapter over
the action; pnpm ops fetch-posts posts to it. The archilyzer posts fetch
bin takes --older and --floor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
MRUNNING_IN_DOCKER.md | 1+
Mcommon/bin/archilyzer.ts | 2+-
Mcommon/bin/fetch-posts.ts | 13+++++++++++--
Meditor/CHANGELOG.md | 1+
Meditor/app/api/ops/_lib.test.ts | 18+++++++++++++++++-
Meditor/app/api/ops/_lib.ts | 11+++++++++++
Aeditor/app/api/ops/fetch-posts/route.ts | 41+++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/components/SocialChannelPanel.tsx | 29+++++++++++++++++++++++++++--
Meditor/app/channels/[slug]/page.tsx | 3+++
Meditor/app/channels/[slug]/socialActions.ts | 37+++++++++++++++++++++++++++++++++++--
Meditor/app/jobs/jobReplayRegistry.ts | 9++++++++-
Meditor/e2e/ops-api.spec.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/FACTS.md | 20++++++++++++++++++++
Mscripts/archilyzer-ops.mjs | 9+++++++++
Mscripts/archilyzer-ops.test.mjs | 12++++++++++++
15 files changed, 265 insertions(+), 9 deletions(-)

diff --git a/RUNNING_IN_DOCKER.md b/RUNNING_IN_DOCKER.md @@ -254,6 +254,7 @@ pnpm ops channel-priority --json '{"slugs":["the-quartering"],"operation":"downl pnpm ops lane --json '{"lane":"download","held":true}' pnpm ops refresh-report --json '{"all":true}' pnpm ops keep-videos --json '{"slug":"paramount-tactical","match":"TheQuartering","dryRun":true}' +pnpm ops fetch-posts --json '{"slug":"example-x","older":true}' --wait pnpm ops get channel the-quartering pnpm ops list # every action name ``` diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -252,7 +252,7 @@ export const COMMANDS: Command[] = [ script(["duplicates"], "duplicate-shorts.ts", "[--threshold N] [--all-durations] [--blocking title|duration|both] [--near F] [--tolerance N] … on-demand duplicate detection (after index + stats)", 8192), script(["posts", "fetch"], "fetch-posts.ts", - "--slug <channel> [--full] [--limit N] fetch a social channel's posts into its posts corpus"), + "--slug <channel> [--full | --older [--floor YYYY-MM-DD]] [--limit N] fetch a social channel's posts into its posts corpus (--older: walk back below the oldest archived post)"), script(["posts", "check"], "check-post-availability.ts", "--slug <channel> [--mode stale|unchecked|all] [--limit N] which archived posts were deleted at the source"), script(["diarize", "backfill"], "diarize-backfill.ts", diff --git a/common/bin/fetch-posts.ts b/common/bin/fetch-posts.ts @@ -2,11 +2,16 @@ // Fetch social posts for one channel into its on-disk posts corpus. // // pnpm --filter yt-dlp-transcript-common exec tsx bin/fetch-posts.ts \ -// --slug <channel-slug> [--full] [--limit N] +// --slug <channel-slug> [--full | --older [--floor YYYY-MM-DD]] [--limit N] // // --full re-walks the account's whole history instead of stopping at the // stored watermark. New posts are still deduped against the posts-archive, so // it repairs a gap rather than creating duplicates. +// +// --older walks the account's history backwards from the oldest archived post +// (X: search windows), below what the timeline reaches; --floor is the date it +// stops at. Its position is saved apart from the timeline's, so a later run +// continues it and a normal fetch is unaffected. import { getPaths } from "../lib/paths"; import { getSettings } from "../lib/settings"; @@ -16,7 +21,9 @@ import { parseFlags } from "./_parseFlags"; const flags = parseFlags(process.argv.slice(2)); const slug = flags.slug; if (!slug) { - console.error("Usage: fetch-posts.ts --slug <channel-slug> [--full] [--limit N]"); + console.error( + "Usage: fetch-posts.ts --slug <channel-slug> [--full | --older [--floor YYYY-MM-DD]] [--limit N]", + ); process.exit(2); } @@ -31,6 +38,8 @@ fetchPosts({ slug, settings: getSettings(), full: flags.full === "true", + older: flags.older === "true", + floor: flags.floor, limit, onLog: (line) => console.log(line), }) diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **An X channel can fetch posts older than its timeline reaches.** X's timeline only pages back so far, so a fetch could end, and call the history done, well short of an account's first post. The new **Fetch older posts** button on an X channel's page (or `pnpm ops fetch-posts --json '{"slug":"<channel>","older":true}'`) walks back from the oldest archived post through X search, three months at a time, and saves posts the same way a normal fetch does; posts already archived are skipped. It needs a login, as search does: without one it stops at once and the channel shows **Needs credentials**. A run saves its place as it goes and stops after three hours; the next run continues from there. The walk ends at the account's creation date, after a year of windows with no posts, or at a date you give as `"floor": "YYYY-MM-DD"`, and the page's **Older posts** line then says it is complete; running it again says so and fetches nothing. A normal **Fetch posts** is unaffected and still fetches new posts from the top. Bluesky channels have no such button: their fetch already reads the whole history. - **`pnpm ops transcribe-bucket` transcribes a channel's downloaded-but-untranscribed videos**, as the channel page's **Transcribe N downloaded** button does, on the transcription queue. `"ids"` runs only some of them; each must be in the bucket, and a stray id is refused by name. `retry-bucket` is not the way to do this: it retries downloads, and counts a video whose audio is on disk as complete. - **`pnpm ops retry-bucket` can run part of a bucket.** Its body takes `"ids"`, a list of video ids, and runs only those, as ticking them on the bucket's card does. Every id must be in the named bucket: one that is not is refused with a 400 naming it, and nothing runs. A job started with `ids` is not replayable, like a checkbox selection in the UI. - **An X post fetch keeps what it has read when it is cancelled, times out or fails part-way, and the next fetch picks up where it stopped.** The gallery-dl fetcher used to receive an account's posts all at once when gallery-dl finished, so a long fetch that X's rate limit held past the 30-minute limit — or one you cancelled — ended with nothing saved. gallery-dl now hands over each post as it reads it: the fetch saves posts every 200 posts or every minute along with gallery-dl's own resume point, and the next fetch of the channel continues from that point instead of starting again. A fetch of new posts stops once it reaches 100 already-archived posts in a row instead of reading the whole timeline, and a fetch of an account's history may run for up to 3 hours (new-post fetches keep the 30-minute limit). gallery-dl's rate-limit waits now appear in the job's log as they happen. diff --git a/editor/app/api/ops/_lib.test.ts b/editor/app/api/ops/_lib.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { OpsInputError, optSubset } from "./_lib"; +import { OpsInputError, optPositiveInt, optSubset } from "./_lib"; // Run with: // pnpm -C editor exec tsx --test "app/**/*.test.ts" @@ -36,3 +36,19 @@ test("optSubset: an empty or non-string list is refused, never read as 'all'", ( ); } }); + +test("optPositiveInt: absent is undefined; a whole number above zero comes back", () => { + assert.equal(optPositiveInt({}, "limit"), undefined); + assert.equal(optPositiveInt({ limit: 200 }, "limit"), 200); +}); + +test("optPositiveInt: zero, negatives, fractions and strings are refused", () => { + for (const limit of [0, -5, 2.5, "10", null, Number.NaN]) { + assert.throws( + () => optPositiveInt({ limit }, "limit"), + (e: unknown) => + e instanceof OpsInputError && /"limit" must be a whole number above zero/.test(e.message), + JSON.stringify(limit), + ); + } +}); diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts @@ -146,6 +146,17 @@ export function optBool(body: OpsBody, key: string): boolean | undefined { return v; } +// A whole number above zero, or undefined when absent. A type check, not a +// rule: what the number means is the action's business. +export function optPositiveInt(body: OpsBody, key: string): number | undefined { + const v = body[key]; + if (v === undefined) return undefined; + if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) { + throw new OpsInputError(`"${key}" must be a whole number above zero`); + } + return v; +} + export function reqStringArray(body: OpsBody, key: string): string[] { const v = body[key]; if ( diff --git a/editor/app/api/ops/fetch-posts/route.ts b/editor/app/api/ops/fetch-posts/route.ts @@ -0,0 +1,41 @@ +import { fetchPostsAction } from "../../../channels/[slug]/socialActions"; +import { + jobResponse, + ops, + optBool, + optPositiveInt, + optString, + reqSlug, +} from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { slug, full?, older?, floor?, limit?, queueKey? } -> { ok: true, jobId } +// +// A social channel's "Fetch posts" button, over HTTP — and its "Re-fetch full +// history" (`full`) and "Fetch older posts" (`older`, with an optional `floor` +// date, YYYY-MM-DD, that the walk stops at). The job runs on the platform's +// queue, as the buttons' does. +// +// Every refusal is the action's own sentence: a channel that is not a social +// one, `full` and `older` together, a fetcher with no older walk, a floor +// without `older` or that is not a date. +export async function POST(request: Request) { + return ops( + request, + ["slug", "full", "older", "floor", "limit", "queueKey"], + async (body) => { + const slug = reqSlug(body, "slug"); + return jobResponse( + await fetchPostsAction( + slug, + optString(body, "queueKey"), + optBool(body, "full"), + optPositiveInt(body, "limit"), + optBool(body, "older"), + optString(body, "floor"), + ), + ); + }, + ); +} diff --git a/editor/app/channels/[slug]/components/SocialChannelPanel.tsx b/editor/app/channels/[slug]/components/SocialChannelPanel.tsx @@ -30,6 +30,10 @@ export type SocialChannelState = { deletedCount: number; checkedCount: number; canCheckAvailability: boolean; + // The older-posts backfill (a fetcher's `fetchOlder`): whether this fetcher + // has one, and where it stands, as one line. + canFetchOlder: boolean; + olderStatus: string; }; export function SocialChannelPanel({ @@ -63,9 +67,15 @@ export function SocialChannelPanel({ if (res.ok) void res.stream.cancel(); }); - const run = (full: boolean) => + const run = (full: boolean, older = false) => startTransition(async () => { - const res = await fetchPostsAction(slug, undefined, full); + const res = await fetchPostsAction( + slug, + undefined, + full, + undefined, + older || undefined, + ); // The job streams to the jobs page; nothing to consume here, but the // stream must be released or its buffered chunks leak. if (res.ok) void res.stream.cancel(); @@ -111,6 +121,9 @@ export function SocialChannelPanel({ : String(state.lastFetchedCount) } /> + {state.canFetchOlder && ( + <Stat label="Older posts" value={state.olderStatus} /> + )} </dl> {state.availableFetchers.length > 1 && ( @@ -181,6 +194,18 @@ export function SocialChannelPanel({ > Re-fetch full history </button> + {state.canFetchOlder && ( + <button + type="button" + onClick={() => run(false, true)} + disabled={pending} + aria-label="fetch older posts" + title="Walk back from the oldest archived post through search, a few months at a time, for posts older than the timeline reaches. Needs a login. Progress is saved as it goes; a later run continues it, and it says so once the history is done." + className="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50" + > + Fetch older posts + </button> + )} </div> {/* The two failure surfaces a social channel actually has. */} diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -11,6 +11,7 @@ import { } from "yt-dlp-transcript-common/lib/posts-server"; import { isPostGone } from "yt-dlp-transcript-common/lib/posts"; import { resolveSocialFetcher } from "yt-dlp-transcript-common/social/fetchers"; +import { describeOlderBackfill } from "yt-dlp-transcript-common/social/olderBackfill"; import "yt-dlp-transcript-common/social/blueskyFetcher"; import "yt-dlp-transcript-common/social/xGalleryDlFetcher"; import "yt-dlp-transcript-common/social/xPlaywrightFetcher"; @@ -186,6 +187,8 @@ export default async function ChannelDetailPage({ checkedCount: availabilityRecords.length, canCheckAvailability: typeof fetcher?.checkAvailability === "function", + canFetchOlder: typeof fetcher?.fetchOlder === "function", + olderStatus: describeOlderBackfill(fetchState?.older), handle: config.socialHandle ?? slug, accountUrl: config.url, }} diff --git a/editor/app/channels/[slug]/socialActions.ts b/editor/app/channels/[slug]/socialActions.ts @@ -19,7 +19,12 @@ import { readChannelConfig, } from "yt-dlp-transcript-common/controller/channels"; import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; -import { fetchPosts } from "yt-dlp-transcript-common/controller/fetchPosts"; +import { + fetchPosts, + FULL_AND_OLDER_REFUSAL, + olderPostsProblem, +} from "yt-dlp-transcript-common/controller/fetchPosts"; +import { isUtcDay } from "yt-dlp-transcript-common/social/olderBackfill"; import { checkPostAvailability, type CheckPostAvailabilityMode, @@ -27,6 +32,7 @@ import { import { getSocialFetcher, listSocialFetchers, + resolveSocialFetcher, } from "yt-dlp-transcript-common/social/fetchers"; import { runManagedFunction, @@ -121,18 +127,39 @@ export async function checkPostAvailabilityAction( }); } +// `older` walks the account's history backwards from the oldest archived post +// (the fetcher's `fetchOlder` — X: search windows) instead of fetching new +// posts; `floor` (YYYY-MM-DD) is the date that walk stops at. Both are refused +// HERE, before a job exists, when they cannot run: with `full`, on a fetcher +// that has no older walk, or with a floor that is not a date. export async function fetchPostsAction( slug: string, queueKey?: string, full?: boolean, limit?: number, + older?: boolean, + floor?: string, ): Promise<StreamActionResult> { + if (full && older) return { ok: false, error: FULL_AND_OLDER_REFUSAL }; + if (floor !== undefined && !older) { + return { ok: false, error: "A floor date applies only to an older-posts fetch." }; + } + if (floor !== undefined && !isUtcDay(floor)) { + return { ok: false, error: `"${floor}" is not a date (YYYY-MM-DD).` }; + } const paths = getPaths(); const config = await readChannelConfig(paths, slug); if (!config) return { ok: false, error: `No such channel: ${slug}` }; if (!isSocialChannel(config)) { return { ok: false, error: `${slug} is not a social channel.` }; } + if (older) { + await registerBuiltinSocialFetchers(); + const problem = olderPostsProblem( + resolveSocialFetcher(config.postFetcher, config.url), + ); + if (problem) return { ok: false, error: problem }; + } // queueKeyForUrl() already routes x.com / bsky.app to platform:x.com / // platform:bsky.app, so per-platform serialization and the existing 429 @@ -144,13 +171,19 @@ export async function fetchPostsAction( queueKey: key, paths, channelSlug: slug, - spec: { kind: "fetch-posts", slug, params: { queueKey, full, limit } }, + spec: { + kind: "fetch-posts", + slug, + params: { queueKey, full, limit, older, floor }, + }, fn: async (onLog, signal) => { const result = await fetchPosts({ paths, slug, settings: getSettings(), full, + older, + floor, limit, onLog, signal, diff --git a/editor/app/jobs/jobReplayRegistry.ts b/editor/app/jobs/jobReplayRegistry.ts @@ -246,7 +246,14 @@ export const JOB_REPLAY_HANDLERS: Record<string, ReplayHandler> = { }, "fetch-posts": (spec) => { const { p, queueKey } = params(spec); - return fetchPostsAction(spec.slug, queueKey, bool(p.full), num(p.limit)); + return fetchPostsAction( + spec.slug, + queueKey, + bool(p.full), + num(p.limit), + bool(p.older), + str(p.floor), + ); }, "download-missing-subs": (spec) => { const { p, queueKey } = params(spec); diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -192,6 +192,7 @@ test("a traversing slug is refused at the door, on every route that takes one", ["channel-priority", { slugs: ["../../escape"], tier: "paused" }], ["relocate", { slugs: ["../../escape"], root: "/tmp/ops-api-never" }], ["relocate-back", { slugs: ["../../escape"] }], + ["fetch-posts", { slug: "../../escape", older: true }], ]; for (const [action, data] of cases) { const { status, body } = await ops(request, action, data); @@ -267,6 +268,73 @@ test("retry-bucket and transcribe-bucket ids must be in the bucket: a stray is r expect(await listJobIds()).toEqual(before); }); +test("fetch-posts refuses a channel that is not social, full with older, and an older fetch its fetcher cannot do — before any job", async ({ + request, +}) => { + await resetData("title-filter-channel"); + await settings(); + // A social channel whose fetcher has no older-posts walk. Nothing below + // reaches a fetch: every case is refused before a job exists. + await writeChannelConfig("example-bsky", { + handling: "transcribe", + sourceKind: "social", + platform: "bluesky", + postFetcher: "bluesky-atproto", + socialHandle: "example.bsky.social", + name: "Example (Bluesky)", + url: "https://bsky.app/profile/example.bsky.social", + }); + const before = await listJobIds(); + + // The sentence the action gives for a video channel. + const video = await ops(request, "fetch-posts", { slug: "test-filter" }); + expect(video.status).toBe(400); + expect(video.body.error).toBe("test-filter is not a social channel."); + const videoOlder = await ops(request, "fetch-posts", { slug: "test-filter", older: true }); + expect(videoOlder.status).toBe(400); + expect(videoOlder.body.error).toBe("test-filter is not a social channel."); + + // Two walks at once, on any channel. + const both = await ops(request, "fetch-posts", { + slug: "example-bsky", + full: true, + older: true, + }); + expect(both.status).toBe(400); + expect(both.body.error).toMatch(/different walks — run one at a time/); + const bothVideo = await ops(request, "fetch-posts", { + slug: "test-filter", + full: true, + older: true, + }); + expect(bothVideo.status).toBe(400); + expect(bothVideo.body.error).toMatch(/different walks/); + + // A fetcher with no older walk, a floor that is not a date, a floor + // without older, and a limit that is not a count. + const bsky = await ops(request, "fetch-posts", { slug: "example-bsky", older: true }); + expect(bsky.status).toBe(400); + expect(bsky.body.error).toMatch(/cannot fetch older posts/); + const floor = await ops(request, "fetch-posts", { + slug: "example-bsky", + older: true, + floor: "2020-13-01", + }); + expect(floor.status).toBe(400); + expect(floor.body.error).toMatch(/is not a date/); + const floorAlone = await ops(request, "fetch-posts", { + slug: "example-bsky", + floor: "2020-01-01", + }); + expect(floorAlone.status).toBe(400); + expect(floorAlone.body.error).toMatch(/only to an older-posts fetch/); + const limit = await ops(request, "fetch-posts", { slug: "example-bsky", limit: 0 }); + expect(limit.status).toBe(400); + expect(limit.body.error).toMatch(/"limit" must be a whole number above zero/); + + expect(await listJobIds()).toEqual(before); +}); + test("channel-config round-trips a download filter and refuses a bad regex", async ({ page, request, diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -8534,6 +8534,26 @@ phase deletes from the destination. `expiry` is seconds (read as ms when too large for seconds); `lastAccessed` is PRTime (µs). The Chromium family is NOT read here (its values are encrypted with a keyring key): the readers say so and gallery-dl reads it. +- **gallery-dl 1.32.9's `twitter:search`** (`extractor/twitter.py`, `TwitterSearchExtractor`) + matches `…/search?…q=<query>` (q may follow other params; `&f=live` after it is fine) and decodes q + as `unquote(q.replace("+", " "))` — so the query is percent-encoded whole and an encoded `+` + survives. It reads the query's single `from:<user>` to fill each record's `user` (with the + account's `date`, its creation time). Product defaults to "Latest"; `search-pagination` defaults + to `max_id`: after each page it REWRITES the query's `max_id:` (adding one if absent) to just below + the oldest tweet read and sets the API cursor to None — so **a search run logs no + `Cursor:` line and cannot be resumed with `extractor.twitter.cursor`**. It stops after + `search-stop` (default 3) empty pages. Quoted tweets are skipped unless `quoted` is set. +- **The older-posts backfill** (`fetchPosts({ older })` → `SocialFetcher.fetchOlder`; for X + `fetchOlderViaSearch` in `xGalleryDlFetcher.ts`, window arithmetic in `social/olderBackfill.ts`). + Windows of 3 months in UTC days, `from:<h> since:<d> until:<d> include:nativeretweets`, the first + ending the day after the oldest archived post; one gallery-dl run per window, 15 s apart, one + 3-hour budget across them. Its resume point is its own: the oldest post id read in the window, + sent back as `max_id:<id>` (inclusive; gallery-dl's rewrite replaces it). State is + `posts-state.json` `older` (`OlderBackfillState`), apart from the timeline `cursor`; a normal fetch + copies `older` through untouched and an older run copies every other field through. Ends at the + later of the `floor` and the account's creation day, or after 4 empty windows in a row; then + `older.complete` and a re-run fetches nothing. No login = refused before spawning + (`needsCookies`). It does not stamp `lastSyncedAt`. - **`node:sqlite`** (Node 22.23 here; unflagged since 22.13, an ExperimentalWarning once per process) is loaded at run time through an assembled specifier with `webpackIgnore` (`common/social/nodeSqlite.ts`), like `playwrightRuntime.ts`: the editor's Turbopack build has diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -100,6 +100,9 @@ const ACTIONS = [ // The TRANSCRIBE half of a channel's "downloaded, not transcribed" bucket // (retry-bucket is the download retry and skips audio already on disk). "transcribe-bucket", + // A social channel's post fetch: new posts, the full re-walk ("full"), or + // the walk back below the oldest archived post ("older"). + "fetch-posts", "build-index", "build-deploy", "build-site", @@ -317,6 +320,12 @@ export function usage() { ' bucket on the transcription queue, as the channel page\'s Transcribe button', ' does: {"slug"}. "ids": [...] narrows it the same way as on retry-bucket.', "", + 'fetch-posts fetches a social channel\'s new posts: {"slug"}. "full": true', + ' re-walks the whole timeline; "older": true walks back from the oldest', + " archived post through search (X; needs a login), saving its place for", + ' the next run, down to "floor": "YYYY-MM-DD" when given. "limit": N caps', + ' the posts one run reads. "full" and "older" together are refused.', + "", '"preview": "<branch>" on deploy-site or build-deploy makes it a Cloudflare', " Pages PREVIEW instead of production: the same bundle goes to a branch", " alias, https://<branch>.<project>.pages.dev, and the live site is left", diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -378,3 +378,15 @@ test("build-homepage and deploy-homepage are POSTs to their own routes, named in assert.match(usage(), /build-homepage builds the homepage package into homepage\/out/); assert.match(usage(), /project archilyzer \(https:\/\/archilyzer\.pages\.dev\)/); }); + +// A social channel's post fetch: a POST to its route, the body passed through +// untouched — the route and the action judge it ("full" with "older" included). +test("fetch-posts is a POST to its route, named in the usage", () => { + const p = parseArgs(["fetch-posts", "--json", '{"slug":"example-x","older":true}', "--wait"]); + assert.equal(p.method, "POST"); + assert.equal(p.path, "/api/ops/fetch-posts"); + assert.deepEqual(p.body, { slug: "example-x", older: true }); + assert.equal(p.wait, true); + assert.match(usage(), /Actions:.*transcribe-bucket, fetch-posts/); + assert.match(usage(), /"older": true walks back from the oldest/); +});