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,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: image-deep-research
|
|
3
|
+
description: Image deep research and visual research - use when a question is better answered by looking than by reading. Finds reference images on open, licensed collections (Openverse, Wikimedia Commons, the Art Institute of Chicago, the Met), renders real websites into screenshot contact sheets, builds a moodboard, measures a live site's palette and typefaces, and reads every picture as a picture. For design references, styles, palettes, typefaces, materials, competitors and moodboards.
|
|
4
|
+
argument-hint: "<topic, style, palette, typeface, competitor list, or URLs to look at>"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Image deep research
|
|
8
|
+
|
|
9
|
+
Most design questions are answered by looking at twenty examples, not by
|
|
10
|
+
reading one article about them. This skill gets the pictures in front of you
|
|
11
|
+
cheaply, checks that they are real, keeps the licence beside every one, and
|
|
12
|
+
then makes you read them before you say anything about them.
|
|
13
|
+
|
|
14
|
+
The request is whatever the user typed with the skill. If it is empty, ask one
|
|
15
|
+
question: what should be looked at.
|
|
16
|
+
|
|
17
|
+
## Where the scripts are
|
|
18
|
+
|
|
19
|
+
The scripts live in `scripts/` next to this file. In Claude Code that folder
|
|
20
|
+
is `${CLAUDE_SKILL_DIR}/scripts`. In Codex, or anywhere that variable comes
|
|
21
|
+
through empty, use the `scripts` folder beside this SKILL.md. They need Node 22
|
|
22
|
+
or newer and nothing else; rendering needs Chrome, Edge or Chromium (set
|
|
23
|
+
`IDR_BROWSER` to the executable if it is somewhere unusual). The browser runs
|
|
24
|
+
headless and never opens a window.
|
|
25
|
+
|
|
26
|
+
## The loop
|
|
27
|
+
|
|
28
|
+
Deep research here means rounds, not one search:
|
|
29
|
+
|
|
30
|
+
1. **Write the question down** in a form a picture can answer. "What do
|
|
31
|
+
luxury watch sites look like" produces mush; "what ground colour, what
|
|
32
|
+
headline size, and what the first screen shows on six luxury watch sites"
|
|
33
|
+
produces numbers you can build with.
|
|
34
|
+
2. **Gather** from the two sources below: real sites for how a thing is done
|
|
35
|
+
on the web, open collections for subjects, palettes, materials and art.
|
|
36
|
+
3. **Read the contact sheets**, then only the two or three individual images
|
|
37
|
+
worth a closer look. A sheet of eight costs one read instead of eight.
|
|
38
|
+
In a coding session or for a first look, add `--compact` to either
|
|
39
|
+
script: one sheet of up to 16 numbered tiles, 1288x812 and at most 1,334
|
|
40
|
+
image tokens on any Claude model, one text line per result or site, no
|
|
41
|
+
URLs (`images.mjs --pick 3,7 --results <path>` prints the ones you keep).
|
|
42
|
+
Open a single image only for what the sheet and the numbers cannot answer.
|
|
43
|
+
4. **Refine and go again.** The first round tells you the right words: the
|
|
44
|
+
name of the style, the photographer, the typeface, the movement. Search
|
|
45
|
+
again with them. Stop when a new round stops changing the answer.
|
|
46
|
+
5. **Report** (see the end of this file).
|
|
47
|
+
|
|
48
|
+
## Render real sites - the highest-signal source
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
node "${CLAUDE_SKILL_DIR}/scripts/study.mjs" <url> <url> ...
|
|
52
|
+
node "${CLAUDE_SKILL_DIR}/scripts/study.mjs" --list editorial
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each site is rendered in a headless browser at the top and one screen down
|
|
56
|
+
(`--scroll 0,900`, `--width 390` for phones, `--reduced-motion` for comparison), tiled into contact sheets
|
|
57
|
+
eight to a sheet, and measured: ground colours by element area (overlaps can count twice), text
|
|
58
|
+
colours by amount of text, and the heading and body typefaces as the browser
|
|
59
|
+
computed them. The measurements print per site and land in `report.json`
|
|
60
|
+
beside the sheets. Curated lists: `editorial`, `object`, `cinema`, `product`.
|
|
61
|
+
Shots record viewport, scroll, time and motion preference; stills do not prove motion.
|
|
62
|
+
|
|
63
|
+
Two things it cannot see, and it says so rather than guessing: a page that
|
|
64
|
+
answers with a bot challenge or Access Denied, or renders next to nothing (a
|
|
65
|
+
preloader, an empty script shell), is reported as a wall with the reason and
|
|
66
|
+
kept off the sheet; and a canvas-heavy page is flagged, because its
|
|
67
|
+
screenshot may be a preloader frame. For those, try the `y900`
|
|
68
|
+
tile, a longer `--wait 8000`, or a video walkthrough of the site.
|
|
69
|
+
|
|
70
|
+
## Find images on open collections
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
node "${CLAUDE_SKILL_DIR}/scripts/images.mjs" "<query>" --sheet
|
|
74
|
+
node "${CLAUDE_SKILL_DIR}/scripts/images.mjs" "<query>" --commercial --download --sources openverse,commons
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Searches Openverse (Creative Commons and public-domain images from Flickr,
|
|
78
|
+
museums and more), Wikimedia Commons, the Art Institute of Chicago (public
|
|
79
|
+
domain works only) and the Met (Open Access works only). No keys. Every result
|
|
80
|
+
URL is fetched before it is reported, and one that does not answer with an
|
|
81
|
+
image is marked FAIL and never put on the moodboard. `--commercial` keeps only
|
|
82
|
+
licences that allow commercial use and changes (it drops NC and ND). `--sheet`
|
|
83
|
+
tiles the verified images into a numbered moodboard; `--download` saves them.
|
|
84
|
+
Results, with title, creator, licence, licence URL and source page, go to
|
|
85
|
+
`results.json`.
|
|
86
|
+
URL checks cover headers, not rights. Sheets cache bounded rasters; read source licences.
|
|
87
|
+
|
|
88
|
+
Unsplash and Pexels need a free key for their APIs; with one, their CDN URLs
|
|
89
|
+
can be used directly. Check them the same way: an image URL is not a result
|
|
90
|
+
until it has answered with an image.
|
|
91
|
+
|
|
92
|
+
## Design galleries, for finding sites worth rendering
|
|
93
|
+
|
|
94
|
+
Awwwards, Godly, Land-book, SiteInspire, Minimal Gallery, Curated.design,
|
|
95
|
+
One Page Love. Fetch the gallery page, pull out the outbound site URLs, then
|
|
96
|
+
render them with `study.mjs`. A gallery's own thumbnails are small and
|
|
97
|
+
colour-shifted: render the real site instead.
|
|
98
|
+
|
|
99
|
+
## Inside Ultimate Frontend Skills
|
|
100
|
+
|
|
101
|
+
This skill also ships inside the Ultimate Frontend Skills plugin. When
|
|
102
|
+
`${CLAUDE_PLUGIN_ROOT}/scripts/webdesign.mjs` exists (in Codex: a
|
|
103
|
+
`scripts/webdesign.mjs` in the plugin folder that holds `.codex-plugin/`),
|
|
104
|
+
there is a corpus of award-winning sites to pick references from by
|
|
105
|
+
technique:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/webdesign.mjs" awards "<topic>" --verbose
|
|
109
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/webdesign.mjs" study --awards "<topic>" --n 6
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
When that file is not there, this is the standalone plugin: skip this
|
|
113
|
+
section and find sites through the galleries above.
|
|
114
|
+
|
|
115
|
+
## Reading what you got
|
|
116
|
+
|
|
117
|
+
Go in with the question from step 1. Worth extracting every time: the type
|
|
118
|
+
pairing and sizes, the ground colours, where the accent appears, section
|
|
119
|
+
rhythm, what the first screen does, and the one move you have not seen before.
|
|
120
|
+
The `report.json` numbers are what the browser computed; the sheet is what a
|
|
121
|
+
person sees. Use both, and say which one a claim comes from.
|
|
122
|
+
|
|
123
|
+
## Report
|
|
124
|
+
|
|
125
|
+
- The file paths of the contact sheets and the moodboard.
|
|
126
|
+
- Every image you recommend with its verified URL, creator, licence and
|
|
127
|
+
source page. Nothing without a licence goes in a deliverable.
|
|
128
|
+
- Per reference, the one concrete move worth taking, with the number
|
|
129
|
+
(a colour, a size, a spacing) when there is one.
|
|
130
|
+
- What could not be seen (walls, preloaders, failed URLs), said plainly.
|
|
131
|
+
|
|
132
|
+
## Do not
|
|
133
|
+
|
|
134
|
+
Do not describe an image you have not opened. Do not treat a gallery thumbnail
|
|
135
|
+
as evidence of colour. Do not conclude a site uses a library because the
|
|
136
|
+
effect looks like it - check the bundle. Do not ship an image whose licence
|
|
137
|
+
you have not read, and do not drop the attribution a CC BY licence requires.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* A small flag parser shared by the scripts: `--name value`, `--name=value`
|
|
2
|
+
and bare `--switch`. A value flag with nothing after it is a usage error,
|
|
3
|
+
reported as one line rather than a stack trace. */
|
|
4
|
+
export function parseArgs(argv, { switches = [] } = {}) {
|
|
5
|
+
const positional = [];
|
|
6
|
+
const flags = {};
|
|
7
|
+
for (let i = 0; i < argv.length; i++) {
|
|
8
|
+
const a = argv[i];
|
|
9
|
+
if (!a.startsWith('--') || a === '--') { positional.push(a); continue; }
|
|
10
|
+
const eq = a.indexOf('=');
|
|
11
|
+
const name = a.slice(2, eq === -1 ? undefined : eq);
|
|
12
|
+
if (eq !== -1) { flags[name] = a.slice(eq + 1); continue; }
|
|
13
|
+
if (switches.includes(name)) { flags[name] = true; continue; }
|
|
14
|
+
const next = argv[i + 1];
|
|
15
|
+
if (next === undefined || next.startsWith('--')) throw new Error(`--${name} needs a value`);
|
|
16
|
+
flags[name] = next;
|
|
17
|
+
i++;
|
|
18
|
+
}
|
|
19
|
+
return { positional, flags };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const int = (v, fallback, { min = -Infinity, max = Infinity } = {}) => {
|
|
23
|
+
if (v === undefined) return fallback;
|
|
24
|
+
const n = Number(v);
|
|
25
|
+
if (!Number.isInteger(n) || n < min || n > max) throw new Error(`expected a whole number between ${min} and ${max}, got "${v}"`);
|
|
26
|
+
return n;
|
|
27
|
+
};
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/* image-deep-research/browser - find, start and drive a headless Chrome,
|
|
2
|
+
Edge or Chromium over the DevTools protocol, with no dependencies.
|
|
3
|
+
|
|
4
|
+
Vendored from Ultimate Frontend Skills scripts/inspect.mjs (MIT, Gev,
|
|
5
|
+
github.com/ridelink0/ultimate-frontend-skills): findBrowser, readPortFile,
|
|
6
|
+
the port handshake in launch() and the Session class are the same code,
|
|
7
|
+
trimmed to what a screenshot needs. The fixes they carry are kept, because
|
|
8
|
+
each one was a real failure there: the half-written DevToolsActivePort file,
|
|
9
|
+
Edge 153 no longer writing that file at all, and the launcher that hands the
|
|
10
|
+
browser to a child and exits.
|
|
11
|
+
|
|
12
|
+
The browser runs --headless=new, so it never opens a window on anyone's
|
|
13
|
+
screen. Needs Node 22+ for the built-in WebSocket. */
|
|
14
|
+
|
|
15
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
16
|
+
import { mkdtempSync, mkdirSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
17
|
+
import { join, posix } from 'node:path';
|
|
18
|
+
import { tmpdir, homedir } from 'node:os';
|
|
19
|
+
import { createServer } from 'node:net';
|
|
20
|
+
|
|
21
|
+
function candidates(platform, env) {
|
|
22
|
+
return platform === 'win32'
|
|
23
|
+
? [
|
|
24
|
+
`${env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
25
|
+
`${env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
26
|
+
`${env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
27
|
+
`${env['PROGRAMFILES(X86)']}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
28
|
+
`${env.PROGRAMFILES}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
29
|
+
]
|
|
30
|
+
: platform === 'darwin'
|
|
31
|
+
? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
32
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
33
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium']
|
|
34
|
+
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium',
|
|
35
|
+
'/usr/bin/chromium-browser', '/usr/bin/microsoft-edge', '/snap/bin/chromium'];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* The Chromium builds Playwright downloads, newest revision first in each
|
|
39
|
+
folder: $PLAYWRIGHT_BROWSERS_PATH, the per-user cache, and /opt/pw-browsers,
|
|
40
|
+
where containers and cloud sandboxes (this repo's own test container among
|
|
41
|
+
them, 2026-09-29) keep one and nothing else. Up to Playwright 1.56 the
|
|
42
|
+
binary sits in chrome-linux/; later ones install Chrome for Testing into
|
|
43
|
+
chrome-linux64/ (x64) or chrome-linux-arm64/ (read from playwright-core
|
|
44
|
+
1.56.1 and 1.63.0's registries). */
|
|
45
|
+
const PW_LAYOUTS = ['chrome-linux', 'chrome-linux64', 'chrome-linux-arm64'];
|
|
46
|
+
|
|
47
|
+
function playwrightChromes({ env, home, readdir }) {
|
|
48
|
+
const bases = [env.PLAYWRIGHT_BROWSERS_PATH, home && posix.join(home, '.cache', 'ms-playwright'), '/opt/pw-browsers'];
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const base of [...new Set(bases.filter(Boolean))]) {
|
|
51
|
+
let names;
|
|
52
|
+
try { names = readdir(base); } catch { continue; }
|
|
53
|
+
const revs = names.map((n) => /^chromium-(\d+)$/.exec(n)).filter(Boolean).sort((a, b) => b[1] - a[1]);
|
|
54
|
+
for (const m of revs) for (const d of PW_LAYOUTS) out.push(posix.join(base, m[0], d, 'chrome'));
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* IDR_BROWSER names the executable. ATELIER_BROWSER is the variable Ultimate
|
|
60
|
+
Frontend Skills reads, honoured too so one setting serves both. Everything
|
|
61
|
+
the lookup touches can be injected, so a test can stand in a file system. */
|
|
62
|
+
export function findBrowser({ env = process.env, platform = process.platform, home = homedir(),
|
|
63
|
+
exists = existsSync, readdir = (d) => readdirSync(d) } = {}) {
|
|
64
|
+
for (const v of ['IDR_BROWSER', 'ATELIER_BROWSER']) {
|
|
65
|
+
const p = env[v];
|
|
66
|
+
if (p && exists(p)) return p;
|
|
67
|
+
}
|
|
68
|
+
const found = candidates(platform, env).find((p) => p && exists(p));
|
|
69
|
+
if (found) return found;
|
|
70
|
+
if (platform !== 'linux') return null;
|
|
71
|
+
return playwrightChromes({ env, home, readdir }).find((p) => exists(p)) || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Chrome refuses to start as root unless its sandbox is switched off, and
|
|
75
|
+
exits before it opens a debugging port (measured in a root container,
|
|
76
|
+
2026-09-29). So --no-sandbox goes in for root, or when IDR_NO_SANDBOX=1
|
|
77
|
+
asks for it, and never otherwise. */
|
|
78
|
+
export function launchArgs(udd, port, { uid = process.getuid?.(), env = process.env } = {}) {
|
|
79
|
+
// --disable-component-update: a fresh profile starts Edge's component
|
|
80
|
+
// updater, which leaves msedge_url_fetcher_* folders in TEMP.
|
|
81
|
+
return [
|
|
82
|
+
'--headless=new', '--hide-scrollbars', '--mute-audio',
|
|
83
|
+
'--no-first-run', '--no-default-browser-check', '--disable-extensions',
|
|
84
|
+
'--disable-background-networking', '--disable-component-update', '--disable-sync', '--disable-features=Translate',
|
|
85
|
+
...(uid === 0 || env.IDR_NO_SANDBOX === '1' ? ['--no-sandbox'] : []),
|
|
86
|
+
`--user-data-dir=${udd}`, `--remote-debugging-port=${port}`, 'about:blank',
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const NO_BROWSER = 'no Chrome, Edge or Chromium found.\n'
|
|
91
|
+
+ ' Windows: Edge is usually already installed; or winget install Google.Chrome\n'
|
|
92
|
+
+ ' macOS: brew install --cask google-chrome\n'
|
|
93
|
+
+ ' Linux: apt-get install chromium\n'
|
|
94
|
+
+ 'Or set IDR_BROWSER to the browser executable.';
|
|
95
|
+
|
|
96
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
97
|
+
|
|
98
|
+
/* Chrome creates DevToolsActivePort before it writes the port into it, so the
|
|
99
|
+
file can exist and be empty, half-written, or (on Windows) locked. All of
|
|
100
|
+
those mean "not yet". Returns the port, or null to keep waiting. */
|
|
101
|
+
export function readPortFile(path) {
|
|
102
|
+
let first;
|
|
103
|
+
try { first = readFileSync(path, 'utf8').split('\n')[0].trim(); } catch { return null; }
|
|
104
|
+
const port = Number(first);
|
|
105
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function freePort() {
|
|
109
|
+
return new Promise((res, rej) => {
|
|
110
|
+
const s = createServer();
|
|
111
|
+
s.once('error', rej);
|
|
112
|
+
s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => res(port)); });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function answers(port) {
|
|
117
|
+
try { return (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500) })).ok; }
|
|
118
|
+
catch { return false; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* The browser's own temp directory, inside the profile that close() deletes.
|
|
122
|
+
Edge leaves an Importer_0_4 folder and cv_debug.log in the TEMP it inherits
|
|
123
|
+
whenever a page makes a network request, outside the profile where no
|
|
124
|
+
profile delete reaches them; with --disable-component-update missing it also
|
|
125
|
+
left msedge_url_fetcher_* folders (Ultimate Frontend Skills judge, round 3,
|
|
126
|
+
2026-09-27). Any casing of the variable is replaced: Windows reads TEMP and
|
|
127
|
+
Temp as one name. */
|
|
128
|
+
function browserEnv(dir) {
|
|
129
|
+
const env = {};
|
|
130
|
+
for (const [k, v] of Object.entries(process.env)) if (!/^(TEMP|TMP|TMPDIR)$/i.test(k)) env[k] = v;
|
|
131
|
+
return { ...env, TEMP: dir, TMP: dir, TMPDIR: dir };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Starts the browser and resolves once DevTools answers. The returned close()
|
|
135
|
+
asks the browser to quit over the protocol first, waits for it to stop
|
|
136
|
+
answering, and only then falls back to ending the processes that carry this
|
|
137
|
+
run's own temporary profile - never any other browser the user has open. */
|
|
138
|
+
export async function launch(bin = findBrowser()) {
|
|
139
|
+
if (!bin) { const e = new Error(NO_BROWSER); e.code = 'no-browser'; throw e; }
|
|
140
|
+
if (typeof WebSocket === 'undefined') throw new Error('image-deep-research needs Node 22 or newer (built-in WebSocket).');
|
|
141
|
+
const udd = mkdtempSync(join(tmpdir(), 'idr-cdp-'));
|
|
142
|
+
const ownTmp = join(udd, 'tmp');
|
|
143
|
+
mkdirSync(ownTmp);
|
|
144
|
+
const asked = await freePort();
|
|
145
|
+
const proc = spawn(bin, launchArgs(udd, asked), { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true, env: browserEnv(ownTmp) });
|
|
146
|
+
let launchError;
|
|
147
|
+
proc.once('error', (err) => { launchError = err; });
|
|
148
|
+
|
|
149
|
+
const portFile = join(udd, 'DevToolsActivePort');
|
|
150
|
+
// 45 s, not 15: the first start of Chrome on a fresh Windows machine (a CI
|
|
151
|
+
// runner, measured 2026-09-25) took longer than 15 s to open its port, while
|
|
152
|
+
// every later start in the same run took about one.
|
|
153
|
+
const deadline = Date.now() + 45000;
|
|
154
|
+
for (let i = 0; Date.now() < deadline; i++) {
|
|
155
|
+
if (launchError || (proc.exitCode !== null && proc.exitCode !== 0)) break;
|
|
156
|
+
const port = readPortFile(portFile) || (i % 3 === 2 && await answers(asked) ? asked : null);
|
|
157
|
+
if (port) return { port, udd, close: () => shutdown(proc, udd, port) };
|
|
158
|
+
await sleep(100);
|
|
159
|
+
}
|
|
160
|
+
await shutdown(proc, udd, asked);
|
|
161
|
+
throw new Error(launchError ? 'browser launch failed: ' + launchError.message : 'browser did not expose a debugging port');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function shutdown(proc, udd, port) {
|
|
165
|
+
try {
|
|
166
|
+
const v = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(1500) })).json();
|
|
167
|
+
await new Promise((res) => {
|
|
168
|
+
const ws = new WebSocket(v.webSocketDebuggerUrl);
|
|
169
|
+
const done = () => { try { ws.close(); } catch {} res(); };
|
|
170
|
+
ws.addEventListener('open', () => { try { ws.send(JSON.stringify({ id: 1, method: 'Browser.close' })); } catch {} setTimeout(done, 300); });
|
|
171
|
+
ws.addEventListener('error', done);
|
|
172
|
+
setTimeout(done, 2000);
|
|
173
|
+
});
|
|
174
|
+
} catch {}
|
|
175
|
+
for (let i = 0; i < 30 && await answers(port); i++) await sleep(100);
|
|
176
|
+
if (proc.exitCode === null) { try { proc.kill(); } catch {} }
|
|
177
|
+
// Helper processes (the crash handler, a utility process) can outlive the
|
|
178
|
+
// browser and hold the profile folder open for seconds. Wait for them first;
|
|
179
|
+
// only if the folder is still locked, end the processes started with this
|
|
180
|
+
// run's own temporary profile on their command line, and no other.
|
|
181
|
+
if (await removeProfile(udd, 3000) || process.platform !== 'win32') return;
|
|
182
|
+
endProfileProcesses(udd);
|
|
183
|
+
await removeProfile(udd, 5000);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function removeProfile(udd, ms) {
|
|
187
|
+
const until = Date.now() + ms;
|
|
188
|
+
for (;;) {
|
|
189
|
+
try { rmSync(udd, { recursive: true, force: true }); } catch {}
|
|
190
|
+
if (!existsSync(udd)) return true;
|
|
191
|
+
if (Date.now() > until) return false;
|
|
192
|
+
await sleep(250);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function endProfileProcesses(udd) {
|
|
197
|
+
const q = udd.replace(/'/g, "''");
|
|
198
|
+
spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
199
|
+
`Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains('${q}') } | ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } catch {} }`],
|
|
200
|
+
{ stdio: 'ignore', windowsHide: true, timeout: 15000 });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export class Session {
|
|
204
|
+
constructor(ws) { this.ws = ws; this.id = 0; this.waiting = new Map(); this.events = []; }
|
|
205
|
+
static async open(port) {
|
|
206
|
+
let target;
|
|
207
|
+
for (let i = 0; i < 60 && !target; i++) {
|
|
208
|
+
try {
|
|
209
|
+
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
|
210
|
+
target = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
|
|
211
|
+
} catch {}
|
|
212
|
+
if (!target) await sleep(100);
|
|
213
|
+
}
|
|
214
|
+
if (!target) throw new Error('no page target');
|
|
215
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
216
|
+
await new Promise((res, rej) => {
|
|
217
|
+
ws.addEventListener('open', res, { once: true });
|
|
218
|
+
ws.addEventListener('error', () => rej(new Error('cdp socket failed')), { once: true });
|
|
219
|
+
});
|
|
220
|
+
const s = new Session(ws);
|
|
221
|
+
ws.addEventListener('message', (e) => {
|
|
222
|
+
const msg = JSON.parse(e.data);
|
|
223
|
+
if (msg.id && s.waiting.has(msg.id)) {
|
|
224
|
+
const { res, rej, timer } = s.waiting.get(msg.id);
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
s.waiting.delete(msg.id);
|
|
227
|
+
msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
|
|
228
|
+
} else if (msg.method) { s.events.push(msg); if (s.events.length > 2000) s.events.shift(); }
|
|
229
|
+
});
|
|
230
|
+
ws.addEventListener('close', () => s.failPending(new Error('CDP connection closed')));
|
|
231
|
+
return s;
|
|
232
|
+
}
|
|
233
|
+
failPending(error) {
|
|
234
|
+
for (const { rej, timer } of this.waiting.values()) { clearTimeout(timer); rej(error); }
|
|
235
|
+
this.waiting.clear();
|
|
236
|
+
}
|
|
237
|
+
send(method, params = {}, ms = 45000) {
|
|
238
|
+
const id = ++this.id;
|
|
239
|
+
return new Promise((res, rej) => {
|
|
240
|
+
const timer = setTimeout(() => {
|
|
241
|
+
if (this.waiting.has(id)) { this.waiting.delete(id); rej(new Error(method + ' timed out')); }
|
|
242
|
+
}, ms);
|
|
243
|
+
this.waiting.set(id, { res, rej, timer });
|
|
244
|
+
try { this.ws.send(JSON.stringify({ id, method, params })); }
|
|
245
|
+
catch (err) { clearTimeout(timer); this.waiting.delete(id); rej(err); }
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
async waitForEvent(method, ms = 25000) {
|
|
249
|
+
const t0 = Date.now();
|
|
250
|
+
while (Date.now() - t0 < ms) {
|
|
251
|
+
const i = this.events.findIndex((e) => e.method === method);
|
|
252
|
+
if (i !== -1) return this.events.splice(i, 1)[0];
|
|
253
|
+
await sleep(50);
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
async evaluate(expression, { awaitPromise = false } = {}) {
|
|
258
|
+
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise });
|
|
259
|
+
if (r.exceptionDetails) throw new Error('page script failed: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text));
|
|
260
|
+
return r.result?.value;
|
|
261
|
+
}
|
|
262
|
+
close() { this.failPending(new Error('CDP session closed')); try { this.ws.close(); } catch {} }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* One browser for a whole run: starts it, hands fn a page session, and
|
|
266
|
+
always shuts the browser down afterwards, even when fn throws. */
|
|
267
|
+
export async function withBrowser(fn) {
|
|
268
|
+
const b = await launch();
|
|
269
|
+
let session;
|
|
270
|
+
try {
|
|
271
|
+
session = await Session.open(b.port);
|
|
272
|
+
await session.send('Page.enable');
|
|
273
|
+
await session.send('Runtime.enable');
|
|
274
|
+
return await fn(session);
|
|
275
|
+
} finally {
|
|
276
|
+
if (session) session.close();
|
|
277
|
+
await b.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* Loads a URL (or a data: / file: page) at a viewport and waits for it to
|
|
282
|
+
settle: the load event, web fonts, then a fixed pause for entrance motion. */
|
|
283
|
+
export async function load(session, url, { width = 1440, height = 900, wait = 3000, timeout = 30000 } = {}) {
|
|
284
|
+
await session.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 700 });
|
|
285
|
+
session.events.length = 0;
|
|
286
|
+
const nav = await session.send('Page.navigate', { url });
|
|
287
|
+
if (nav.errorText) throw new Error('navigation failed: ' + nav.errorText);
|
|
288
|
+
const loaded = await session.waitForEvent('Page.loadEventFired', timeout);
|
|
289
|
+
await session.evaluate('document.fonts ? document.fonts.ready.then(() => 1) : 1', { awaitPromise: true }).catch(() => {});
|
|
290
|
+
await sleep(wait);
|
|
291
|
+
return { loaded: !!loaded };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function screenshot(session, { format = 'png', quality, clip } = {}) {
|
|
295
|
+
const params = { format, captureBeyondViewport: !!clip };
|
|
296
|
+
if (format === 'jpeg') params.quality = quality ?? 82;
|
|
297
|
+
if (clip) params.clip = { ...clip, scale: 1 };
|
|
298
|
+
const r = await session.send('Page.captureScreenshot', params);
|
|
299
|
+
return Buffer.from(r.data, 'base64');
|
|
300
|
+
}
|