evolcore 0.0.32 → 0.0.34
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/CHANGELOG.md +61 -0
- package/bin/ec.js +3 -2
- package/bin/install-codex-managed-hooks.mjs +135 -37
- package/dist/agents/baseagent-availability.js +20 -36
- package/dist/agents/claude-runner.js +111 -15
- package/dist/agents/codex/app-server-client.js +3 -0
- package/dist/agents/codex/diagnostics.js +112 -54
- package/dist/agents/codex-runner.js +241 -63
- package/dist/agents/ecagent-runner.js +78 -33
- package/dist/agents/runner-types.js +5 -6
- package/dist/aun/aid/identity.js +1 -1
- package/dist/aun/group/operations.js +0 -1
- package/dist/aun/msg/history.js +1 -0
- package/dist/aun/msg/p2p.js +0 -1
- package/dist/aun/outbox.js +50 -1
- package/dist/channels/aun.js +44 -8
- package/dist/channels/daemon.js +261 -3
- package/dist/cli/agent-command.js +58 -24
- package/dist/cli/agent.js +1 -1
- package/dist/cli/aun-commands.js +368 -31
- package/dist/cli/contact.js +8 -2
- package/dist/cli/ctl-command.js +3 -1
- package/dist/cli/daemon-commands.js +197 -27
- package/dist/cli/debug-mode.js +154 -0
- package/dist/cli/fs-command.js +2 -1
- package/dist/cli/handoff-command.js +3 -0
- package/dist/cli/index.js +98 -67
- package/dist/cli/init.js +2 -2
- package/dist/cli/integrity-command.js +196 -0
- package/dist/cli/managed-command-guard.js +5 -0
- package/dist/cli/memory-command.js +166 -146
- package/dist/cli/queue-command.js +9 -4
- package/dist/cli/session-command.js +194 -0
- package/dist/cli/skill-command.js +78 -2
- package/dist/cli/task-context.js +1 -0
- package/dist/cli/thread-command.js +622 -0
- package/dist/cli/trigger-command.js +10 -3
- package/dist/config/builtin-roles.js +1 -0
- package/dist/config/config-field-policy.js +6 -3
- package/dist/config/config-manager.js +26 -2
- package/dist/config/contact-book-store.js +5 -0
- package/dist/config/resolved-config-op.js +3 -0
- package/dist/config/role-service.js +13 -0
- package/dist/config/runtime-env.js +16 -0
- package/dist/config-store.js +45 -0
- package/dist/core/audit/log-integrity.js +3 -1
- package/dist/core/auth/agent-delegation.js +133 -36
- package/dist/core/auth/debug-context.js +139 -0
- package/dist/core/auth/delegation-diagnostics.js +414 -0
- package/dist/core/auth/operation-authorizer.js +25 -2
- package/dist/core/auth/operation-catalog.js +49 -0
- package/dist/core/background-session/session-cleanup.js +212 -0
- package/dist/core/background-session/types.js +10 -0
- package/dist/core/bootstrap-messages.js +1 -1
- package/dist/core/bootstrap-service.js +6 -3
- package/dist/core/command/agent-control.js +18 -26
- package/dist/core/command/command-handler.js +197 -27
- package/dist/core/command/daemon-diagnostics.js +1 -0
- package/dist/core/command/memory-slash.js +6 -2
- package/dist/core/command/menu-catalog.js +51 -1
- package/dist/core/command/menu-handler.js +114 -23
- package/dist/core/command/menu-protocol.js +25 -1
- package/dist/core/command/skill-slash.js +322 -0
- package/dist/core/command/slash-gate.js +6 -4
- package/dist/core/command/slash-handler.js +195 -13
- package/dist/core/command/topic-slash.js +528 -0
- package/dist/core/daemon-bootstrap.js +4 -7
- package/dist/core/evolagent-registry.js +20 -5
- package/dist/core/file-integrity/read.js +61 -0
- package/dist/core/file-integrity/store.js +262 -0
- package/dist/core/handoff/runtime.js +5 -11
- package/dist/core/handoff/store.js +5 -0
- package/dist/core/ipc-registration.js +175 -3
- package/dist/core/memory/batch-meta.js +132 -0
- package/dist/core/memory/consolidation-context.js +83 -29
- package/dist/core/memory/consolidation-ops-records.js +49 -16
- package/dist/core/memory/consolidation-queue-format.js +21 -0
- package/dist/core/memory/consolidation.js +264 -205
- package/dist/core/memory/index-manager.js +19 -28
- package/dist/core/memory/memory-admin.js +19 -14
- package/dist/core/memory/memory-document.js +53 -9
- package/dist/core/memory/memory-writer.js +30 -15
- package/dist/core/message/aun-ipc-sender.js +161 -6
- package/dist/core/message/im-renderer.js +169 -10
- package/dist/core/message/message-bridge.js +15 -4
- package/dist/core/message/message-log.js +1 -0
- package/dist/core/message/peer-mode.js +6 -1
- package/dist/core/message/response-engine.js +929 -110
- package/dist/core/message/subtask-tool-projection.js +140 -0
- package/dist/core/model/catalog-service.js +17 -12
- package/dist/core/permission/ec-command-parser.js +217 -9
- package/dist/core/permission/protected-paths.js +5 -1
- package/dist/core/permission/readonly-shell-query.js +43 -5
- package/dist/core/permission/session-path-grants.js +334 -0
- package/dist/core/permission/tool-canonical.js +226 -0
- package/dist/core/permission/tool-policy.js +30 -62
- package/dist/core/role/runtime-policy.js +21 -10
- package/dist/core/session/session-fs-store.js +123 -8
- package/dist/core/session/session-idle-monitor.js +249 -22
- package/dist/core/session/session-manager.js +129 -14
- package/dist/core/skill/command-parser.js +13 -0
- package/dist/core/skill/index-generator.js +21 -4
- package/dist/core/skill/startup.js +41 -0
- package/dist/core/skill/switch-resolver.js +55 -29
- package/dist/core/system-session/grants.js +40 -0
- package/dist/core/system-session/memory.js +222 -0
- package/dist/core/system-session/registry.js +74 -0
- package/dist/core/system-session/types.js +14 -0
- package/dist/core/thread/command-parser.js +125 -0
- package/dist/core/thread/index.js +1 -0
- package/dist/core/thread/ipc-operation.js +95 -0
- package/dist/core/thread/thread-context-storage.js +131 -0
- package/dist/core/thread/thread-operation.js +258 -0
- package/dist/core/thread/thread-route.js +33 -0
- package/dist/core/thread/thread-service.js +770 -0
- package/dist/core/thread/types.js +1 -0
- package/dist/eck/kit-renderer.js +7 -2
- package/dist/eck/manifest-engine.js +24 -4
- package/dist/eck/message-renderer.js +35 -0
- package/dist/index.js +19 -7
- package/dist/ipc.js +140 -50
- package/dist/paths.js +93 -0
- package/dist/response-system/engines/v1/proactive-flow.js +169 -80
- package/dist/stats/billing.js +20 -8
- package/dist/trigger/authorization.js +57 -28
- package/dist/trigger/feedback.js +13 -2
- package/dist/trigger/parser.js +70 -1
- package/dist/trigger/system-definitions.js +3 -3
- package/dist/trigger/validation.js +14 -0
- package/dist/utils/cross-platform.js +11 -2
- package/dist/utils/error-dict.json +18 -4
- package/dist/utils/error-utils.js +143 -1
- package/dist/utils/instance-registry.js +13 -8
- package/dist/utils/log-writer.js +12 -0
- package/dist/utils/process-introspect.js +11 -0
- package/kits/docs/context-assembly.md +1 -1
- package/kits/docs/evolcore/INDEX.md +1 -0
- package/kits/docs/evolcore/agent-create.md +40 -28
- package/kits/docs/evolcore/agent.md +21 -6
- package/kits/docs/evolcore/config.md +2 -2
- package/kits/docs/evolcore/ctl.md +2 -2
- package/kits/docs/evolcore/ec-capabilities.md +5 -1
- package/kits/docs/evolcore/fs-architecture.md +4 -5
- package/kits/docs/evolcore/fs.md +1 -1
- package/kits/docs/evolcore/group-collaboration.md +4 -6
- package/kits/docs/evolcore/group.md +11 -12
- package/kits/docs/evolcore/memory-architecture.md +4 -5
- package/kits/docs/evolcore/memory-consolidation.md +19 -15
- package/kits/docs/evolcore/memory-tags.md +3 -1
- package/kits/docs/evolcore/memory.md +24 -12
- package/kits/docs/evolcore/menu-exec-schema-commands.md +2 -2
- package/kits/docs/evolcore/menu-protocol.md +7 -0
- package/kits/docs/evolcore/model.md +16 -0
- package/kits/docs/evolcore/msg.md +8 -11
- package/kits/docs/evolcore/thread-chain.md +267 -0
- package/kits/docs/prompt-loading-architecture.md +1 -1
- package/kits/eck_manifest.json +55 -40
- package/kits/eck_manifest.memory.json +0 -12
- package/kits/eck_message_manifest.json +169 -10
- package/kits/rules/01-overview.md +11 -0
- package/kits/rules/03-identity.md +6 -3
- package/kits/rules/05-venue.md +1 -1
- package/kits/rules/06-channel.md +1 -1
- package/kits/rules/07-memory-and-thread.md +199 -0
- package/kits/schemas/_meta.json +7 -1
- package/kits/schemas/agent-config.schema.13.json +1 -1
- package/kits/schemas/daemon.schema.7.json +140 -0
- package/kits/schemas/relation-config.schema.9.json +18 -3
- package/kits/schemas/role-config.schema.1.json +6 -6
- package/kits/schemas/single-session.schema.1.json +6 -6
- package/kits/schemas/single-session.schema.2.json +6 -6
- package/kits/schemas/single-session.schema.3.json +6 -6
- package/kits/skills/.switches.json +12 -1
- package/kits/skills/ec-group/SKILL.md +234 -0
- package/kits/skills/ec-skill/SKILL.md +10 -1
- package/kits/skills/ec-skill/scripts/cli.mjs +16 -2
- package/kits/skills/ec-skill/scripts/skill.mjs +1 -1
- package/kits/skills/scrapling-article-fetch/SKILL.md +128 -0
- package/kits/skills/scrapling-article-fetch/scripts/check_python_env.py +54 -0
- package/kits/skills/scrapling-article-fetch/scripts/render_markdown_reply.py +35 -0
- package/kits/skills/scrapling-article-fetch/scripts/run_pipeline.sh +16 -0
- package/kits/skills/scrapling-article-fetch/scripts/scrapling_fetch.py +344 -0
- package/kits/skills/thread-ops/SKILL.md +492 -0
- package/kits/skills/wechat-article-media-download/SKILL.md +65 -0
- package/kits/skills/wechat-article-media-download/scripts/download.py +163 -0
- package/kits/skills/wechat-article-reader/SKILL.md +97 -0
- package/kits/skills/wechat-article-reader/scripts/fetch.mjs +256 -0
- package/kits/templates/bootstrap-welcome.md +7 -7
- package/kits/templates/memory-consolidation/forget.md +7 -2
- package/kits/templates/memory-consolidation/generalize.md +9 -4
- package/kits/templates/memory-consolidation/notes.md +11 -11
- package/kits/templates/memory-consolidation/profile.md +8 -3
- package/kits/templates/memory-consolidation/promote.md +8 -3
- package/kits/templates/memory-consolidation/workflow.md +6 -6
- package/kits/templates/message-fragments/handoff-request-to-target.md +1 -1
- package/kits/templates/message-fragments/item.md +2 -1
- package/kits/templates/message-fragments/thread-context.md +6 -0
- package/kits/templates/message-fragments/trigger-session.md +2 -0
- package/kits/templates/message-fragments/trigger-thread-check.md +2 -0
- package/kits/templates/roles/member.json +2 -0
- package/kits/templates/roles/visitor.json +2 -0
- package/kits/templates/system-fragments/bootstrap.md +67 -72
- package/kits/templates/system-fragments/channel.md +3 -6
- package/kits/templates/system-fragments/commands.md +1 -0
- package/kits/templates/system-fragments/managed-runtime.md +15 -0
- package/kits/templates/system-fragments/memory.md +16 -9
- package/kits/templates/system-fragments/reply.md +7 -6
- package/kits/templates/system-fragments/session.md +5 -14
- package/package.json +6 -3
- package/dist/core/memory/memory-session-policy.js +0 -41
- package/dist/core/memory/memory-signature.js +0 -257
- package/kits/docs/evolcore/group-fs.md +0 -17
- package/kits/docs/venues/aun-group.md +0 -45
- package/kits/docs/venues/group.md +0 -43
|
@@ -367,7 +367,7 @@ export function resolveSkillDirArg(target) {
|
|
|
367
367
|
|
|
368
368
|
export function cmdCreate(name, { desc = '', force = false, enable = false, json = false } = {}) {
|
|
369
369
|
assertPermission('local'); // 骨架固定建在本机级,与 install 同档
|
|
370
|
-
if (!name) throw new Error(
|
|
370
|
+
if (!name) throw new Error(`缺少技能名。用法:ec skill create <name> --desc '...'`);
|
|
371
371
|
if (!NAME_RE.test(name)) {
|
|
372
372
|
throw new Error(`技能名「${name}」不符合 kebab-case(小写字母/数字/连字符,如 my-skill)`);
|
|
373
373
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: scrapling-article-fetch
|
|
3
|
+
description: 使用 html2text 从 URL 抓取可读正文(含图片),按优先级选择器提取并按字符数截断,最终以 Markdown 代码块回复。适用于用户发送文章/博客/新闻链接(尤其是微信公众号 mp.weixin.qq.com)并希望快速验收正文内容的场景。不适用于需要登录墙、纯 JS 渲染且无原文 HTML 的页面。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## 目标
|
|
7
|
+
|
|
8
|
+
把文章正文抓成 Markdown(保留标题、原文链接、图片),直接交付给用户对照验收。
|
|
9
|
+
|
|
10
|
+
## 依赖方案(重要)
|
|
11
|
+
|
|
12
|
+
本技能有两种依赖实现方案,安装时根据实际情况选择:
|
|
13
|
+
|
|
14
|
+
### 方案 A:轻量级(推荐,已默认采用)
|
|
15
|
+
|
|
16
|
+
**依赖**:`lxml + cssselect + html2text`(总计约 5MB,秒级启动)
|
|
17
|
+
|
|
18
|
+
**适用场景**:
|
|
19
|
+
- 微信公众号(mp.weixin.qq.com)
|
|
20
|
+
- Medium、个人博客、新闻网站等服务端渲染页面
|
|
21
|
+
- 95% 的文章抓取需求
|
|
22
|
+
|
|
23
|
+
**实现**:`urllib.request + lxml.html`(Python 标准库 + 轻量 DOM 解析)
|
|
24
|
+
|
|
25
|
+
**本机安装时已采用此方案**:`scrapling_fetch.py` 已改为此实现,无需安装 scrapling/patchright/playwright。
|
|
26
|
+
|
|
27
|
+
### 方案 B:完整浏览器(可选,仅特殊场景)
|
|
28
|
+
|
|
29
|
+
**依赖**:`scrapling[fetchers]`(拉取 patchright 37MB + playwright 37MB,首次安装需 5-10 分钟)
|
|
30
|
+
|
|
31
|
+
**适用场景**:
|
|
32
|
+
- 纯 JS 渲染页面(SPA 无服务端渲染)
|
|
33
|
+
- 需要登录后才能看到正文
|
|
34
|
+
- 有反爬虫检测需要真实浏览器指纹
|
|
35
|
+
|
|
36
|
+
**注意**:如果网络慢或 PyPI 访问受限,此方案会超时失败。
|
|
37
|
+
|
|
38
|
+
## 前提条件
|
|
39
|
+
|
|
40
|
+
1. Python 3.10+。
|
|
41
|
+
2. 已安装 `uv`(依赖走 uv 全局缓存,不需要 venv)。
|
|
42
|
+
3. 机器可访问目标 URL。
|
|
43
|
+
|
|
44
|
+
脚本位置不固定,执行前先确定 `<技能目录>`(即本技能所在目录,由技能索引注入)。约定写法见下方「路径约定」。
|
|
45
|
+
|
|
46
|
+
## 路径约定
|
|
47
|
+
|
|
48
|
+
EC 不会把 cwd 设到技能目录,所以命令里的脚本路径**必须写成绝对路径**。`<技能目录>` 由上下文注入的技能索引给出(索引里有「根目录 + 技能名」的拼法)。不要依赖 cwd 或 `~`。
|
|
49
|
+
|
|
50
|
+
脚本内部也不依赖 cwd:`run_pipeline.sh` 用自身位置定位同目录的其它脚本。
|
|
51
|
+
|
|
52
|
+
## 环境检查(必须先做)
|
|
53
|
+
|
|
54
|
+
不要假设系统一定有 `python` 别名,统一先跑 wrapper:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bash "<技能目录>/scripts/run_pipeline.sh"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
该 wrapper 会优先选 `python3`,其次 `python`,避免「环境有 Python,但入口命令写死导致误判」。
|
|
61
|
+
|
|
62
|
+
执行规则:
|
|
63
|
+
- `python_ok=true`:继续抓取。
|
|
64
|
+
- 无 Python 或版本低于 3.10:先提醒用户安装,或在征得同意后帮装;不要跳过检查硬跑。
|
|
65
|
+
- 无 `uv`:先提醒用户安装 `uv`(依赖装不进去),不要硬跑。
|
|
66
|
+
|
|
67
|
+
## 抓取
|
|
68
|
+
|
|
69
|
+
用 `uv` 的全局缓存装依赖,使用 `--no-project` 避免在当前目录建 `.venv`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uv run --no-project --with lxml --with cssselect --with html2text python "<技能目录>/scripts/scrapling_fetch.py" <url> [max_chars]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
依赖只进 uv 全局缓存(`uv cache dir`),跨技能共享、装一次后秒级启动,不落任何项目目录。
|
|
76
|
+
|
|
77
|
+
默认 `max_chars=30000`。需要结构化结果(标题、封面、正文)时加 `--json`。
|
|
78
|
+
|
|
79
|
+
## 安装注意(避免踩坑)
|
|
80
|
+
|
|
81
|
+
**不要用 `scrapling[fetchers]`**。该 extra 会拉取 patchright(37MB)+ playwright(37MB),且 scrapling 源码在 `engines/toolbelt/convertor.py` 顶层硬 import `patchright`,即使只调用基础 Fetcher 也无法绕开。网络慢时首次安装需要 30 分钟以上,极易超时。
|
|
82
|
+
|
|
83
|
+
本技能脚本已将底层 fetch 换为 `urllib.request + lxml`,依赖只有 `lxml + cssselect + html2text`(约 5MB),uv 缓存后秒级启动。微信公众号、Medium、普通博客等服务端渲染页面全部覆盖。
|
|
84
|
+
|
|
85
|
+
**只有纯 JS 渲染页面(SPA)才需要无头浏览器**,届时再单独评估是否切回 scrapling 原实现。
|
|
86
|
+
|
|
87
|
+
## 抓取规则
|
|
88
|
+
|
|
89
|
+
脚本按以下逻辑执行:
|
|
90
|
+
|
|
91
|
+
1. `urllib.request` 拉取页面 HTML,带浏览器 User-Agent 头避免被拦截。
|
|
92
|
+
2. 微信公众号(`mp.weixin.qq.com`)优先尝试:`#js_content`、`.rich_media_content`、`.rich_media_area_primary`。
|
|
93
|
+
3. 再试通用选择器:`article`、`main`、`.post-content`、`[class*="body"]`。
|
|
94
|
+
4. 用 `html2text` 转 Markdown,默认保留链接和图片。
|
|
95
|
+
5. 清理常见微信尾部噪音(扫码提示、授权弹窗等)。
|
|
96
|
+
6. 按 `max_chars` 截断。
|
|
97
|
+
7. 未命中正文容器时,回退为整页 HTML 转换结果。
|
|
98
|
+
|
|
99
|
+
## 交付流程(默认执行,无需额外确认)
|
|
100
|
+
|
|
101
|
+
1. 环境检查(见上)。
|
|
102
|
+
2. 运行抓取脚本,得到 Markdown(需要标题等元信息时加 `--json`)。
|
|
103
|
+
3. 用 `render_markdown_reply.py` 生成固定格式的回复:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python3 "<技能目录>/scripts/render_markdown_reply.py" <title> <url> <markdown_file>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
无 `python3` 时改用 `python`,优先复用 wrapper 选出的解释器。
|
|
110
|
+
|
|
111
|
+
输出格式固定为:
|
|
112
|
+
|
|
113
|
+
````markdown
|
|
114
|
+
```markdown
|
|
115
|
+
# <文章标题>
|
|
116
|
+
|
|
117
|
+
原文链接:<url>
|
|
118
|
+
|
|
119
|
+
<完整 Markdown 正文>
|
|
120
|
+
```
|
|
121
|
+
````
|
|
122
|
+
|
|
123
|
+
4. 把结果交付给用户,至少包含:原文链接、标题、完整正文(含图片)。
|
|
124
|
+
|
|
125
|
+
## 边界情况
|
|
126
|
+
|
|
127
|
+
- 页面高度依赖 JS 导致抓取为空:后续可切换 DynamicFetcher 变体。
|
|
128
|
+
- 输出格式保持稳定,便于与原文逐段对照。
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check_python() -> dict:
|
|
9
|
+
result = {
|
|
10
|
+
"python_found": False,
|
|
11
|
+
"python_cmd": None,
|
|
12
|
+
"python_version": None,
|
|
13
|
+
"python_ok": False,
|
|
14
|
+
"uv_found": False,
|
|
15
|
+
"venv_available": False,
|
|
16
|
+
"install_hint": "未检测到可用 Python 3.10+。请先安装 Python,或在你确认后由 agent 帮你安装。",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
candidates = ["python3", "python"]
|
|
20
|
+
for cmd in candidates:
|
|
21
|
+
path = shutil.which(cmd)
|
|
22
|
+
if not path:
|
|
23
|
+
continue
|
|
24
|
+
try:
|
|
25
|
+
out = subprocess.check_output([cmd, "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"], text=True).strip()
|
|
26
|
+
parts = tuple(int(x) for x in out.split("."))
|
|
27
|
+
result["python_found"] = True
|
|
28
|
+
result["python_cmd"] = cmd
|
|
29
|
+
result["python_version"] = out
|
|
30
|
+
result["python_ok"] = parts >= (3, 10)
|
|
31
|
+
break
|
|
32
|
+
except Exception:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
result["uv_found"] = shutil.which("uv") is not None
|
|
36
|
+
|
|
37
|
+
if result["python_found"]:
|
|
38
|
+
try:
|
|
39
|
+
subprocess.check_call([result["python_cmd"], "-c", "import venv"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
40
|
+
result["venv_available"] = True
|
|
41
|
+
except Exception:
|
|
42
|
+
result["venv_available"] = False
|
|
43
|
+
|
|
44
|
+
if result["python_ok"]:
|
|
45
|
+
result["install_hint"] = "Python 环境可用。优先用 uv;若无 uv,可用 venv + pip。"
|
|
46
|
+
else:
|
|
47
|
+
result["install_hint"] = "检测到 Python,但版本低于 3.10。请升级 Python,或在你确认后由 agent 帮你安装/升级。"
|
|
48
|
+
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
json.dump(check_python(), sys.stdout, ensure_ascii=False)
|
|
54
|
+
sys.stdout.write("\n")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Windows 终端默认 GBK,强制 stdout/stderr 用 UTF-8
|
|
7
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
8
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
9
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
10
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_reply(title: str, src_url: str, markdown: str) -> str:
|
|
14
|
+
body = markdown.strip()
|
|
15
|
+
return (
|
|
16
|
+
"```markdown\n"
|
|
17
|
+
f"# {title}\n\n"
|
|
18
|
+
f"原文链接:{src_url}\n\n"
|
|
19
|
+
f"{body}\n"
|
|
20
|
+
"```"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> int:
|
|
25
|
+
if len(sys.argv) != 4:
|
|
26
|
+
print("usage: render_markdown_reply.py <title> <src_url> <markdown_file>", file=sys.stderr)
|
|
27
|
+
return 2
|
|
28
|
+
title, src_url, markdown_file = sys.argv[1:4]
|
|
29
|
+
markdown = Path(markdown_file).read_text(encoding="utf-8")
|
|
30
|
+
print(render_reply(title, src_url, markdown))
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# 自定位:从脚本自身位置推导同目录脚本,不依赖 cwd 或任何绝对路径。
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
|
|
7
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
8
|
+
PYTHON_BIN=python3
|
|
9
|
+
elif command -v python >/dev/null 2>&1; then
|
|
10
|
+
PYTHON_BIN=python
|
|
11
|
+
else
|
|
12
|
+
echo 'Python not found' >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
"$PYTHON_BIN" "$SCRIPT_DIR/check_python_env.py"
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.request
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
# Windows 终端默认 GBK,强制 stdout/stderr 用 UTF-8
|
|
10
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
11
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
12
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
13
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
14
|
+
|
|
15
|
+
import html2text
|
|
16
|
+
import lxml.html
|
|
17
|
+
import lxml.cssselect # noqa: F401 — pulls cssselect into lxml's registry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Thin compatibility shim — mimics the scrapling page/node API surface used
|
|
22
|
+
# in this script, backed by urllib + lxml so patchright is never needed.
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
_HEADERS = {
|
|
26
|
+
"User-Agent": (
|
|
27
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
28
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
29
|
+
"Chrome/124.0.0.0 Safari/537.36"
|
|
30
|
+
),
|
|
31
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
32
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _NodeList:
|
|
37
|
+
def __init__(self, nodes):
|
|
38
|
+
self._nodes = nodes
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def first(self):
|
|
42
|
+
return _Node(self._nodes[0]) if self._nodes else None
|
|
43
|
+
|
|
44
|
+
def __bool__(self):
|
|
45
|
+
return bool(self._nodes)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Node:
|
|
49
|
+
def __init__(self, el):
|
|
50
|
+
self._el = el
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def html_content(self):
|
|
54
|
+
return lxml.html.tostring(self._el, encoding="unicode", with_tail=False)
|
|
55
|
+
|
|
56
|
+
def css(self, sel):
|
|
57
|
+
try:
|
|
58
|
+
return _NodeList(self._el.cssselect(sel))
|
|
59
|
+
except Exception:
|
|
60
|
+
return _NodeList([])
|
|
61
|
+
|
|
62
|
+
def get(self, attr, default=None):
|
|
63
|
+
return self._el.get(attr, default)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def attributes(self):
|
|
67
|
+
return dict(self._el.attrib)
|
|
68
|
+
|
|
69
|
+
def __str__(self):
|
|
70
|
+
return self.html_content
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Page:
|
|
74
|
+
def __init__(self, html_bytes: bytes, status: int, url: str):
|
|
75
|
+
self.status = status
|
|
76
|
+
self._url = url
|
|
77
|
+
self._doc = lxml.html.fromstring(html_bytes, base_url=url)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def html_content(self):
|
|
81
|
+
return lxml.html.tostring(self._doc, encoding="unicode", with_tail=False)
|
|
82
|
+
|
|
83
|
+
def css(self, sel):
|
|
84
|
+
try:
|
|
85
|
+
return _NodeList(self._doc.cssselect(sel))
|
|
86
|
+
except Exception:
|
|
87
|
+
return _NodeList([])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Fetcher:
|
|
91
|
+
@staticmethod
|
|
92
|
+
def get(url: str) -> _Page:
|
|
93
|
+
req = urllib.request.Request(url, headers=_HEADERS)
|
|
94
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
95
|
+
status = resp.status
|
|
96
|
+
html_bytes = resp.read()
|
|
97
|
+
return _Page(html_bytes, status, url)
|
|
98
|
+
|
|
99
|
+
DEFAULT_SELECTORS = [
|
|
100
|
+
"article",
|
|
101
|
+
"main",
|
|
102
|
+
".post-content",
|
|
103
|
+
'[class*="body"]',
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
WECHAT_SELECTORS = [
|
|
107
|
+
"#js_content",
|
|
108
|
+
".rich_media_content",
|
|
109
|
+
".rich_media_area_primary",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
WECHAT_TITLE_SELECTORS = [
|
|
113
|
+
"#activity-name",
|
|
114
|
+
".rich_media_title",
|
|
115
|
+
"h1",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
WECHAT_COVER_SELECTORS = [
|
|
119
|
+
'.rich_media_thumb[style*="background-image"]',
|
|
120
|
+
'.rich_media_thumb',
|
|
121
|
+
'meta[property="og:image"]',
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
NOISE_MARKERS = [
|
|
125
|
+
"Scan with Weixin to",
|
|
126
|
+
"微信扫一扫可打开此内容",
|
|
127
|
+
"轻触阅读原文",
|
|
128
|
+
"预览时标签不可点",
|
|
129
|
+
"Got It",
|
|
130
|
+
"Cancel",
|
|
131
|
+
"Allow",
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def choose_selectors(url: str):
|
|
136
|
+
host = (urlparse(url).hostname or "").lower()
|
|
137
|
+
if host.endswith("mp.weixin.qq.com"):
|
|
138
|
+
return WECHAT_SELECTORS + DEFAULT_SELECTORS
|
|
139
|
+
return DEFAULT_SELECTORS
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def normalize_html_for_images(html: str) -> str:
|
|
143
|
+
return re.sub(
|
|
144
|
+
r'<img([^>]*?)\sdata-src="([^"]+)"([^>]*?)>',
|
|
145
|
+
lambda m: f'<img{m.group(1)} src="{m.group(2)}"{m.group(3)}>',
|
|
146
|
+
html,
|
|
147
|
+
flags=re.IGNORECASE,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def strip_tail_noise(markdown: str) -> str:
|
|
152
|
+
cut_positions = [markdown.find(marker) for marker in NOISE_MARKERS if marker in markdown]
|
|
153
|
+
cut_positions = [p for p in cut_positions if p >= 0]
|
|
154
|
+
if cut_positions:
|
|
155
|
+
markdown = markdown[: min(cut_positions)]
|
|
156
|
+
|
|
157
|
+
while "\n\n\n" in markdown:
|
|
158
|
+
markdown = markdown.replace("\n\n\n", "\n\n")
|
|
159
|
+
|
|
160
|
+
return markdown.strip()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def clean_text(text: str) -> str:
|
|
164
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
165
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_text(node) -> str:
|
|
169
|
+
if not node:
|
|
170
|
+
return ""
|
|
171
|
+
for attr in ("text", "text_content", "html_content"):
|
|
172
|
+
value = getattr(node, attr, None)
|
|
173
|
+
if isinstance(value, str) and value.strip():
|
|
174
|
+
return clean_text(value)
|
|
175
|
+
if callable(value):
|
|
176
|
+
try:
|
|
177
|
+
out = value()
|
|
178
|
+
if isinstance(out, str) and out.strip():
|
|
179
|
+
return clean_text(out)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
return clean_text(str(node))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_attr(node, attr: str) -> str:
|
|
186
|
+
if not node:
|
|
187
|
+
return ""
|
|
188
|
+
attributes = getattr(node, "attributes", None)
|
|
189
|
+
if isinstance(attributes, dict) and attributes.get(attr):
|
|
190
|
+
return attributes[attr]
|
|
191
|
+
attrib = getattr(node, "attrib", None)
|
|
192
|
+
if isinstance(attrib, dict) and attrib.get(attr):
|
|
193
|
+
return attrib[attr]
|
|
194
|
+
fn = getattr(node, "get", None)
|
|
195
|
+
if callable(fn):
|
|
196
|
+
try:
|
|
197
|
+
value = fn(attr)
|
|
198
|
+
if value:
|
|
199
|
+
return value
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
html = getattr(node, "html_content", None) or str(node)
|
|
203
|
+
m = re.search(fr'{attr}="([^"]+)"', html)
|
|
204
|
+
return m.group(1) if m else ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extract_title(page, url: str, markdown: str = "") -> str:
|
|
208
|
+
host = (urlparse(url).hostname or "").lower()
|
|
209
|
+
if host.endswith("mp.weixin.qq.com"):
|
|
210
|
+
for sel in WECHAT_TITLE_SELECTORS:
|
|
211
|
+
nodes = page.css(sel)
|
|
212
|
+
node = nodes.first if nodes else None
|
|
213
|
+
text = get_text(node)
|
|
214
|
+
if text:
|
|
215
|
+
return text.strip()
|
|
216
|
+
|
|
217
|
+
html = getattr(page, "html_content", None) or ""
|
|
218
|
+
for pattern in [
|
|
219
|
+
r'<meta[^>]+property="og:title"[^>]+content="([^"]+)"',
|
|
220
|
+
r'<meta[^>]+name="twitter:title"[^>]+content="([^"]+)"',
|
|
221
|
+
r'<title>(.*?)</title>',
|
|
222
|
+
]:
|
|
223
|
+
m = re.search(pattern, html, re.I | re.S)
|
|
224
|
+
if m:
|
|
225
|
+
text = re.sub(r"\s+", " ", m.group(1)).strip()
|
|
226
|
+
if text:
|
|
227
|
+
return text
|
|
228
|
+
|
|
229
|
+
if markdown:
|
|
230
|
+
for line in markdown.splitlines():
|
|
231
|
+
line = line.strip()
|
|
232
|
+
if not line:
|
|
233
|
+
continue
|
|
234
|
+
if re.match(r"!\[[^\]]*\]\(([^)]+)\)", line):
|
|
235
|
+
continue
|
|
236
|
+
line = re.sub(r"^>\s*", "", line)
|
|
237
|
+
line = re.sub(r"^[#\s]+", "", line)
|
|
238
|
+
line = re.sub(r"^\*\*(.*?)\*\*$", r"\1", line)
|
|
239
|
+
if line:
|
|
240
|
+
return line.strip()
|
|
241
|
+
return ""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def extract_cover_url(page, url: str, markdown: str = "") -> str:
|
|
245
|
+
host = (urlparse(url).hostname or "").lower()
|
|
246
|
+
html = getattr(page, "html_content", None) or ""
|
|
247
|
+
if host.endswith("mp.weixin.qq.com"):
|
|
248
|
+
for sel in WECHAT_COVER_SELECTORS:
|
|
249
|
+
nodes = page.css(sel)
|
|
250
|
+
node = nodes.first if nodes else None
|
|
251
|
+
if not node:
|
|
252
|
+
continue
|
|
253
|
+
if sel.startswith("meta"):
|
|
254
|
+
content = get_attr(node, "content")
|
|
255
|
+
if content:
|
|
256
|
+
return content.strip()
|
|
257
|
+
style = get_attr(node, "style")
|
|
258
|
+
if style:
|
|
259
|
+
m = re.search(r'background-image:\s*url\([\"\']?([^\)\"\']+)', style)
|
|
260
|
+
if m:
|
|
261
|
+
return m.group(1).strip()
|
|
262
|
+
for attr in ("data-src", "src"):
|
|
263
|
+
value = get_attr(node, attr)
|
|
264
|
+
if value:
|
|
265
|
+
return value.strip()
|
|
266
|
+
|
|
267
|
+
m = re.search(r'<meta[^>]+property="og:image"[^>]+content="([^"]+)"', html, re.I)
|
|
268
|
+
if m:
|
|
269
|
+
return m.group(1).strip()
|
|
270
|
+
|
|
271
|
+
if markdown:
|
|
272
|
+
m = re.search(r'!\[[^\]]*\]\(([^)]+)\)', markdown)
|
|
273
|
+
if m:
|
|
274
|
+
return m.group(1).strip()
|
|
275
|
+
return ""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def extract_markdown(url: str, max_chars: int = 30000) -> str:
|
|
279
|
+
return extract_article(url, max_chars)["markdown"]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def extract_article(url: str, max_chars: int = 30000) -> dict:
|
|
283
|
+
page = Fetcher.get(url)
|
|
284
|
+
status = getattr(page, "status", None)
|
|
285
|
+
if isinstance(status, int) and status >= 400:
|
|
286
|
+
raise RuntimeError(f"HTTP {status} for {url}")
|
|
287
|
+
|
|
288
|
+
chosen_html = None
|
|
289
|
+
for sel in choose_selectors(url):
|
|
290
|
+
nodes = page.css(sel)
|
|
291
|
+
node = nodes.first if nodes else None
|
|
292
|
+
if node:
|
|
293
|
+
chosen_html = getattr(node, "html_content", None) or str(node)
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
if not chosen_html:
|
|
297
|
+
chosen_html = getattr(page, "html_content", None) or ""
|
|
298
|
+
|
|
299
|
+
chosen_html = normalize_html_for_images(chosen_html)
|
|
300
|
+
|
|
301
|
+
parser = html2text.HTML2Text()
|
|
302
|
+
parser.ignore_links = False
|
|
303
|
+
parser.ignore_images = False
|
|
304
|
+
parser.body_width = 0
|
|
305
|
+
|
|
306
|
+
markdown = parser.handle(chosen_html).strip()
|
|
307
|
+
markdown = strip_tail_noise(markdown)
|
|
308
|
+
title = extract_title(page, url, markdown)
|
|
309
|
+
cover_url = extract_cover_url(page, url, markdown)
|
|
310
|
+
if cover_url and cover_url not in markdown:
|
|
311
|
+
markdown = f"\n\n{markdown}".strip()
|
|
312
|
+
markdown = markdown[:max_chars]
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
"title": title,
|
|
316
|
+
"cover_url": cover_url,
|
|
317
|
+
"markdown": markdown,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def main() -> int:
|
|
322
|
+
if len(sys.argv) < 2:
|
|
323
|
+
print("Usage: python scrapling_fetch.py <url> [max_chars] [--json]", file=sys.stderr)
|
|
324
|
+
return 1
|
|
325
|
+
|
|
326
|
+
args = [a for a in sys.argv[1:] if a != "--json"]
|
|
327
|
+
as_json = "--json" in sys.argv[1:]
|
|
328
|
+
url = args[0]
|
|
329
|
+
max_chars = int(args[1]) if len(args) > 1 else 30000
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
article = extract_article(url, max_chars)
|
|
333
|
+
if as_json:
|
|
334
|
+
print(json.dumps(article, ensure_ascii=False))
|
|
335
|
+
else:
|
|
336
|
+
print(article["markdown"])
|
|
337
|
+
return 0
|
|
338
|
+
except Exception as exc:
|
|
339
|
+
print(f"[ERROR] {exc}", file=sys.stderr)
|
|
340
|
+
return 2
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
raise SystemExit(main())
|