everyhexcode 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/render.js ADDED
@@ -0,0 +1,329 @@
1
+ // The rendered scan: load a page in your own Chrome (headless, a throwaway profile, closed afterwards), and for every
2
+ // visible text read its colour and the colour actually behind it: the stack of backgrounds under the text's own spot,
3
+ // translucent layers composited, and where an image or gradient sits behind it, the screen pixels themselves (the text
4
+ // hidden for the shot), taking the worst part of what is behind. Light and dark mode via prefers-color-scheme. The
5
+ // result is written as CSS rules (one per distinct text, background and size) that the checker reads like any CSS.
6
+ import { execFileSync, spawn } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import zlib from 'node:zlib';
11
+
12
+ const CANDIDATES = {
13
+ darwin: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
14
+ linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium', '/usr/bin/microsoft-edge'],
15
+ win32: [`${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`],
16
+ };
17
+ // chrome-headless-shell (Google's build for automation) first: full Chrome in its new headless mode froze and lost its
18
+ // renderer on stripe.com where the shell of the same version did not. Installed by
19
+ // `npx @puppeteer/browsers install chrome-headless-shell@stable` (into ./chrome-headless-shell) or by Puppeteer (~/.cache/puppeteer).
20
+ function headlessShells() {
21
+ const out = [];
22
+ for (const root of [path.join(process.cwd(), 'chrome-headless-shell'), path.join(os.homedir(), '.cache', 'puppeteer', 'chrome-headless-shell')]) {
23
+ let vers = [];
24
+ try { vers = fs.readdirSync(root).sort().reverse(); } catch { continue; }
25
+ for (const v of vers) {
26
+ let subs = [];
27
+ try { subs = fs.readdirSync(path.join(root, v)); } catch { continue; }
28
+ for (const sub of subs) out.push(path.join(root, v, sub, process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell'));
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+ export function findChrome(given) {
34
+ const list = [given, process.env.CHROME_PATH, ...headlessShells(), ...(CANDIDATES[process.platform] || [])].filter(Boolean);
35
+ const hit = list.find((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } });
36
+ if (!hit) throw new Error('No Chrome or Chromium found. Install one, or set CHROME_PATH (or --chrome) to its binary.');
37
+ return hit;
38
+ }
39
+
40
+ /** Start Chrome headless with a temporary profile; resolves with its DevTools address and a way to stop it. */
41
+ export function launch(bin) {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'everyhexcode-chrome-'));
43
+ const shell = /headless[-_]shell/i.test(bin);
44
+ const args = [...(shell ? [] : ['--headless=new']), '--remote-debugging-port=0', `--user-data-dir=${dir}`, '--no-first-run', '--no-default-browser-check', '--use-mock-keychain', '--password-store=basic',
45
+ '--hide-scrollbars', '--mute-audio', '--disable-extensions', '--disable-background-networking', '--disable-sync', '--force-color-profile=srgb', 'about:blank'];
46
+ if (process.getuid?.() === 0) args.unshift('--no-sandbox'); // root in a CI container
47
+ if (process.env.EHC_FLAGS != null) args.splice(0, args.length, '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`, ...process.env.EHC_FLAGS.split(' ').filter(Boolean), 'about:blank');
48
+ const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
49
+ // if this process ends early (Ctrl-C, a CI timeout), Chrome goes with it
50
+ const bye = () => { try { child.kill('SIGKILL'); } catch { /* gone */ } try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* gone */ } };
51
+ process.once('exit', bye);
52
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(sig, () => { bye(); process.exit(130); });
53
+ // stop: ask nicely (the caller sends Browser.close first), then SIGTERM, and SIGKILL only if Chrome is still there,
54
+ // so its helper processes go with it; the profile folder is removed once it has exited
55
+ let exited = false;
56
+ child.on('exit', () => { exited = true; });
57
+ const stop = () => new Promise((resolve) => {
58
+ // helper processes that outlive the browser carry our profile folder in their command line: those, and only those, go too
59
+ const sweep = () => {
60
+ if (process.platform === 'win32') return;
61
+ try {
62
+ for (const line of execFileSync('ps', ['-ax', '-o', 'pid=,command='], { encoding: 'utf8' }).split('\n')) {
63
+ const m = line.trim().match(/^(\d+)\s+(.*)$/);
64
+ if (m && m[2].includes(dir) && Number(m[1]) !== process.pid) try { process.kill(Number(m[1]), 'SIGKILL'); } catch { /* gone */ }
65
+ }
66
+ } catch { /* no ps */ }
67
+ };
68
+ const done = () => { setTimeout(() => { sweep(); fs.rmSync(dir, { recursive: true, force: true }); resolve(); }, 500); };
69
+ if (exited) return done();
70
+ child.once('exit', done);
71
+ setTimeout(() => { if (!exited) try { child.kill('SIGTERM'); } catch { /* gone */ } }, 1500);
72
+ setTimeout(() => { if (!exited) try { child.kill('SIGKILL'); } catch { /* gone */ } }, 5000);
73
+ setTimeout(() => { if (!exited) done(); }, 6000);
74
+ });
75
+ return new Promise((resolve, reject) => {
76
+ let err = '';
77
+ const t = setTimeout(() => { stop(); reject(new Error('Chrome did not start within 45 s.')); }, 45000);
78
+ child.stderr.on('data', (d) => {
79
+ err += d;
80
+ const m = err.match(/DevTools listening on (ws:\/\/\S+)/);
81
+ if (m) { clearTimeout(t); resolve({ ws: m[1], stop, pid: child.pid }); }
82
+ });
83
+ child.on('exit', (code) => { clearTimeout(t); reject(new Error(`Chrome exited (${code}): ${err.slice(-300)}`)); });
84
+ });
85
+ }
86
+
87
+ /** A minimal DevTools protocol client over Node's built-in WebSocket. */
88
+ export function connect(url) {
89
+ if (typeof WebSocket === 'undefined') throw new Error('The rendered scan needs Node 22 or newer (built-in WebSocket).');
90
+ const ws = new WebSocket(url);
91
+ let id = 0;
92
+ const wait = new Map(), on = new Map();
93
+ ws.addEventListener('message', (ev) => {
94
+ const m = JSON.parse(ev.data);
95
+ if (m.id && wait.has(m.id)) { const w = wait.get(m.id); wait.delete(m.id); m.error ? w.reject(new Error(m.error.message)) : w.resolve(m.result); }
96
+ else if (m.method) for (const f of on.get(m.method) || []) f(m.params, m.sessionId);
97
+ });
98
+ // every call has a time limit, so a page that never settles cannot hang the scan
99
+ const send = (method, params = {}, sessionId, ms = 20000) => new Promise((resolve, reject) => {
100
+ const i = ++id;
101
+ const t = setTimeout(() => { wait.delete(i); reject(new Error(`Chrome did not answer ${method} within ${ms / 1000} s.`)); }, ms);
102
+ wait.set(i, { resolve: (v) => { clearTimeout(t); resolve(v); }, reject: (e) => { clearTimeout(t); reject(e); } });
103
+ ws.send(JSON.stringify({ id: i, method, params, sessionId }));
104
+ });
105
+ const once = (method, ms = 30000, when = () => true) => new Promise((resolve) => { const t = setTimeout(() => resolve(null), ms); const f = (p) => { if (!when(p)) return; clearTimeout(t); on.set(method, (on.get(method) || []).filter((x) => x !== f)); resolve(p); }; on.set(method, [...(on.get(method) || []), f]); });
106
+ const listen = (method, f) => on.set(method, [...(on.get(method) || []), f]);
107
+ return new Promise((resolve, reject) => { ws.addEventListener('open', () => resolve({ send, once, on: listen, close: () => ws.close() })); ws.addEventListener('error', () => reject(new Error('Could not connect to Chrome.'))); });
108
+ }
109
+
110
+ /** PNG (8-bit RGB or RGBA, as Chrome writes screenshots) → { width, height, px(x, y) → [r, g, b] }. */
111
+ export function decodePng(buf) {
112
+ let p = 8, w = 0, h = 0, type = 6;
113
+ const idat = [];
114
+ while (p < buf.length) {
115
+ const len = buf.readUInt32BE(p), kind = buf.toString('ascii', p + 4, p + 8), data = buf.subarray(p + 8, p + 8 + len);
116
+ if (kind === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); type = data[9]; if (data[8] !== 8) throw new Error('Only 8-bit PNG'); }
117
+ else if (kind === 'IDAT') idat.push(data);
118
+ else if (kind === 'IEND') break;
119
+ p += 12 + len;
120
+ }
121
+ const bpp = type === 6 ? 4 : type === 2 ? 3 : (() => { throw new Error('Unsupported PNG'); })();
122
+ const raw = zlib.inflateSync(Buffer.concat(idat)), stride = w * bpp, out = Buffer.alloc(h * stride);
123
+ for (let y = 0; y < h; y++) {
124
+ const f = raw[y * (stride + 1)], row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
125
+ for (let x = 0; x < stride; x++) {
126
+ const a = x >= bpp ? out[y * stride + x - bpp] : 0, b = y ? out[(y - 1) * stride + x] : 0, c = x >= bpp && y ? out[(y - 1) * stride + x - bpp] : 0;
127
+ let v = row[x];
128
+ if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1;
129
+ else if (f === 4) { const pp = a + b - c, pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
130
+ out[y * stride + x] = v & 255;
131
+ }
132
+ }
133
+ return { width: w, height: h, px: (x, y) => { const i = y * stride + x * bpp; return [out[i], out[i + 1], out[i + 2]]; } };
134
+ }
135
+
136
+ const lum = ([r, g, b]) => { const f = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
137
+ const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
138
+ const hex = (c) => '#' + c.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
139
+
140
+ // Runs in the page: every visible element with its own text, its colour and the colours behind it. Colours are
141
+ // normalised through a canvas, so oklch() and color() come back as rgb, alpha kept.
142
+ // before reading: transitions end at once (a colour mid-fade is neither state), and scrolling jumps (scroll-behavior:
143
+ // smooth would leave the page at the top and the rest unread)
144
+ export const SETTLE = `if (!document.getElementById('ehc-settle')) { const st = document.createElement('style'); st.id = 'ehc-settle'; st.textContent = '*, *::before, *::after { transition-duration: 0s !important; transition-delay: 0s !important; } html, body { scroll-behavior: auto !important; }'; document.head.appendChild(st); }`;
145
+ export const COLLECT = `(() => {
146
+ // any CSS colour (rgb, oklch, color(), color-mix…) to sRGB 0-255 with alpha, by painting one pixel and reading it back
147
+ const c1 = document.createElement('canvas'); c1.width = c1.height = 1;
148
+ const cv = c1.getContext('2d', { willReadFrequently: true, colorSpace: 'srgb' });
149
+ const memo = new Map();
150
+ const rgba = (s) => { if (memo.has(s)) return memo.get(s).slice(); if (!s || s === 'transparent' || s === 'rgba(0, 0, 0, 0)') return [0, 0, 0, 0]; cv.clearRect(0, 0, 1, 1); cv.fillStyle = 'rgba(0,0,0,0)'; cv.fillStyle = s; cv.fillRect(0, 0, 1, 1); const d = cv.getImageData(0, 0, 1, 1).data; const v = [d[0], d[1], d[2], d[3] / 255]; memo.set(s, v); return v.slice(); };
151
+ const over = (top, under) => { const a = top[3]; return [top[0]*a + under[0]*(1-a), top[1]*a + under[1]*(1-a), top[2]*a + under[2]*(1-a), 1]; };
152
+ const canvasBg = (() => { const h = rgba(getComputedStyle(document.documentElement).backgroundColor), b = rgba(getComputedStyle(document.body).backgroundColor); const base = matchMedia('(prefers-color-scheme: dark)').matches && /dark/.test(getComputedStyle(document.documentElement).colorScheme) ? [18,18,18,1] : [255,255,255,1]; return over(h, h[3] < 1 ? over(b, base) : base); })();
153
+ const path = (el) => { const p = []; for (let e = el; e && e !== document.body && p.length < 3; e = e.parentElement) { let s = e.tagName.toLowerCase(); if (e.id) { s += '#' + e.id; p.unshift(s); break; } const c = [...e.classList].filter((x) => !/[:\\[\\]/]/.test(x)).slice(0, 2); if (c.length) s += '.' + c.join('.'); p.unshift(s); } return p.join(' > ') || el.tagName.toLowerCase(); };
154
+ const out = [];
155
+ const els = document.body ? document.body.querySelectorAll('*') : [];
156
+ const opc = new Map();
157
+ // elements already read on an earlier screen; marked in a set, not an attribute, because changing attributes
158
+ // while reading styles makes the browser recompute every style on a big page
159
+ const seen = window.__ehcSeen || (window.__ehcSeen = new WeakSet());
160
+ const mark = [];
161
+ let n = window.__ehcN || 0;
162
+ for (const el of els) {
163
+ if (n >= 3000) break;
164
+ if (seen.has(el)) continue;
165
+ if (/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|SVG|PATH|IFRAME|CANVAS|VIDEO|IMG|OPTION)$/i.test(el.tagName)) continue;
166
+ const own = [...el.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim().length > 1);
167
+ const field = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(el.tagName);
168
+ if (!own && !field) continue;
169
+ if (el.disabled || el.closest('[aria-disabled="true"], fieldset:disabled')) continue; // disabled controls are exempt
170
+ const cs = getComputedStyle(el);
171
+ if (cs.visibility !== 'visible' || cs.display === 'none' || parseFloat(cs.fontSize) < 6) continue;
172
+ const r = el.getBoundingClientRect();
173
+ if (r.width < 2 || r.height < 2 || r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth) continue;
174
+ const opOf = (e) => { if (!e) return 1; if (opc.has(e)) return opc.get(e); const v = parseFloat(getComputedStyle(e).opacity) * opOf(e.parentElement); opc.set(e, v); return v; };
175
+ const op = opOf(el);
176
+ if (op < 0.1) continue;
177
+ const x = Math.min(innerWidth - 1, Math.max(0, r.left + Math.min(r.width / 2, 12))), y = Math.min(innerHeight - 1, Math.max(0, r.top + r.height / 2));
178
+ // what lies under that spot, from the element itself downwards
179
+ const stack = document.elementsFromPoint(x, y);
180
+ // an element with pointer-events: none is not in that list: then it and the elements it sits in
181
+ const under = stack.includes(el) ? stack.slice(stack.indexOf(el)) : (() => { const l = []; for (let e = el; e; e = e.parentElement) l.push(e); return l; })();
182
+ const layers = []; let image = false;
183
+ for (const e of under) {
184
+ const s = getComputedStyle(e);
185
+ if (s.backgroundImage && s.backgroundImage !== 'none') { image = true; break; }
186
+ if (/^(IMG|VIDEO|CANVAS|SVG|PICTURE)$/i.test(e.tagName) && e !== el) { image = true; break; }
187
+ const c = rgba(s.backgroundColor);
188
+ if (c[3] > 0) { layers.push(c); if (c[3] >= 1) break; }
189
+ }
190
+ let bg = layers.length && layers[layers.length - 1][3] >= 1 ? layers[layers.length - 1] : canvasBg;
191
+ for (let i = layers.length - (layers.length && layers[layers.length - 1][3] >= 1 ? 2 : 1); i >= 0; i--) bg = over(layers[i], bg);
192
+ const fg = rgba(cs.color); fg[3] *= op;
193
+ n++;
194
+ out.push({ i: n - 1, sel: path(el), text: (el.innerText || el.value || '').trim().slice(0, 40), fg, bg, image, size: parseFloat(cs.fontSize), weight: parseInt(cs.fontWeight) || 400, rect: [r.left, r.top + scrollY, r.width, r.height] });
195
+ seen.add(el);
196
+ if (image) mark.push([el, n - 1]);
197
+ }
198
+ for (const [e, i] of mark) e.setAttribute('data-ehc-i', String(i));
199
+ window.__ehcN = n;
200
+ return { items: out, height: Math.min(document.documentElement.scrollHeight, 12000), url: location.href };
201
+ })()`;
202
+
203
+ export async function renderScan(url, opts = {}) {
204
+ const bin = findChrome(opts.chrome);
205
+ const width = opts.width || 1280;
206
+ const ch = await launch(bin);
207
+ opts.log?.(`${/headless[-_]shell/i.test(bin) ? 'chrome-headless-shell' : 'Chrome'} started (pid ${ch.pid}, headless, temporary profile).`);
208
+ let cdp;
209
+ try {
210
+ cdp = await connect(ch.ws);
211
+ // the tab Chrome opened with, brought to the front: a background tab can be frozen and never answer
212
+ const { targetInfos } = await cdp.send('Target.getTargets');
213
+ const targetId = targetInfos.find((t) => t.type === 'page')?.targetId || (await cdp.send('Target.createTarget', { url: 'about:blank' })).targetId;
214
+ let { sessionId: s } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
215
+ await cdp.send('Page.enable', {}, s);
216
+ await cdp.send('Page.setLifecycleEventsEnabled', { enabled: true }, s);
217
+ // Runtime.enable is left off: with it and Page.enable both on, some pages (stripe.com) never answer again
218
+ await cdp.send('Page.bringToFront', {}, s);
219
+ // some sites stall a browser that calls itself HeadlessChrome; the page is read, never automated, so say Chrome
220
+ const { userAgent } = await cdp.send('Browser.getVersion');
221
+ await cdp.send('Emulation.setUserAgentOverride', { userAgent: userAgent.replace('HeadlessChrome', 'Chrome') }, s);
222
+ await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: true }, s);
223
+ // an alert, confirm or beforeunload dialog stops the page until answered: dismiss it
224
+ cdp.on('Page.javascriptDialogOpening', (p) => { opts.log?.(`Dismissed a ${p.type} dialog: ${String(p.message).slice(0, 80)}`); cdp.send('Page.handleJavaScriptDialog', { accept: false }, s).catch(() => {}); });
225
+ const rules = [], notes = [];
226
+ let gone = false;
227
+ cdp.on('Inspector.detached', () => { gone = true; });
228
+ cdp.on('Target.detachedFromTarget', (p) => { if (p.targetId === targetId) gone = true; });
229
+ const started = Date.now(), budget = (opts.timeout || 150) * 1000;
230
+ // a call sent while the page is still swapping documents (a redirect or reload done by script) is never answered:
231
+ // ask again, a few times, before giving up
232
+ const evaluate = async (expression) => {
233
+ for (let i = 0; ; i++) {
234
+ try { return await cdp.send('Runtime.evaluate', { expression, returnByValue: true }, s, 15000); } catch (e) {
235
+ if (!/did not answer/.test(e.message)) throw e;
236
+ if (i >= 1 || gone || Date.now() - started > budget) throw new Error('STALLED');
237
+ // an unanswered call blocks the ones after it: a fresh session to the same tab clears it
238
+ opts.log?.('The page is not answering; reconnecting to it.');
239
+ try { await cdp.send('Target.detachFromTarget', { sessionId: s }, undefined, 5000); } catch { /* already gone */ }
240
+ ({ sessionId: s } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }));
241
+ await cdp.send('Page.enable', {}, s).catch(() => {});
242
+ await new Promise((r) => setTimeout(r, 1500));
243
+ }
244
+ }
245
+ };
246
+ let elements = 0, images = 0, finalUrl = url;
247
+ for (const mode of opts.modes || ['light', 'dark']) {
248
+ const items = [];
249
+ let over = [];
250
+ try {
251
+ await cdp.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: mode }, { name: 'prefers-reduced-motion', value: 'reduce' }] }, s);
252
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: false }, s);
253
+ opts.log?.(`${mode}: loading ${url}`);
254
+ const loaded = cdp.once('Page.loadEventFired', 30000);
255
+ // network idle is the better sign a script-built page has settled; a page that keeps polling never gets there
256
+ const idle = cdp.once('Page.lifecycleEvent', 30000, (p) => p.name === 'networkIdle');
257
+ const nav = await cdp.send('Page.navigate', { url }, s);
258
+ if (nav.errorText) throw new Error(`Could not load ${url}: ${nav.errorText}`);
259
+ const ev = await loaded;
260
+ await Promise.race([idle, new Promise((r) => setTimeout(r, 6000))]);
261
+ if (process.env.EHC_DEBUG) opts.log?.(`load event: ${ev ? 'yes' : 'no (30 s)'}`);
262
+ await new Promise((r) => setTimeout(r, 1200));
263
+ // walk down the page a screen at a time: what is under each text is read while it is in view
264
+ const until = Date.now() + 60000;
265
+ let shotsOk = true;
266
+ for (let step = 0; step < 15; step++) {
267
+ const res = await evaluate(`${SETTLE} scrollTo({ top: ${step * 800}, behavior: 'instant' }); ${COLLECT}`);
268
+ if (res.exceptionDetails) throw new Error('The page could not be read: ' + (res.exceptionDetails.exception?.description || res.exceptionDetails.text));
269
+ const got = res.result.value;
270
+ finalUrl = got.url;
271
+ items.push(...got.items);
272
+ // text over an image or gradient: photograph the box, take the worst tenth of what is behind it. All text on the
273
+ // page is hidden for the shots: the box around wrapped text also holds the text beside it
274
+ const shots = got.items.filter((x) => x.image);
275
+ if (shots.length) await cdp.send('Runtime.evaluate', { expression: `(() => { const st = document.createElement('style'); st.id = 'ehc-notext'; st.textContent = '*, *::before, *::after { color: transparent !important; -webkit-text-fill-color: transparent !important; text-shadow: none !important; text-decoration-color: transparent !important; caret-color: transparent !important; transition: none !important; }'; document.head.appendChild(st); return new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok))); })()`, awaitPromise: true }, s);
276
+ for (const it of shots) {
277
+ if (!shotsOk || over.length >= 60) break;
278
+ if (Date.now() > until) { opts.log?.('Image sampling stopped after 60 s; the rest keep their composited background.'); shotsOk = false; break; }
279
+ over.push(it);
280
+ const [x, y, w, hh] = it.rect;
281
+ try {
282
+ const shot = await cdp.send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, x), y: Math.max(0, y), width: Math.max(1, Math.min(w, 600)), height: Math.max(1, Math.min(hh, 300)), scale: 1 } }, s, 8000);
283
+ const img = decodePng(Buffer.from(shot.data, 'base64'));
284
+ const fg = it.fg.slice(0, 3), samples = [];
285
+ const stepPx = Math.max(1, Math.floor(Math.sqrt((img.width * img.height) / 3000)));
286
+ for (let yy = 0; yy < img.height; yy += stepPx) for (let xx = 0; xx < img.width; xx += stepPx) { const c = img.px(xx, yy); samples.push({ c, r: ratio(fg, c) }); }
287
+ samples.sort((a, b) => a.r - b.r);
288
+ const worst = samples[Math.floor(samples.length * 0.1)];
289
+ if (worst) { it.bg = [...worst.c, 1]; it.sampled = true; }
290
+ } catch (e) { if (/did not answer/.test(e.message)) { opts.log?.('Screenshots are not answering on this page; image sampling stopped.'); shotsOk = false; } }
291
+ }
292
+ if (shots.length) await cdp.send('Runtime.evaluate', { expression: `document.getElementById('ehc-notext')?.remove()` }, s);
293
+ if ((step + 1) * 800 >= got.height) break;
294
+ }
295
+ } catch (e) {
296
+ // a page that stops answering, or whose renderer dies: keep what was read, say so, and stop
297
+ if (!/STALLED|did not answer|detached|closed|Target closed|No session/i.test(e.message) && !gone) throw e;
298
+ notes.push(`${mode} mode: the page stopped answering after ${items.length ? `${items.length} texts were read` : 'loading'}${gone ? ' (its renderer quit)' : ''}; ${items.length ? 'the result is partial' : 'nothing could be read'}.`);
299
+ gone = true;
300
+ }
301
+ opts.log?.(`${mode}: ${items.length} text elements, ${over.length} over images or gradients`);
302
+ elements += items.length;
303
+ images += over.length;
304
+ // one rule per distinct text colour, background and size class, named after the first element that has it
305
+ const seen = new Map();
306
+ for (const it of items) {
307
+ const large = it.size >= 24 || (it.weight >= 700 && it.size >= 18.66);
308
+ const k = `${hex(it.fg.slice(0, 3))}${it.fg[3]}|${hex(it.bg.slice(0, 3))}|${large}`;
309
+ const prev = seen.get(k);
310
+ if (prev) { prev.count++; continue; }
311
+ seen.set(k, { it, large, count: 1 });
312
+ }
313
+ const block = [...seen.values()].map(({ it, count }) => {
314
+ const fg = it.fg[3] < 1 ? `rgb(${it.fg.slice(0, 3).map(Math.round).join(' ')} / ${Math.round(it.fg[3] * 100) / 100})` : hex(it.fg.slice(0, 3));
315
+ const sel = `${it.sel.replace(/[{}()]/g, '')}${count > 1 ? ` (and ${count - 1} more)` : ''}${it.sampled ? ' (over an image: the worst part behind the text)' : it.image ? ' (over an image)' : ''}`;
316
+ return `${sel} { color: ${fg}; background-color: ${hex(it.bg.slice(0, 3))}; font-size: ${it.size}px; font-weight: ${it.weight}; }`;
317
+ }).join('\n');
318
+ if (block) rules.push(mode === 'dark' ? `@media (prefers-color-scheme: dark) {\n${block}\n}` : block);
319
+ if (gone) break;
320
+ }
321
+ if (!elements && !notes.length) throw new Error('No visible text was found on the page. Sites that block automated browsers (nytimes.com does) or need a sign-in show nothing to read; check their stylesheets with `everyhexcode check`, or scan a page you run yourself.');
322
+ if (!elements) throw new Error(`The page stopped answering before any text could be read: its scripts keep the browser busy.${/headless[-_]shell/i.test(bin) ? '' : ' chrome-headless-shell often copes where full Chrome does not: npx @puppeteer/browsers install chrome-headless-shell@stable, then run the scan again.'} Or check the stylesheets with \`everyhexcode check\`.`);
323
+ return { url: finalUrl, css: rules.join('\n\n'), elements, images, notes };
324
+ } finally {
325
+ try { await Promise.race([cdp?.send('Browser.close'), new Promise((r) => setTimeout(r, 1500))]); } catch { /* closing */ }
326
+ try { cdp?.close(); } catch { /* closed */ }
327
+ await ch.stop();
328
+ }
329
+ }