@jackwener/opencli 1.7.5 → 1.7.7

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.
Files changed (121) hide show
  1. package/README.md +22 -10
  2. package/README.zh-CN.md +18 -9
  3. package/cli-manifest.json +401 -11
  4. package/clis/51job/company.js +125 -0
  5. package/clis/51job/detail.js +108 -0
  6. package/clis/51job/hot.js +55 -0
  7. package/clis/51job/search.js +79 -0
  8. package/clis/51job/utils.js +302 -0
  9. package/clis/51job/utils.test.js +69 -0
  10. package/clis/bilibili/video.js +68 -0
  11. package/clis/bilibili/video.test.js +132 -0
  12. package/clis/chatgpt/image.js +1 -1
  13. package/clis/deepseek/ask.js +37 -11
  14. package/clis/deepseek/ask.test.js +165 -0
  15. package/clis/deepseek/utils.js +192 -24
  16. package/clis/deepseek/utils.test.js +145 -0
  17. package/clis/gemini/image.js +1 -1
  18. package/clis/instagram/download.js +1 -1
  19. package/clis/jianyu/search.js +139 -3
  20. package/clis/jianyu/search.test.js +25 -0
  21. package/clis/jianyu/shared/procurement-detail.js +15 -0
  22. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  23. package/clis/twitter/likes.js +3 -2
  24. package/clis/twitter/search.js +4 -2
  25. package/clis/twitter/search.test.js +4 -0
  26. package/clis/twitter/shared.js +35 -2
  27. package/clis/twitter/shared.test.js +96 -0
  28. package/clis/twitter/thread.js +3 -1
  29. package/clis/twitter/timeline.js +3 -2
  30. package/clis/twitter/tweets.js +219 -0
  31. package/clis/twitter/tweets.test.js +125 -0
  32. package/clis/web/read.js +25 -5
  33. package/clis/web/read.test.js +76 -0
  34. package/clis/weread/ai-outline.js +170 -0
  35. package/clis/weread/ai-outline.test.js +83 -0
  36. package/clis/weread/book.js +57 -44
  37. package/clis/weread/commands.test.js +24 -0
  38. package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
  39. package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
  40. package/clis/youtube/channel.js +35 -0
  41. package/dist/src/browser/analyze.d.ts +103 -0
  42. package/dist/src/browser/analyze.js +230 -0
  43. package/dist/src/browser/analyze.test.d.ts +1 -0
  44. package/dist/src/browser/analyze.test.js +164 -0
  45. package/dist/src/browser/article-extract.d.ts +57 -0
  46. package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
  47. package/dist/src/browser/article-extract.e2e.test.js +105 -0
  48. package/dist/src/browser/article-extract.js +169 -0
  49. package/dist/src/browser/article-extract.test.d.ts +1 -0
  50. package/dist/src/browser/article-extract.test.js +94 -0
  51. package/dist/src/browser/base-page.d.ts +13 -3
  52. package/dist/src/browser/base-page.js +35 -25
  53. package/dist/src/browser/cdp.d.ts +1 -0
  54. package/dist/src/browser/cdp.js +23 -5
  55. package/dist/src/browser/compound.d.ts +59 -0
  56. package/dist/src/browser/compound.js +112 -0
  57. package/dist/src/browser/compound.test.d.ts +1 -0
  58. package/dist/src/browser/compound.test.js +175 -0
  59. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  60. package/dist/src/browser/dom-snapshot.js +76 -3
  61. package/dist/src/browser/dom-snapshot.test.js +65 -0
  62. package/dist/src/browser/extract.d.ts +69 -0
  63. package/dist/src/browser/extract.js +132 -0
  64. package/dist/src/browser/extract.test.d.ts +1 -0
  65. package/dist/src/browser/extract.test.js +129 -0
  66. package/dist/src/browser/find.d.ts +76 -0
  67. package/dist/src/browser/find.js +179 -0
  68. package/dist/src/browser/find.test.d.ts +1 -0
  69. package/dist/src/browser/find.test.js +120 -0
  70. package/dist/src/browser/html-tree.d.ts +75 -0
  71. package/dist/src/browser/html-tree.js +112 -0
  72. package/dist/src/browser/html-tree.test.d.ts +1 -0
  73. package/dist/src/browser/html-tree.test.js +181 -0
  74. package/dist/src/browser/network-cache.d.ts +48 -0
  75. package/dist/src/browser/network-cache.js +66 -0
  76. package/dist/src/browser/network-cache.test.d.ts +1 -0
  77. package/dist/src/browser/network-cache.test.js +58 -0
  78. package/dist/src/browser/network-key.d.ts +22 -0
  79. package/dist/src/browser/network-key.js +66 -0
  80. package/dist/src/browser/network-key.test.d.ts +1 -0
  81. package/dist/src/browser/network-key.test.js +49 -0
  82. package/dist/src/browser/shape-filter.d.ts +52 -0
  83. package/dist/src/browser/shape-filter.js +101 -0
  84. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  85. package/dist/src/browser/shape-filter.test.js +101 -0
  86. package/dist/src/browser/shape.d.ts +23 -0
  87. package/dist/src/browser/shape.js +95 -0
  88. package/dist/src/browser/shape.test.d.ts +1 -0
  89. package/dist/src/browser/shape.test.js +82 -0
  90. package/dist/src/browser/target-errors.d.ts +14 -1
  91. package/dist/src/browser/target-errors.js +13 -0
  92. package/dist/src/browser/target-errors.test.js +39 -6
  93. package/dist/src/browser/target-resolver.d.ts +57 -10
  94. package/dist/src/browser/target-resolver.js +195 -75
  95. package/dist/src/browser/target-resolver.test.js +80 -5
  96. package/dist/src/browser/verify-fixture.d.ts +59 -0
  97. package/dist/src/browser/verify-fixture.js +213 -0
  98. package/dist/src/browser/verify-fixture.test.d.ts +1 -0
  99. package/dist/src/browser/verify-fixture.test.js +161 -0
  100. package/dist/src/cli.d.ts +32 -0
  101. package/dist/src/cli.js +936 -141
  102. package/dist/src/cli.test.js +1051 -1
  103. package/dist/src/daemon.d.ts +3 -2
  104. package/dist/src/daemon.js +16 -4
  105. package/dist/src/daemon.test.d.ts +1 -0
  106. package/dist/src/daemon.test.js +19 -0
  107. package/dist/src/download/article-download.d.ts +12 -0
  108. package/dist/src/download/article-download.js +141 -17
  109. package/dist/src/download/article-download.test.js +196 -0
  110. package/dist/src/download/index.js +73 -86
  111. package/dist/src/errors.js +4 -2
  112. package/dist/src/errors.test.js +13 -0
  113. package/dist/src/execution.js +7 -2
  114. package/dist/src/execution.test.js +54 -0
  115. package/dist/src/launcher.d.ts +1 -1
  116. package/dist/src/launcher.js +3 -3
  117. package/dist/src/main.js +16 -0
  118. package/dist/src/output.js +1 -1
  119. package/dist/src/output.test.js +6 -0
  120. package/dist/src/types.d.ts +18 -3
  121. package/package.json +5 -1
@@ -0,0 +1,105 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ import * as fs from 'node:fs';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { buildExtractArticleJs } from './article-extract.js';
8
+ import { downloadArticle } from '../download/article-download.js';
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const fixturesDir = path.join(__dirname, '__fixtures__', 'article-extract');
11
+ const tempDirs = [];
12
+ afterEach(() => {
13
+ for (const dir of tempDirs)
14
+ fs.rmSync(dir, { recursive: true, force: true });
15
+ tempDirs.length = 0;
16
+ });
17
+ function loadFixture(name) {
18
+ return fs.readFileSync(path.join(fixturesDir, name), 'utf8');
19
+ }
20
+ function escapeHtml(text) {
21
+ return text.replace(/[&<>]/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[ch]));
22
+ }
23
+ function runExtract(html, url, options = {}, contentType) {
24
+ const dom = new JSDOM(html, {
25
+ url,
26
+ contentType: 'text/html',
27
+ pretendToBeVisual: true,
28
+ runScripts: 'outside-only',
29
+ });
30
+ if (contentType) {
31
+ Object.defineProperty(dom.window.document, 'contentType', {
32
+ value: contentType,
33
+ configurable: true,
34
+ });
35
+ }
36
+ return dom.window.eval(buildExtractArticleJs(options));
37
+ }
38
+ async function renderMarkdown(article, url, options = {}) {
39
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-article-e2e-'));
40
+ tempDirs.push(tempDir);
41
+ const result = await downloadArticle({
42
+ title: article.title || 'untitled',
43
+ contentHtml: article.html,
44
+ sourceUrl: url,
45
+ }, {
46
+ output: tempDir,
47
+ downloadImages: false,
48
+ cleanSelectors: options.cleanSelectors,
49
+ });
50
+ expect(result[0].status).toBe('success');
51
+ return fs.readFileSync(result[0].saved, 'utf8');
52
+ }
53
+ describe('article extract → markdown e2e fixtures', () => {
54
+ it('extracts a Wikipedia article fixture and keeps infobox/reference noise out of markdown', async () => {
55
+ const url = 'https://en.wikipedia.org/wiki/Markdown';
56
+ const cleanSelectors = ['.infobox', '.navbox', '.reference', '.mw-editsection', '.metadata'];
57
+ const article = runExtract(loadFixture('wikipedia-markdown.html'), url, { cleanSelectors });
58
+ expect(article?.source).toBe('readability');
59
+ expect(article?.title).toBe('Markdown');
60
+ if (!article)
61
+ throw new Error('expected extracted article');
62
+ const md = await renderMarkdown(article, url, { cleanSelectors });
63
+ expect(md).toContain('lightweight markup language');
64
+ expect(md).toContain('John Gruber');
65
+ expect(md).not.toContain('Syntax description');
66
+ expect(md).not.toContain('Standard file extension');
67
+ });
68
+ it('extracts a Deno blog fixture, preserves embedded iframes as markdown links, and drops page chrome', async () => {
69
+ const url = 'https://deno.com/blog/v2.0';
70
+ const article = runExtract(loadFixture('deno-v2.html'), url);
71
+ expect(article?.source).toBe('readability');
72
+ expect(article?.title).toBe('Announcing Deno 2 | Deno');
73
+ if (!article)
74
+ throw new Error('expected extracted article');
75
+ const md = await renderMarkdown(article, url);
76
+ expect(md).toContain('## Announcing Deno 2');
77
+ expect(md).toContain('The web is humanity’s largest software platform');
78
+ expect(md).toMatch(/\]\(https:\/\/www\.youtube(?:-nocookie)?\.com\/embed\/[^)]+\)/);
79
+ expect(md).not.toContain('Skip to main content');
80
+ });
81
+ it('short-circuits non-HTML raw text pages end-to-end', async () => {
82
+ const url = 'https://raw.githubusercontent.com/openai/openai-cookbook/main/README.md';
83
+ const text = loadFixture('openai-cookbook-readme.txt');
84
+ const html = `<html><head><title>OpenAI Cookbook README</title></head><body><pre>${escapeHtml(text)}</pre></body></html>`;
85
+ const article = runExtract(html, url, {}, 'text/plain');
86
+ expect(article?.source).toBe('raw-text');
87
+ if (!article)
88
+ throw new Error('expected extracted article');
89
+ const md = await renderMarkdown(article, url);
90
+ expect(md).toContain('OPENAI\\_API\\_KEY');
91
+ expect(md).toContain('Example code and guides for accomplishing common tasks');
92
+ });
93
+ it('short-circuits a single-pre document end-to-end', async () => {
94
+ const url = 'https://raw.githubusercontent.com/openai/openai-cookbook/main/README.md';
95
+ const text = loadFixture('openai-cookbook-readme.txt');
96
+ const html = `<html><head><title>OpenAI Cookbook README</title></head><body><pre>${escapeHtml(text)}</pre></body></html>`;
97
+ const article = runExtract(html, url);
98
+ expect(article?.source).toBe('pre');
99
+ if (!article)
100
+ throw new Error('expected extracted article');
101
+ const md = await renderMarkdown(article, url);
102
+ expect(md).toContain('OPENAI\\_API\\_KEY');
103
+ expect(md).toContain('Most code examples are written in Python');
104
+ });
105
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Article extraction via Readability — generic `page → article HTML` pipeline.
3
+ *
4
+ * Complements `src/browser/extract.ts`: that one takes a caller-supplied
5
+ * selector. This one works with zero configuration on arbitrary article pages
6
+ * (blogs, news, docs) by running `@mozilla/readability` inside the page
7
+ * context via CDP evaluate.
8
+ *
9
+ * Pipeline:
10
+ * 1. Short-circuit non-HTML documents (`text/plain`, JSON, XML) — a page
11
+ * renderer wrapping a plain-text file would pollute the DOM pipeline.
12
+ * 2. Short-circuit the "body is a single <pre>" case, which browsers use
13
+ * when loading *.txt / *.md over file:// or raw.githubusercontent.com.
14
+ * 3. Deep-clone the document, apply caller-supplied `cleanSelectors` to the
15
+ * clone (preserves live page state for subsequent snapshot/click).
16
+ * 4. Inject Readability + isProbablyReaderable sources into the page,
17
+ * parse on the clone. `isProbablyReaderable` gates the parse unless
18
+ * `force: true`.
19
+ * 5. On Readability miss, walk a fallback selector chain
20
+ * (main → [role="main"] → #main-content → … → body) and return the
21
+ * first root with >80 characters of text.
22
+ *
23
+ * Readability runs in the page's own window because it needs real DOM APIs
24
+ * (getComputedStyle, treeWalker). Running it Node-side would require jsdom —
25
+ * a heavy dep the rest of OpenCLI doesn't need.
26
+ */
27
+ import * as fs from 'node:fs';
28
+ import { createRequire } from 'node:module';
29
+ const requireFromHere = createRequire(import.meta.url);
30
+ let cachedSources = null;
31
+ function readabilitySources() {
32
+ if (cachedSources)
33
+ return cachedSources;
34
+ const readabilityPath = requireFromHere.resolve('@mozilla/readability/Readability.js');
35
+ const readerablePath = requireFromHere.resolve('@mozilla/readability/Readability-readerable.js');
36
+ cachedSources = {
37
+ readability: fs.readFileSync(readabilityPath, 'utf8'),
38
+ readerable: fs.readFileSync(readerablePath, 'utf8'),
39
+ };
40
+ return cachedSources;
41
+ }
42
+ export const DEFAULT_FALLBACK_SELECTORS = [
43
+ 'main',
44
+ '[role="main"]',
45
+ '#main-content',
46
+ '#main',
47
+ '#content',
48
+ '.content',
49
+ 'article',
50
+ 'body',
51
+ ];
52
+ const MIN_FALLBACK_TEXT_LENGTH = 80;
53
+ /**
54
+ * Build the JS expression evaluated in-page to extract the article. Exported
55
+ * for testability — callers on the host side should use `extractArticle`.
56
+ */
57
+ export function buildExtractArticleJs(options = {}) {
58
+ const { readability, readerable } = readabilitySources();
59
+ const cleanSelectors = options.cleanSelectors ?? [];
60
+ const fallbackSelectors = options.fallbackSelectors ?? DEFAULT_FALLBACK_SELECTORS;
61
+ const force = !!options.force;
62
+ // Library sources contain backticks and ${...} fragments, so we embed them
63
+ // as JSON-encoded string literals and eval them inside a Function() scope.
64
+ // This isolates their var declarations from the outer IIFE without polluting
65
+ // window globals.
66
+ const readabilityLit = JSON.stringify(readability);
67
+ const readerableLit = JSON.stringify(readerable);
68
+ const cleanLit = JSON.stringify(cleanSelectors);
69
+ const fallbackLit = JSON.stringify(fallbackSelectors);
70
+ const forceLit = JSON.stringify(force);
71
+ return [
72
+ '(() => {',
73
+ ' const cleanSelectors = ' + cleanLit + ';',
74
+ ' const fallbackSelectors = ' + fallbackLit + ';',
75
+ ' const force = ' + forceLit + ';',
76
+ ' const minFallbackText = ' + MIN_FALLBACK_TEXT_LENGTH + ';',
77
+ ' const readabilitySrc = ' + readabilityLit + ';',
78
+ ' const readerableSrc = ' + readerableLit + ';',
79
+ '',
80
+ ' function escapeHtml(s) {',
81
+ ' return String(s).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));',
82
+ ' }',
83
+ '',
84
+ ' // Short-circuit 1: non-HTML document',
85
+ ' const ct = document.contentType || "";',
86
+ ' if (ct && ct !== "text/html" && ct !== "application/xhtml+xml") {',
87
+ ' const body = document.body ? (document.body.textContent || "") : "";',
88
+ ' return { source: "raw-text", html: "<pre>" + escapeHtml(body) + "</pre>", title: document.title || "" };',
89
+ ' }',
90
+ '',
91
+ ' // Short-circuit 2: body is a single <pre>',
92
+ ' if (document.body) {',
93
+ ' const kids = document.body.children;',
94
+ ' if (kids.length === 1 && kids[0] && kids[0].tagName === "PRE") {',
95
+ ' return { source: "pre", html: document.body.outerHTML, title: document.title || "" };',
96
+ ' }',
97
+ ' }',
98
+ '',
99
+ ' // Deep-clone + adapter-supplied dirty-node removal',
100
+ ' const cloneDoc = document.cloneNode(true);',
101
+ ' for (const sel of cleanSelectors) {',
102
+ ' try { for (const n of cloneDoc.querySelectorAll(sel)) n.remove(); }',
103
+ ' catch (e) { /* ignore invalid selector */ }',
104
+ ' }',
105
+ '',
106
+ ' // Inject Readability into an isolated Function scope and extract the',
107
+ ' // constructors we need. Library sources use their own module.exports',
108
+ ' // guard (if typeof module === "object"), which is falsy here.',
109
+ ' const libs = (new Function(',
110
+ ' readabilitySrc + "\\n" + readerableSrc + "\\nreturn {" +',
111
+ ' " Readability: typeof Readability !== \\"undefined\\" ? Readability : null," +',
112
+ ' " isProbablyReaderable: typeof isProbablyReaderable !== \\"undefined\\" ? isProbablyReaderable : null" +',
113
+ ' " };"',
114
+ ' ))();',
115
+ ' const Readability = libs.Readability;',
116
+ ' const isProbablyReaderable = libs.isProbablyReaderable;',
117
+ '',
118
+ ' const readerableOk = force || (typeof isProbablyReaderable === "function" ? isProbablyReaderable(cloneDoc) : true);',
119
+ ' let article = null;',
120
+ ' if (readerableOk && typeof Readability === "function") {',
121
+ ' try { article = new Readability(cloneDoc).parse(); } catch (e) { article = null; }',
122
+ ' }',
123
+ ' if (article && article.content) {',
124
+ ' return {',
125
+ ' source: "readability",',
126
+ ' html: article.content,',
127
+ ' title: article.title || document.title || "",',
128
+ ' byline: article.byline || undefined,',
129
+ ' publishedTime: article.publishedTime || undefined,',
130
+ ' siteName: article.siteName || undefined,',
131
+ ' };',
132
+ ' }',
133
+ '',
134
+ ' // Fallback chain',
135
+ ' for (const sel of fallbackSelectors) {',
136
+ ' let el = null;',
137
+ ' try { el = cloneDoc.querySelector(sel); } catch (e) { continue; }',
138
+ ' if (!el) continue;',
139
+ ' const text = (el.textContent || "").trim();',
140
+ ' if (text.length < minFallbackText) continue;',
141
+ ' return { source: "fallback", html: el.outerHTML, title: document.title || "" };',
142
+ ' }',
143
+ '',
144
+ ' return null;',
145
+ '})()',
146
+ ].join('\n');
147
+ }
148
+ /**
149
+ * Run the extract pipeline on the given page. Returns `null` when no usable
150
+ * content is found (Readability miss + empty fallback chain).
151
+ */
152
+ export async function extractArticle(page, options = {}) {
153
+ const js = buildExtractArticleJs(options);
154
+ const raw = await page.evaluate(js);
155
+ if (raw == null || typeof raw !== 'object')
156
+ return null;
157
+ const r = raw;
158
+ if (typeof r.html !== 'string' || typeof r.source !== 'string')
159
+ return null;
160
+ const source = r.source;
161
+ return {
162
+ html: r.html,
163
+ title: typeof r.title === 'string' ? r.title : '',
164
+ ...(r.byline && { byline: r.byline }),
165
+ ...(r.publishedTime && { publishedTime: r.publishedTime }),
166
+ ...(r.siteName && { siteName: r.siteName }),
167
+ source,
168
+ };
169
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildExtractArticleJs, extractArticle, DEFAULT_FALLBACK_SELECTORS, } from './article-extract.js';
3
+ function fakePage(response) {
4
+ const state = { lastJs: null };
5
+ return {
6
+ lastJs: null,
7
+ async evaluate(js) {
8
+ state.lastJs = js;
9
+ Object.assign(this, state);
10
+ return response;
11
+ },
12
+ };
13
+ }
14
+ describe('buildExtractArticleJs', () => {
15
+ it('embeds Readability + Readerable sources once per evaluation', () => {
16
+ const js = buildExtractArticleJs();
17
+ // Both libs should be inlined (matched by identifying strings from the
18
+ // upstream @mozilla/readability sources).
19
+ expect(js).toContain('function Readability(doc, options)');
20
+ expect(js).toContain('function isProbablyReaderable');
21
+ });
22
+ it('serializes caller-supplied options into the evaluated JS', () => {
23
+ const js = buildExtractArticleJs({
24
+ cleanSelectors: ['.ads', '#banner'],
25
+ fallbackSelectors: ['article', 'body'],
26
+ force: true,
27
+ });
28
+ expect(js).toContain('[".ads","#banner"]');
29
+ expect(js).toContain('["article","body"]');
30
+ expect(js).toContain('const force = true;');
31
+ });
32
+ it('uses the default fallback chain when none is supplied', () => {
33
+ const js = buildExtractArticleJs();
34
+ for (const sel of DEFAULT_FALLBACK_SELECTORS) {
35
+ expect(js).toContain(JSON.stringify(sel));
36
+ }
37
+ });
38
+ it('runs fallback selection against the cleaned clone', () => {
39
+ const js = buildExtractArticleJs({ cleanSelectors: ['.noise'] });
40
+ expect(js).toContain('el = cloneDoc.querySelector(sel);');
41
+ expect(js).not.toContain('el = document.querySelector(sel);');
42
+ });
43
+ it('produces syntactically valid JavaScript', () => {
44
+ // Parsing via the Function constructor rejects any syntax error in the
45
+ // generated code — including accidental template-literal break-outs from
46
+ // the embedded Readability sources.
47
+ expect(() => new Function(buildExtractArticleJs())).not.toThrow();
48
+ expect(() => new Function(buildExtractArticleJs({ force: true }))).not.toThrow();
49
+ expect(() => new Function(buildExtractArticleJs({
50
+ cleanSelectors: ['.a', '.b'],
51
+ fallbackSelectors: ['main', 'body'],
52
+ }))).not.toThrow();
53
+ });
54
+ });
55
+ describe('extractArticle (host-side)', () => {
56
+ it('returns a normalized ExtractedArticle when the page responds with one', async () => {
57
+ const page = fakePage({
58
+ source: 'readability',
59
+ html: '<p>hello</p>',
60
+ title: 'Hello',
61
+ byline: 'Alice',
62
+ publishedTime: '2026-04-22',
63
+ siteName: 'Example',
64
+ });
65
+ const res = await extractArticle(page);
66
+ expect(res).toEqual({
67
+ source: 'readability',
68
+ html: '<p>hello</p>',
69
+ title: 'Hello',
70
+ byline: 'Alice',
71
+ publishedTime: '2026-04-22',
72
+ siteName: 'Example',
73
+ });
74
+ });
75
+ it('drops undefined optional fields cleanly', async () => {
76
+ const page = fakePage({ source: 'fallback', html: '<main>x</main>', title: 't' });
77
+ const res = await extractArticle(page);
78
+ expect(res).toEqual({ source: 'fallback', html: '<main>x</main>', title: 't' });
79
+ expect(res).not.toHaveProperty('byline');
80
+ expect(res).not.toHaveProperty('publishedTime');
81
+ });
82
+ it('returns null on a missing body or malformed payload', async () => {
83
+ expect(await extractArticle(fakePage(null))).toBeNull();
84
+ expect(await extractArticle(fakePage('oops'))).toBeNull();
85
+ expect(await extractArticle(fakePage({ source: 'readability' }))).toBeNull();
86
+ expect(await extractArticle(fakePage({ html: '<p>x</p>' }))).toBeNull();
87
+ });
88
+ it('defaults title to empty string when the page omits it', async () => {
89
+ const page = fakePage({ source: 'pre', html: '<body><pre>x</pre></body>' });
90
+ const res = await extractArticle(page);
91
+ expect(res?.title).toBe('');
92
+ expect(res?.source).toBe('pre');
93
+ });
94
+ });
@@ -9,6 +9,16 @@
9
9
  * getCookies, screenshot, tabs, etc.
10
10
  */
11
11
  import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
12
+ import { type ResolveOptions, type TargetMatchLevel } from './target-resolver.js';
13
+ export interface ResolveSuccess {
14
+ matches_n: number;
15
+ /**
16
+ * Cascading stale-ref tier the resolver traversed. Callers surface this to
17
+ * agents so `stable` / `reidentified` hits are visibly distinct from a
18
+ * clean `exact` match — the page changed, the action still succeeded.
19
+ */
20
+ match_level: TargetMatchLevel;
21
+ }
12
22
  export declare abstract class BasePage implements IPage {
13
23
  protected _lastUrl: string | null;
14
24
  /** Cached previous snapshot hashes for incremental diff marking */
@@ -34,12 +44,12 @@ export declare abstract class BasePage implements IPage {
34
44
  abstract screenshot(options?: ScreenshotOptions): Promise<string>;
35
45
  abstract tabs(): Promise<unknown[]>;
36
46
  abstract selectTab(target: number | string): Promise<void>;
37
- click(ref: string): Promise<void>;
47
+ click(ref: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
38
48
  /** Override in subclasses with CDP native click support */
39
49
  protected tryNativeClick(_x: number, _y: number): Promise<boolean>;
40
- typeText(ref: string, text: string): Promise<void>;
50
+ typeText(ref: string, text: string, opts?: ResolveOptions): Promise<ResolveSuccess>;
41
51
  pressKey(key: string): Promise<void>;
42
- scrollTo(ref: string): Promise<unknown>;
52
+ scrollTo(ref: string, opts?: ResolveOptions): Promise<unknown>;
43
53
  getFormState(): Promise<Record<string, unknown>>;
44
54
  scroll(direction?: string, amount?: number): Promise<void>;
45
55
  autoScroll(options?: {
@@ -10,8 +10,26 @@
10
10
  */
11
11
  import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
12
12
  import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
13
- import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs } from './target-resolver.js';
13
+ import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs, } from './target-resolver.js';
14
14
  import { TargetError } from './target-errors.js';
15
+ /**
16
+ * Execute `resolveTargetJs` once, throw structured `TargetError` on failure.
17
+ * Single helper so click/typeText/scrollTo share one resolution pathway,
18
+ * which is what the selector-first contract promises agents.
19
+ */
20
+ async function runResolve(page, ref, opts = {}) {
21
+ const resolution = (await page.evaluate(resolveTargetJs(ref, opts)));
22
+ if (!resolution.ok) {
23
+ throw new TargetError({
24
+ code: resolution.code,
25
+ message: resolution.message,
26
+ hint: resolution.hint,
27
+ candidates: resolution.candidates,
28
+ matches_n: resolution.matches_n,
29
+ });
30
+ }
31
+ return { matches_n: resolution.matches_n, match_level: resolution.match_level };
32
+ }
15
33
  import { formatSnapshot } from '../snapshotFormatter.js';
16
34
  export class BasePage {
17
35
  _lastUrl = null;
@@ -37,25 +55,20 @@ export class BasePage {
37
55
  return this.evaluate(`${declarations}\n${js}`);
38
56
  }
39
57
  // ── Shared DOM helper implementations ──
40
- async click(ref) {
58
+ async click(ref, opts = {}) {
41
59
  // Phase 1: Resolve target with fingerprint verification
42
- const resolution = await this.evaluate(resolveTargetJs(ref));
43
- if (!resolution.ok) {
44
- throw new TargetError(resolution);
45
- }
60
+ const resolved = await runResolve(this, ref, opts);
46
61
  // Phase 2: Execute click on resolved element
47
62
  const result = await this.evaluate(clickResolvedJs());
48
- // Backwards compat: old format returned 'clicked' string
49
63
  if (typeof result === 'string' || result == null)
50
- return;
51
- // JS click succeeded
64
+ return resolved;
52
65
  if (result.status === 'clicked')
53
- return;
66
+ return resolved;
54
67
  // JS click failed — try CDP native click if coordinates available
55
68
  if (result.x != null && result.y != null) {
56
69
  const success = await this.tryNativeClick(result.x, result.y);
57
70
  if (success)
58
- return;
71
+ return resolved;
59
72
  }
60
73
  throw new Error(`Click failed: ${result.error ?? 'JS click and CDP fallback both failed'}`);
61
74
  }
@@ -63,26 +76,23 @@ export class BasePage {
63
76
  async tryNativeClick(_x, _y) {
64
77
  return false;
65
78
  }
66
- async typeText(ref, text) {
67
- // Phase 1: Resolve target with fingerprint verification
68
- const resolution = await this.evaluate(resolveTargetJs(ref));
69
- if (!resolution.ok) {
70
- throw new TargetError(resolution);
71
- }
72
- // Phase 2: Execute type on resolved element
79
+ async typeText(ref, text, opts = {}) {
80
+ const resolved = await runResolve(this, ref, opts);
73
81
  await this.evaluate(typeResolvedJs(text));
82
+ return resolved;
74
83
  }
75
84
  async pressKey(key) {
76
85
  await this.evaluate(pressKeyJs(key));
77
86
  }
78
- async scrollTo(ref) {
79
- // Phase 1: Resolve target with fingerprint verification
80
- const resolution = await this.evaluate(resolveTargetJs(ref));
81
- if (!resolution.ok) {
82
- throw new TargetError(resolution);
87
+ async scrollTo(ref, opts = {}) {
88
+ const resolved = await runResolve(this, ref, opts);
89
+ const result = (await this.evaluate(scrollResolvedJs()));
90
+ // Fold match_level into the scroll payload so the user-facing envelope
91
+ // carries it the same way click / type do.
92
+ if (result && typeof result === 'object') {
93
+ return { ...result, matches_n: resolved.matches_n, match_level: resolved.match_level };
83
94
  }
84
- // Phase 2: Scroll to resolved element
85
- return this.evaluate(scrollResolvedJs());
95
+ return { matches_n: resolved.matches_n, match_level: resolved.match_level };
86
96
  }
87
97
  async getFormState() {
88
98
  return (await this.evaluate(getFormStateJs()));
@@ -15,6 +15,7 @@ export interface CDPTarget {
15
15
  title?: string;
16
16
  webSocketDebuggerUrl?: string;
17
17
  }
18
+ export declare const CDP_RESPONSE_BODY_CAPTURE_LIMIT: number;
18
19
  export declare class CDPBridge implements IBrowserFactory {
19
20
  private _ws;
20
21
  private _idCounter;
@@ -17,6 +17,12 @@ import { isRecord, saveBase64ToFile } from '../utils.js';
17
17
  import { getAllElectronApps } from '../electron-apps.js';
18
18
  import { BasePage } from './base-page.js';
19
19
  const CDP_SEND_TIMEOUT = 30_000;
20
+ // Memory guard for in-process capture. The 4k cap we used to apply everywhere
21
+ // silently truncated JSON so `JSON.parse` failed or gave partial objects — the
22
+ // primary agent-facing bug. Now we keep the full body up to a large cap and
23
+ // surface `responseBodyFullSize` + `responseBodyTruncated` so downstream layers
24
+ // can tell the agent what happened instead of lying about the payload.
25
+ export const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024;
20
26
  export class CDPBridge {
21
27
  _ws = null;
22
28
  _idCounter = 0;
@@ -85,7 +91,12 @@ export class CDPBridge {
85
91
  }
86
92
  }
87
93
  }
88
- catch { }
94
+ catch (err) {
95
+ if (process.env.OPENCLI_VERBOSE) {
96
+ // eslint-disable-next-line no-console
97
+ console.error('[cdp] Failed to parse WebSocket message:', err instanceof Error ? err.message : err);
98
+ }
99
+ }
89
100
  });
90
101
  });
91
102
  }
@@ -240,12 +251,19 @@ class CDPPage extends BasePage {
240
251
  const bodyFetch = this.bridge.send('Network.getResponseBody', { requestId: p.requestId }).then((result) => {
241
252
  const r = result;
242
253
  if (typeof r?.body === 'string') {
243
- this._networkEntries[idx].responsePreview = r.base64Encoded
244
- ? `base64:${r.body.slice(0, 4000)}`
245
- : r.body.slice(0, 4000);
254
+ const fullSize = r.body.length;
255
+ const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT;
256
+ const body = truncated ? r.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : r.body;
257
+ this._networkEntries[idx].responsePreview = r.base64Encoded ? `base64:${body}` : body;
258
+ this._networkEntries[idx].responseBodyFullSize = fullSize;
259
+ this._networkEntries[idx].responseBodyTruncated = truncated;
246
260
  }
247
- }).catch(() => {
261
+ }).catch((err) => {
248
262
  // Body unavailable for some requests (e.g. uploads) — non-fatal
263
+ if (process.env.OPENCLI_VERBOSE) {
264
+ // eslint-disable-next-line no-console
265
+ console.error(`[cdp] getResponseBody failed for ${p.requestId}:`, err instanceof Error ? err.message : err);
266
+ }
249
267
  }).finally(() => {
250
268
  this._pendingBodyFetches.delete(bodyFetch);
251
269
  });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Compound-component expansion for high-agent-failure form controls.
3
+ *
4
+ * Agents burn turns on three recurring input categories because the raw
5
+ * attribute dump from `browser state` under-specifies them:
6
+ *
7
+ * - date / time / datetime-local / month / week — agents type
8
+ * free-form strings and the browser silently ignores mismatched formats.
9
+ * - select — the snapshot caps visible options at ~6; agents don't know
10
+ * the full option set, can't match by label, and waste turns clicking
11
+ * to open the dropdown just to read options.
12
+ * - file — the snapshot shows current filenames but not `accept` or
13
+ * `multiple`; agents re-upload or pick unsupported MIME types.
14
+ *
15
+ * `compoundInfoOf(el)` returns a structured JSON summary agents can rely
16
+ * on. Included in `browser find --css` envelope so the agent gets the
17
+ * rich view without extra round-trips.
18
+ *
19
+ * Emitted as a JS source string (`COMPOUND_INFO_JS`) so it can be inlined
20
+ * into the generated evaluate scripts under find / snapshot / eval.
21
+ */
22
+ export type DateLikeControl = 'date' | 'time' | 'datetime-local' | 'month' | 'week';
23
+ export interface DateCompound {
24
+ control: DateLikeControl;
25
+ format: string;
26
+ current: string;
27
+ min?: string;
28
+ max?: string;
29
+ }
30
+ export interface SelectOption {
31
+ label: string;
32
+ value: string;
33
+ selected: boolean;
34
+ disabled?: boolean;
35
+ }
36
+ export interface SelectCompound {
37
+ control: 'select';
38
+ multiple: boolean;
39
+ current: string | string[];
40
+ options: SelectOption[];
41
+ options_total: number;
42
+ }
43
+ export interface FileCompound {
44
+ control: 'file';
45
+ multiple: boolean;
46
+ current: string[];
47
+ accept?: string;
48
+ }
49
+ export type CompoundInfo = DateCompound | SelectCompound | FileCompound;
50
+ /** Max options included in a SelectCompound.options[]. Above this, `options_total` still reflects the true count. */
51
+ export declare const COMPOUND_SELECT_OPTIONS_CAP = 50;
52
+ /** Max characters per option label / file name. */
53
+ export declare const COMPOUND_LABEL_CAP = 80;
54
+ /**
55
+ * JavaScript source declaring `compoundInfoOf(el)`. Inlined into the JS
56
+ * emitted by `buildFindJs` (and any other evaluate script that needs the
57
+ * rich compound view). Returns a `CompoundInfo` object or `null`.
58
+ */
59
+ export declare const COMPOUND_INFO_JS = "\nfunction compoundInfoOf(el) {\n if (!el || !el.tagName) return null;\n const tag = el.tagName;\n const LABEL_CAP = 80;\n const OPTS_CAP = 50;\n if (tag === 'INPUT') {\n const type = (el.getAttribute('type') || 'text').toLowerCase();\n const FORMATS = {\n 'date': 'YYYY-MM-DD',\n 'time': 'HH:MM',\n 'datetime-local': 'YYYY-MM-DDTHH:MM',\n 'month': 'YYYY-MM',\n 'week': 'YYYY-W##',\n };\n if (FORMATS[type]) {\n const info = {\n control: type,\n format: FORMATS[type],\n current: (el.value == null ? '' : String(el.value)),\n };\n const min = el.getAttribute('min');\n if (min) info.min = min;\n const max = el.getAttribute('max');\n if (max) info.max = max;\n return info;\n }\n if (type === 'file') {\n const info = {\n control: 'file',\n multiple: !!el.multiple,\n current: [],\n };\n const accept = el.getAttribute('accept');\n if (accept) info.accept = accept;\n try {\n if (el.files && el.files.length) {\n for (let i = 0; i < el.files.length; i++) {\n const name = (el.files[i].name || '').slice(0, LABEL_CAP);\n info.current.push(name);\n }\n }\n } catch (_) {}\n return info;\n }\n return null;\n }\n if (tag === 'SELECT') {\n const multiple = !!el.multiple;\n const options = [];\n const selectedLabels = [];\n let total = 0;\n try {\n const opts = el.options || [];\n total = opts.length;\n // Walk ALL options so `current` reflects selections that sit beyond the\n // serialization cap. Only the first OPTS_CAP entries get pushed into\n // options[]; anything past the cap still contributes to selectedLabels\n // so agents see the true current state of big dropdowns.\n for (let i = 0; i < opts.length; i++) {\n const o = opts[i];\n const labelRaw = (o.label != null && o.label !== '') ? o.label : (o.text || '');\n const label = String(labelRaw).trim().slice(0, LABEL_CAP);\n if (i < OPTS_CAP) {\n const entry = { label: label, value: o.value, selected: !!o.selected };\n if (o.disabled) entry.disabled = true;\n options.push(entry);\n }\n if (o.selected) selectedLabels.push(label);\n }\n } catch (_) {}\n return {\n control: 'select',\n multiple: multiple,\n current: multiple ? selectedLabels : (selectedLabels[0] || ''),\n options: options,\n options_total: total,\n };\n }\n return null;\n}\n";