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.
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env node
2
+ /* image-deep-research/images - search open image collections, check every
3
+ result is really an image, and keep the licence beside it.
4
+
5
+ node images.mjs "<query>" [--sources openverse,commons,aic,met] [--n 6]
6
+ --commercial keep only licences that allow commercial use and changes
7
+ (drops NC and ND; CC0, public domain, BY and BY-SA stay)
8
+ --out <dir> where results.json, the moodboard and downloads go
9
+ --sheet tile the verified images into a moodboard contact sheet
10
+ (needs Chrome, Edge or Chromium)
11
+ --download save each verified image into <out>/images
12
+ --json print the results as JSON instead of a table
13
+ --compact one line per verified result, no URLs, and one compact
14
+ sheet of up to 16 numbered tiles (1288x812, 1,334 image
15
+ tokens); implies --sheet, and --n defaults to 4
16
+
17
+ node images.mjs --pick 3,7 --results <out>/results.json
18
+ the full record (licence URL, image URL, page) of the
19
+ results numbered 3 and 7 in that run; no network
20
+
21
+ No keys. Openverse (about 800M CC and public-domain images), Wikimedia
22
+ Commons, the Art Institute of Chicago (public-domain works only) and the
23
+ Metropolitan Museum of Art (Open Access works only). Every URL is fetched
24
+ before it is reported: a result whose URL does not answer with an image is
25
+ listed as unverified and never put on the moodboard. */
26
+
27
+ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
28
+ import { join, resolve } from 'node:path';
29
+ import { tmpdir } from 'node:os';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { parseArgs, int } from './args.mjs';
32
+ import { checkUrl, publicFetch, readLimited } from './network.mjs';
33
+
34
+ /* The Art Institute of Chicago's image server answers 403 to any user agent
35
+ that carries a URL (measured 2026-09-25: "image-deep-research/1.0" gets the
36
+ image, the same string plus "(https://github.com/...)" gets 403), while
37
+ Wikimedia asks for contact details in it. So the contact goes to Wikimedia
38
+ only, and to the AIC in the header its API documents for that purpose. */
39
+ export const UA = 'image-deep-research/1.0';
40
+ export const UA_CONTACT = 'image-deep-research/1.0 (https://github.com/ridelink0/image-deep-research)';
41
+ export const SOURCES = ['openverse', 'commons', 'aic', 'met'];
42
+
43
+ // Commons wraps QuickStatements hints in hidden spans (title QS:P1476,...); they are not the title.
44
+ const strip = (html) => String(html || '').replace(/<(span|div)[^>]*display:\s*none[^>]*>[\s\S]*?<\/\1>/gi, '').replace(/<[^>]*>/g, '').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#0?39;/g, "'").replace(/\s+/g, ' ').trim();
45
+
46
+ /* True when a licence allows commercial use and modification. */
47
+ export function commercialOk(licence) {
48
+ const l = String(licence || '').toLowerCase();
49
+ if (!l) return false;
50
+ if (/\b(nc|nd)\b|noncommercial|non-commercial|noderiv|no-deriv/.test(l)) return false;
51
+ return /^(?:cc0(?:\s+\((?:public domain|met open access)\))?|public domain|pdm|pd|(?:cc[ -])?by(?:-sa)?(?:\s+\d(?:\.\d)?)?)$/.test(l);
52
+ }
53
+
54
+ export function parseOpenverse(json) {
55
+ return (json.results || []).map((r) => ({
56
+ source: 'openverse',
57
+ title: r.title || '',
58
+ creator: r.creator || '',
59
+ licence: r.license ? (['cc0', 'pdm'].includes(r.license) ? r.license.toUpperCase() : `CC ${r.license.toUpperCase()} ${r.license_version || ''}`.trim()) : '',
60
+ licenceUrl: r.license_url || '',
61
+ url: r.url,
62
+ page: r.foreign_landing_url || '',
63
+ width: r.width || null,
64
+ height: r.height || null,
65
+ via: r.provider || r.source || '',
66
+ })).filter((r) => r.url);
67
+ }
68
+
69
+ export function parseCommons(json) {
70
+ const pages = Object.values((json.query && json.query.pages) || {}).sort((a, b) => (a.index || 0) - (b.index || 0));
71
+ return pages.map((p) => {
72
+ const ii = (p.imageinfo || [])[0] || {};
73
+ const md = ii.extmetadata || {};
74
+ return {
75
+ source: 'commons',
76
+ title: strip(md.ObjectName && md.ObjectName.value) || String(p.title || '').replace(/^File:/, ''),
77
+ creator: strip(md.Artist && md.Artist.value),
78
+ licence: strip(md.LicenseShortName && md.LicenseShortName.value),
79
+ licenceUrl: (md.LicenseUrl && md.LicenseUrl.value) || '',
80
+ url: ii.thumburl || ii.url,
81
+ original: ii.url || '',
82
+ page: ii.descriptionurl || '',
83
+ width: ii.width || null,
84
+ height: ii.height || null,
85
+ mime: ii.mime || '',
86
+ };
87
+ }).filter((r) => r.url && /^image\/(jpeg|png|webp|gif)/.test(r.mime || 'image/jpeg'));
88
+ }
89
+
90
+ export function parseAic(json) {
91
+ const base = (json.config && json.config.iiif_url) || 'https://www.artic.edu/iiif/2';
92
+ return (json.data || []).filter((d) => d.image_id && d.is_public_domain === true).map((d) => ({
93
+ source: 'aic',
94
+ title: d.title || '',
95
+ creator: String(d.artist_display || '').split('\n')[0],
96
+ licence: 'CC0 (public domain)',
97
+ licenceUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
98
+ url: `${base}/${d.image_id}/full/843,/0/default.jpg`,
99
+ page: `https://www.artic.edu/artworks/${d.id}`,
100
+ width: (d.thumbnail && d.thumbnail.width) || null,
101
+ height: (d.thumbnail && d.thumbnail.height) || null,
102
+ }));
103
+ }
104
+
105
+ export function parseMetObject(o) {
106
+ if (!o || o.isPublicDomain !== true || !o.primaryImageSmall) return null;
107
+ return {
108
+ source: 'met',
109
+ title: o.title || '',
110
+ creator: o.artistDisplayName || '',
111
+ licence: 'CC0 (Met Open Access)',
112
+ licenceUrl: 'https://creativecommons.org/publicdomain/zero/1.0/',
113
+ url: o.primaryImageSmall,
114
+ original: o.primaryImage || '',
115
+ page: o.objectURL || `https://www.metmuseum.org/art/collection/search/${o.objectID}`,
116
+ width: null,
117
+ height: null,
118
+ };
119
+ }
120
+
121
+ async function getJson(url, headers = {}, ms = 20000) {
122
+ const r = await publicFetch(url, { headers: { 'user-agent': UA, accept: 'application/json', ...headers }, signal: AbortSignal.timeout(ms) });
123
+ if (!r.ok) { await r.body?.cancel(); throw new Error(`${new URL(url).host} answered ${r.status}`); }
124
+ return JSON.parse((await readLimited(r, 2 * 1024 * 1024)).toString('utf8'));
125
+ }
126
+
127
+ export const SEARCH = {
128
+ async openverse(q, n) {
129
+ return parseOpenverse(await getJson(`https://api.openverse.org/v1/images/?q=${encodeURIComponent(q)}&page_size=${Math.min(n * 2, 40)}&mature=false`));
130
+ },
131
+ async commons(q, n) {
132
+ const u = 'https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrnamespace=6'
133
+ + `&gsrsearch=${encodeURIComponent(q + ' filetype:bitmap')}&gsrlimit=${Math.min(n * 2, 40)}`
134
+ + '&prop=imageinfo&iiprop=url|size|mime|extmetadata&iiurlwidth=1280'
135
+ + '&iiextmetadatafilter=LicenseShortName|LicenseUrl|Artist|ObjectName';
136
+ return parseCommons(await getJson(u, { 'user-agent': UA_CONTACT }));
137
+ },
138
+ async aic(q, n) {
139
+ const u = `https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(q)}&limit=${Math.min(n * 3, 60)}`
140
+ + '&fields=id,title,image_id,artist_display,is_public_domain,thumbnail';
141
+ return parseAic(await getJson(u, { 'AIC-User-Agent': UA_CONTACT }));
142
+ },
143
+ async met(q, n) {
144
+ const s = await getJson(`https://collectionapi.metmuseum.org/public/collection/v1/search?hasImages=true&q=${encodeURIComponent(q)}`);
145
+ const out = [];
146
+ for (const id of (s.objectIDs || []).slice(0, n * 4)) {
147
+ if (out.length >= n * 2) break;
148
+ try {
149
+ const r = parseMetObject(await getJson(`https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`));
150
+ if (r) out.push(r);
151
+ } catch {}
152
+ }
153
+ return out;
154
+ },
155
+ };
156
+
157
+ /* Fetches the URL the way a page would load it. HEAD first; a server that
158
+ refuses HEAD (405, 403, 416 and friends) gets a one-byte ranged GET. The
159
+ result counts only if the answer is 2xx and says it is an image. */
160
+ export async function verifyImage(url, { fetchImpl = publicFetch, ms = 15000 } = {}) {
161
+ try { checkUrl(url); } catch (e) { return { ok: false, status: 0, type: '', error: e.message }; }
162
+ const tryOnce = async (method) => {
163
+ const headers = { 'user-agent': UA };
164
+ if (method === 'GET') headers.range = 'bytes=0-0';
165
+ const r = await fetchImpl(url, { method, headers, redirect: 'follow', signal: AbortSignal.timeout(ms) });
166
+ try { if (r.body && r.body.cancel) await r.body.cancel(); } catch {}
167
+ return { status: r.status, type: (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(),
168
+ url: r.url || url, checkedAt: new Date().toISOString(), evidence: 'response headers only; decode checked when tiled' };
169
+ };
170
+ let res;
171
+ try {
172
+ res = await tryOnce('HEAD');
173
+ if (!(res.status >= 200 && res.status < 300 && res.type.startsWith('image/'))) res = await tryOnce('GET');
174
+ } catch (e) {
175
+ try { res = await tryOnce('GET'); } catch (e2) { return { ok: false, status: 0, type: '', error: (e2 && e2.message) || String(e2) }; }
176
+ }
177
+ return { ok: res.status >= 200 && res.status < 300 && /^image\/(jpeg|png|webp|gif)$/.test(res.type), ...res };
178
+ }
179
+
180
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
181
+ export function rasterType(bytes) {
182
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return { type: 'image/png', ext: 'png' };
183
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return { type: 'image/jpeg', ext: 'jpg' };
184
+ if (/^GIF8[79]a/.test(bytes.subarray(0, 6).toString('ascii'))) return { type: 'image/gif', ext: 'gif' };
185
+ if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return { type: 'image/webp', ext: 'webp' };
186
+ return null;
187
+ }
188
+
189
+ export async function downloadImage(url, { fetchImpl = publicFetch, maxBytes = MAX_IMAGE_BYTES, ms = 30000 } = {}) {
190
+ checkUrl(url);
191
+ const response = await fetchImpl(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(ms) });
192
+ if (!response.ok) { await response.body?.cancel(); throw new Error('HTTP ' + response.status); }
193
+ const declared = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
194
+ if (!/^image\/(jpeg|png|gif|webp)$/.test(declared)) {
195
+ await response.body?.cancel();
196
+ throw new Error('unsupported raster image content type: ' + declared);
197
+ }
198
+ const bytes = await readLimited(response, maxBytes);
199
+ const actual = rasterType(bytes);
200
+ if (!actual || actual.type !== declared) throw new Error('image bytes do not match the declared raster format');
201
+ return { bytes, ext: actual.ext, url: response.url || url };
202
+ }
203
+
204
+ async function mapLimit(items, limit, fn) {
205
+ const out = new Array(items.length);
206
+ let next = 0;
207
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
208
+ while (next < items.length) { const i = next++; out[i] = await fn(items[i], i); }
209
+ }));
210
+ return out;
211
+ }
212
+
213
+ export async function searchImages(q, { sources = SOURCES, n = 6, commercial = false, verify = verifyImage } = {}) {
214
+ const errors = [];
215
+ const per = await Promise.all(sources.map(async (s) => {
216
+ try {
217
+ let rows = await SEARCH[s](q, n);
218
+ if (commercial) rows = rows.filter((r) => commercialOk(r.licence));
219
+ return rows.slice(0, n);
220
+ } catch (e) { errors.push({ source: s, error: (e && e.message) || String(e) }); return []; }
221
+ }));
222
+ const results = per.flat();
223
+ await mapLimit(results, 6, async (r) => { r.verified = await verify(r.url); });
224
+ numberResults(results);
225
+ return { query: q, sources, commercial, results, errors };
226
+ }
227
+
228
+ /* One number per verified result, 1..k in list order, and none for a failed
229
+ one. The table, the compact lines, the moodboard tiles, the download file
230
+ names and --pick all use it, so "05" names one image everywhere. Before
231
+ 1.1.0 the table counted failures and the sheet did not, so they parted
232
+ from the first failed result on. */
233
+ export function numberResults(results) {
234
+ let k = 0;
235
+ for (const r of results) {
236
+ if (r.verified && r.verified.ok) r.n = ++k;
237
+ else delete r.n;
238
+ }
239
+ return results;
240
+ }
241
+
242
+ const nn = (n) => String(n).padStart(2, '0');
243
+
244
+ export function formatTable(res) {
245
+ const lines = [];
246
+ lines.push(`images "${res.query}" ${res.results.filter((r) => r.verified.ok).length} verified of ${res.results.length}`);
247
+ for (const e of res.errors) lines.push(` source ${e.source} failed: ${e.error}`);
248
+ for (const r of res.results) {
249
+ const mark = r.verified.ok ? 'ok ' : `FAIL ${r.verified.status || ''}`.padEnd(8);
250
+ lines.push(` ${r.verified.ok ? nn(r.n) : '--'} ${mark} [${r.source}] ${r.title.slice(0, 70)}${r.creator ? ' - ' + r.creator.slice(0, 40) : ''}`);
251
+ lines.push(` ${r.licence || 'licence not stated'} ${r.url}`);
252
+ if (r.page) lines.push(` page ${r.page}`);
253
+ if (r.downloadError) lines.push(` download failed: ${r.downloadError}`);
254
+ }
255
+ return lines.join('\n');
256
+ }
257
+
258
+ /* Squeezes a field for a compact line: one line, no URL, at most max chars. */
259
+ const squeeze = (s, max) => {
260
+ const t = String(s || '').replace(/\b(https?|ftp):\/\/\S*/gi, '').replace(/\s+/g, ' ').trim();
261
+ return t.length > max ? t.slice(0, max - 3).trimEnd() + '...' : t;
262
+ };
263
+
264
+ /* "CC0 (Met Open Access)" -> "CC0": the source already says where it is from,
265
+ and --pick prints the licence in full. */
266
+ const shortLicence = (l) => squeeze(String(l || '').replace(/\s*\([^)]*\)\s*/g, ' '), 16) || 'licence?';
267
+
268
+ /* A path the details line can hand to a shell as it is. Git Bash, which
269
+ Claude Code runs on Windows, eats backslashes (C:\Users -> C:Users), and a
270
+ space splits the argument; Node reads C:/Users/... on Windows. */
271
+ const shellPath = (p) => {
272
+ const f = String(p).replace(/\\/g, '/');
273
+ return /^[\w./:@%+,=-]+$/.test(f) ? f : `"${f}"`;
274
+ };
275
+
276
+ /* The compact report: a header, one line per verified result, the sheet
277
+ lines, how many failed, and how to get the rest. Never a URL: those stay in
278
+ results.json and come out through --pick. */
279
+ export function formatCompact(res, { out, resultsPath = join(out, 'results.json') } = {}) {
280
+ const good = res.results.filter((r) => r.verified.ok);
281
+ const lines = [`images "${squeeze(res.query, 80)}" ${good.length}/${res.results.length} verified -> ${out}`];
282
+ for (const e of res.errors || []) lines.push(`source ${e.source} failed: ${squeeze(e.error, 60)}`);
283
+ for (const r of good) {
284
+ const creator = squeeze(r.creator, 24);
285
+ lines.push(`${nn(r.n)} ${r.source} ${shortLicence(r.licence)} | ${squeeze(r.title, 48) || 'untitled'}${creator ? ' | ' + creator : ''}`);
286
+ }
287
+ for (const s of res.sheets || []) {
288
+ const missing = s.tiles.filter((t) => !t.loaded).map((t) => nn(t.n));
289
+ lines.push(`sheet ${s.file} ${s.w}x${s.h} ${s.tokens} tok${missing.length ? ` (${missing.join(',')} did not load)` : ''}`);
290
+ }
291
+ if (res.sheetError) lines.push('sheet none: ' + squeeze(res.sheetError.split('\n')[0], 100));
292
+ const downloadsFailed = good.filter((r) => r.downloadError).length;
293
+ if (downloadsFailed) lines.push(`download failed ${downloadsFailed} (see results.json)`);
294
+ const failed = res.results.length - good.length;
295
+ if (failed) lines.push(`failed ${failed} (see results.json)`);
296
+ if (good.length) lines.push(`details: node images.mjs --pick ${good.slice(0, 2).map((r) => r.n).join(',')} --results ${shellPath(resultsPath)}`);
297
+ return lines.join('\n');
298
+ }
299
+
300
+ /* --pick: the full record of chosen results from a saved run, three lines
301
+ each. Reads results.json only; no network. Throws on a number that is not
302
+ a verified result of that run. */
303
+ export function formatPick(saved, picks) {
304
+ const results = numberResults(saved.results || []).filter((r) => r.n);
305
+ const byN = new Map(results.map((r) => [r.n, r]));
306
+ const lines = [];
307
+ for (const n of picks) {
308
+ const r = byN.get(n);
309
+ if (!r) throw new Error(`no verified result ${n} in this run (${results.length ? '1-' + results.length : 'none verified'})`);
310
+ lines.push(`${nn(n)} ${r.title || 'untitled'} | ${r.creator || 'creator not stated'} | ${r.licence || 'licence not stated'}${r.licenceUrl ? ' ' + r.licenceUrl : ''}`);
311
+ lines.push(` image ${r.url}${r.file ? ' file ' + r.file : ''}`);
312
+ lines.push(` page ${r.page || 'not stated'}`);
313
+ }
314
+ return lines.join('\n');
315
+ }
316
+
317
+ function pick(flags) {
318
+ const fail = (m) => { console.error('images: ' + m); process.exit(2); };
319
+ if (!flags.results) fail('--pick needs --results <path to results.json>');
320
+ let picks;
321
+ try { picks = String(flags.pick).split(',').map((v) => v.trim()).filter(Boolean).map((v) => int(v, 0, { min: 1, max: 999 })); }
322
+ catch (e) { fail('--pick ' + e.message); }
323
+ if (!picks.length) fail('--pick needs numbers, such as --pick 3,7');
324
+ let saved;
325
+ try { saved = JSON.parse(readFileSync(flags.results, 'utf8')); }
326
+ catch (e) { fail(`cannot read ${flags.results}: ${(e && e.code) || (e && e.message) || e}`); }
327
+ try { console.log(formatPick(saved, picks)); } catch (e) { fail(e.message); }
328
+ }
329
+
330
+ async function main() {
331
+ let a;
332
+ try { a = parseArgs(process.argv.slice(2), { switches: ['commercial', 'sheet', 'download', 'json', 'compact'] }); }
333
+ catch (e) { console.error('images: ' + e.message); process.exit(2); }
334
+ const { positional, flags } = a;
335
+ if (flags.pick !== undefined) return pick(flags);
336
+ const q = positional.join(' ').trim();
337
+ if (!q) { console.error('usage: node images.mjs "<query>" [--sources ' + SOURCES.join(',') + '] [--n 6] [--commercial] [--sheet] [--download] [--compact] [--out dir] [--json]\n node images.mjs --pick 3,7 --results <path to results.json>'); process.exit(2); }
338
+ const compact = !!flags.compact;
339
+ const sources = String(flags.sources || SOURCES.join(',')).split(',').map((s) => s.trim()).filter(Boolean);
340
+ const unknown = sources.filter((s) => !SOURCES.includes(s));
341
+ if (unknown.length) { console.error(`images: unknown source ${unknown.join(', ')}. Sources: ${SOURCES.join(', ')}`); process.exit(2); }
342
+ let n;
343
+ try { n = int(flags.n, compact ? 4 : 6, { min: 1, max: 20 }); } catch (e) { console.error('images: --n ' + e.message); process.exit(2); }
344
+ const out = resolve(flags.out || join(tmpdir(), 'image-deep-research', 'images-' + new Date().toISOString().replace(/[:.]/g, '-')));
345
+ mkdirSync(out, { recursive: true });
346
+
347
+ const res = await searchImages(q, { sources, n, commercial: !!flags.commercial });
348
+ const good = res.results.filter((r) => r.verified.ok);
349
+
350
+ if ((flags.download || flags.sheet || compact) && good.length) {
351
+ const dir = join(out, 'images');
352
+ mkdirSync(dir, { recursive: true });
353
+ await mapLimit(good, 4, async (r) => {
354
+ try {
355
+ const image = await downloadImage(r.url);
356
+ const file = join(dir, `${nn(r.n)}-${r.source}.${image.ext}`);
357
+ writeFileSync(file, image.bytes);
358
+ r.file = file;
359
+ r.downloaded = { bytes: image.bytes.length, url: image.url, checkedAt: new Date().toISOString() };
360
+ } catch (e) { r.downloadError = (e && e.message) || String(e); }
361
+ });
362
+ }
363
+
364
+ if ((flags.sheet || compact) && good.length) {
365
+ try {
366
+ if (!good.some((r) => r.file)) throw new Error('no raster files downloaded; see downloadError in results.json');
367
+ const { withBrowser } = await import('./browser.mjs');
368
+ const { renderSheets } = await import('./sheet.mjs');
369
+ res.sheets = await withBrowser((session) => renderSheets(session,
370
+ good.filter((r) => r.file).map((r) => ({ n: r.n, src: r.file, label: `[${r.source}] ${r.licence} ${r.title}` })), { out, prefix: 'moodboard', fit: 'contain', compact }));
371
+ for (const sheet of res.sheets) for (const tile of sheet.tiles) {
372
+ const result = good.find((r) => r.n === tile.n);
373
+ if (result) result.decoded = tile.loaded;
374
+ }
375
+ } catch (e) { res.sheetError = (e && e.message) || String(e); }
376
+ }
377
+
378
+ writeFileSync(join(out, 'results.json'), JSON.stringify(res, null, 2));
379
+ if (flags.json) { console.log(JSON.stringify(res, null, 2)); }
380
+ else if (compact) console.log(formatCompact(res, { out }));
381
+ else {
382
+ console.log(formatTable(res));
383
+ if (res.sheets) for (const s of res.sheets) {
384
+ const missing = s.tiles.filter((t) => !t.loaded).map((t) => t.n);
385
+ console.log(` moodboard ${s.file}${missing.length ? ' (tiles ' + missing.join(', ') + ' did not load in the browser)' : ''}`);
386
+ }
387
+ if (res.sheetError) console.log(' no moodboard: ' + res.sheetError.split('\n')[0]);
388
+ console.log(' results: ' + join(out, 'results.json'));
389
+ }
390
+ if (!good.length || ((flags.sheet || compact) && !(res.sheets || []).some((s) => s.tiles.some((t) => t.loaded)))) process.exitCode = 1;
391
+ }
392
+
393
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
@@ -0,0 +1,104 @@
1
+ /* Public collection responses are untrusted. Resolve every redirect, reject
2
+ private destinations, and pin the checked address for the actual socket. */
3
+ import { lookup } from 'node:dns/promises';
4
+ import { isIP } from 'node:net';
5
+ import { request as httpRequest } from 'node:http';
6
+ import { request as httpsRequest } from 'node:https';
7
+ import { Readable } from 'node:stream';
8
+
9
+ export function publicAddress(address) {
10
+ if (isIP(address) === 4) {
11
+ const [a, b, c] = address.split('.').map(Number);
12
+ return !(a === 0 || a === 10 || a === 127 || a >= 224
13
+ || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
14
+ || (a === 172 && b >= 16 && b <= 31) || (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2))))
15
+ || (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100)))
16
+ || (a === 203 && b === 0 && c === 113));
17
+ }
18
+ if (isIP(address) !== 6) return false;
19
+ const first = Number.parseInt(address.split(':')[0], 16);
20
+ const second = Number.parseInt(address.split(':')[1] || '0', 16);
21
+ // Only global unicast; exclude documentation, transition and special-use ranges.
22
+ return first >= 0x2000 && first < 0x3fff && first !== 0x2002
23
+ && !(first === 0x2001 && (second < 0x200 || second === 0xdb8));
24
+ }
25
+
26
+ export function checkUrl(value) {
27
+ const u = new URL(value);
28
+ if (!['https:', 'http:'].includes(u.protocol) || u.username || u.password)
29
+ throw new Error('image URL must use HTTP(S) without credentials');
30
+ const hostname = u.hostname.replace(/^\[|\]$/g, '');
31
+ if (isIP(hostname) && !publicAddress(hostname)) throw new Error('private or special-use image destination blocked');
32
+ if (hostname === 'localhost' || /\.(?:localhost|local|internal)$/i.test(hostname))
33
+ throw new Error('private image destination blocked');
34
+ return u;
35
+ }
36
+
37
+ export async function publicFetch(value, { method = 'GET', headers = {}, signal,
38
+ lookupImpl = lookup, requestImpl, maxRedirects = 5 } = {}) {
39
+ let u = checkUrl(value);
40
+ for (let redirects = 0; ; redirects++) {
41
+ signal?.throwIfAborted();
42
+ const hostname = u.hostname.replace(/^\[|\]$/g, '');
43
+ let onAbort;
44
+ const aborted = signal && new Promise((_, reject) => {
45
+ onAbort = () => reject(signal.reason);
46
+ signal.addEventListener('abort', onAbort, { once: true });
47
+ });
48
+ let addresses;
49
+ try {
50
+ const resolving = isIP(hostname) ? Promise.resolve([{ address: hostname, family: isIP(hostname) }])
51
+ : lookupImpl(hostname, { all: true, verbatim: true });
52
+ addresses = await (aborted ? Promise.race([resolving, aborted]) : resolving);
53
+ } finally { if (onAbort) signal.removeEventListener('abort', onAbort); }
54
+ if (!addresses.length || addresses.some((a) => !publicAddress(a.address)))
55
+ throw new Error('private or special-use image destination blocked');
56
+ const chosen = addresses[0];
57
+ const request = requestImpl || (u.protocol === 'https:' ? httpsRequest : httpRequest);
58
+ const res = await new Promise((resolve, reject) => {
59
+ const req = request(u, { method, headers, signal, agent: false,
60
+ lookup: (_host, options, cb) => cb(null, options?.all ? [chosen] : chosen.address, chosen.family) }, resolve);
61
+ req.once('error', reject);
62
+ req.end();
63
+ });
64
+ const responseHeaders = new Headers();
65
+ for (const [name, val] of Object.entries(res.headers)) {
66
+ if (val !== undefined) responseHeaders.set(name, Array.isArray(val) ? val.join(', ') : String(val));
67
+ }
68
+ const status = res.statusCode;
69
+ const location = responseHeaders.get('location');
70
+ if ([301, 302, 303, 307, 308].includes(status) && location) {
71
+ res.destroy();
72
+ if (redirects >= maxRedirects) throw new Error('too many image redirects');
73
+ u = checkUrl(new URL(location, u).href);
74
+ continue;
75
+ }
76
+ const response = new Response(method === 'HEAD' || [204, 205, 304].includes(status) ? null : Readable.toWeb(res),
77
+ { status, headers: responseHeaders });
78
+ if (method === 'HEAD') res.destroy();
79
+ Object.defineProperty(response, 'url', { value: u.href });
80
+ return response;
81
+ }
82
+ }
83
+
84
+ export async function readLimited(response, maxBytes) {
85
+ const declared = Number(response.headers.get('content-length'));
86
+ if (declared > maxBytes) {
87
+ await response.body?.cancel();
88
+ throw new Error(`response exceeds ${maxBytes} bytes`);
89
+ }
90
+ const reader = response.body?.getReader();
91
+ if (!reader) return Buffer.alloc(0);
92
+ const parts = [];
93
+ let size = 0;
94
+ try {
95
+ while (true) {
96
+ const { done, value } = await reader.read();
97
+ if (done) break;
98
+ size += value.byteLength;
99
+ if (size > maxBytes) throw new Error(`response exceeds ${maxBytes} bytes`);
100
+ parts.push(Buffer.from(value));
101
+ }
102
+ return Buffer.concat(parts, size);
103
+ } finally { await reader.cancel().catch(() => {}); }
104
+ }
@@ -0,0 +1,168 @@
1
+ /* image-deep-research/sheet - tile pictures into one contact sheet.
2
+
3
+ The browser does the tiling: a small HTML page lays the images out on a
4
+ 4 x 2 grid, each cell numbered, and the page is screenshotted. No ffmpeg,
5
+ no image codec, and it works the same for local screenshots and for remote
6
+ image URLs (a moodboard). One sheet costs one image read instead of eight.
7
+
8
+ --compact draws a denser sheet, sized for what a Claude model pays: 16
9
+ tiles with a number badge and no caption row, 1288 x 812 px, which is
10
+ 46 x 29 patches of 28 px, 1,334 image tokens, under every model's cap. */
11
+
12
+ import { writeFileSync, mkdirSync } from 'node:fs';
13
+ import { join, relative, isAbsolute, dirname, resolve, sep } from 'node:path';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { load, screenshot } from './browser.mjs';
16
+
17
+ export const PER_SHEET = 8;
18
+ export const COLS = 4;
19
+ export const CELL_W = 640;
20
+ export const CELL_H = 400;
21
+
22
+ /* The compact geometry: 4 x 4 cells of 317 x 198 with a 4 px gap and pad. */
23
+ export const COMPACT = { cols: 4, perSheet: 16, cellW: 317, cellH: 198, gap: 4, pad: 4 };
24
+ export const COMPACT_MAX_BYTES = 150000;
25
+ export const COMPACT_QUALITIES = [80, 70, 60];
26
+
27
+ /* What an image costs a Claude model, as Anthropic's vision docs price it
28
+ (platform.claude.com/docs/en/build-with-claude/vision, read 2026-09-29):
29
+ ceil(w/28) x ceil(h/28) tokens, after the image is scaled down, keeping its
30
+ aspect ratio, to the largest size that fits both the tier's long edge and
31
+ its token cap. The high tier (Claude 4.7 and later) allows a 2576 px long
32
+ edge and 4,784 tokens; the standard tier 1568 px and 1,568 tokens. */
33
+ export const PATCH = 28;
34
+ export const TIERS = { high: { edge: 2576, cap: 4784 }, standard: { edge: 1568, cap: 1568 } };
35
+
36
+ export function visionTokens(w, h, { tier = 'high' } = {}) {
37
+ const t = TIERS[tier];
38
+ if (!t) throw new Error(`unknown tier "${tier}"`);
39
+ const cost = (W, H) => Math.ceil(W / PATCH) * Math.ceil(H / PATCH);
40
+ const long = Math.max(w, h);
41
+ const at = (edge) => {
42
+ const s = edge / long;
43
+ return [Math.max(1, Math.round(w * s)), Math.max(1, Math.round(h * s))];
44
+ };
45
+ let edge = Math.min(long, t.edge);
46
+ if (cost(...at(edge)) > t.cap) {
47
+ // The cost only grows with the long edge, so the largest edge that fits
48
+ // the cap is found by halving the interval.
49
+ let lo = 1, hi = edge;
50
+ while (lo < hi) {
51
+ const mid = Math.ceil((lo + hi) / 2);
52
+ if (cost(...at(mid)) <= t.cap) lo = mid; else hi = mid - 1;
53
+ }
54
+ edge = lo;
55
+ }
56
+ return cost(...at(edge));
57
+ }
58
+
59
+ /* The pixel size of a sheet of count tiles in a badge-only geometry. */
60
+ export function sheetSize(count, geom = COMPACT) {
61
+ const cols = Math.max(1, Math.min(geom.cols, count));
62
+ const rows = Math.max(1, Math.ceil(count / geom.cols));
63
+ const w = 2 * geom.pad + cols * geom.cellW + (cols - 1) * geom.gap;
64
+ const h = 2 * geom.pad + rows * geom.cellH + (rows - 1) * geom.gap;
65
+ return { w, h, tokens: visionTokens(w, h) };
66
+ }
67
+
68
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
69
+
70
+ /* Splits items into sheets of PER_SHEET, numbering every tile across the run
71
+ so "tile 11" means the same thing in the sheet and in the printed legend.
72
+ An item that already carries its number keeps it: images.mjs numbers the
73
+ verified results, and a failed one before them must not shift the tiles. */
74
+ export function planSheets(items, per = PER_SHEET) {
75
+ const sheets = [];
76
+ items.forEach((item, i) => {
77
+ const s = Math.floor(i / per);
78
+ (sheets[s] ||= []).push({ ...item, n: item.n ?? i + 1 });
79
+ });
80
+ return sheets;
81
+ }
82
+
83
+ /* The HTML for one sheet. src is a URL, or a local path that is written
84
+ relative to the sheet so the page can load it from disk. */
85
+ export function sheetHtml(tiles, { baseDir, fit = 'cover', title = '', compact = false } = {}) {
86
+ const src = (s) => {
87
+ if (/^(https?|data):/i.test(s)) return s;
88
+ const p = isAbsolute(s) ? s : resolve(s);
89
+ return relative(baseDir, p).split(sep).map(encodeURIComponent).join('/');
90
+ };
91
+ if (compact) return compactHtml(tiles, { src, fit, title });
92
+ const cells = tiles.map((t) => `<figure><img src="${esc(src(t.src))}" alt="" referrerpolicy="no-referrer"><figcaption>${String(t.n).padStart(2, '0')} ${esc(t.label || '')}</figcaption></figure>`).join('\n');
93
+ return `<!doctype html><meta charset="utf-8"><title>${esc(title)}</title>
94
+ <style>
95
+ html,body{margin:0;background:#111}
96
+ main{display:grid;grid-template-columns:repeat(${Math.max(1, Math.min(COLS, tiles.length))},${CELL_W}px);gap:6px;padding:6px;width:max-content}
97
+ figure{margin:0;background:#1b1b1b}
98
+ img{display:block;width:${CELL_W}px;height:${CELL_H}px;object-fit:${fit === 'contain' ? 'contain' : 'cover'};object-position:${fit === 'contain' ? 'center' : 'top center'}}
99
+ figcaption{font:13px/1.2 ui-monospace,Consolas,monospace;color:#ddd;padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:${CELL_W - 12}px}
100
+ </style>
101
+ <main>
102
+ ${cells}
103
+ </main>`;
104
+ }
105
+
106
+ /* The compact sheet: no caption row, only the tile's number on a dark badge
107
+ in its top-left corner (or the tile's own badge text, such as s03 for a
108
+ site). Every other fact about a tile goes out as text. */
109
+ function compactHtml(tiles, { src, fit, title }) {
110
+ const g = COMPACT;
111
+ const cells = tiles.map((t) => `<figure><img src="${esc(src(t.src))}" alt="" referrerpolicy="no-referrer"><span>${esc(t.badge ?? String(t.n).padStart(2, '0'))}</span></figure>`).join('\n');
112
+ return `<!doctype html><meta charset="utf-8"><title>${esc(title)}</title>
113
+ <style>
114
+ html,body{margin:0;background:#111}
115
+ main{display:grid;grid-template-columns:repeat(${Math.max(1, Math.min(g.cols, tiles.length))},${g.cellW}px);gap:${g.gap}px;padding:${g.pad}px;width:max-content}
116
+ figure{margin:0;position:relative;background:#1b1b1b;width:${g.cellW}px;height:${g.cellH}px}
117
+ img{display:block;width:${g.cellW}px;height:${g.cellH}px;object-fit:${fit === 'contain' ? 'contain' : 'cover'};object-position:${fit === 'contain' ? 'center' : 'top center'}}
118
+ span{position:absolute;top:0;left:0;font:12px/1 ui-monospace,Consolas,monospace;color:#fff;background:rgba(0,0,0,.7);padding:2px 4px}
119
+ </style>
120
+ <main>
121
+ ${cells}
122
+ </main>`;
123
+ }
124
+
125
+ /* Takes a picture at each quality in turn until one is at most maxBytes, so a
126
+ busy sheet still fits a Read without a silent downscale. shoot(quality)
127
+ returns the JPEG bytes. The last quality is kept even when it is over. */
128
+ export async function underBytes(shoot, { maxBytes = COMPACT_MAX_BYTES, qualities = COMPACT_QUALITIES } = {}) {
129
+ let buf, quality;
130
+ for (quality of qualities) {
131
+ buf = await shoot(quality);
132
+ if (buf.length <= maxBytes) break;
133
+ }
134
+ return { buf, quality };
135
+ }
136
+
137
+ /* Renders every sheet with an open browser session. Returns
138
+ [{ file, tiles: [{n, label, src, loaded}] }]. A tile whose image failed to
139
+ load is reported as loaded:false rather than silently left grey. With
140
+ compact, sixteen to a sheet in the compact geometry, and each sheet also
141
+ reports { w, h, bytes, quality, tokens }. */
142
+ export async function renderSheets(session, items, { out, prefix = 'sheet', fit = 'cover', per, compact = false } = {}) {
143
+ mkdirSync(out, { recursive: true });
144
+ per ??= compact ? COMPACT.perSheet : PER_SHEET;
145
+ const results = [];
146
+ for (const [i, tiles] of planSheets(items, per).entries()) {
147
+ const html = join(out, `_${prefix}${i + 1}.html`);
148
+ writeFileSync(html, sheetHtml(tiles, { baseDir: dirname(html), fit, title: `${prefix} ${i + 1}`, compact }));
149
+ const width = compact ? sheetSize(COMPACT.cols, COMPACT).w : COLS * (CELL_W + 6) + 6;
150
+ await load(session, pathToFileURL(html).href, { width, height: 900, wait: 200 });
151
+ const state = await session.evaluate(`Promise.all([...document.images].map((im) =>
152
+ im.complete ? 0 : new Promise((r) => { im.onload = im.onerror = r; setTimeout(r, 15000); })))
153
+ .then(() => { const m = document.querySelector('main').getBoundingClientRect();
154
+ return { w: Math.ceil(m.width), h: Math.ceil(m.height), ok: [...document.images].map((im) => im.naturalWidth > 0) }; })`, { awaitPromise: true });
155
+ const file = join(out, `${prefix}${i + 1}.jpg`);
156
+ const clip = { x: 0, y: 0, width: state.w, height: state.h };
157
+ const shown = tiles.map((t, k) => ({ n: t.n, label: t.label, src: t.src, loaded: !!state.ok[k] }));
158
+ if (!compact) {
159
+ writeFileSync(file, await screenshot(session, { format: 'jpeg', quality: 84, clip }));
160
+ results.push({ file, tiles: shown });
161
+ continue;
162
+ }
163
+ const { buf, quality } = await underBytes((q) => screenshot(session, { format: 'jpeg', quality: q, clip }));
164
+ writeFileSync(file, buf);
165
+ results.push({ file, w: state.w, h: state.h, bytes: buf.length, quality, tokens: visionTokens(state.w, state.h), tiles: shown });
166
+ }
167
+ return results;
168
+ }