@remixmate/cli 0.9.26 → 0.9.28
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 +7 -4
- package/README.zh-CN.md +8 -4
- package/dist/handlers/gen-voice.d.ts +11 -1
- package/dist/handlers/gen-voice.js +17 -1
- package/dist/manifest.json +161 -10
- package/package.json +3 -1
- package/skills/gen-script/scripts/gen_script.py +1 -1
- package/skills/gen-voice/SKILL.md +5 -3
- package/skills/gen-voice/skill.json +3 -8
- package/skills/render-video/scripts/remote_renderer_client.py +16 -2
- package/skills/render-video/scripts/render_video.py +118 -28
- package/skills/web-read/SKILL.md +146 -0
- package/skills/web-read/skill.json +131 -0
- package/skills/web-screenshot/scripts/_media_screenshot/__init__.py +2 -0
- package/skills/web-screenshot/scripts/_media_screenshot/js/extract_article.js +373 -0
- package/skills/web-screenshot/scripts/_media_screenshot/reader.py +245 -0
- package/skills/web-screenshot/scripts/_media_screenshot/urlguard.py +90 -0
- package/skills/web-screenshot/scripts/read_page.py +136 -0
|
@@ -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="保留图片为 (默认丢弃)",
|
|
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()
|