@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.
- 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 +2 -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/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,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""
|
|
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="保留图片为 (默认丢弃)",
|
|
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()
|