@remixmate/cli 0.9.25 → 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,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()