@remixmate/cli 0.9.26 → 0.9.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ {
2
+ "name": "web-read",
3
+ "toolName": "web_read",
4
+ "tier": "tool",
5
+ "category": "consuming",
6
+ "title": "Web Page Reader",
7
+ "description": "Open any URL in a headless browser (Playwright Python) and return the page's MAIN TEXT — title, headings, paragraphs, lists, code blocks and tables — as Markdown, plain text, or structured JSON. Boilerplate (nav / sidebar / comments / ads / footer) is stripped by a Readability-style pass, and JS-rendered pages work because a real browser runs the page. This is the tool to use whenever you need to KNOW WHAT A PAGE SAYS: summarizing an article, pulling source material for a script, reading a README or docs page, checking what a link contains. It returns text, not pictures — for a screenshot (png/jpg) use web_screenshot, for a recording (mp4/webm) use web_record. Output is capped by max_chars (default 20000) and truncated on a block boundary; pass an `output` path to keep the full text on disk.",
8
+ "auth": "none",
9
+ "envVars": [
10
+ "WEB_CAPTURE_BROWSER",
11
+ "WEB_CAPTURE_ALLOW_PRIVATE_HOSTS",
12
+ "PLAYWRIGHT_BROWSERS_PATH"
13
+ ],
14
+ "entry": {
15
+ "type": "python",
16
+ "scriptPath": "../web-screenshot/scripts/read_page.py"
17
+ },
18
+ "parameters": {
19
+ "type": "object",
20
+ "properties": {
21
+ "url": {
22
+ "type": "string",
23
+ "description": "Target page URL (http/https). Private / loopback / link-local addresses are refused unless WEB_CAPTURE_ALLOW_PRIVATE_HOSTS=1."
24
+ },
25
+ "format": {
26
+ "type": "string",
27
+ "enum": ["markdown", "text", "json"],
28
+ "description": "markdown (default: keeps headings, lists, code fences, tables) | text (plain) | json (structured blocks + metadata, not truncated)"
29
+ },
30
+ "max_chars": {
31
+ "type": "number",
32
+ "description": "Cap on the printed text, cut at a block boundary with an explicit [truncated] notice (default 20000, 0 = unlimited). Raise it when you need the whole document; a very long page will otherwise fill your context."
33
+ },
34
+ "selector": {
35
+ "type": "string",
36
+ "description": "Read only inside this CSS selector. Leave empty to auto-detect the article container — only reach for this when the auto-detected container was wrong."
37
+ },
38
+ "include_links": {
39
+ "type": "boolean",
40
+ "description": "Keep hyperlinks as [text](url) instead of plain text. Useful when you need to follow links from the page."
41
+ },
42
+ "include_images": {
43
+ "type": "boolean",
44
+ "description": "Keep images as ![alt](src). Useful for harvesting illustration URLs out of an article."
45
+ },
46
+ "output": {
47
+ "type": "string",
48
+ "description": "Also write the FULL (untruncated) text to this local path. stdout still respects max_chars — use this when a long page must be kept for later steps."
49
+ },
50
+ "settle_ms": {
51
+ "type": "number",
52
+ "description": "Extra wait before extracting, in ms. Raise for pages that render content late."
53
+ },
54
+ "wait_for_selector": {
55
+ "type": "string",
56
+ "description": "Wait for this CSS selector before extracting (the reliable fix for JS-rendered content)"
57
+ },
58
+ "wait_for_timeout": {
59
+ "type": "number",
60
+ "description": "Fixed wait before extracting, in ms"
61
+ },
62
+ "device": {
63
+ "type": "string",
64
+ "description": "Device emulation name, e.g. 'iPhone 15 Pro' — some sites serve a leaner page to mobile"
65
+ },
66
+ "viewport": {
67
+ "type": "string",
68
+ "description": "Viewport as 'width,height', e.g. '1280,800'"
69
+ },
70
+ "color_scheme": {
71
+ "type": "string",
72
+ "enum": ["light", "dark", "no-preference"],
73
+ "description": "Emulate prefers-color-scheme"
74
+ },
75
+ "user_agent": {
76
+ "type": "string",
77
+ "description": "Override the User-Agent (try this when a site blocks headless browsers)"
78
+ },
79
+ "timeout": {
80
+ "type": "number",
81
+ "description": "Global Playwright action timeout in ms"
82
+ },
83
+ "ignore_https_errors": {
84
+ "type": "boolean",
85
+ "description": "Ignore HTTPS certificate errors"
86
+ },
87
+ "storage_state": {
88
+ "type": "string",
89
+ "description": "Path to a Playwright storageState JSON file (logged-in session)"
90
+ },
91
+ "cookies": {
92
+ "type": "string",
93
+ "description": "Playwright cookies as a JSON string or a path to a JSON file (top level is an array)"
94
+ },
95
+ "browser": {
96
+ "type": "string",
97
+ "enum": ["chromium", "firefox", "webkit"],
98
+ "description": "Browser engine (default chromium)"
99
+ },
100
+ "quiet": {
101
+ "type": "boolean",
102
+ "description": "Suppress the extraction diagnostics on stderr"
103
+ }
104
+ },
105
+ "required": ["url"]
106
+ },
107
+ "ui": {
108
+ "primary": ["url", "format", "max_chars"],
109
+ "advanced": [
110
+ "selector",
111
+ "include_links",
112
+ "include_images",
113
+ "wait_for_selector",
114
+ "settle_ms",
115
+ "device",
116
+ "viewport",
117
+ "color_scheme",
118
+ "timeout"
119
+ ],
120
+ "hidden": [
121
+ "output",
122
+ "quiet",
123
+ "user_agent",
124
+ "ignore_https_errors",
125
+ "storage_state",
126
+ "cookies",
127
+ "browser",
128
+ "wait_for_timeout"
129
+ ]
130
+ }
131
+ }
@@ -6,12 +6,14 @@ package is implementation detail.
6
6
  from __future__ import annotations
7
7
 
8
8
  from . import cli_args, scenes, template, trim
9
+ from .reader import do_read
9
10
  from .recording import do_record
10
11
  from .screenshot import do_screenshot
11
12
  from .storyboard import do_storyboard
12
13
 
13
14
  __all__ = [
14
15
  "cli_args",
16
+ "do_read",
15
17
  "do_record",
16
18
  "do_screenshot",
17
19
  "do_storyboard",
@@ -0,0 +1,373 @@
1
+ // Extract a page's main readable content as an ordered list of typed blocks.
2
+ // Injected via page.evaluate(js_loader.load("extract_article"), cfg).
3
+ //
4
+ // A compact Readability-style pass: strip chrome, score paragraph density to
5
+ // pick the content container, then walk it in document order. It returns
6
+ // structured blocks rather than a formatted string so that every output format
7
+ // (markdown / plain text / json) is a pure function of one single extraction —
8
+ // the alternative, formatting in the page and re-parsing in Python, loses the
9
+ // block boundaries that make truncation land somewhere readable.
10
+ //
11
+ // Runs against the live DOM and mutates it (junk removal). That is safe here:
12
+ // the page is headless and thrown away immediately after this call.
13
+ (cfg) => {
14
+ const opts = cfg || {};
15
+ const wantLinks = !!opts.includeLinks;
16
+ const wantImages = !!opts.includeImages;
17
+ const inlineMarkdown = opts.inline === 'markdown';
18
+
19
+ const squash = (s) => (s || '').replace(/\s+/g, ' ').trim();
20
+ const rawLen = (el) => squash(el.textContent).length;
21
+
22
+ // ── metadata (read before junk removal strips the <head> siblings) ────────
23
+ const meta = (sel, attr) => {
24
+ const el = document.querySelector(sel);
25
+ if (!el) return '';
26
+ return squash(attr ? el.getAttribute(attr) : el.textContent);
27
+ };
28
+ const metadata = {
29
+ title:
30
+ meta('meta[property="og:title"]', 'content') ||
31
+ meta('meta[name="twitter:title"]', 'content') ||
32
+ squash(document.title) ||
33
+ meta('h1'),
34
+ byline:
35
+ meta('meta[name="author"]', 'content') ||
36
+ meta('meta[property="article:author"]', 'content') ||
37
+ meta('[rel="author"]'),
38
+ siteName: meta('meta[property="og:site_name"]', 'content'),
39
+ publishedTime:
40
+ meta('meta[property="article:published_time"]', 'content') ||
41
+ meta('time[datetime]', 'datetime'),
42
+ description:
43
+ meta('meta[name="description"]', 'content') ||
44
+ meta('meta[property="og:description"]', 'content'),
45
+ lang: squash(document.documentElement.getAttribute('lang') || ''),
46
+ url: location.href,
47
+ };
48
+
49
+ const bodyCharCount = rawLen(document.body);
50
+
51
+ // ── 1. strip boilerplate (structural only, see below) ────────────────────
52
+ const JUNK_TAGS = [
53
+ 'script', 'style', 'noscript', 'template', 'svg', 'canvas', 'iframe',
54
+ 'form', 'button', 'select', 'textarea', 'nav', 'aside', 'footer',
55
+ '[aria-hidden="true"]', '[hidden]', '[role="navigation"]', '[role="banner"]',
56
+ '[role="complementary"]', '[role="search"]', '[role="dialog"]',
57
+ ].join(',');
58
+
59
+ // Matched against class + id, on "words" delimited by -, _ or space.
60
+ const JUNK_WORDS =
61
+ /(^|[-_\s])(comments?|disqus|sidebar|side-?bar|related|recommend(ed|ations?)?|advert(isement)?|ads?|adsbygoogle|banner|breadcrumbs?|share|sharing|social|subscribe|newsletter|popup|modal|overlay|cookie|toolbar|pagination|pager|footer|masthead|menu|promo|sponsor|widget|skip-link|back-to-top)($|[-_\s])/i;
62
+
63
+ const isJunkClass = (el) => {
64
+ const tokens = `${el.className || ''} ${el.id || ''}`;
65
+ return typeof tokens === 'string' && JUNK_WORDS.test(tokens);
66
+ };
67
+
68
+ document.querySelectorAll(JUNK_TAGS).forEach((el) => {
69
+ try { el.remove(); } catch (e) { /* already detached */ }
70
+ });
71
+
72
+ // Class-based junk is NOT removed here — only scored down in step 2 and
73
+ // pruned inside the winner in step 3. Removing it up front looks tidier and
74
+ // is how this first went wrong: Tencent Cloud's whole page wrapper carries
75
+ // `show-ad`, the `ads?` rule matched it, and the extractor deleted the entire
76
+ // article before it had a chance to score anything. A class name is a hint
77
+ // about a subtree's role, never grounds for deleting an ancestor of the text
78
+ // we came for.
79
+
80
+ // ── 2. pick the content container ────────────────────────────────────────
81
+ const linkDensity = (el) => {
82
+ const total = rawLen(el);
83
+ if (!total) return 1;
84
+ let linked = 0;
85
+ el.querySelectorAll('a').forEach((a) => { linked += rawLen(a); });
86
+ return Math.min(1, linked / total);
87
+ };
88
+
89
+ const tagBonus = (el) => {
90
+ const tag = el.tagName;
91
+ if (isJunkClass(el)) return 0.2;
92
+ if (tag === 'ARTICLE' || tag === 'MAIN') return 1.5;
93
+ const tokens = `${el.className || ''} ${el.id || ''}`;
94
+ if (typeof tokens === 'string' &&
95
+ /(^|[-_\s])(article|post|content|entry|markdown-body|rich_media|story|body)($|[-_\s])/i.test(tokens)) {
96
+ return 1.25;
97
+ }
98
+ return 1;
99
+ };
100
+
101
+ let container = null;
102
+ let containerReason = '';
103
+
104
+ if (opts.selector) {
105
+ container = document.querySelector(opts.selector);
106
+ if (!container) {
107
+ return { error: 'selector-not-found', selector: opts.selector, metadata };
108
+ }
109
+ containerReason = `selector:${opts.selector}`;
110
+ } else {
111
+ // Readability's scoring:每个够长的段落给自己父/祖父/曾祖父记分(1/2、1/3 衰减),
112
+ // 所以赢家是"段落最密集的那层",而不是 <body>——<body> 拿不到直接段落的分。
113
+ const scores = new Map();
114
+ const add = (el, amount) => {
115
+ if (!el || el.nodeType !== 1 || el === document.body ||
116
+ el === document.documentElement) return;
117
+ scores.set(el, (scores.get(el) || 0) + amount);
118
+ };
119
+ document.querySelectorAll('p, pre, blockquote, li, td, h2, h3').forEach((node) => {
120
+ const text = squash(node.textContent);
121
+ if (text.length < 25) return;
122
+ const commas = (text.match(/[,,、.。;;]/g) || []).length;
123
+ let s = 1 + Math.min(text.length / 100, 3) + Math.min(commas, 3);
124
+ // 列表项 / 单元格常常是导航或目录,给一半权重。
125
+ if (node.tagName === 'LI' || node.tagName === 'TD') s *= 0.5;
126
+ const p = node.parentElement;
127
+ const gp = p && p.parentElement;
128
+ const ggp = gp && gp.parentElement;
129
+ add(p, s);
130
+ add(gp, s / 2);
131
+ add(ggp, s / 3);
132
+ });
133
+
134
+ let bestScore = 0;
135
+ scores.forEach((s, el) => {
136
+ const adjusted = s * (1 - linkDensity(el)) * tagBonus(el);
137
+ if (adjusted > bestScore) { bestScore = adjusted; container = el; }
138
+ });
139
+ containerReason = container ? `scored:${bestScore.toFixed(1)}` : 'fallback:body';
140
+
141
+ // 赢家常常只是正文里**最密的那一层**,而不是正文本身:docs.python.org 上
142
+ // 打分赢家是 `section#text`(第 3.1.2 节),它上面还有三节同级内容。所以向上
143
+ // 合并兄弟小节,直到父层开始引入噪音(体量暴涨或链接占比变高)为止。
144
+ let guard = 0;
145
+ while (container && container.parentElement &&
146
+ container.parentElement !== document.body &&
147
+ container.parentElement !== document.documentElement &&
148
+ guard++ < 8) {
149
+ const parent = container.parentElement;
150
+ if (isJunkClass(parent)) break;
151
+ const tooSmall = rawLen(container) < 200;
152
+ const growth = rawLen(parent) / Math.max(rawLen(container), 1);
153
+ if (!tooSmall && (growth > 3 || linkDensity(parent) > 0.3)) break;
154
+ container = parent;
155
+ containerReason += '+up';
156
+ }
157
+ if (!container) container = document.body;
158
+ }
159
+
160
+ // ── 3. prune junk *inside* the winner, then walk it into blocks ──────────
161
+ // Safe by construction: a descendant can never be the container, and the
162
+ // 40% rule keeps a mislabelled wrapper (the `show-ad` case, one level down)
163
+ // from taking the article with it.
164
+ const containerChars = rawLen(container) || 1;
165
+ Array.prototype.slice.call(container.querySelectorAll('div,section,ul,ol,span,header,p,figure'))
166
+ .forEach((el) => {
167
+ if (!container.contains(el) || el === container) return;
168
+ if (!isJunkClass(el)) return;
169
+ if (rawLen(el) / containerChars > 0.4) return;
170
+ try { el.remove(); } catch (e) { /* already detached */ }
171
+ });
172
+
173
+ const describe = (el) => {
174
+ if (!el || !el.tagName) return '';
175
+ const id = el.id ? `#${el.id}` : '';
176
+ const cls = typeof el.className === 'string' && el.className.trim()
177
+ ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
178
+ : '';
179
+ return `${el.tagName.toLowerCase()}${id}${cls}`;
180
+ };
181
+
182
+ // Inline serializer — the only place that knows about markdown syntax.
183
+ const inline = (node) => {
184
+ let out = '';
185
+ node.childNodes.forEach((child) => {
186
+ if (child.nodeType === 3) { out += child.nodeValue; return; }
187
+ if (child.nodeType !== 1) return;
188
+ const tag = child.tagName;
189
+ if (tag === 'BR') { out += ' '; return; }
190
+ if (tag === 'IMG') {
191
+ if (wantImages) {
192
+ const alt = squash(child.getAttribute('alt'));
193
+ const src = child.currentSrc || child.src ||
194
+ child.getAttribute('data-src') || '';
195
+ if (src) out += `![${alt}](${src})`;
196
+ }
197
+ return;
198
+ }
199
+ const inner = inline(child);
200
+ if (!inner.trim()) return;
201
+ if (tag === 'A' && wantLinks) {
202
+ const href = child.href || child.getAttribute('href') || '';
203
+ out += href ? `[${inner}](${href})` : inner;
204
+ return;
205
+ }
206
+ if (!inlineMarkdown) { out += inner; return; }
207
+ if (tag === 'CODE' || tag === 'KBD' || tag === 'SAMP') out += `\`${inner}\``;
208
+ else if (tag === 'STRONG' || tag === 'B') out += `**${inner}**`;
209
+ else if (tag === 'EM' || tag === 'I') out += `*${inner}*`;
210
+ else if (tag === 'DEL' || tag === 'S') out += `~~${inner}~~`;
211
+ else out += inner;
212
+ });
213
+ return out;
214
+ };
215
+
216
+ const text = (node) => squash(inline(node));
217
+
218
+ const listItems = (el, depth) => {
219
+ const items = [];
220
+ Array.prototype.forEach.call(el.children, (li) => {
221
+ if (li.tagName !== 'LI') return;
222
+ // Nested lists are flattened with an indent prefix rather than nested
223
+ // structures — every consumer here renders to flat text anyway.
224
+ const nested = li.querySelector(':scope > ul, :scope > ol');
225
+ const own = squash(
226
+ Array.prototype.filter
227
+ .call(li.childNodes, (n) => n !== nested)
228
+ .map((n) => (n.nodeType === 1 ? inline(n) : n.nodeValue || ''))
229
+ .join(''),
230
+ );
231
+ if (own) items.push({ depth, text: own });
232
+ if (nested && depth < 3) items.push(...listItems(nested, depth + 1));
233
+ });
234
+ return items;
235
+ };
236
+
237
+ const EMIT = new Set([
238
+ 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'PRE', 'BLOCKQUOTE',
239
+ 'UL', 'OL', 'TABLE', 'FIGURE', 'IMG', 'HR', 'DL',
240
+ ]);
241
+ const SKIP_DESCEND = new Set(['PRE', 'TABLE', 'UL', 'OL', 'FIGURE', 'DL']);
242
+
243
+ const blocks = [];
244
+ const push = (block) => {
245
+ if (!block) return;
246
+ const prev = blocks[blocks.length - 1];
247
+ // 同一段文字在页面里出现两次(懒加载占位 + 真身)时只留一份。
248
+ if (prev && prev.type === block.type && prev.text && prev.text === block.text) return;
249
+ blocks.push(block);
250
+ };
251
+
252
+ const emit = (el) => {
253
+ const tag = el.tagName;
254
+ if (/^H[1-6]$/.test(tag)) {
255
+ const t = text(el);
256
+ if (t) push({ type: 'heading', level: Number(tag[1]), text: t });
257
+ return;
258
+ }
259
+ if (tag === 'P') {
260
+ const t = text(el);
261
+ if (t) push({ type: 'paragraph', text: t });
262
+ return;
263
+ }
264
+ if (tag === 'PRE') {
265
+ const code = el.querySelector('code') || el;
266
+ const cls = `${el.className || ''} ${code.className || ''}`;
267
+ const m = typeof cls === 'string' ? cls.match(/(?:language|lang|highlight)[-_](\w+)/i) : null;
268
+ const body = (code.innerText || code.textContent || '').replace(/\s+$/, '');
269
+ if (body.trim()) push({ type: 'code', lang: m ? m[1].toLowerCase() : '', text: body });
270
+ return;
271
+ }
272
+ if (tag === 'BLOCKQUOTE') {
273
+ const t = text(el);
274
+ if (t) push({ type: 'quote', text: t });
275
+ return;
276
+ }
277
+ if (tag === 'UL' || tag === 'OL') {
278
+ const items = listItems(el, 0);
279
+ if (items.length) push({ type: 'list', ordered: tag === 'OL', items });
280
+ return;
281
+ }
282
+ if (tag === 'DL') {
283
+ const items = [];
284
+ Array.prototype.forEach.call(el.children, (child) => {
285
+ const t = text(child);
286
+ if (!t) return;
287
+ items.push({ depth: child.tagName === 'DD' ? 1 : 0, text: t });
288
+ });
289
+ if (items.length) push({ type: 'list', ordered: false, items });
290
+ return;
291
+ }
292
+ if (tag === 'TABLE') {
293
+ const rows = [];
294
+ el.querySelectorAll('tr').forEach((tr) => {
295
+ const cells = [];
296
+ tr.querySelectorAll('th,td').forEach((cell) => cells.push(text(cell)));
297
+ if (cells.some((c) => c)) rows.push(cells);
298
+ });
299
+ if (rows.length) push({ type: 'table', rows });
300
+ return;
301
+ }
302
+ if (tag === 'FIGURE') {
303
+ const img = el.querySelector('img');
304
+ const cap = el.querySelector('figcaption');
305
+ const caption = cap ? text(cap) : '';
306
+ if (wantImages && img) {
307
+ const src = img.currentSrc || img.src || img.getAttribute('data-src') || '';
308
+ if (src) push({ type: 'image', src, alt: squash(img.getAttribute('alt')), caption });
309
+ return;
310
+ }
311
+ if (caption) push({ type: 'paragraph', text: caption });
312
+ return;
313
+ }
314
+ if (tag === 'IMG') {
315
+ if (!wantImages) return;
316
+ const src = el.currentSrc || el.src || el.getAttribute('data-src') || '';
317
+ if (src) push({ type: 'image', src, alt: squash(el.getAttribute('alt')), caption: '' });
318
+ return;
319
+ }
320
+ if (tag === 'HR') push({ type: 'rule' });
321
+ };
322
+
323
+ // 直接挂在容器上、没有被 <p> 包起来的裸文本(老网页很常见)也要收;
324
+ // 用 buffer 在遇到下一个块级元素或本层结束时冲刷成一个段落。
325
+ const walk = (el, depth) => {
326
+ if (depth > 24) return;
327
+ let buffer = '';
328
+ const flush = () => {
329
+ const t = squash(buffer);
330
+ buffer = '';
331
+ if (t.length >= 2) push({ type: 'paragraph', text: t });
332
+ };
333
+ el.childNodes.forEach((child) => {
334
+ if (child.nodeType === 3) { buffer += child.nodeValue; return; }
335
+ if (child.nodeType !== 1) return;
336
+ const tag = child.tagName;
337
+ if (EMIT.has(tag)) {
338
+ flush();
339
+ emit(child);
340
+ return;
341
+ }
342
+ if (SKIP_DESCEND.has(tag)) return;
343
+ if (tag === 'BR') { buffer += ' '; return; }
344
+ // Inline-level element sitting between block siblings: keep its text in
345
+ // the running buffer instead of dropping it on the floor.
346
+ if (['A', 'SPAN', 'CODE', 'STRONG', 'B', 'EM', 'I', 'SMALL', 'LABEL', 'TIME',
347
+ 'SUP', 'SUB', 'MARK', 'ABBR', 'CITE', 'Q', 'DEL', 'S', 'U'].includes(tag)) {
348
+ buffer += inline(child);
349
+ return;
350
+ }
351
+ flush();
352
+ walk(child, depth + 1);
353
+ });
354
+ flush();
355
+ };
356
+
357
+ walk(container, 0);
358
+
359
+ const charCount = blocks.reduce((n, b) => {
360
+ if (b.type === 'list') return n + b.items.reduce((m, i) => m + i.text.length, 0);
361
+ if (b.type === 'table') return n + b.rows.reduce((m, r) => m + r.join('').length, 0);
362
+ return n + (b.text || '').length;
363
+ }, 0);
364
+
365
+ return {
366
+ metadata,
367
+ blocks,
368
+ container: describe(container),
369
+ containerReason,
370
+ charCount,
371
+ bodyCharCount,
372
+ };
373
+ }