image-deep-research 0.0.0-stage → 1.2.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/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +38 -0
- package/.codex-plugin/plugin.json +53 -0
- package/LICENSE +21 -0
- package/PRIVACY.md +42 -0
- package/README.md +243 -2
- package/commands/find-reference-images.md +30 -0
- package/commands/website-contact-sheet.md +33 -0
- package/package.json +50 -4
- package/skills/image-deep-research/SKILL.md +137 -0
- package/skills/image-deep-research/scripts/args.mjs +27 -0
- package/skills/image-deep-research/scripts/browser.mjs +300 -0
- package/skills/image-deep-research/scripts/images.mjs +393 -0
- package/skills/image-deep-research/scripts/network.mjs +104 -0
- package/skills/image-deep-research/scripts/sheet.mjs +168 -0
- package/skills/image-deep-research/scripts/study.mjs +272 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* image-deep-research/study - render real websites and read them as pictures.
|
|
3
|
+
|
|
4
|
+
node study.mjs <url> <url> ... render these
|
|
5
|
+
node study.mjs --list editorial a curated set (editorial, object, cinema, product)
|
|
6
|
+
--out <dir> where the renders go (default: a temp folder, printed)
|
|
7
|
+
--scroll 0,900 scroll positions to capture, in px (default 0,900)
|
|
8
|
+
--width 1440 viewport width (default 1440; 390 for a phone read)
|
|
9
|
+
--wait 3500 ms to let the page settle after load (default 3500)
|
|
10
|
+
--json print the full report as JSON instead of text
|
|
11
|
+
--compact one line per site, and compact sheets of 16 tiles
|
|
12
|
+
(1288x812, 1,334 image tokens each), each tile
|
|
13
|
+
badged with its site and scroll (s03, s03 y900)
|
|
14
|
+
|
|
15
|
+
Each site is loaded in a headless browser, screenshotted at each scroll
|
|
16
|
+
position, and measured: the ground colours by painted area, the text
|
|
17
|
+
colours by amount of text, and the heading and body typefaces as computed.
|
|
18
|
+
Then every screenshot is tiled into contact sheets, eight to a sheet.
|
|
19
|
+
|
|
20
|
+
A page that renders almost no text is reported as a wall (a bot challenge,
|
|
21
|
+
a login, or a WebGL preloader) and kept out of the sheets, because it is
|
|
22
|
+
not a reference. Nothing is guessed about what is behind it. */
|
|
23
|
+
|
|
24
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join, resolve } from 'node:path';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { parseArgs, int } from './args.mjs';
|
|
29
|
+
import { withBrowser, load, screenshot, sleep } from './browser.mjs';
|
|
30
|
+
import { renderSheets } from './sheet.mjs';
|
|
31
|
+
|
|
32
|
+
/* The four registers Ultimate Frontend Skills ships as study lists. Sites that
|
|
33
|
+
answered a headless browser with a challenge or Access Denied when every list
|
|
34
|
+
was rendered on 2026-09-25 (aesop.com, cartier.com, rolex.com, nytimes.com)
|
|
35
|
+
are left out: a list entry that is always a wall is not a reference. */
|
|
36
|
+
export const LISTS = {
|
|
37
|
+
editorial: ['https://www.are.na', 'https://readymag.com', 'https://www.kinfolk.com', 'https://www.hodinkee.com',
|
|
38
|
+
'https://www.anthropic.com/research', 'https://www.itsnicethat.com', 'https://www.monocle.com', 'https://www.apartamentomagazine.com'],
|
|
39
|
+
object: ['https://www.apple.com/airpods-pro/', 'https://www.apple.com/watch/', 'https://www.apple.com/iphone/', 'https://www.teenage.engineering',
|
|
40
|
+
'https://www.leica-camera.com', 'https://www.bang-olufsen.com', 'https://www.dyson.com', 'https://nothing.tech'],
|
|
41
|
+
cinema: ['https://lusion.co', 'https://rauno.me', 'https://www.awwwards.com/websites/parallax/', 'https://tympanus.net/codrops/',
|
|
42
|
+
'https://www.nationalgeographic.com', 'https://www.patagonia.com', 'https://www.rapha.cc', 'https://www.arcteryx.com'],
|
|
43
|
+
product: ['https://linear.app', 'https://stripe.com', 'https://vercel.com', 'https://resend.com',
|
|
44
|
+
'https://www.framer.com', 'https://arc.net', 'https://www.raycast.com', 'https://cursor.com'],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/* Is this render a reference, or something standing in front of one?
|
|
48
|
+
A count of text elements alone was wrong both ways when measured on the
|
|
49
|
+
curated lists (2026-09-25): rauno.me, a real and deliberately sparse page,
|
|
50
|
+
has 8, while Aesop's "Just a moment..." challenge has 8 as well. So the
|
|
51
|
+
signatures of a challenge or a block decide first, and only a page with
|
|
52
|
+
next to nothing on it (a preloader, a blank WebGL shell) is judged empty.
|
|
53
|
+
Returns { status: 'ok' | 'wall', reason }. */
|
|
54
|
+
const BLOCK_TITLE = /just a moment|access denied|attention required|are you a robot|captcha|verify (you are|that you are) human|security check|request blocked|403 forbidden|pardon our interruption/i;
|
|
55
|
+
const BLOCK_TEXT = /verify you are human|you have been blocked|suspect that you('|’)?re a robot|enable javascript and cookies to continue|checking (if the site connection is secure|your browser)|access to this page has been denied|press (&|and) hold/i;
|
|
56
|
+
const BLOCK_FRAME = /challenges\.cloudflare\.com|captcha-delivery\.com|hcaptcha\.com|google\.com\/recaptcha|perimeterx|px-captcha|geo\.captcha/i;
|
|
57
|
+
export const EMPTY_TEXT_ELEMENTS = 3;
|
|
58
|
+
|
|
59
|
+
export function classify(p) {
|
|
60
|
+
if (BLOCK_TITLE.test(p.title || '')) return { status: 'wall', reason: `blocked: the page title is "${p.title}"` };
|
|
61
|
+
if (BLOCK_TEXT.test(p.text || '')) return { status: 'wall', reason: 'blocked: the page shows a bot or access challenge' };
|
|
62
|
+
const frame = (p.frames || []).find((f) => BLOCK_FRAME.test(f));
|
|
63
|
+
if (frame && p.textElements < 20) return { status: 'wall', reason: 'blocked: a challenge frame from ' + new URL(frame, 'https://x').host };
|
|
64
|
+
if (p.textElements < EMPTY_TEXT_ELEMENTS && !p.images && !p.canvases)
|
|
65
|
+
return { status: 'wall', reason: `almost nothing rendered (${p.textElements} text elements, no images): a preloader or a script-only page that had not started` };
|
|
66
|
+
return { status: 'ok' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Runs in the page. Colours go through a 1x1 canvas so oklch(), color-mix()
|
|
70
|
+
and every other CSS Color 4 form come back as plain hex. */
|
|
71
|
+
export const PROBE = `(() => {
|
|
72
|
+
const cv = document.createElement('canvas'); cv.width = cv.height = 1;
|
|
73
|
+
const cx = cv.getContext('2d', { willReadFrequently: true });
|
|
74
|
+
const memo = new Map();
|
|
75
|
+
const hex = (str) => {
|
|
76
|
+
if (!str || str === 'transparent') return null;
|
|
77
|
+
if (memo.has(str)) return memo.get(str);
|
|
78
|
+
let out = null;
|
|
79
|
+
try {
|
|
80
|
+
cx.clearRect(0, 0, 1, 1); cx.fillStyle = '#000'; cx.fillStyle = str; cx.fillRect(0, 0, 1, 1);
|
|
81
|
+
const d = cx.getImageData(0, 0, 1, 1).data;
|
|
82
|
+
out = d[3] < 200 ? null : '#' + [d[0], d[1], d[2]].map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
83
|
+
} catch {}
|
|
84
|
+
memo.set(str, out);
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
const W = innerWidth, H = Math.max(innerHeight, document.documentElement.scrollHeight);
|
|
88
|
+
const grounds = new Map(), inks = new Map();
|
|
89
|
+
const add = (m, k, v) => { if (k) m.set(k, (m.get(k) || 0) + v); };
|
|
90
|
+
const visible = (el) => {
|
|
91
|
+
for (let p = el; p; p = p.parentElement) {
|
|
92
|
+
const cs = getComputedStyle(p);
|
|
93
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || +cs.opacity === 0) return false;
|
|
94
|
+
}
|
|
95
|
+
return el.getClientRects().length > 0;
|
|
96
|
+
};
|
|
97
|
+
let textElements = 0;
|
|
98
|
+
const visibleText = [];
|
|
99
|
+
const all = document.querySelectorAll('body, body *');
|
|
100
|
+
for (let i = 0; i < all.length && i < 6000; i++) {
|
|
101
|
+
const el = all[i];
|
|
102
|
+
const cs = getComputedStyle(el);
|
|
103
|
+
if (!visible(el)) continue;
|
|
104
|
+
const r = el.getBoundingClientRect();
|
|
105
|
+
const w = Math.max(0, Math.min(r.right, W) - Math.max(r.left, 0));
|
|
106
|
+
const h = Math.max(0, Math.min(r.bottom + scrollY, H) - Math.max(r.top + scrollY, 0));
|
|
107
|
+
if (!w || !h) continue;
|
|
108
|
+
add(grounds, hex(cs.backgroundColor), w * h);
|
|
109
|
+
let own = 0;
|
|
110
|
+
for (const n of el.childNodes) if (n.nodeType === 3) own += n.textContent.trim().length;
|
|
111
|
+
if (own) { textElements++; add(inks, hex(cs.color), own);
|
|
112
|
+
visibleText.push([...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join(' ')); }
|
|
113
|
+
}
|
|
114
|
+
const htmlBg = hex(getComputedStyle(document.documentElement).backgroundColor);
|
|
115
|
+
const bodyBg = hex(getComputedStyle(document.body).backgroundColor);
|
|
116
|
+
if (!grounds.size) add(grounds, bodyBg || htmlBg || '#ffffff', W * H);
|
|
117
|
+
const top = (m, n) => { const t = [...m.values()].reduce((a, b) => a + b, 0) || 1;
|
|
118
|
+
return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, n).map(([c, v]) => ({ color: c, share: Math.round(v / t * 1000) / 10 })); };
|
|
119
|
+
const face = (sel) => { const el = [...document.querySelectorAll(sel)].find(visible); if (!el) return null;
|
|
120
|
+
const cs = getComputedStyle(el);
|
|
121
|
+
return { selector: sel, family: cs.fontFamily, size: cs.fontSize, weight: cs.fontWeight, lineHeight: cs.lineHeight, letterSpacing: cs.letterSpacing,
|
|
122
|
+
text: (el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 80) }; };
|
|
123
|
+
return JSON.stringify({
|
|
124
|
+
title: document.title, textElements, height: document.documentElement.scrollHeight,
|
|
125
|
+
viewport: { width: innerWidth, height: innerHeight, scroll: Math.round(scrollY), devicePixelRatio },
|
|
126
|
+
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
127
|
+
measurementMethod: 'computed element rectangles and own text; overlapping backgrounds can be counted more than once; not pixel sampling',
|
|
128
|
+
grounds: top(grounds, 6), inks: top(inks, 4),
|
|
129
|
+
type: { heading: face('h1') || face('h2'), body: face('main p') || face('p') },
|
|
130
|
+
canvases: [...document.querySelectorAll('canvas')].filter(visible).length,
|
|
131
|
+
images: [...document.images].filter((im) => { const r = im.getBoundingClientRect(); return visible(im) && im.naturalWidth > 0 && r.width >= 120 && r.height >= 80; }).length,
|
|
132
|
+
frames: [...document.querySelectorAll('iframe')].filter(visible).map((f) => f.src).filter(Boolean).slice(0, 10),
|
|
133
|
+
text: visibleText.join(' ').replace(/\\s+/g, ' ').slice(0, 1500),
|
|
134
|
+
});
|
|
135
|
+
})()`;
|
|
136
|
+
|
|
137
|
+
export const slugFor = (i) => 's' + String(i + 1).padStart(2, '0');
|
|
138
|
+
|
|
139
|
+
export async function study(urls, { out, scrolls = [0, 900], width = 1440, wait = 3500, compact = false, reducedMotion = false, log = () => {} } = {}) {
|
|
140
|
+
mkdirSync(out, { recursive: true });
|
|
141
|
+
return withBrowser(async (session) => {
|
|
142
|
+
await session.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: reducedMotion ? 'reduce' : 'no-preference' }] });
|
|
143
|
+
const sites = [];
|
|
144
|
+
for (const [i, url] of urls.entries()) {
|
|
145
|
+
const dir = join(out, slugFor(i));
|
|
146
|
+
const site = { url, dir, shots: [], status: 'ok' };
|
|
147
|
+
try {
|
|
148
|
+
mkdirSync(dir, { recursive: true });
|
|
149
|
+
const { loaded } = await load(session, url, { width, height: 900, wait });
|
|
150
|
+
if (!loaded) site.note = 'the load event never fired; captured what had rendered after 30 s';
|
|
151
|
+
site.probe = JSON.parse(await session.evaluate(PROBE));
|
|
152
|
+
const verdict = classify(site.probe);
|
|
153
|
+
if (verdict.status === 'wall') {
|
|
154
|
+
site.status = 'wall';
|
|
155
|
+
site.note = `${verdict.reason}; left out of the sheet, see ${join(dir, 'top.png')}`;
|
|
156
|
+
}
|
|
157
|
+
for (const y of scrolls) {
|
|
158
|
+
await session.evaluate(`window.scrollTo({ top: ${y}, behavior: 'instant' }); window.dispatchEvent(new Event('scroll')); 1`);
|
|
159
|
+
await sleep(y ? 800 : 100);
|
|
160
|
+
const actual = await session.evaluate('Math.round(scrollY)');
|
|
161
|
+
const file = join(dir, y ? `y${y}.png` : 'top.png');
|
|
162
|
+
writeFileSync(file, await screenshot(session));
|
|
163
|
+
const probe = JSON.parse(await session.evaluate(PROBE));
|
|
164
|
+
site.shots.push({ file, scroll: y, reached: actual, capturedAt: new Date().toISOString(), probe });
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
site.status = 'failed';
|
|
168
|
+
site.note = (e && e.message) || String(e);
|
|
169
|
+
}
|
|
170
|
+
log(site, i);
|
|
171
|
+
sites.push(site);
|
|
172
|
+
}
|
|
173
|
+
const tiles = sites.filter((s) => s.status === 'ok')
|
|
174
|
+
.flatMap((s) => s.shots.map((sh) => ({ src: sh.file, label: `${slugFor(sites.indexOf(s))} ${sh.scroll ? 'y' + sh.scroll : 'top'} ${s.url.replace(/^https?:\/\/(www\.)?/, '')}`,
|
|
175
|
+
...(compact && { badge: slugFor(sites.indexOf(s)) + (sh.scroll ? ' y' + sh.scroll : '') }) })));
|
|
176
|
+
const sheets = tiles.length ? await renderSheets(session, tiles, { out, prefix: 'sheet', fit: 'cover', compact }) : [];
|
|
177
|
+
const report = { out, width, scrolls, reducedMotion, evidence: 'still captures only; motion quality and hidden content remain unverified', sites, sheets };
|
|
178
|
+
writeFileSync(join(out, 'report.json'), JSON.stringify(report, null, 2));
|
|
179
|
+
return report;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function formatSite(s) {
|
|
184
|
+
const lines = [` ${s.status.padEnd(6)} ${s.url}${s.note ? ' (' + s.note + ')' : ''}`];
|
|
185
|
+
const p = s.probe;
|
|
186
|
+
if (p && s.status === 'ok') {
|
|
187
|
+
lines.push(` grounds ${p.grounds.map((g) => `${g.color} ${g.share}%`).join(' ')}`);
|
|
188
|
+
lines.push(` ink ${p.inks.map((g) => `${g.color} ${g.share}%`).join(' ')}`);
|
|
189
|
+
const f = (t) => t ? `${t.family.split(',')[0].replace(/["']/g, '')} ${t.size}/${t.lineHeight} w${t.weight}` : 'none';
|
|
190
|
+
lines.push(` type heading ${f(p.type.heading)} body ${f(p.type.body)}`);
|
|
191
|
+
if (p.canvases) lines.push(` canvas ${p.canvases} (WebGL or 2D; a screenshot may show a preloader frame)`);
|
|
192
|
+
}
|
|
193
|
+
return lines.join('\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const hostOf = (url) => { try { return new URL(url).host.replace(/^www\./, ''); } catch { return String(url); } };
|
|
197
|
+
const cut = (t, max) => (t.length > max ? t.slice(0, max - 1) + '~' : t);
|
|
198
|
+
const px = (v) => String(v || '?').replace(/px$/, '').replace(/^(\d+\.\d)\d+$/, '$1');
|
|
199
|
+
|
|
200
|
+
/* One line per site for --compact, at most 160 characters for a rendered
|
|
201
|
+
one: the leading ground and ink with their shares, and the heading and body
|
|
202
|
+
faces as family size/line-height and weight. A wall or a failure keeps its
|
|
203
|
+
reason, and nothing else. */
|
|
204
|
+
export function formatSiteCompact(s, i) {
|
|
205
|
+
const slug = slugFor(i);
|
|
206
|
+
const host = cut(hostOf(s.url), 28);
|
|
207
|
+
if (s.status === 'wall') {
|
|
208
|
+
const reason = (s.probe && classify(s.probe).reason) || String(s.note || '').split('; left out of the sheet')[0];
|
|
209
|
+
return `${slug} wall ${host} (${reason.replace(/\s+/g, ' ')})`;
|
|
210
|
+
}
|
|
211
|
+
if (s.status !== 'ok' || !s.probe) return `${slug} ${s.status} ${host} (${String(s.note || 'no measurements').replace(/\s+/g, ' ')})`;
|
|
212
|
+
const p = s.probe;
|
|
213
|
+
const c = (g) => (g ? `${g.color} ${g.share}%` : 'none');
|
|
214
|
+
const f = (t) => (t ? `${cut(t.family.split(',')[0].replace(/["']/g, '').trim(), 16)} ${px(t.size)}/${px(t.lineHeight)} w${t.weight}` : 'none');
|
|
215
|
+
let line = `${slug} ok ${host} | ground ${c(p.grounds[0])} | ink ${c(p.inks[0])} | heading ${f(p.type.heading)} | body ${f(p.type.body)}`;
|
|
216
|
+
if (p.canvases && line.length + 10 <= 160) line += ` | canvas ${p.canvases}`;
|
|
217
|
+
return cut(line, 160);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
let a;
|
|
222
|
+
try { a = parseArgs(process.argv.slice(2), { switches: ['json', 'compact', 'reduced-motion'] }); } catch (e) { console.error('study: ' + e.message); process.exit(2); }
|
|
223
|
+
const { positional, flags } = a;
|
|
224
|
+
let urls = positional.filter((u) => /^https?:\/\//i.test(u));
|
|
225
|
+
const bad = positional.filter((u) => !/^https?:\/\//i.test(u));
|
|
226
|
+
if (bad.length) { console.error('study: not a URL: ' + bad.join(' ') + ' (URLs start with http:// or https://)'); process.exit(2); }
|
|
227
|
+
if (flags.list) {
|
|
228
|
+
const l = LISTS[flags.list];
|
|
229
|
+
if (!l) { console.error(`study: unknown list "${flags.list}". Lists: ${Object.keys(LISTS).join(', ')}`); process.exit(2); }
|
|
230
|
+
urls = urls.concat(l);
|
|
231
|
+
}
|
|
232
|
+
if (!urls.length) {
|
|
233
|
+
console.error('usage: node study.mjs <url> ... | --list ' + Object.keys(LISTS).join('|') + ' [--out dir] [--scroll 0,900] [--width 1440] [--wait 3500] [--compact] [--json]');
|
|
234
|
+
process.exit(2);
|
|
235
|
+
}
|
|
236
|
+
let scrolls, width, wait;
|
|
237
|
+
try {
|
|
238
|
+
scrolls = String(flags.scroll ?? '0,900').split(',').map((s) => int(s.trim(), 0, { min: 0, max: 100000 }));
|
|
239
|
+
width = int(flags.width, 1440, { min: 320, max: 3840 });
|
|
240
|
+
wait = int(flags.wait, 3500, { min: 0, max: 30000 });
|
|
241
|
+
} catch (e) { console.error('study: ' + e.message); process.exit(2); }
|
|
242
|
+
const out = resolve(flags.out || join(tmpdir(), 'image-deep-research', 'study-' + new Date().toISOString().replace(/[:.]/g, '-')));
|
|
243
|
+
const compact = !!flags.compact;
|
|
244
|
+
const log = flags.json ? () => {} : compact ? (s, i) => console.log(formatSiteCompact(s, i)) : (s) => console.log(formatSite(s));
|
|
245
|
+
let report;
|
|
246
|
+
try {
|
|
247
|
+
report = await study(urls, { out, scrolls, width, wait, compact, reducedMotion: !!flags['reduced-motion'], log });
|
|
248
|
+
} catch (e) {
|
|
249
|
+
console.error('study: ' + ((e && e.message) || e));
|
|
250
|
+
process.exit(e && e.code === 'no-browser' ? 3 : 1);
|
|
251
|
+
}
|
|
252
|
+
const ok = report.sites.filter((s) => s.status === 'ok').length;
|
|
253
|
+
if (!ok) process.exitCode = 1;
|
|
254
|
+
if (flags.json) { console.log(JSON.stringify(report, null, 2)); return; }
|
|
255
|
+
if (compact) {
|
|
256
|
+
console.log(`study ${urls.length} site(s), ${ok} rendered -> ${out}`);
|
|
257
|
+
for (const s of report.sheets) console.log(`sheet ${s.file} ${s.w}x${s.h} ${s.tokens} tok`);
|
|
258
|
+
if (!report.sheets.length) console.log('sheet none: nothing rendered cleanly (see the lines above)');
|
|
259
|
+
console.log('report ' + join(out, 'report.json'));
|
|
260
|
+
if (!ok) process.exitCode = 1;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
console.log(`\nstudy ${urls.length} site(s), ${ok} rendered -> ${out}`);
|
|
264
|
+
if (report.sheets.length) {
|
|
265
|
+
console.log(' Open these contact sheets first, left to right, top to bottom:');
|
|
266
|
+
for (const s of report.sheets) console.log(' ' + s.file);
|
|
267
|
+
} else console.log(' nothing rendered cleanly, so there is no sheet: see the notes above');
|
|
268
|
+
console.log(' Full measurements: ' + join(out, 'report.json'));
|
|
269
|
+
if (!ok) process.exitCode = 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|