Spaces:
Sleeping
Sleeping
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import crypto from 'node:crypto'; | |
| import { DIRS } from './config.js'; | |
| /** | |
| * Per-domain crawl state. This is the ONLY mutable state in the system. | |
| * Everything under data/ is append-only; everything under docs/ is | |
| * derived. If state is lost, the crawler reseeds from the sitemap and | |
| * history in data/ is unaffected. | |
| * | |
| * Shape: | |
| * { | |
| * domain: "example.gov", | |
| * seededAt: "2026-06-12T...", | |
| * pages: { | |
| * "<pageId>": { | |
| * url, discoveredAt, depth, | |
| * lastScannedWeek, lastScannedAt, lastStatus, failCount | |
| * } | |
| * } | |
| * } | |
| */ | |
| function statePath(domainKey) { | |
| return path.join(DIRS.state, domainKey, 'crawl.json'); | |
| } | |
| export function loadState(domainKey, domain) { | |
| const p = statePath(domainKey); | |
| if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')); | |
| return { domain, seededAt: null, pages: {} }; | |
| } | |
| export function saveState(domainKey, state) { | |
| const p = statePath(domainKey); | |
| fs.mkdirSync(path.dirname(p), { recursive: true }); | |
| const tmp = p + '.tmp'; | |
| fs.writeFileSync(tmp, JSON.stringify(state)); | |
| fs.renameSync(tmp, p); // atomic-ish: never leave a half-written state file | |
| } | |
| export function addPage(state, id, url, depth, { priority = false } = {}) { | |
| if (state.pages[id]) { | |
| // A URL already in the frontier that is now also a configured priority | |
| // URL gets promoted (e.g. discovered by crawl, then added to the | |
| // top-tasks file). Promotion only, never demotion. | |
| if (priority && !state.pages[id].priority) { | |
| state.pages[id].priority = true; | |
| return true; | |
| } | |
| return false; | |
| } | |
| state.pages[id] = { | |
| url, | |
| discoveredAt: new Date().toISOString(), | |
| depth, | |
| priority, | |
| lastScannedWeek: null, | |
| lastScannedAt: null, | |
| lastStatus: null, | |
| failCount: 0, | |
| }; | |
| return true; | |
| } | |
| /** | |
| * Pick the next batch to scan this week. | |
| * | |
| * Pages with a completed outcome are not rescanned in the same ISO week: | |
| * pages with lastScannedWeek === week are excluded, so coverage accumulates | |
| * across the week's runs without repeats. | |
| * | |
| * Timeout/error pages are intentionally left unmarked for the week and can | |
| * be retried in-week (up to the fail threshold) to recover from transient | |
| * failures. Within that constraint, ordering is: | |
| * | |
| * 1. Priority pages (configured top tasks) first, so they are always | |
| * covered early in the week before the budget runs out. | |
| * 2. Then the rest in a stable per-week random order — a different | |
| * random spread of a large site each week, deterministic for replay. | |
| * | |
| * Pages with failCount >= 3 are excluded until a successful scan resets | |
| * the counter. | |
| */ | |
| export function pickBatch(state, week, budget, scannedThisWeekCap) { | |
| const entries = Object.entries(state.pages); | |
| const scannedThisWeek = entries.filter(([, p]) => p.lastScannedWeek === week).length; | |
| const remainingWeekly = Math.max(0, scannedThisWeekCap - scannedThisWeek); | |
| const n = Math.min(budget, remainingWeekly); | |
| if (n === 0) return { batch: [], scannedThisWeek }; | |
| const candidates = entries | |
| .filter(([, p]) => p.lastScannedWeek !== week && p.failCount < 3) | |
| .map(([id, p]) => ({ id, p, rank: weeklyRank(id, week) })) | |
| .sort((a, b) => { | |
| // Priority first. | |
| const ap = a.p.priority ? 0 : 1; | |
| const bp = b.p.priority ? 0 : 1; | |
| if (ap !== bp) return ap - bp; | |
| // Then never-scanned before previously-scanned (freshness for trends). | |
| const an = a.p.lastScannedWeek === null ? 0 : 1; | |
| const bn = b.p.lastScannedWeek === null ? 0 : 1; | |
| if (an !== bn) return an - bn; | |
| // Then a stable per-week random order, so large sites get a | |
| // different random sample each week rather than the same shallow set. | |
| return a.rank - b.rank; | |
| }) | |
| .slice(0, n) | |
| .map(({ id, p }) => ({ id, url: p.url, depth: p.depth, priority: !!p.priority })); | |
| return { batch: candidates, scannedThisWeek }; | |
| } | |
| /** Stable per-week uniform rank in [0,1) for shuffling the frontier. */ | |
| function weeklyRank(pageId, week) { | |
| const hex = crypto.createHash('sha256').update(`${pageId}|${week}`).digest('hex').slice(0, 13); | |
| return parseInt(hex, 16) / 2 ** 52; | |
| } | |