#!/usr/bin/env node // shoot-page.mjs — a highlighted sentence from a SAVED web page, as a PNG. // // An article a report cites is evidence the way a clip is, and an `image` entry // is how a still gets into the cut. This makes that still: load the page as it // was saved to disk, find the quoted sentence in its text, paint it like a // highlighter, and screenshot the paragraph that holds it. // // The page is loaded OFFLINE. Every request is refused except a file:// one // under the page's own directory (the `_files/` folder a browser's "save // page, complete" writes), so a shot never reaches the network and never reads // a file the page was not saved with. Page scripts are off by default: a saved // page is already rendered, and its scripts, with nothing to talk to, are more // likely to hide the article than to finish drawing it (`--js` turns them on). // // The quote is found by TEXT, not by selector, and loosely in exactly the ways // copying text off a page is loose: curly and straight quotes and apostrophes // are the same character, NBSP and every other space are a space, runs of // whitespace are one, and soft hyphens and zero-width characters are not there // at all. A match runs across element boundaries — a link, an ``, a `
` // — and the highlight is one `` per text node it covers. A quote that is // not on the page is a MISS: listed in the results, summarised on stderr, and // the exit status is 1. It is never skipped. // // On the CLI: // node umtool/report-to-video/shoot-page.mjs --page --quote "" --out // node umtool/report-to-video/shoot-page.mjs --batch --out // // Batch items are `[{ id, page, quote, context?, crop? }]` (or `{ items: [...] }`), // `page` relative to the items file. Each writes `/.png`, and the run // writes `/results.json`. `context` is a longer stretch of text around the // quote, for a quote that occurs more than once. // // Options: // --context (single) as an item's `context` // --color The highlight colour (default: #ffe14d) // --padding CSS px around the block (default: 24) // --width Viewport width in CSS px (default: 1280) // --scale deviceScaleFactor (default: 2) // --max-height A taller block is trimmed to this, centred on the // quote (default: 1200) // --js Run the page's own scripts // --no-isolate Leave the neighbouring content visible in the padding // --crop block|mark Shoot the whole block (default), or only the quote's // lines and their context; an item's `crop` overrides it // --context-lines (mark crop) whole lines kept above and below (default: 1) // --timeout Page load timeout (default: 20000) import { existsSync, statSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; export const DEFAULTS = Object.freeze({ color: "#ffe14d", padding: 24, width: 1280, height: 900, scale: 2, maxHeight: 1200, js: false, isolate: true, crop: "block", contextLines: 1, timeout: 20_000, }); export const CROP_MODES = Object.freeze(["block", "mark"]); // --------------------------------------------------------------------------- // The matcher. Pure, and SELF-CONTAINED: these two functions are also sent into // the page as source text (see browserShoot), so neither may reference anything // outside itself but the other by name and the language's own globals. // --------------------------------------------------------------------------- // `raw` normalised for matching, with `map[k]` = the index in `raw` of the // normalised text's k-th character. Several normalised characters may map to // one raw one (an ellipsis is three dots); a raw character may map to none. export function normaliseWithMap(raw) { const out = []; const map = []; for (let i = 0; i < raw.length; i++) { const c = raw[i]; // Soft hyphen, zero-width space / non-joiner / joiner, word joiner, BOM: // present in the DOM, invisible on the page, never in a copied quote. if (/[\u00AD\u200B-\u200D\u2060\uFEFF]/.test(c)) continue; // \s covers NBSP, the U+2000 block, narrow NBSP and the ideographic space. if (/\s/.test(c)) { if (out.length === 0 || out[out.length - 1] === " ") continue; out.push(" "); map.push(i); continue; } let n = c; if (/[\u2018\u2019\u201A\u201B\u2032\u02BC]/.test(c)) n = "'"; else if (/[\u201C\u201D\u201E\u201F\u2033]/.test(c)) n = '"'; else if (c === "\u2026") n = "..."; for (const ch of n) { out.push(ch); map.push(i); } } return { text: out.join(""), map }; } // Find `quote` in the text of `segments` (strings, in document order, joined // with nothing between them — a caller puts a " " segment where a block or a // `
` breaks the text). With `context`, the quote must lie inside the first // occurrence of the context. // // Returns `{ ok: true, start, end, occurrences }`, `start`/`end` being // `{ segment, offset }` in the RAW segment strings (`end` exclusive, and always // in the segment holding the match's last character), or `{ ok: false, reason }`. export function matchInSegments(segments, quote, context) { const q = normaliseWithMap(String(quote ?? "")).text.trim(); if (!q) return { ok: false, reason: "empty quote" }; const raw = segments.join(""); const hay = normaliseWithMap(raw); let occurrences = 0; for (let i = hay.text.indexOf(q); i !== -1; i = hay.text.indexOf(q, i + 1)) occurrences++; let at; if (context != null && String(context).trim()) { const c = normaliseWithMap(String(context)).text.trim(); const ci = hay.text.indexOf(c); if (ci === -1) return { ok: false, reason: "context not found", occurrences }; at = hay.text.indexOf(q, ci); if (at === -1 || at + q.length > ci + c.length) { return { ok: false, reason: "quote not found inside its context", occurrences }; } } else { at = hay.text.indexOf(q); if (at === -1) return { ok: false, reason: "quote not found", occurrences }; } const rawStart = hay.map[at]; const rawLast = hay.map[at + q.length - 1]; const locate = (idx) => { let base = 0; for (let s = 0; s < segments.length; s++) { const len = segments[s].length; if (idx < base + len) return { segment: s, offset: idx - base }; base += len; } return null; }; const start = locate(rawStart); const last = locate(rawLast); return { ok: true, start, end: { segment: last.segment, offset: last.offset + 1 }, occurrences }; } // The `mark` crop: the quote's own lines and `contextLines` lines either side, // across the block's content box, plus `padding` — for a block that is a whole // forum post of `
`-separated paragraphs, where the block crop is a slab. // Pure and self-contained like the matcher; it runs in the page too. // // `rects` are the marks' line fragments, `content` the block's content box, // both in document coordinates. A fragment's box is its glyphs' content area, // centred in its line box, so the quote's first line box is centred on the // first fragment and its last on the last; from there the crop moves in whole // `lineHeight`s, which is what keeps a context line from being cut mid-glyph. // It never leaves the content box, so a quote on the block's first line has no // context above it rather than the bottom of whatever sits above the block. // // Returns `{ crop, band }`: `band` is the lines' own top and bottom. Between it // and the crop's edges is padding, which on a page of text is the next line's // glyphs, cut — so the page side masks it. export function markCrop({ rects, lineHeight, content, contextLines, padding, docW, docH }) { let first = rects[0]; let last = rects[0]; for (const r of rects) { if (r.top < first.top) first = r; if (r.bottom > last.bottom) last = r; } const lh = lineHeight; const n = contextLines; const top = Math.max(content.top, (first.top + first.bottom) / 2 - lh / 2 - n * lh); const bottom = Math.min(content.bottom, (last.top + last.bottom) / 2 + lh / 2 + n * lh); const x0 = Math.max(0, Math.floor(content.left - padding)); const y0 = Math.max(0, Math.floor(top - padding)); const x1 = Math.min(docW, Math.ceil(content.right + padding)); const y1 = Math.min(docH, Math.ceil(bottom + padding)); return { crop: { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, band: { top, bottom } }; } // --------------------------------------------------------------------------- // The page side. Runs INSIDE the browser (serialised by `pageExpression`): walk // the visible text, match, wrap the match in marks, measure what to shoot. // --------------------------------------------------------------------------- function browserShoot(args) { const { quote, context, color, padding, maxHeight, isolate, cropMode, contextLines } = args; const doc = document; const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "TITLE", "HEAD", "SVG", "MATH", "IFRAME", "OBJECT", "SELECT", "TEXTAREA"]); const blockCache = new Map(); const isBlock = (el) => { const d = getComputedStyle(el).display; return !(d.startsWith("inline") || d === "contents" || d === "ruby" || d === "none"); }; const blockOf = (node) => { let el = node.nodeType === 1 ? node : node.parentElement; const seen = []; while (el && el !== doc.body && el !== doc.documentElement) { if (blockCache.has(el)) { const b = blockCache.get(el); for (const s of seen) blockCache.set(s, b); return b; } seen.push(el); if (isBlock(el)) break; el = el.parentElement; } const b = el ?? doc.body; for (const s of seen) blockCache.set(s, b); return b; }; // A hand-rolled walk, not a TreeWalker with a filter: with the page's scripts // off, the DOM refuses to call back into script ("callback is no longer // runnable"), and a filter is a callback. const segs = []; let lastBlock = null; const stack = [doc.body ?? doc.documentElement]; while (stack.length) { const n = stack.pop(); if (n.nodeType === 3) { const b = blockOf(n); if (lastBlock && b !== lastBlock) segs.push({ node: null, text: " " }); lastBlock = b; segs.push({ node: n, text: n.data }); continue; } if (n.nodeType !== 1 || SKIP.has(n.tagName.toUpperCase())) continue; const st = getComputedStyle(n); if (st.display === "none") continue; if (n.tagName === "BR") { segs.push({ node: null, text: " " }); continue; } // A hidden element's children may be visible again, so only its own text // is dropped; children are pushed in reverse to pop in document order. const kids = [...n.childNodes].filter((k) => k.nodeType === 1 || st.visibility !== "hidden"); for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]); } const m = matchInSegments(segs.map((s) => s.text), quote, context); if (!m.ok) return m; const range = doc.createRange(); range.setStart(segs[m.start.segment].node, m.start.offset); range.setEnd(segs[m.end.segment].node, m.end.offset); const matched = range.toString(); let common = range.commonAncestorContainer; if (common.nodeType !== 1) common = common.parentElement; const block = common === doc.body || isBlock(common) ? common : blockOf(common); // One mark per text node the match covers. splitText at the end first, so // the node in hand stays the left part; then at the start, which returns the // covered middle. const marks = []; for (let i = m.start.segment; i <= m.end.segment; i++) { let node = segs[i].node; if (!node) continue; const a = i === m.start.segment ? m.start.offset : 0; const b = i === m.end.segment ? m.end.offset : node.data.length; if (a >= b) continue; if (b < node.data.length) node.splitText(b); if (a > 0) node = node.splitText(a); const mark = doc.createElement("mark"); mark.setAttribute("data-shoot-page", ""); node.parentNode.insertBefore(mark, node); mark.appendChild(node); marks.push(mark); } // The highlight must not reflow the paragraph: vertical padding on an inline // box does not move a line, horizontal padding would, so there is none. // Each mark is positioned, EARLIER marks on top: inline boxes paint in tree // order, so otherwise the next mark's background is laid over the previous // one's glyph overhang — an italic word loses its last letter's tail. Only // the two outer ends are rounded, or every element boundary shows a notch. marks.forEach((mark, i) => { const r = (left, right) => `${left ? "0.15em" : "0"} ${right ? "0.15em" : "0"} ${right ? "0.15em" : "0"} ${left ? "0.15em" : "0"}`; mark.style.cssText = `background:${color} !important;color:inherit !important;padding:0.08em 0 !important;margin:0 !important;` + `position:relative;z-index:${marks.length - i};border-radius:${r(i === 0, i === marks.length - 1)};` + "-webkit-box-decoration-break:clone;box-decoration-break:clone;"; }); // Everything that is neither the block, inside it, nor around it is hidden // for the shot, so the padding shows the page's ground and not the bottom of // the paragraph above. Hidden, not removed: nothing moves. if (isolate) { block.setAttribute("data-shoot-page-block", ""); const style = doc.createElement("style"); style.textContent = "body *:not([data-shoot-page-block]):not([data-shoot-page-block] *):not(:has([data-shoot-page-block]))" + " { visibility: hidden !important; }"; (doc.head ?? doc.documentElement).appendChild(style); } const sx = window.scrollX; const sy = window.scrollY; const de = doc.documentElement; const docW = Math.max(de.scrollWidth, doc.body?.scrollWidth ?? 0); const docH = Math.max(de.scrollHeight, doc.body?.scrollHeight ?? 0); const br = block.getBoundingClientRect(); // The marks' line fragments. Their vertical padding is symmetric, so it // moves no fragment's centre, which is all the mark crop reads. const rects = []; for (const mk of marks) { for (const r of mk.getClientRects()) rects.push({ top: r.top + sy, bottom: r.bottom + sy }); } let top = br.top + sy; let bottom = br.bottom + sy; let trimmed = false; let crop; if (cropMode === "mark") { const bs = getComputedStyle(block); const px = (v) => parseFloat(v) || 0; const content = { left: br.left + sx + px(bs.borderLeftWidth) + px(bs.paddingLeft), right: br.right + sx - px(bs.borderRightWidth) - px(bs.paddingRight), top: br.top + sy + px(bs.borderTopWidth) + px(bs.paddingTop), bottom: br.bottom + sy - px(bs.borderBottomWidth) - px(bs.paddingBottom), }; // The line-height of the text the quote sits in. `normal` has no number in // computed style; 1.2 × the font size is what browsers use for most fonts. const ps = getComputedStyle(marks[0].parentElement); const lineHeight = parseFloat(ps.lineHeight) || parseFloat(ps.fontSize) * 1.2; const mc = markCrop({ rects, lineHeight, content, contextLines, padding, docW, docH }); crop = mc.crop; // Mask the padding above and below the lines with the block's own ground, // across its padding box: past that the ground is the page's, and the // isolation already blanks whatever else stands there. The masks hang off // , so they are positioned in document coordinates and the // isolation rule (`body *`) does not hide them. let ground = "#ffffff"; for (let el = block; el; el = el.parentElement) { const bg = getComputedStyle(el).backgroundColor; if (bg && bg !== "transparent" && !/^rgba\(.*,\s*0\)$/.test(bg)) { ground = bg; break; } } const padLeft = Math.max(crop.x, br.left + sx + px(bs.borderLeftWidth)); const padRight = Math.min(crop.x + crop.width, br.right + sx - px(bs.borderRightWidth)); const mask = (y0, y1) => { if (y1 <= y0 || padRight <= padLeft) return; const d = doc.createElement("div"); d.setAttribute("data-shoot-page-mask", ""); d.style.cssText = `position:absolute;left:${padLeft}px;top:${y0}px;width:${padRight - padLeft}px;height:${y1 - y0}px;` + `background:${ground};z-index:2147483647;pointer-events:none;margin:0;padding:0;border:0;`; de.appendChild(d); }; mask(crop.y, mc.band.top); mask(mc.band.bottom, crop.y + crop.height); } else { if (bottom - top > maxHeight) { let mt = Infinity; let mb = -Infinity; for (const r of rects) { mt = Math.min(mt, r.top); mb = Math.max(mb, r.bottom); } if (mb - mt >= maxHeight) { top = mt; bottom = mb; } else { const mid = (mt + mb) / 2; const t = Math.min(Math.max(top, mid - maxHeight / 2), bottom - maxHeight); top = t; bottom = t + maxHeight; } trimmed = true; } const x0 = Math.max(0, Math.floor(br.left + sx - padding)); const y0 = Math.max(0, Math.floor(top - padding)); const x1 = Math.min(docW, Math.ceil(br.right + sx + padding)); const y1 = Math.min(docH, Math.ceil(bottom + padding)); crop = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }; } const cssPath = (el) => { const parts = []; while (el && el.nodeType === 1 && el !== de) { if (el.id && doc.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1) { parts.unshift(`#${CSS.escape(el.id)}`); return parts.join(" > "); } const tag = el.tagName.toLowerCase(); const same = el.parentElement ? [...el.parentElement.children].filter((c) => c.tagName === el.tagName) : []; parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(el) + 1})` : tag); el = el.parentElement; } return ["html", ...parts].join(" > "); }; return { ok: true, matched, block: cssPath(block), crop, cropMode: cropMode === "mark" ? "mark" : "block", trimmed, occurrences: m.occurrences, marks: marks.length, }; } // The expression handed to page.evaluate: the matcher and the walker as source, // then a call. A string rather than a function so the helpers travel with it. export function pageExpression(args) { return `(() => {\n${normaliseWithMap}\n${matchInSegments}\n${markCrop}\n${browserShoot}\nreturn browserShoot(${JSON.stringify(args)});\n})()`; } // --------------------------------------------------------------------------- // The browser. // --------------------------------------------------------------------------- // Is `url` a file the page at `pagePath` may load: file:// and under its dir. export function allowedRequest(url, pagePath) { if (!url.startsWith("file:")) return false; let p; try { p = fileURLToPath(url); } catch { return false; } const rel = path.relative(/* turbopackIgnore: true */ path.dirname(/* turbopackIgnore: true */ path.resolve(/* turbopackIgnore: true */ pagePath)), path.resolve(/* turbopackIgnore: true */ p)); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); } async function loadChromium() { // umtool installs @playwright/test; this package does not declare it, so it // resolves from umtool's node_modules. It is CommonJS: accept either shape. let mod; try { mod = await import("@playwright/test"); } catch (err) { throw new Error(`Playwright is not available here (it ships with umtool): ${err.message}`); } const chromium = mod.chromium ?? mod.default?.chromium; if (!chromium) throw new Error("Playwright loaded but has no chromium export"); return chromium; } // Shoot every item; one browser for the lot, a fresh page per item (the marks // mutate the DOM). Returns the results array; writes nothing but the PNGs. // // `items`: [{ id, page (absolute or relative to `baseDir`), quote, context?, out? }] export async function shootPages(items, opts = {}) { const o = { ...DEFAULTS, ...opts }; const chromium = await loadChromium(); const browser = await chromium.launch({ headless: true }); const results = []; try { const ctx = await browser.newContext({ viewport: { width: o.width, height: o.height }, deviceScaleFactor: o.scale, javaScriptEnabled: o.js, bypassCSP: true, serviceWorkers: "block", offline: true, }); for (const item of items) results.push(await shootOne(ctx, item, o)); await ctx.close(); } finally { await browser.close(); } return results; } async function shootOne(ctx, item, o) { const pagePath = path.resolve(/* turbopackIgnore: true */ o.baseDir ?? process.cwd(), String(item.page ?? "")); const base = { id: item.id, page: item.page, quote: item.quote }; if (!item.page || !existsSync(/* turbopackIgnore: true */ pagePath) || !statSync(/* turbopackIgnore: true */ pagePath).isFile()) { return { ...base, ok: false, reason: "page not found" }; } const blocked = []; const page = await ctx.newPage(); try { await page.route(() => true, (route) => { const url = route.request().url(); if (allowedRequest(url, pagePath)) return route.continue(); blocked.push(url.length > 200 ? `${url.slice(0, 200)}\u2026` : url); return route.abort("blockedbyclient"); }); await page.goto(pathToFileURL(pagePath).href, { waitUntil: "load", timeout: o.timeout }); // Fonts are not part of "load"; a shot taken before them reflows after. await page.evaluate("document.fonts ? document.fonts.ready.then(() => true) : true"); const r = await page.evaluate( pageExpression({ quote: item.quote, context: item.context ?? null, color: o.color, padding: o.padding, maxHeight: o.maxHeight, isolate: o.isolate, cropMode: item.crop ?? o.crop, contextLines: o.contextLines, }), ); if (!r.ok) return { ...base, ok: false, reason: r.reason, occurrences: r.occurrences, blocked }; await mkdir(path.dirname(item.out), { recursive: true }); await page.screenshot({ path: item.out, type: "png", fullPage: true, clip: r.crop }); return { ...base, ok: true, png: item.out, matched: r.matched, block: r.block, crop: r.crop, cropMode: r.cropMode, scale: o.scale, pixels: { width: Math.round(r.crop.width * o.scale), height: Math.round(r.crop.height * o.scale) }, trimmed: r.trimmed, occurrences: r.occurrences, blocked, }; } catch (err) { return { ...base, ok: false, reason: `error: ${err.message?.split("\n")[0] ?? err}`, blocked }; } finally { await page.close(); } } // --------------------------------------------------------------------------- // The CLI. // --------------------------------------------------------------------------- // Items from a batch file: an array, or `{ items: [...] }`. Every item needs an // id that is a safe file name, a page and a quote; ids are unique. Throws with // every problem at once rather than the first. export function validateItems(data) { const items = Array.isArray(data) ? data : data?.items; if (!Array.isArray(items)) throw new Error("batch file must be an array of items or { items: [...] }"); const problems = []; const seen = new Set(); items.forEach((it, i) => { const where = `item ${i}${it?.id ? ` (${it.id})` : ""}`; if (!it || typeof it !== "object") return problems.push(`${where}: not an object`); if (typeof it.id !== "string" || !/^[A-Za-z0-9._-]+$/.test(it.id) || it.id.startsWith(".")) { problems.push(`${where}: id must be a file-name-safe string`); } else if (seen.has(it.id)) problems.push(`${where}: duplicate id`); else seen.add(it.id); if (typeof it.page !== "string" || !it.page) problems.push(`${where}: page is required`); if (typeof it.quote !== "string" || !it.quote.trim()) problems.push(`${where}: quote is required`); if (it.context != null && typeof it.context !== "string") problems.push(`${where}: context must be a string`); if (it.crop != null && !CROP_MODES.includes(it.crop)) problems.push(`${where}: crop must be "block" or "mark"`); }); if (problems.length) throw new Error(problems.join("\n")); return items; } export function parseArgs(argv) { const opts = {}; const out = { mode: null, opts }; const num = (name, v) => { const n = Number(v); if (!Number.isFinite(n) || n < 0) throw new Error(`${name} needs a non-negative number`); return n; }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; const next = () => { if (i + 1 >= argv.length) throw new Error(`${a} needs a value`); return argv[++i]; }; switch (a) { case "--page": out.page = next(); break; case "--quote": out.quote = next(); break; case "--context": out.context = next(); break; case "--batch": out.batch = next(); break; case "--out": out.out = next(); break; case "--color": opts.color = next(); break; case "--padding": opts.padding = num(a, next()); break; case "--width": opts.width = num(a, next()); break; case "--scale": opts.scale = num(a, next()); break; case "--max-height": opts.maxHeight = num(a, next()); break; case "--timeout": opts.timeout = num(a, next()); break; case "--js": opts.js = true; break; case "--no-isolate": opts.isolate = false; break; case "--crop": { const v = next(); if (!CROP_MODES.includes(v)) throw new Error(`--crop must be "block" or "mark", not "${v}"`); opts.crop = v; break; } case "--context-lines": { const v = num(a, next()); if (!Number.isInteger(v)) throw new Error("--context-lines needs a whole number"); opts.contextLines = v; break; } default: throw new Error(`unknown argument: ${a}`); } } if (out.batch && (out.page || out.quote)) throw new Error("--batch and --page/--quote are two modes; pick one"); if (!out.out) throw new Error("--out is required"); if (out.batch) out.mode = "batch"; else if (out.page && out.quote) out.mode = "single"; else throw new Error("give --batch , or --page and --quote"); return out; } const USAGE = "usage: shoot-page.mjs --page --quote [--context ] --out \n" + " shoot-page.mjs --batch --out \n" + " [--color ] [--padding ] [--width ] [--scale ] [--max-height ] [--js] [--no-isolate]\n" + " [--crop block|mark] [--context-lines ] [--timeout ]"; async function main() { let args; try { args = parseArgs(process.argv.slice(2)); } catch (err) { console.error(`${err.message}\n${USAGE}`); process.exit(2); } let items; let resultsFile = null; if (args.mode === "batch") { const batchPath = path.resolve(args.batch); items = validateItems(JSON.parse(await readFile(batchPath, "utf8"))); const outDir = path.resolve(args.out); items = items.map((it) => ({ ...it, out: path.join(outDir, `${it.id}.png`) })); args.opts.baseDir = path.dirname(batchPath); resultsFile = path.join(/* turbopackIgnore: true */ outDir, "results.json"); } else { items = [{ id: "shot", page: args.page, quote: args.quote, context: args.context, out: path.resolve(args.out) }]; } const results = await shootPages(items, args.opts); const missed = results.filter((r) => !r.ok); const report = { generatedAt: new Date().toISOString(), options: { ...DEFAULTS, ...args.opts }, shot: results.length - missed.length, missed: missed.length, items: results, }; if (resultsFile) { await mkdir(path.dirname(resultsFile), { recursive: true }); await writeFile(/* turbopackIgnore: true */ resultsFile, JSON.stringify(report, null, 2) + "\n", "utf8"); } else { console.log(JSON.stringify(results[0], null, 2)); } for (const r of results) { if (r.ok && r.occurrences > 1) { console.error(`note: ${r.id}: the quote occurs ${r.occurrences} times; shot the first (give a context to pick another)`); } } if (missed.length) { console.error(`MISSED ${missed.length} of ${results.length}:`); for (const r of missed) console.error(` ${r.id}: ${r.reason} — ${r.page}`); if (resultsFile) console.error(`results: ${resultsFile}`); process.exit(1); } if (resultsFile) console.error(`shot ${results.length} -> ${resultsFile}`); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { console.error(err.message ?? err); process.exit(1); }); }