dsh-moli 1.1.15 → 1.1.16

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/README.md CHANGED
@@ -242,8 +242,29 @@ Measured on a page whose `scroll` handler fills a slot: without `scroll` the dum
242
242
  `STILL-EMPTY`, with `scroll=1000` it says `LAZY-CONTENT-1000`. It also moves the screenshot, so a
243
243
  scrolled `dump=screenshot` shows that part of the page.
244
244
 
245
- It runs as part of the engine's own pre-capture evaluation and therefore composes with `eval`: the
246
- scroll happens first, and the expression still returns its own value.
245
+ `scroll` is a side effect, so it composes and stands alone:
246
+
247
+ ```
248
+ moli_fetch url=... dump=markdown scroll=600 # scroll, then dump the page
249
+ moli_fetch url=... eval="document.title" scroll=600 # scroll, then read a value
250
+ moli_fetch url=... scroll=600 # just scroll; you still get the page
251
+ ```
252
+
253
+ The last form is worth spelling out because it was broken. The scroll runs in the engine's own
254
+ pre-capture evaluation, and without `dump` the engine prints whatever that evaluation returns — so an
255
+ earlier version that returned the scroll position made this call answer `600` instead of the document,
256
+ while the identical call without `scroll` returned 1,989 characters of HTML. A scroll reports nothing
257
+ now; it only happens.
258
+
259
+ ### `eval` with `dump` — the expression value is dropped
260
+
261
+ The engine documents this and it is kept, because using an expression to change a page before
262
+ capturing it is a real pattern. What is not acceptable is that it used to look like a clean answer:
263
+ `eval='JSON.stringify({probe:1})'` with `dump=markdown` returned 900 characters of Markdown, reported
264
+ `ok: true`, and said nothing. The result now carries `evalDiscarded: true` and a note, and the rendered
265
+ output leads with the warning rather than burying it under the body.
266
+
267
+ If you want the expression's value, pass `eval` without `dump`.
247
268
 
248
269
  ### `failOnHttpError` — stop reading an error page as content
249
270
 
@@ -472,6 +493,8 @@ Eight suites live in [`verify/`](verify/), and all are runnable against a workin
472
493
  reaches the engine is indistinguishable from a feature that does not work. It then fetches a 404 and
473
494
  checks that `failOnHttpError` reports it while keeping the body, that a healthy page is unaffected,
474
495
  and that a screenshot of an error page is still written as a valid PNG rather than decoded as text.
496
+ It also covers the two silent-answer cases: `eval` with `dump` is flagged `evalDiscarded` and the
497
+ warning renders first, and `scroll` alone returns the document rather than the scroll offset.
475
498
  The end-to-end half needs an HTTP error from a real server: loopback is unreachable from inside this
476
499
  sandbox — `curl.exe` times out on `127.0.0.1` while the Node host reaches it — so it uses a path
477
500
  under `example.com` and skips when the network is away.
package/README.zh.md CHANGED
@@ -227,8 +227,26 @@ moli_fetch url=https://example.com/feed dump=markdown scroll=-400 # 往回滚
227
227
  `scroll=1000` 时是 `LAZY-CONTENT-1000`。它同样会改变截图,所以带滚动的 `dump=screenshot` 拍到的是页面的
228
228
  那一段。
229
229
 
230
- 它作为引擎自身"抓取前求值"的一部分执行,因此与 `eval` 可组合:**先滚动,表达式再读取页面,并且仍然返回
231
- 它自己的值**。
230
+ `scroll` 是副作用,因此既可组合也可单独使用:
231
+
232
+ ```
233
+ moli_fetch url=... dump=markdown scroll=600 # 先滚动,再 dump 页面
234
+ moli_fetch url=... eval="document.title" scroll=600 # 先滚动,再取一个值
235
+ moli_fetch url=... scroll=600 # 只滚动,你仍然拿到页面
236
+ ```
237
+
238
+ **最后一种写法值得单独说明,因为它曾经是坏的。** 滚动在引擎自身的"抓取前求值"里执行,而在没有 `dump` 时,
239
+ 引擎打印的就是这个求值的返回值——所以早期版本返回滚动位置,导致这一调用回的是 `600` 而不是页面,而**同样的
240
+ 调用不加 `scroll` 会返回 1989 个字符的 HTML**。现在滚动不报告任何东西,它只是发生。
241
+
242
+ ### `eval` 与 `dump` 同用 —— 表达式值会被丢弃
243
+
244
+ 这是引擎写明并有意的行为,予以保留——**用表达式在抓取前改变页面**是真实用法。不能接受的是它**看起来像一个
245
+ 干净答案**:`eval='JSON.stringify({probe:1})'` 配 `dump=markdown` 会返回 900 个字符的 Markdown、报
246
+ `ok: true`、什么都不说。现在结果会带 `evalDiscarded: true` 和说明,而且渲染输出**把警告放在最前面**,不会
247
+ 被正文埋掉。
248
+
249
+ 要拿表达式的值,就只传 `eval`、不要传 `dump`。
232
250
 
233
251
  ### `failOnHttpError` —— 别再把错误页当正文读
234
252
 
@@ -428,9 +446,10 @@ profile。无论生效的是哪个代理,loopback 地址一律绕过它。
428
446
  断言的是**输出**的性质,而不是内部调用。
429
447
  - `http-verify.mjs` 直接断言"参数 → 引擎选项"的映射,因为**参数没送到引擎和功能不生效,从外面看是一样的**。
430
448
  随后抓一个 404,验证 `failOnHttpError` 会报告失败同时保留响应体、健康页面不受影响、以及错误页的截图仍然被
431
- 写为合法 PNG 而不是被当文本解码。端到端部分需要一个真实服务器的 HTTP 错误:**本沙箱内无法访问 loopback**
432
- ——`curl.exe` 连 `127.0.0.1` 会超时,而 Node 宿主进程可以——所以它用 `example.com` 下的一个路径,网络不可用
433
- 时跳过。
449
+ 写为合法 PNG 而不是被当文本解码。它还覆盖那两个"静默给错答案"的情形:`eval` 配 `dump` 会带
450
+ `evalDiscarded` 标记且警告渲染在最前,`scroll` 单独用返回的是文档而不是滚动偏移。端到端部分需要一个真实
451
+ 服务器的 HTTP 错误:**本沙箱内无法访问 loopback**——`curl.exe` 连 `127.0.0.1` 会超时,而 Node 宿主进程
452
+ 可以——所以它用 `example.com` 下的一个路径,网络不可用时跳过。
434
453
 
435
454
  ### 测试前置条件
436
455
 
package/index.js CHANGED
@@ -174,6 +174,7 @@ const FETCH_OUTPUT_SCHEMA = {
174
174
  empty: { type: 'boolean' },
175
175
  omitted: { type: 'integer' },
176
176
  evalFailed: { type: 'boolean' },
177
+ evalDiscarded: { type: 'boolean' },
177
178
  truncated: { type: 'boolean' },
178
179
  error: { type: 'string' },
179
180
  hint: { type: 'string' },
@@ -243,6 +244,12 @@ function renderFetch(_args, value) {
243
244
  }];
244
245
  }
245
246
  const parts = [];
247
+ // A discarded `eval` value is the one case where the output looks like a clean
248
+ // answer to a question that was never answered, so the warning goes first rather
249
+ // than after the body, where a 900-character page would bury it.
250
+ if (value.evalDiscarded === true) {
251
+ parts.push('Note: the `eval` expression was dropped because `dump` was given too — the engine discards the expression value in that combination. What follows is the page, not the expression.');
252
+ }
246
253
  if (typeof value.note === 'string' && value.note !== '') parts.push(value.note);
247
254
  parts.push(String(value.content ?? ''));
248
255
  return [{ type: 'text', text: parts.join('\n\n') }];
package/lib/moli.js CHANGED
@@ -56,6 +56,14 @@ const SCROLL_TOKENS = {
56
56
  * content that "below the fold" already contains; it is about triggering the
57
57
  * handler, the intersection observer, or the lazy image that only runs on a scroll.
58
58
  *
59
+ * **The expression must not return the scroll position.** Without `--dump` the
60
+ * engine prints whatever `--eval` evaluates to, so returning `String(window.scrollY)`
61
+ * here replaced the page with the number `600`: `moli_fetch url=... scroll=600`
62
+ * answered `600`, while the identical call without `scroll` returned the document's
63
+ * HTML. The scroll is a side effect; the value it wants to report is recorded on
64
+ * `window` instead, where a later read can pick it up without changing what the
65
+ * engine prints.
66
+ *
59
67
  * @param scroll - pixels down (negative scrolls up), or `top` / `bottom`.
60
68
  * @returns a JavaScript expression, or undefined when there is nothing to do.
61
69
  */
@@ -74,26 +82,55 @@ export function scrollScript(scroll) {
74
82
  return undefined;
75
83
  }
76
84
  // Dispatch a scroll event as well as moving: `window.scrollTo` updates the
77
- // position, but a page listening for the event is the reason this exists.
78
- return `(() => { window.scrollTo(0, ${target}); window.dispatchEvent(new Event('scroll')); return String(window.scrollY); })()`;
85
+ // position, but a page listening for the event is the reason this exists. The
86
+ // height is captured on both sides of the move so a caller can tell whether the
87
+ // page grew -- which is the one signal that says lazy loading actually happened.
88
+ return `(() => {
89
+ const before = document.body === null ? 0 : document.body.scrollHeight;
90
+ window.scrollTo(0, ${target});
91
+ window.dispatchEvent(new Event('scroll'));
92
+ window.__dshMoliScroll = {
93
+ requested: ${target},
94
+ y: window.scrollY,
95
+ heightBefore: before,
96
+ heightAfter: document.body === null ? 0 : document.body.scrollHeight,
97
+ };
98
+ })()`;
79
99
  }
80
100
 
81
101
  /**
82
102
  * Combine the caller's `eval` with a scroll, if one was asked for.
83
103
  *
84
- * The two have to share one expression because the engine accepts a single
85
- * `--eval`. The scroll goes first so the page is in its final state before the
86
- * caller's expression reads it, and the caller's value still becomes the result.
104
+ * The two share one expression because the engine accepts a single `--eval`.
105
+ *
106
+ * What gets returned depends on whether anything else will produce the output,
107
+ * and getting this wrong breaks the feature in one direction or the other:
87
108
  *
88
- * @returns the expression to pass, or undefined when neither was given.
109
+ * - **A `dump` was requested.** The dump produces the output, so the expression is
110
+ * there only to scroll first and its value is discarded by the engine anyway.
111
+ * It must still be passed, or the scroll never runs.
112
+ * - **No `dump`, and the caller gave an expression.** The expression value *is* the
113
+ * output, and the scroll runs as part of it.
114
+ * - **No `dump`, no caller expression.** The engine prints whatever `--eval`
115
+ * evaluates to, so passing the scroll expression here would replace the page with
116
+ * the scroll's return value. `undefined` is returned instead, leaving the engine's
117
+ * default output -- the serialized document -- intact. This is the case that
118
+ * shipped broken: `moli_fetch url=... scroll=600` answered `600`.
119
+ *
120
+ * @param evalExpression - the caller's expression, if any.
121
+ * @param scroll - the caller's `scroll` value, if any.
122
+ * @param hasDump - whether the caller also asked for a dump.
123
+ * @returns the expression to pass, or undefined to let the engine decide.
89
124
  */
90
- export function combineEval(evalExpression, scroll) {
125
+ export function combineEval(evalExpression, scroll, hasDump = false) {
91
126
  const move = scrollScript(scroll);
92
- if (move === undefined) return evalExpression;
93
- if (evalExpression === undefined || evalExpression === '') return move;
94
- // The caller's expression is kept last and wrapped so its value is the one the
95
- // engine reports. Losing it would contradict the documented `eval` behaviour.
96
- return `${move}; (() => (${evalExpression}))()`;
127
+ const caller = evalExpression === undefined || evalExpression === '' ? undefined : evalExpression;
128
+ if (move === undefined) return caller;
129
+ // The caller's expression goes last and is wrapped so its value is the one the
130
+ // engine reports; losing it would contradict the documented `eval` behaviour.
131
+ if (caller !== undefined) return `${move}; (() => (${caller}))()`;
132
+ // No caller expression: keep the scroll only when a dump will produce the output.
133
+ return hasDump ? move : undefined;
97
134
  }
98
135
 
99
136
  /**
@@ -334,7 +371,11 @@ export function buildArgs(args) {
334
371
  if (args.scrollbars === true) argv.push('--scrollbars');
335
372
  // `scroll` rides along in the same expression: the engine takes a single `--eval`,
336
373
  // and the scroll has to happen before the page is captured.
337
- const evaluation = combineEval(args.eval, args.scroll);
374
+ // `scroll` rides along in the same expression: the engine takes a single `--eval`,
375
+ // and the scroll has to happen before the page is captured. `hasDump` matters --
376
+ // see combineEval -- because with a dump the expression exists only for its side
377
+ // effect, and without one it would become the output.
378
+ const evaluation = combineEval(args.eval, args.scroll, args.dump !== undefined);
338
379
  if (evaluation !== undefined) argv.push('--eval', evaluation);
339
380
  if (args.wait !== undefined && args.wait !== '') argv.push('--wait', args.wait);
340
381
  if (args.waitSelector !== undefined && args.waitSelector !== '') argv.push('--wait-selector', args.waitSelector);
@@ -561,6 +602,16 @@ export function runFetch(options) {
561
602
  }
562
603
 
563
604
  const text = outcome.stdout.toString('utf8');
605
+ // `eval` together with `dump` is the engine's documented behaviour -- it discards
606
+ // the expression's value and dumps the page state the expression produced -- but
607
+ // it reads as a successful answer to a question that was never answered. The
608
+ // caller asked for `{probe:1}` and got 900 characters of Markdown. The discard is
609
+ // kept, because mutating a page before capturing it is a real use, so the result
610
+ // has to say what happened instead of looking clean.
611
+ const evalDiscarded = args.eval !== undefined && args.eval !== '' && args.dump !== undefined;
612
+ const evalNote = evalDiscarded
613
+ ? 'The `eval` expression produced no value here: `dump` was given too, and the engine discards the expression result in that combination. The output above is the page, not the expression. Run again with `eval` alone for the value, or keep both deliberately when the expression only changes the page before it is captured.'
614
+ : undefined;
564
615
  // A JSON dump is the only format that reports the HTTP status, so an error
565
616
  // page is otherwise indistinguishable from content. Surface it rather than
566
617
  // letting the model read a 404 body as if the fetch succeeded.
@@ -591,7 +642,10 @@ export function runFetch(options) {
591
642
  path: args.writeTo,
592
643
  chars: text.length,
593
644
  bytes,
594
- note: 'Full output saved to file; read it for the complete text.',
645
+ note: evalNote === undefined
646
+ ? 'Full output saved to file; read it for the complete text.'
647
+ : `Full output saved to file; read it for the complete text. ${evalNote}`,
648
+ ...(evalDiscarded ? { evalDiscarded: true } : {}),
595
649
  };
596
650
  }
597
651
 
@@ -617,6 +671,12 @@ export function runFetch(options) {
617
671
  content = text.slice(0, headLen) + mark + text.slice(text.length - tailLen);
618
672
  note = `Output truncated: showing the first ${String(headLen)} and the last ${String(tailLen)} of ${String(text.length)} characters, with ${String(omitted)} omitted in the middle. Pass writeTo to keep all ${String(text.length)}.`;
619
673
  }
674
+ // `eval` together with `dump` is the engine's documented behaviour -- it discards
675
+ // the expression's value and dumps the page state the expression produced -- but
676
+ // it reads as a successful answer to a question that was never answered. The
677
+ // caller asked for `{probe:1}` and got 900 characters of Markdown. The value is
678
+ // still discarded (that combination is genuinely useful for mutating a page before
679
+ // capturing it), so the result has to say so rather than look clean.
620
680
  return {
621
681
  ok: true,
622
682
  url: args.url,
@@ -626,7 +686,11 @@ export function runFetch(options) {
626
686
  chars: text.length,
627
687
  bytes,
628
688
  truncated,
629
- ...(truncated ? { omitted, note } : {}),
689
+ ...(truncated ? { omitted } : {}),
690
+ ...(evalDiscarded ? { evalDiscarded: true } : {}),
691
+ ...(note === undefined && evalNote === undefined
692
+ ? {}
693
+ : { note: [note, evalNote].filter(part => part !== undefined).join(' ') }),
630
694
  };
631
695
  }
632
696
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-moli",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
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",