UNPKG

@accounter/scraper-app

Version:
166 lines (146 loc) 5.25 kB
import { randomUUID } from 'node:crypto'; import type { RunRecord as SerializedRunRecord } from '../shared/types.js'; import type { ServerMessage } from '../shared/ws-protocol.js'; import type { ScraperUploadResult } from './gql/index.js'; export const ERR_RUN_IN_PROGRESS = 'Run already in progress'; /** * Thrown by buildTask when a source is blocked (unknown accounts). * Signals runTask to skip task-done and task-error — task-blocked was already emitted. */ export class BlockedError extends Error { readonly blockedAccounts: string[]; constructor(blockedAccounts: string[]) { super('blocked'); this.name = 'BlockedError'; this.blockedAccounts = blockedAccounts; } } // ── Types ───────────────────────────────────────────────────────────────────── export type ScrapeTask = { sourceId: string; nickname: string; type: string; run: () => Promise<ScraperUploadResult>; }; export type { SourceRunRecord } from '../shared/types.js'; export type RunRecord = Omit<SerializedRunRecord, 'startedAt' | 'completedAt'> & { startedAt: Date; completedAt: Date; }; // ── State ───────────────────────────────────────────────────────────────────── let _running = false; export function isRunning(): boolean { return _running; } export function _resetRunState(): void { _running = false; } // ── Runner ──────────────────────────────────────────────────────────────────── export async function startRun( tasks: ScrapeTask[], concurrent: boolean, emit: (msg: ServerMessage) => void, ): Promise<RunRecord> { if (_running) throw new Error(ERR_RUN_IN_PROGRESS); _running = true; const id = randomUUID(); const startedAt = new Date(); let errorCount = 0; try { for (const task of tasks) { emit({ type: 'task-pending', sourceId: task.sourceId }); } type TaskOutcome = { task: ScrapeTask; result: ScraperUploadResult; status: 'done' | 'error' | 'blocked'; error?: string; blockedAccounts?: string[]; }; const runTask = async (task: ScrapeTask): Promise<TaskOutcome> => { emit({ type: 'task-running', sourceId: task.sourceId }); try { const result = await task.run(); emit({ type: 'task-done', sourceId: task.sourceId, inserted: result.inserted, skipped: result.skipped, insertedIds: result.insertedIds, ...(result.insertedTransactions != null && { insertedTransactions: result.insertedTransactions, }), ...(result.changedTransactions != null && { changedTransactions: result.changedTransactions, }), }); return { task, result, status: 'done' }; } catch (e) { // task-blocked was already emitted by buildTask — skip task-done and task-error if (e instanceof BlockedError) { return { task, result: { inserted: 0, skipped: 0, insertedIds: [], insertedTransactions: [], changedTransactions: [], }, status: 'blocked', blockedAccounts: e.blockedAccounts, }; } errorCount++; const message = e instanceof Error ? e.message : String(e); const stack = e instanceof Error ? e.stack : undefined; emit({ type: 'task-error', sourceId: task.sourceId, message, ...(stack && { stack }) }); return { task, result: { inserted: 0, skipped: 0, insertedIds: [], insertedTransactions: [], changedTransactions: [], }, status: 'error', error: message, }; } }; let outcomes: TaskOutcome[]; if (concurrent) { outcomes = await Promise.all(tasks.map(runTask)); } else { outcomes = []; for (const task of tasks) { outcomes.push(await runTask(task)); } } const totalInserted = outcomes.reduce((sum, o) => sum + o.result.inserted, 0); const totalSkipped = outcomes.reduce((sum, o) => sum + o.result.skipped, 0); const sources: SourceRunRecord[] = outcomes.map(o => ({ sourceId: o.task.sourceId, nickname: o.task.nickname, sourceType: o.task.type, status: o.status, inserted: o.result.inserted, skipped: o.result.skipped, ...(o.error != null && { error: o.error }), ...(o.blockedAccounts != null && { blockedAccounts: o.blockedAccounts }), })); emit({ type: 'run-complete', totalInserted, totalSkipped, errors: errorCount }); return { id, startedAt, completedAt: new Date(), totalInserted, totalSkipped, errorCount, sources, }; } finally { _running = false; } }