Add the media scrape engine, store, and puppet walker

This commit is contained in:
Fisto
2026-09-16 08:03:21 +02:00
parent 92429f1e83
commit 4f953cefdc
12 changed files with 1086 additions and 4 deletions
+268
View File
@@ -0,0 +1,268 @@
/*
* The scrape engine: queue, walk, pacing, budgets, backoff, resume.
* Rules: docs/media-scraper.md.
*/
import { join } from "node:path";
import {
createScrapeDirs,
listScrapes,
readScrapeMeta,
ScrapeStore,
uniqueScrapeId,
writeScrapeMeta
} from "./store.js";
import {
emptyMediaCounts,
MEDIA_KINDS,
type BackendName,
type CoreMessage,
type ScrapeMeta,
type ScrapeMode
} from "./types.js";
export interface FetchPage {
messages: CoreMessage[];
/** Set by a backend that saw a rate-limit hint. The engine sleeps before the next page. */
rateLimitedMs?: number;
}
export interface ScrapeBackend {
readonly name: BackendName;
fetchPage(channelId: string, before: string | null, limit: number): Promise<FetchPage>;
}
export interface ScrapeEngineOptions {
rootDir: string;
pageSize?: number;
pageMinMs?: number;
pageMaxMs?: number;
pagesPerHour?: number;
pagesPerDay?: number;
maxRetries?: number;
sleep?: (ms: number) => Promise<void>;
random?: () => number;
now?: () => number;
log?: (line: string) => void;
}
interface QueuedJob {
meta: ScrapeMeta;
dir: string;
backend: ScrapeBackend;
}
const defaultSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
function oldestId(messages: CoreMessage[]): string | null {
let oldest: string | null = null;
for (const message of messages) {
if (oldest === null || BigInt(message.id) < BigInt(oldest)) oldest = message.id;
}
return oldest;
}
export class ScrapeEngine {
private readonly queue: QueuedJob[] = [];
private readonly active = new Map<string, ScrapeMeta>();
private readonly stopRequested = new Set<string>();
private readonly pageTimes: number[] = [];
private processingPromise: Promise<void> | null = null;
constructor(private readonly options: ScrapeEngineOptions) {}
/** Create a scrape and queue the walk. Returns immediately. */
async start(input: {
channelId: string;
guildId: string | null;
mode: ScrapeMode;
backend: ScrapeBackend;
}): Promise<ScrapeMeta> {
const now = this.now();
const id = uniqueScrapeId(this.options.rootDir, input.channelId, new Date(now));
const dir = createScrapeDirs(this.options.rootDir, id);
const timestamp = new Date(now).toISOString();
const meta: ScrapeMeta = {
id,
channelId: input.channelId,
guildId: input.guildId,
backend: input.backend.name,
mode: input.mode,
state: "queued",
cursor: null,
createdAt: timestamp,
updatedAt: timestamp,
pages: 0,
messages: 0,
media: emptyMediaCounts(),
errors: 0,
lastError: null
};
writeScrapeMeta(dir, meta);
this.active.set(id, meta);
this.queue.push({ meta, dir, backend: input.backend });
void this.processQueue();
return { ...meta };
}
/** Run queued jobs until the queue is empty. Tests await this; the daemon lets it run. */
processQueue(): Promise<void> {
if (!this.processingPromise) {
this.processingPromise = this.drainQueue().finally(() => {
this.processingPromise = null;
});
}
return this.processingPromise;
}
private async drainQueue(): Promise<void> {
while (this.queue.length > 0) {
const job = this.queue.shift();
if (!job) break;
await this.runJob(job);
}
}
get(id: string): ScrapeMeta | null {
const active = this.active.get(id);
if (active) return { ...active };
return readScrapeMeta(join(this.options.rootDir, id));
}
/** Every scrape on disk, merged with the jobs this engine still holds in memory. */
list(): ScrapeMeta[] {
const merged = new Map<string, ScrapeMeta>();
for (const meta of listScrapes(this.options.rootDir)) merged.set(meta.id, meta);
for (const [id, meta] of this.active) merged.set(id, meta);
return [...merged.values()].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
}
/** Ask a running or queued scrape to stop. It pauses after the current page. */
stop(id: string): void {
this.stopRequested.add(id);
}
private async runJob(job: QueuedJob): Promise<void> {
const { meta, dir, backend } = job;
const store = new ScrapeStore(dir);
const pageSize = this.options.pageSize ?? 100;
try {
if (this.stopRequested.has(meta.id)) {
meta.state = "paused";
return;
}
meta.state = "running";
this.persist(dir, meta);
for (;;) {
if (this.stopRequested.has(meta.id)) {
meta.state = "paused";
break;
}
if (!this.allowPage()) {
meta.state = "paused";
this.log(`${meta.id}: page budget reached, paused`);
break;
}
const page = await this.fetchWithBackoff(meta, backend);
if (page.rateLimitedMs) await this.sleep(page.rateLimitedMs);
if (page.messages.length === 0) {
meta.state = "done";
break;
}
const added = store.insertPage(page.messages);
for (const kind of MEDIA_KINDS) meta.media[kind] += added[kind];
meta.pages++;
meta.messages += page.messages.length;
meta.cursor = oldestId(page.messages) ?? meta.cursor;
this.pageTimes.push(this.now());
this.persist(dir, meta);
if (page.messages.length < pageSize) {
meta.state = "done";
break;
}
await this.sleep(this.gapMs());
}
} catch (error) {
meta.state = "failed";
meta.lastError = error instanceof Error ? error.message : String(error);
this.log(`${meta.id}: failed: ${meta.lastError}`);
} finally {
this.persist(dir, meta);
store.close();
this.active.set(meta.id, meta);
}
}
private async fetchWithBackoff(meta: ScrapeMeta, backend: ScrapeBackend): Promise<FetchPage> {
const pageSize = this.options.pageSize ?? 100;
const maxRetries = this.options.maxRetries ?? 8;
let attempt = 0;
for (;;) {
try {
return await backend.fetchPage(meta.channelId, meta.cursor, pageSize);
} catch (error) {
meta.errors++;
if (attempt >= maxRetries) throw error;
const retryAfter = (error as { retryAfterMs?: number }).retryAfterMs;
const delay = retryAfter ?? Math.pow(2, attempt) * 1000 + 1000;
this.log(`${meta.id}: page failed, retrying in ${delay} ms`);
await this.sleep(delay);
attempt++;
}
}
}
private allowPage(): boolean {
const now = this.now();
while (this.pageTimes.length > 0 && this.pageTimes[0] < now - 86_400_000) {
this.pageTimes.shift();
}
const lastHour = this.pageTimes.filter(time => time > now - 3_600_000).length;
if (lastHour >= (this.options.pagesPerHour ?? 300)) return false;
if (this.pageTimes.length >= (this.options.pagesPerDay ?? 3000)) return false;
return true;
}
private gapMs(): number {
const min = this.options.pageMinMs ?? 1200;
const max = Math.max(min, this.options.pageMaxMs ?? 3500);
return min + Math.floor(this.random() * (max - min + 1));
}
private persist(dir: string, meta: ScrapeMeta): void {
meta.updatedAt = new Date(this.now()).toISOString();
writeScrapeMeta(dir, meta);
}
private now(): number {
return (this.options.now ?? Date.now)();
}
private random(): number {
return (this.options.random ?? Math.random)();
}
private sleep(ms: number): Promise<void> {
return (this.options.sleep ?? defaultSleep)(ms);
}
private log(line: string): void {
this.options.log?.(line);
}
}