dsh-moli 1.1.11 → 1.1.13

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/lib/moli.js CHANGED
@@ -31,6 +31,64 @@ const TEXT_DUMPS = new Set([undefined, 'html', 'markdown', 'json', 'semantic_tre
31
31
  /** Accepted `--wait` lifecycle stages. */
32
32
  const WAIT_STAGES = new Set(['domcontentloaded', 'load', 'networkidle', 'domstable', 'done']);
33
33
 
34
+ /** `scroll` keywords, mapped to what they mean in the page. */
35
+ const SCROLL_TOKENS = {
36
+ top: '0',
37
+ bottom: 'document.documentElement.scrollHeight',
38
+ };
39
+
40
+ /**
41
+ * Build the expression that scrolls the page, to be run by the engine's own
42
+ * `--eval` before the dump.
43
+ *
44
+ * Scrolling is not a nicety: a page can load content on its first scroll event, and
45
+ * then a fetch without scrolling sees a placeholder while the same fetch with it
46
+ * sees the content. Verified against a page whose scroll handler fills a slot --
47
+ * the dump carried the loaded text with `scroll` and the placeholder without it.
48
+ * Moli does serialize the whole document either way, so this is not about reaching
49
+ * content that "below the fold" already contains; it is about triggering the
50
+ * handler, the intersection observer, or the lazy image that only runs on a scroll.
51
+ *
52
+ * @param scroll - pixels down (negative scrolls up), or `top` / `bottom`.
53
+ * @returns a JavaScript expression, or undefined when there is nothing to do.
54
+ */
55
+ export function scrollScript(scroll) {
56
+ if (scroll === undefined || scroll === null) return undefined;
57
+ let target;
58
+ if (typeof scroll === 'number') {
59
+ if (!Number.isFinite(scroll)) return undefined;
60
+ target = String(Math.trunc(scroll));
61
+ } else if (typeof scroll === 'string') {
62
+ const key = scroll.trim().toLowerCase();
63
+ if (key === '') return undefined;
64
+ target = SCROLL_TOKENS[key] ?? (/^-?\d+$/u.test(key) ? key : undefined);
65
+ if (target === undefined) return undefined;
66
+ } else {
67
+ return undefined;
68
+ }
69
+ // Dispatch a scroll event as well as moving: `window.scrollTo` updates the
70
+ // position, but a page listening for the event is the reason this exists.
71
+ return `(() => { window.scrollTo(0, ${target}); window.dispatchEvent(new Event('scroll')); return String(window.scrollY); })()`;
72
+ }
73
+
74
+ /**
75
+ * Combine the caller's `eval` with a scroll, if one was asked for.
76
+ *
77
+ * The two have to share one expression because the engine accepts a single
78
+ * `--eval`. The scroll goes first so the page is in its final state before the
79
+ * caller's expression reads it, and the caller's value still becomes the result.
80
+ *
81
+ * @returns the expression to pass, or undefined when neither was given.
82
+ */
83
+ export function combineEval(evalExpression, scroll) {
84
+ const move = scrollScript(scroll);
85
+ if (move === undefined) return evalExpression;
86
+ if (evalExpression === undefined || evalExpression === '') return move;
87
+ // The caller's expression is kept last and wrapped so its value is the one the
88
+ // engine reports. Losing it would contradict the documented `eval` behaviour.
89
+ return `${move}; (() => (${evalExpression}))()`;
90
+ }
91
+
34
92
  /**
35
93
  * Every place this plugin will look for `moli.exe`, most explicit first.
36
94
  *
@@ -256,7 +314,10 @@ function buildArgs(args) {
256
314
  // Screenshots and PDFs silently produce nothing without the layout renderer.
257
315
  if (needsLayout) argv.push('--layout');
258
316
  if (args.scrollbars === true) argv.push('--scrollbars');
259
- if (args.eval !== undefined) argv.push('--eval', args.eval);
317
+ // `scroll` rides along in the same expression: the engine takes a single `--eval`,
318
+ // and the scroll has to happen before the page is captured.
319
+ const evaluation = combineEval(args.eval, args.scroll);
320
+ if (evaluation !== undefined) argv.push('--eval', evaluation);
260
321
  if (args.wait !== undefined && args.wait !== '') argv.push('--wait', args.wait);
261
322
  if (args.waitSelector !== undefined && args.waitSelector !== '') argv.push('--wait-selector', args.waitSelector);
262
323
  if (args.delayMs !== undefined) argv.push('--delay-ms', String(args.delayMs));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-moli",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "type": "module",
5
5
  "description": "DeepSeek Harness tools for the local Moli headless browser: rendered fetches, markdown, semantic trees, screenshots, and PDFs.",
6
6
  "license": "MIT",
package/lib/actions.js DELETED
@@ -1,380 +0,0 @@
1
- /**
2
- * The actions a session can perform.
3
- *
4
- * Kept apart from {@link ./session.js} so the transport (server lifecycle, CDP
5
- * plumbing) and the behaviour (what `click` means) can be read and tested
6
- * separately. Every action takes the CDP connection and returns a plain value;
7
- * none of them throws for a page-level problem, because "that selector matched
8
- * nothing" is a result the caller has to see, not a transport failure.
9
- *
10
- * Selector handling is deliberately uniform: an element is located by CSS
11
- * selector in the page, measured, and then driven through real input events. The
12
- * alternative -- taking viewport coordinates from the caller -- makes the model
13
- * guess pixels, and the coordinates go stale the moment the page reflows.
14
- *
15
- * @module dsh-moli/lib/actions
16
- */
17
- import { existsSync, writeFileSync } from 'node:fs';
18
- import { dirname } from 'node:path';
19
-
20
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
21
-
22
- /** CSS-selector quoting, so a selector can be embedded in an expression safely. */
23
- const quote = value => JSON.stringify(String(value));
24
-
25
- /**
26
- * Resolve a selector to a click point, or explain why there is not one.
27
- *
28
- * **This engine's geometry is document-absolute and ignores scrolling.**
29
- * `getBoundingClientRect()` returns the same `y` at `scrollY` 0, 400 and 800, and
30
- * `elementFromPoint()` agrees with it -- scanning the hit test at
31
- * `rect.y + scrollY` returns null while `rect.y` hits the element. The mouse
32
- * input APIs share that frame, so a click dispatched at `rect.y` lands even when
33
- * the page is scrolled.
34
- *
35
- * Measured against moli 1.1.16. This is not the web platform's behaviour, and it
36
- * matters: the textbook recipe of "scroll the element into view, then read the
37
- * viewport rectangle" produces coordinates that are wrong by the scroll offset,
38
- * and the failure is silent on a short page and total on a long one. Nothing here
39
- * may depend on the scroll position.
40
- *
41
- * @returns `{ box, point, tag, text, covered }` on success, `{ error }` otherwise.
42
- */
43
- async function boxOf(connection, selector) {
44
- const expression = `(() => {
45
- const el = document.querySelector(${quote(selector)});
46
- if (el === null) return { missing: true };
47
- const rect = el.getBoundingClientRect();
48
- const style = getComputedStyle(el);
49
- const cx = rect.x + rect.width / 2;
50
- const cy = rect.y + rect.height / 2;
51
- // The hit test is the authority on whether a click can land, and it uses the
52
- // same frame as the rectangle. An element that is scrolled out of view still
53
- // passes, because in this engine the input is not clipped to the viewport.
54
- const found = document.elementFromPoint(cx, cy);
55
- const hitSelf = found !== null && (found === el || el.contains(found) || found.contains(el));
56
- return {
57
- box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
58
- point: hitSelf ? { x: cx, y: cy } : null,
59
- covered: hitSelf || found === null ? null : found.tagName.toLowerCase() + (found.id ? '#' + found.id : ''),
60
- tag: el.tagName.toLowerCase(),
61
- text: (el.innerText || el.value || el.getAttribute('aria-label') || '').trim().slice(0, 80),
62
- visible: style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0,
63
- scrollY: window.scrollY,
64
- viewportHeight: innerHeight,
65
- };
66
- })()`;
67
- const result = await connection.evaluate(expression);
68
- if (result.throws !== undefined) return { error: result.throws };
69
- const value = result.value;
70
- if (value === undefined) return { error: 'the page returned nothing for that selector' };
71
- if (value.missing === true) return { error: `no element matches ${JSON.stringify(selector)}` };
72
- return value;
73
- }
74
-
75
- /**
76
- * Measure a selector.
77
- *
78
- * `scrollIntoView` is attempted because it is harmless and does move the page for
79
- * a human reading it, but nothing here depends on it: the geometry does not
80
- * follow the scroll, so the measurement is identical either way. See {@link boxOf}
81
- * for the engine detail and its consequence.
82
- */
83
- async function ensureVisible(connection, selector) {
84
- await connection.evaluate(`(() => {
85
- const el = document.querySelector(${quote(selector)});
86
- if (el !== null) el.scrollIntoView({ block: 'center', inline: 'center' });
87
- })()`);
88
- await sleep(50);
89
- return boxOf(connection, selector);
90
- }
91
-
92
- /**
93
- * Turn a measurement into a click that is known to land on the element.
94
- *
95
- * A click outside the viewport, or one whose point is covered by another element,
96
- * is reported rather than dispatched: dispatching anyway produces a silent no-op
97
- * that looks like success to the caller.
98
- */
99
- async function clickSelector(connection, selector) {
100
- const found = await ensureVisible(connection, selector);
101
- if (found.error !== undefined) return { ok: false, error: found.error };
102
- if (found.visible !== true) return { ok: false, error: `${JSON.stringify(selector)} is not visible (display:none or visibility:hidden)` };
103
- if (found.point === null) {
104
- // The engine's hit test is clipped to the viewport height while its geometry
105
- // ignores scrolling, so an element whose absolute y is past the viewport
106
- // cannot be reached by synthetic input at all -- scrolling does not change
107
- // either number. Report that plainly and point at the route that does work.
108
- const beyondViewport = found.box.y + found.box.height / 2 > (found.viewportHeight ?? 0);
109
- return {
110
- ok: false,
111
- selector,
112
- error: found.covered === null
113
- ? (beyondViewport
114
- ? `${JSON.stringify(selector)} is below the viewport (y=${String(Math.round(found.box.y))}) and this engine cannot send input there`
115
- : `${JSON.stringify(selector)} has no box to click`)
116
- : `${JSON.stringify(selector)} is covered by another element (${found.covered})`,
117
- hint: beyondViewport
118
- ? 'Moli\'s hit test is bounded by the viewport height and its element geometry does not follow scrolling, so synthetic clicks cannot reach elements further down the document. Use eval to click it in the page instead -- for example `document.querySelector("SEL").click()` -- which bypasses the input layer entirely.'
119
- : found.covered === null
120
- ? 'The element may be display:none, zero-sized, or only a wrapper.'
121
- : 'Something is on top of it -- commonly a consent banner or a modal. Dismiss that first, or press Escape.',
122
- };
123
- }
124
- await clickAt(connection, found.point.x, found.point.y);
125
- return { ok: true, found, x: found.point.x, y: found.point.y };
126
- }
127
-
128
- /** Dispatch a full click: move, press, release. Real events, so page handlers run. */
129
- async function clickAt(connection, x, y, { clickCount = 1 } = {}) {
130
- await connection.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
131
- await connection.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount });
132
- await connection.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount });
133
- }
134
-
135
- /**
136
- * Wait until the page has no pending load and the document is unchanged for a
137
- * short settle window. Used after navigation and after clicks that may navigate.
138
- */
139
- async function settle(connection, { quietMs = 250, timeoutMs = 20000 } = {}) {
140
- const started = Date.now();
141
- let lastSignature;
142
- let stableSince = Date.now();
143
- while (Date.now() - started < timeoutMs) {
144
- const result = await connection.evaluate('document.readyState + "|" + document.title + "|" + location.href');
145
- const signature = String(result.value);
146
- if (signature !== lastSignature) {
147
- lastSignature = signature;
148
- stableSince = Date.now();
149
- } else if (Date.now() - stableSince >= quietMs) {
150
- return { settled: true, ms: Date.now() - started };
151
- }
152
- await sleep(100);
153
- }
154
- return { settled: false, ms: Date.now() - started };
155
- }
156
-
157
- /** A readable, size-bounded view of the rendered page. */
158
- async function snapshot(connection, { maxChars, includeHtml }) {
159
- const expression = `(() => {
160
- const text = document.body === null ? '' : document.body.innerText.trim();
161
- const links = [...document.querySelectorAll('a[href]')].slice(0, 200).map(a => ({ text: (a.innerText || '').trim().slice(0, 80), href: a.href }));
162
- return JSON.stringify({
163
- url: location.href,
164
- title: document.title,
165
- text,
166
- links,
167
- forms: [...document.querySelectorAll('input,textarea,select')].slice(0, 50).map(el => ({
168
- tag: el.tagName.toLowerCase(), type: el.type || '', name: el.name || '', id: el.id || '', placeholder: el.placeholder || '',
169
- })),
170
- html: ${includeHtml === true ? 'document.documentElement.outerHTML' : "''"},
171
- });
172
- })()`;
173
- const result = await connection.evaluate(expression);
174
- if (result.throws !== undefined) return { ok: false, error: result.throws };
175
- const data = JSON.parse(String(result.value));
176
- const limit = typeof maxChars === 'number' && maxChars > 0 ? maxChars : 200000;
177
- const truncated = data.text.length > limit;
178
- return {
179
- ok: true,
180
- url: data.url,
181
- title: data.title,
182
- text: truncated ? `${data.text.slice(0, limit)}\n\n… [dsh-moli] ${String(data.text.length - limit)} characters omitted; pass a larger maxChars to keep them.` : data.text,
183
- chars: data.text.length,
184
- truncated,
185
- links: data.links,
186
- fields: data.forms,
187
- ...(includeHtml === true ? { html: data.html.slice(0, limit) } : {}),
188
- };
189
- }
190
-
191
- // ---------------------------------------------------------------------------
192
- // the action table
193
- // ---------------------------------------------------------------------------
194
-
195
- /**
196
- * Run one action against a live connection.
197
- *
198
- * @param connection - the CDP connection from {@link SessionManager#ensure}.
199
- * @param args - the tool arguments.
200
- * @param options - output limits and the resolved session manager.
201
- * @returns the action's value.
202
- */
203
- export async function performAction(connection, args, options) {
204
- const action = args.action;
205
- const timeoutMs = args.timeoutMs ?? 30000;
206
-
207
- switch (action) {
208
- case 'open': {
209
- if (typeof args.url !== 'string' || args.url === '') return { ok: false, error: 'open needs a url' };
210
- await connection.send('Page.navigate', { url: args.url }, timeoutMs);
211
- const result = await settle(connection, { timeoutMs });
212
- const url = await connection.evaluate('location.href');
213
- const title = await connection.evaluate('document.title');
214
- return { ok: true, action, url: url.value, title: title.value, settled: result.settled, settledMs: result.ms };
215
- }
216
-
217
- case 'snapshot':
218
- return snapshot(connection, { maxChars: args.maxChars ?? options.maxChars, includeHtml: args.includeHtml === true });
219
-
220
- case 'click': {
221
- if (typeof args.selector !== 'string' || args.selector === '') return { ok: false, error: 'click needs a selector' };
222
- const before = await connection.evaluate('location.href');
223
- const outcome = await clickSelector(connection, args.selector);
224
- if (outcome.ok !== true) return { ok: false, action, selector: args.selector, ...outcome };
225
- // A click may navigate, open a dialog, or only run a handler; settling
226
- // covers the first, and reporting the URL change covers all three.
227
- const settled = await settle(connection, { timeoutMs: Math.min(timeoutMs, 10000) });
228
- const after = await connection.evaluate('location.href');
229
- return {
230
- ok: true,
231
- action,
232
- selector: args.selector,
233
- clicked: { tag: outcome.found.tag, text: outcome.found.text, x: Math.round(outcome.x), y: Math.round(outcome.y) },
234
- navigated: before.value !== after.value,
235
- url: after.value,
236
- settled: settled.settled,
237
- };
238
- }
239
-
240
- case 'type': {
241
- if (typeof args.selector !== 'string' || args.selector === '') return { ok: false, error: 'type needs a selector' };
242
- if (typeof args.text !== 'string') return { ok: false, error: 'type needs text' };
243
- // Same measurement discipline as click: a field far down the page has to be
244
- // scrolled into view before it can be focused and typed into reliably.
245
- const found = await ensureVisible(connection, args.selector);
246
- if (found.error !== undefined) return { ok: false, error: found.error, selector: args.selector };
247
- // Focus through the page so the element is the real editing target, then
248
- // clear it when asked: typing into a pre-filled field would append.
249
- const focused = await connection.evaluate(
250
- `(() => { const el = document.querySelector(${quote(args.selector)}); if (el === null) return 'missing'; el.focus(); ${args.clear === true ? 'if ("value" in el) { el.value = ""; }' : ''} return document.activeElement === el ? 'focused' : 'not-focused'; })()`);
251
- if (focused.value !== 'focused') return { ok: false, error: `could not focus ${JSON.stringify(args.selector)} (${String(focused.value ?? focused.throws)})` };
252
- await connection.send('Input.insertText', { text: args.text });
253
- let submitted = false;
254
- if (args.submit === true) {
255
- await keyPress(connection, 'Enter');
256
- submitted = true;
257
- await settle(connection, { timeoutMs: Math.min(timeoutMs, 10000) });
258
- }
259
- const value = await connection.evaluate(`(() => { const el = document.querySelector(${quote(args.selector)}); return el === null ? null : (el.value ?? el.innerText ?? null); })()`);
260
- return { ok: true, action, selector: args.selector, submitted, value: value.value };
261
- }
262
-
263
- case 'press':
264
- if (typeof args.key !== 'string' || args.key === '') return { ok: false, error: 'press needs key' };
265
- await keyPress(connection, args.key);
266
- return { ok: true, action, key: args.key };
267
-
268
- case 'scroll': {
269
- let expression;
270
- if (typeof args.selector === 'string' && args.selector !== '') {
271
- expression = `(() => { const el = document.querySelector(${quote(args.selector)}); if (el === null) return null; el.scrollIntoView({ block: '${String(args.block ?? 'center')}' }); return window.scrollY; })()`;
272
- } else {
273
- const dy = typeof args.dy === 'number' ? args.dy : 800;
274
- expression = `(() => { window.scrollBy(0, ${String(dy)}); return window.scrollY; })()`;
275
- }
276
- const result = await connection.evaluate(expression);
277
- if (result.value === null) return { ok: false, error: `no element matches ${JSON.stringify(String(args.selector))}` };
278
- const metrics = await connection.evaluate('JSON.stringify({ y: window.scrollY, height: document.body === null ? 0 : document.body.scrollHeight, viewport: innerHeight })');
279
- return { ok: true, action, ...JSON.parse(String(metrics.value)) };
280
- }
281
-
282
- case 'waitFor': {
283
- if (typeof args.selector !== 'string' || args.selector === '') return { ok: false, error: 'waitFor needs a selector' };
284
- const deadline = Date.now() + timeoutMs;
285
- const start = Date.now();
286
- // `detached` means the selector matches nothing. It is deliberately not
287
- // "hidden": a `display:none` element still matches, and conflating the two
288
- // would make this wait forever on something the caller can already see is
289
- // absent from the DOM.
290
- const wantPresent = args.state !== 'detached';
291
- for (;;) {
292
- const present = (await connection.evaluate(`document.querySelector(${quote(args.selector)}) !== null`)).value === true;
293
- if (present === wantPresent) {
294
- return {
295
- ok: true, action, selector: args.selector,
296
- state: present ? 'attached' : 'detached',
297
- waitedMs: Date.now() - start,
298
- };
299
- }
300
- if (Date.now() > deadline) {
301
- return {
302
- ok: false, action, selector: args.selector,
303
- error: `timed out after ${String(timeoutMs)}ms waiting for ${JSON.stringify(args.selector)} to be ${wantPresent ? 'attached' : 'detached'}`,
304
- // A hidden element still matches the selector, which is the usual
305
- // reason someone reaches for `detached` and waits for ever.
306
- hint: '`detached` means the element is absent from the DOM; a hidden element still matches the selector. Use eval to check getComputedStyle, or wait for the attribute or class that hides it.',
307
- };
308
- }
309
- await sleep(150);
310
- }
311
- }
312
-
313
- case 'eval': {
314
- if (typeof args.expression !== 'string' || args.expression === '') return { ok: false, error: 'eval needs expression' };
315
- const result = await connection.evaluate(args.expression, { awaitPromise: true, timeoutMs });
316
- if (result.throws !== undefined) return { ok: false, action, error: result.throws, hint: 'The expression threw. Check that the page is in the state you expect and that the selector matches an element.' };
317
- const value = result.value;
318
- const serialized = typeof value === 'string' ? value : JSON.stringify(value);
319
- return { ok: true, action, value, serialized: serialized ?? 'undefined' };
320
- }
321
-
322
- case 'screenshot': {
323
- const result = await connection.send('Page.captureScreenshot', { format: args.format ?? 'png' }, timeoutMs);
324
- const data = result.data;
325
- if (typeof data !== 'string') return { ok: false, error: 'the engine returned no image data' };
326
- if (typeof args.writeTo !== 'string' || args.writeTo === '') {
327
- return { ok: false, error: 'screenshot needs writeTo (an absolute path): the image cannot be returned inline', bytes: Buffer.from(data, 'base64').length };
328
- }
329
- const directory = dirname(args.writeTo);
330
- if (!existsSync(directory)) return { ok: false, error: `the destination directory does not exist: ${directory}` };
331
- const buffer = Buffer.from(data, 'base64');
332
- writeFileSync(args.writeTo, buffer);
333
- return { ok: true, action, path: args.writeTo, bytes: buffer.length, format: args.format ?? 'png' };
334
- }
335
-
336
- case 'status':
337
- return { ok: true, action };
338
-
339
- case 'close':
340
- return { ok: true, action };
341
-
342
- default:
343
- return { ok: false, error: `unknown action ${JSON.stringify(String(action))}` };
344
- }
345
- }
346
-
347
- /**
348
- * Press one key.
349
- *
350
- * The engine needs the key, the code and the legacy virtual key code to produce a
351
- * complete event; `Enter` also needs the character fields, which is why the table
352
- * carries more than the key name.
353
- */
354
- const KEYS = {
355
- Enter: { code: 'Enter', keyCode: 13, text: '\r' },
356
- Tab: { code: 'Tab', keyCode: 9 },
357
- Escape: { code: 'Escape', keyCode: 27 },
358
- Backspace: { code: 'Backspace', keyCode: 8 },
359
- ArrowUp: { code: 'ArrowUp', keyCode: 38 },
360
- ArrowDown: { code: 'ArrowDown', keyCode: 40 },
361
- ArrowLeft: { code: 'ArrowLeft', keyCode: 37 },
362
- ArrowRight: { code: 'ArrowRight', keyCode: 39 },
363
- PageUp: { code: 'PageUp', keyCode: 33 },
364
- PageDown: { code: 'PageDown', keyCode: 34 },
365
- Home: { code: 'Home', keyCode: 36 },
366
- End: { code: 'End', keyCode: 35 },
367
- Delete: { code: 'Delete', keyCode: 46 },
368
- };
369
-
370
- async function keyPress(connection, name) {
371
- const spec = KEYS[name] ?? { code: name, keyCode: 0 };
372
- const base = { key: name, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode };
373
- const down = { ...base, type: spec.text === undefined ? 'rawKeyDown' : 'keyDown' };
374
- if (spec.text !== undefined) down.text = spec.text;
375
- await connection.send('Input.dispatchKeyEvent', down);
376
- await connection.send('Input.dispatchKeyEvent', { ...base, type: 'keyUp' });
377
- }
378
-
379
- /** Exported for tests: the selector-and-box step, without an input dispatch. */
380
- export const __internal = { boxOf, ensureVisible, settle, snapshot, KEYS };