@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,245 @@
1
+ """Read operation: load a page and extract its main content as text.
2
+
3
+ The DOM work lives in ``js/extract_article.js`` (one injected pass returning
4
+ typed blocks); this module drives the browser, renders those blocks into the
5
+ requested format, and enforces the character budget.
6
+
7
+ Truncation is the part that matters for the caller: a 200k-character page
8
+ pasted into a model's context is worse than useless. ``max_chars`` cuts on a
9
+ block boundary and says so in-band, and ``--output`` keeps the full text on
10
+ disk so nothing is actually lost.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ from . import js_loader
18
+ from .bootstrap import ensure_runtime
19
+ from .browser import apply_pre_action_waits, build_context_options, launch_with_browser_install
20
+
21
+
22
+ def extract(cfg: dict) -> dict:
23
+ """Load cfg['url'] and return the raw extraction dict from the page."""
24
+ ensure_runtime()
25
+ from playwright.sync_api import sync_playwright
26
+
27
+ js_cfg = {
28
+ "selector": cfg.get("selector") or "",
29
+ "includeLinks": bool(cfg.get("includeLinks")),
30
+ "includeImages": bool(cfg.get("includeImages")),
31
+ "inline": "markdown" if cfg.get("format", "markdown") == "markdown" else "plain",
32
+ }
33
+
34
+ with sync_playwright() as p:
35
+ browser = launch_with_browser_install(p, cfg.get("browser"))
36
+ try:
37
+ context = browser.new_context(**build_context_options(p, cfg, None))
38
+ if cfg.get("timeout"):
39
+ context.set_default_timeout(cfg["timeout"])
40
+ page = context.new_page()
41
+ try:
42
+ try:
43
+ response = page.goto(cfg["url"], wait_until="domcontentloaded")
44
+ except Exception as e:
45
+ # 一条模型能据以行动的错误,胜过 30 行 Playwright traceback。
46
+ # 导航失败是这个 skill 最常见的失败形态(打不开 / 超时 / DNS),
47
+ # 让它读起来像"这个网址打不开",而不是像程序崩了。
48
+ raise SystemExit(
49
+ f"打开页面失败:{cfg['url']}\n"
50
+ f"{type(e).__name__}: {str(e).splitlines()[0]}\n"
51
+ "可能是网络不可达、站点屏蔽了无头浏览器,或加载超过了超时时间。"
52
+ "可尝试加大 --timeout、换 --user-agent,或确认该 URL 在本机能打开。"
53
+ )
54
+ status = response.status if response else None
55
+ if status is not None and status >= 400:
56
+ # Keep going: many sites serve real content under a 403/404
57
+ # (paywalls, soft 404s). The status rides along in the output
58
+ # so the caller can tell "empty page" from "blocked".
59
+ pass
60
+ apply_pre_action_waits(page, cfg)
61
+ # SPA 正文往往在 domcontentloaded 之后才注水。networkidle 等不到就
62
+ # 算了——静态页本来就不会再有请求,硬等只是白白花掉 20 秒。
63
+ try:
64
+ page.wait_for_load_state("networkidle", timeout=cfg.get("networkIdleMs", 8000))
65
+ except Exception:
66
+ pass
67
+ settle = cfg.get("settleMs")
68
+ if settle:
69
+ page.wait_for_timeout(settle)
70
+
71
+ data = page.evaluate(js_loader.load("extract_article"), js_cfg)
72
+ data["status"] = status
73
+ data["finalUrl"] = page.url
74
+ return data
75
+ finally:
76
+ context.close()
77
+ finally:
78
+ browser.close()
79
+
80
+
81
+ # ── rendering ────────────────────────────────────────────────────────────────
82
+
83
+ def _render_blocks(blocks: list, fmt: str) -> list[str]:
84
+ """One string per block, ready to be joined with a blank line."""
85
+ md = fmt == "markdown"
86
+ out: list[str] = []
87
+ for block in blocks:
88
+ kind = block.get("type")
89
+ if kind == "heading":
90
+ level = int(block.get("level") or 2)
91
+ out.append(f"{'#' * min(level, 6)} {block['text']}" if md else block["text"].upper())
92
+ elif kind == "paragraph":
93
+ out.append(block["text"])
94
+ elif kind == "quote":
95
+ out.append(f"> {block['text']}" if md else f'"{block["text"]}"')
96
+ elif kind == "code":
97
+ body = block.get("text", "")
98
+ out.append(f"```{block.get('lang') or ''}\n{body}\n```" if md else body)
99
+ elif kind == "list":
100
+ ordered = bool(block.get("ordered"))
101
+ lines = []
102
+ counter = 1
103
+ for item in block.get("items") or []:
104
+ indent = " " * int(item.get("depth") or 0)
105
+ if ordered and not item.get("depth"):
106
+ lines.append(f"{indent}{counter}. {item['text']}")
107
+ counter += 1
108
+ else:
109
+ lines.append(f"{indent}- {item['text']}")
110
+ out.append("\n".join(lines))
111
+ elif kind == "table":
112
+ rows = block.get("rows") or []
113
+ if not rows:
114
+ continue
115
+ if md:
116
+ width = max(len(r) for r in rows)
117
+ padded = [r + [""] * (width - len(r)) for r in rows]
118
+ lines = ["| " + " | ".join(padded[0]) + " |",
119
+ "| " + " | ".join(["---"] * width) + " |"]
120
+ lines += ["| " + " | ".join(r) + " |" for r in padded[1:]]
121
+ out.append("\n".join(lines))
122
+ else:
123
+ out.append("\n".join("\t".join(r) for r in rows))
124
+ elif kind == "image":
125
+ src = block.get("src") or ""
126
+ alt = block.get("alt") or ""
127
+ caption = block.get("caption") or ""
128
+ if md:
129
+ line = f"![{alt}]({src})"
130
+ out.append(f"{line}\n{caption}" if caption else line)
131
+ else:
132
+ out.append(f"[image: {alt or caption or src}]")
133
+ elif kind == "rule":
134
+ out.append("---" if md else "—")
135
+ return [s for s in out if s.strip()]
136
+
137
+
138
+ def _header(data: dict, fmt: str) -> str:
139
+ meta = data.get("metadata") or {}
140
+ lines = []
141
+ title = meta.get("title")
142
+ if title:
143
+ lines.append(f"# {title}" if fmt == "markdown" else title)
144
+ trailer = []
145
+ if meta.get("siteName"):
146
+ trailer.append(meta["siteName"])
147
+ if meta.get("byline"):
148
+ trailer.append(meta["byline"])
149
+ if meta.get("publishedTime"):
150
+ trailer.append(meta["publishedTime"])
151
+ trailer.append(data.get("finalUrl") or meta.get("url") or "")
152
+ lines.append(" · ".join(t for t in trailer if t))
153
+ return "\n".join(lines)
154
+
155
+
156
+ def render(data: dict, cfg: dict) -> tuple[str, str, bool]:
157
+ """Return (stdout_text, full_text, truncated).
158
+
159
+ ``full_text`` is always the complete document; ``stdout_text`` is what the
160
+ caller should print, i.e. the same thing cut to ``maxChars``.
161
+ """
162
+ fmt = cfg.get("format", "markdown")
163
+ blocks = list(data.get("blocks") or [])
164
+ # 标题已经在 header 里印过一次;正文第一个块又是同一句时,去掉重复的那个。
165
+ title = ((data.get("metadata") or {}).get("title") or "").strip()
166
+ if blocks and title and blocks[0].get("type") == "heading":
167
+ first = (blocks[0].get("text") or "").strip().rstrip("¶").strip()
168
+ if first and (first == title or title.startswith(first)):
169
+ blocks = blocks[1:]
170
+
171
+ body_parts = _render_blocks(blocks, fmt)
172
+ body = "\n\n".join(body_parts)
173
+
174
+ if fmt == "json":
175
+ payload = {
176
+ "url": data.get("finalUrl"),
177
+ "status": data.get("status"),
178
+ "metadata": data.get("metadata"),
179
+ "container": data.get("container"),
180
+ "charCount": data.get("charCount"),
181
+ "blocks": data.get("blocks"),
182
+ }
183
+ full = json.dumps(payload, ensure_ascii=False, indent=2)
184
+ # JSON 不做块级截断:切一半的 JSON 不是 JSON。超预算时只报告,
185
+ # 让调用方自己决定是改格式还是配 --output。
186
+ return full, full, False
187
+
188
+ full = f"{_header(data, fmt)}\n\n{body}".strip()
189
+
190
+ max_chars = int(cfg.get("maxChars") or 0)
191
+ if max_chars <= 0 or len(full) <= max_chars:
192
+ return full, full, False
193
+
194
+ # Cut on a block boundary so the tail is a whole paragraph, not half a word.
195
+ head = f"{_header(data, fmt)}\n\n"
196
+ kept: list[str] = []
197
+ used = len(head)
198
+ for part in body_parts:
199
+ if used + len(part) + 2 > max_chars:
200
+ break
201
+ kept.append(part)
202
+ used += len(part) + 2
203
+ if not kept:
204
+ # Single oversized block (one giant <pre>, say) — fall back to a hard cut.
205
+ kept = [body[: max(0, max_chars - len(head))]]
206
+ shown = (head + "\n\n".join(kept)).strip()
207
+ notice = (
208
+ f"\n\n---\n[truncated] 已显示 {len(shown)} / {len(full)} 字符。"
209
+ "需要全文时加大 --max-chars(0 = 不限),或用 --output 把全文写到文件再按需读取。"
210
+ )
211
+ return shown + notice, full, True
212
+
213
+
214
+ def do_read(cfg: dict) -> dict:
215
+ """Extract, render, optionally persist. Returns a small result summary."""
216
+ data = extract(cfg)
217
+
218
+ if data.get("error") == "selector-not-found":
219
+ raise SystemExit(
220
+ f'selector "{data.get("selector")}" 在页面上未找到({cfg["url"]})。'
221
+ "请换一个选择器,或去掉 --selector 让正文自动识别。"
222
+ )
223
+
224
+ stdout_text, full_text, truncated = render(data, cfg)
225
+
226
+ out_path = None
227
+ if cfg.get("output"):
228
+ out_path = Path(cfg["output"]).resolve()
229
+ out_path.parent.mkdir(parents=True, exist_ok=True)
230
+ out_path.write_text(full_text, encoding="utf-8")
231
+
232
+ return {
233
+ "text": stdout_text,
234
+ "fullText": full_text,
235
+ "truncated": truncated,
236
+ "outputPath": str(out_path) if out_path else None,
237
+ "charCount": data.get("charCount") or 0,
238
+ "bodyCharCount": data.get("bodyCharCount") or 0,
239
+ "blockCount": len(data.get("blocks") or []),
240
+ "container": data.get("container"),
241
+ "containerReason": data.get("containerReason"),
242
+ "status": data.get("status"),
243
+ "finalUrl": data.get("finalUrl"),
244
+ "metadata": data.get("metadata") or {},
245
+ }
@@ -0,0 +1,90 @@
1
+ """Refuse to fetch private / link-local addresses (SSRF guard).
2
+
3
+ Only ``read_page.py`` uses this today, and that asymmetry is deliberate:
4
+ screenshot and record hand back a *file path*, while web_read hands the page's
5
+ **text** straight to a model that is driven by user input. A prompt that says
6
+ "read http://169.254.169.254/latest/meta-data/iam/..." is an exfiltration
7
+ attempt, not a reading request.
8
+
9
+ ``WEB_CAPTURE_ALLOW_PRIVATE_HOSTS=1`` opts out — needed when pointing the skill
10
+ at a dev server or an intranet page on purpose.
11
+
12
+ Caveat: the check resolves DNS once and then hands the URL to the browser,
13
+ which resolves it again. A name that flips between answers (DNS rebinding) can
14
+ slip through that gap. Closing it properly needs request-level interception in
15
+ Playwright; this guard is aimed at the ordinary case of a hostile or careless
16
+ URL, not at an attacker who controls a nameserver.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import ipaddress
21
+ import os
22
+ import socket
23
+ from urllib.parse import urlparse
24
+
25
+ _TRUTHY = {"1", "true", "yes", "on"}
26
+
27
+
28
+ def allow_private_hosts() -> bool:
29
+ return os.environ.get("WEB_CAPTURE_ALLOW_PRIVATE_HOSTS", "").strip().lower() in _TRUTHY
30
+
31
+
32
+ def _is_blocked(ip: ipaddress._BaseAddress) -> bool:
33
+ return bool(
34
+ ip.is_private
35
+ or ip.is_loopback
36
+ or ip.is_link_local
37
+ or ip.is_reserved
38
+ or ip.is_multicast
39
+ or ip.is_unspecified
40
+ )
41
+
42
+
43
+ def assert_public_url(raw_url: str) -> None:
44
+ """Raise SystemExit when *raw_url* is not an ordinary public http(s) page."""
45
+ parsed = urlparse(raw_url)
46
+ if parsed.scheme not in ("http", "https"):
47
+ raise SystemExit(
48
+ f"--url 只接受 http/https,收到 {parsed.scheme or '(空)'}:{raw_url}"
49
+ )
50
+ host = parsed.hostname
51
+ if not host:
52
+ raise SystemExit(f"--url 缺少主机名:{raw_url}")
53
+
54
+ if allow_private_hosts():
55
+ return
56
+
57
+ # A literal IP needs no DNS round-trip.
58
+ try:
59
+ literal = ipaddress.ip_address(host)
60
+ except ValueError:
61
+ literal = None
62
+ if literal is not None:
63
+ if _is_blocked(literal):
64
+ raise SystemExit(_refusal(host, str(literal)))
65
+ return
66
+
67
+ try:
68
+ infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80))
69
+ except socket.gaierror as e:
70
+ raise SystemExit(f"无法解析主机 {host}:{e}")
71
+
72
+ for info in infos:
73
+ addr = info[4][0]
74
+ try:
75
+ ip = ipaddress.ip_address(addr)
76
+ except ValueError:
77
+ continue
78
+ # Any resolved address being internal is enough to refuse — a name that
79
+ # answers with one public and one private A record is the exact shape of
80
+ # an SSRF attempt.
81
+ if _is_blocked(ip):
82
+ raise SystemExit(_refusal(host, addr))
83
+
84
+
85
+ def _refusal(host: str, addr: str) -> str:
86
+ return (
87
+ f"拒绝读取内网地址:{host} 解析到 {addr}(私有 / 环回 / 链路本地)。"
88
+ "web_read 会把页面正文交给模型,因此默认只读公网页面。"
89
+ "确实要读内网页面时设 WEB_CAPTURE_ALLOW_PRIVATE_HOSTS=1。"
90
+ )
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """Playwright 正文抽取入口(薄壳,逻辑在 _media_screenshot.reader)。
3
+
4
+ 与 screenshot.py / record.py 同住一个 scripts/ 目录,因为三者共用
5
+ `_media_screenshot/` 里的浏览器启动、等待、设备模拟与 cookie 处理。
6
+ 对外它是独立的 skill:`skills/web-read/skill.json` 指到这里。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ from _media_screenshot import cli_args # noqa: E402
17
+ from _media_screenshot.reader import do_read # noqa: E402
18
+ from _media_screenshot.urlguard import assert_public_url # noqa: E402
19
+
20
+ DEFAULT_MAX_CHARS = 20000
21
+
22
+
23
+ def main() -> None:
24
+ ap = argparse.ArgumentParser(
25
+ description="通过 Playwright 打开网页并抽取正文文本(Markdown / 纯文本 / JSON)。"
26
+ )
27
+ ap.add_argument("-u", "--url", required=True, help="待读取的页面 URL(http/https)")
28
+ ap.add_argument(
29
+ "--selector",
30
+ help="只抽取该 CSS 选择器内的内容;不给则自动识别正文容器",
31
+ )
32
+ ap.add_argument(
33
+ "--format", choices=["markdown", "text", "json"], default="markdown",
34
+ help="输出格式:markdown(默认,保留标题/列表/代码块)| text(纯文本)| json(结构化块)",
35
+ )
36
+ ap.add_argument(
37
+ "--max-chars", type=int, default=DEFAULT_MAX_CHARS,
38
+ help=f"stdout 字符上限,按块截断(默认 {DEFAULT_MAX_CHARS},0 = 不限)",
39
+ )
40
+ ap.add_argument(
41
+ "--include-links", action="store_true",
42
+ help="正文里的链接保留为 [文字](URL)(默认只留文字)",
43
+ )
44
+ ap.add_argument(
45
+ "--include-images", action="store_true",
46
+ help="保留图片为 ![alt](src)(默认丢弃)",
47
+ )
48
+ ap.add_argument(
49
+ "-o", "--output",
50
+ help="把**完整**正文(不截断)另存到该文件;stdout 仍受 --max-chars 限制",
51
+ )
52
+ ap.add_argument(
53
+ "--settle-ms", type=int,
54
+ help="抽取前额外静置毫秒数(给动画/懒加载留时间)",
55
+ )
56
+ ap.add_argument(
57
+ "--quiet", action="store_true",
58
+ help="只打印正文,不打印 stderr 上的抽取诊断",
59
+ )
60
+ cli_args.add_common_args(ap)
61
+
62
+ args = ap.parse_args()
63
+
64
+ # 内网地址守卫(见 urlguard.py:web_read 会把正文交给模型)。
65
+ assert_public_url(args.url)
66
+
67
+ storage_path, is_temp = cli_args.build_storage(args)
68
+
69
+ cfg: dict = {
70
+ "url": args.url,
71
+ "browser": args.browser or "chromium",
72
+ "format": args.format,
73
+ "maxChars": args.max_chars,
74
+ "includeLinks": bool(args.include_links),
75
+ "includeImages": bool(args.include_images),
76
+ }
77
+ cli_args.common_args_to_cfg(args, cfg)
78
+ if storage_path:
79
+ cfg["storageState"] = storage_path
80
+ if args.selector:
81
+ cfg["selector"] = args.selector
82
+ if args.output:
83
+ cfg["output"] = args.output
84
+ if args.settle_ms is not None:
85
+ cfg["settleMs"] = args.settle_ms
86
+
87
+ try:
88
+ result = do_read(cfg)
89
+ finally:
90
+ if is_temp and storage_path:
91
+ try:
92
+ os.unlink(storage_path)
93
+ except OSError:
94
+ pass
95
+
96
+ # 只在「基本没抽到东西」时报警。一篇文章页的整页文本本来就是正文的好几倍
97
+ # (导航、推荐、页脚),拿这个比例当告警条件会让警告天天响、从而没人再看。
98
+ chars = result["charCount"]
99
+ body_chars = result["bodyCharCount"]
100
+ status = result.get("status")
101
+ if isinstance(status, int) and status >= 400:
102
+ # 错误页也有正文("404 Not Found"),照读不误——但不说一声的话,调用方
103
+ # 会把一张错误页当成文章内容去写脚本。
104
+ print(
105
+ f"⚠️ 服务器返回 HTTP {status},下面读到的很可能是错误页而不是目标内容。",
106
+ file=sys.stderr,
107
+ )
108
+ if chars < 100 and body_chars >= 2000:
109
+ print(
110
+ f"⚠️ 整页有 {body_chars} 字符,却只抽到 {chars} 字符"
111
+ f"(容器 {result.get('container')})——正文容器多半没认对。"
112
+ "可用 --selector <正文容器> 指定,或 --format json 看抽到了哪些块。",
113
+ file=sys.stderr,
114
+ )
115
+ elif chars < 100:
116
+ print(
117
+ f"⚠️ 这个页面几乎没有文本(整页 {body_chars} 字符,HTTP {result.get('status')})。"
118
+ "多半是登录墙、反爬拦截,或正文由 JS 延迟渲染还没出来。"
119
+ "可尝试:--wait-for-selector <正文选择器> / --settle-ms 3000 / --cookies <登录态>。",
120
+ file=sys.stderr,
121
+ )
122
+ elif not args.quiet:
123
+ print(
124
+ f"[web_read] {result['blockCount']} blocks · {result['charCount']} chars · "
125
+ f"container={result.get('container')} ({result.get('containerReason')})",
126
+ file=sys.stderr,
127
+ )
128
+
129
+ if result["outputPath"]:
130
+ print(f"[web_read] 全文已写入 {result['outputPath']}", file=sys.stderr)
131
+
132
+ print(result["text"])
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()