evolcore 0.0.31 → 0.0.33

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.
Files changed (179) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/bin/install-codex-managed-hooks.mjs +124 -37
  3. package/dist/agents/claude-runner.js +29 -2
  4. package/dist/agents/codex/app-server-client.js +3 -0
  5. package/dist/agents/codex/diagnostics.js +76 -44
  6. package/dist/agents/codex-runner.js +51 -24
  7. package/dist/agents/ecagent-runner.js +9 -13
  8. package/dist/agents/gemini-runner.js +4 -0
  9. package/dist/aun/aid/store.js +0 -81
  10. package/dist/aun/group/fs-download.js +1 -10
  11. package/dist/aun/group/operations.js +0 -1
  12. package/dist/aun/msg/history.js +1 -0
  13. package/dist/aun/msg/managed-operation.js +130 -32
  14. package/dist/aun/msg/p2p.js +0 -1
  15. package/dist/aun/outbox.js +50 -1
  16. package/dist/aun/service-proxy.js +12 -18
  17. package/dist/aun/storage/index.js +29 -0
  18. package/dist/channels/aun.js +38 -137
  19. package/dist/channels/daemon.js +257 -2
  20. package/dist/cli/agent-command.js +64 -31
  21. package/dist/cli/agent-create.js +43 -24
  22. package/dist/cli/agent.js +16 -70
  23. package/dist/cli/aun-commands.js +512 -32
  24. package/dist/cli/contact.js +8 -2
  25. package/dist/cli/ctl-command.js +1 -2
  26. package/dist/cli/daemon-commands.js +82 -13
  27. package/dist/cli/debug-mode.js +154 -0
  28. package/dist/cli/diag.js +2 -2
  29. package/dist/cli/handoff-command.js +18 -5
  30. package/dist/cli/index.js +81 -64
  31. package/dist/cli/init.js +2 -2
  32. package/dist/cli/managed-command-guard.js +5 -0
  33. package/dist/cli/memory-command.js +93 -12
  34. package/dist/cli/model.js +14 -138
  35. package/dist/cli/queue-command.js +51 -0
  36. package/dist/cli/response.js +5 -54
  37. package/dist/cli/session-command.js +194 -0
  38. package/dist/cli/skill-command.js +85 -3
  39. package/dist/cli/stats.js +10 -4
  40. package/dist/cli/task-command.js +1 -0
  41. package/dist/cli/task-context.js +16 -0
  42. package/dist/cli/thread-command.js +620 -0
  43. package/dist/cli/trigger-command.js +76 -50
  44. package/dist/config/builtin-roles.js +9 -7
  45. package/dist/config/config-field-policy.js +2 -3
  46. package/dist/config/config-manager.js +7 -2
  47. package/dist/config/resolved-config-op.js +3 -0
  48. package/dist/config-store.js +15 -0
  49. package/dist/core/agent-ipc-registration.js +65 -22
  50. package/dist/core/auth/agent-delegation.js +42 -2
  51. package/dist/core/auth/debug-context.js +139 -0
  52. package/dist/core/auth/delegation-authorization.js +1 -0
  53. package/dist/core/auth/operation-authorizer.js +24 -1
  54. package/dist/core/auth/operation-catalog.js +44 -3
  55. package/dist/core/background-session/session-cleanup.js +212 -0
  56. package/dist/core/background-session/types.js +10 -0
  57. package/dist/core/bootstrap-messages.js +1 -1
  58. package/dist/core/bootstrap-service.js +6 -3
  59. package/dist/core/command/command-handler.js +224 -45
  60. package/dist/core/command/diag-service.js +5 -1
  61. package/dist/core/command/memory-slash.js +6 -2
  62. package/dist/core/command/menu-catalog.js +48 -1
  63. package/dist/core/command/menu-handler.js +92 -16
  64. package/dist/core/command/menu-protocol.js +24 -1
  65. package/dist/core/command/skill-slash.js +322 -0
  66. package/dist/core/command/slash-gate.js +6 -4
  67. package/dist/core/command/slash-handler.js +193 -13
  68. package/dist/core/command/topic-slash.js +528 -0
  69. package/dist/core/evolagent-registry.js +20 -5
  70. package/dist/core/handoff/ipc-registration.js +38 -0
  71. package/dist/core/handoff/runtime.js +5 -11
  72. package/dist/core/handoff/store.js +5 -0
  73. package/dist/core/ipc-registration.js +210 -9
  74. package/dist/core/memory/consolidation-context.js +49 -16
  75. package/dist/core/memory/consolidation-ops-records.js +35 -0
  76. package/dist/core/memory/consolidation-queue-format.js +21 -0
  77. package/dist/core/memory/consolidation.js +174 -180
  78. package/dist/core/message/aun-ipc-sender.js +207 -8
  79. package/dist/core/message/im-renderer.js +149 -6
  80. package/dist/core/message/message-bridge.js +13 -3
  81. package/dist/core/message/message-log.js +1 -0
  82. package/dist/core/message/message-queue.js +131 -3
  83. package/dist/core/message/peer-mode.js +6 -1
  84. package/dist/core/message/response-engine.js +379 -69
  85. package/dist/core/message/subtask-tool-projection.js +140 -0
  86. package/dist/core/model/catalog-service.js +17 -12
  87. package/dist/core/permission/ec-command-parser.js +126 -7
  88. package/dist/core/permission/sandbox-runtime.js +14 -1
  89. package/dist/core/session/session-idle-monitor.js +249 -22
  90. package/dist/core/session/session-manager.js +54 -9
  91. package/dist/core/skill/command-parser.js +13 -0
  92. package/dist/core/skill/index-generator.js +4 -1
  93. package/dist/core/skill/startup.js +41 -0
  94. package/dist/core/skill/switch-resolver.js +55 -29
  95. package/dist/core/task/task-recovery-service.js +2 -0
  96. package/dist/core/task/task-resume-operation.js +11 -0
  97. package/dist/core/thread/command-parser.js +125 -0
  98. package/dist/core/thread/index.js +1 -0
  99. package/dist/core/thread/ipc-operation.js +95 -0
  100. package/dist/core/thread/thread-context-storage.js +131 -0
  101. package/dist/core/thread/thread-operation.js +258 -0
  102. package/dist/core/thread/thread-route.js +33 -0
  103. package/dist/core/thread/thread-service.js +777 -0
  104. package/dist/core/thread/types.js +1 -0
  105. package/dist/eck/message-renderer.js +35 -0
  106. package/dist/index.js +10 -7
  107. package/dist/ipc.js +234 -17
  108. package/dist/paths.js +23 -0
  109. package/dist/response-system/engines/v1/proactive-flow.js +169 -80
  110. package/dist/trigger/authorization.js +59 -29
  111. package/dist/trigger/feedback.js +7 -1
  112. package/dist/trigger/ipc-service.js +49 -35
  113. package/dist/trigger/parser.js +63 -1
  114. package/dist/trigger/script-sandbox.js +58 -77
  115. package/dist/trigger/validation.js +14 -0
  116. package/dist/utils/error-utils.js +22 -1
  117. package/kits/docs/AUTHORING.md +1 -1
  118. package/kits/docs/channels/aun.md +8 -0
  119. package/kits/docs/context-assembly.md +1 -0
  120. package/kits/docs/evolcore/INDEX.md +3 -2
  121. package/kits/docs/evolcore/agent-create.md +39 -28
  122. package/kits/docs/evolcore/agent.md +19 -4
  123. package/kits/docs/evolcore/ec-capabilities.md +4 -0
  124. package/kits/docs/evolcore/group-collaboration.md +3 -5
  125. package/kits/docs/evolcore/group.md +3 -4
  126. package/kits/docs/evolcore/memory-consolidation.md +1 -1
  127. package/kits/docs/evolcore/menu-exec-schema-commands.md +2 -2
  128. package/kits/docs/evolcore/menu-protocol.md +7 -0
  129. package/kits/docs/evolcore/msg.md +5 -8
  130. package/kits/docs/evolcore/task.md +2 -0
  131. package/kits/docs/evolcore/thread-chain.md +267 -0
  132. package/kits/docs/evolcore/trigger.md +2 -2
  133. package/kits/docs/prompt-loading-architecture.md +1 -0
  134. package/kits/eck_manifest.json +61 -26
  135. package/kits/eck_message_manifest.json +169 -10
  136. package/kits/rules/01-overview.md +0 -9
  137. package/kits/rules/05-venue.md +1 -1
  138. package/kits/rules/06-channel.md +7 -1
  139. package/kits/rules/07-memory-and-thread.md +202 -0
  140. package/kits/schemas/relation-config.schema.9.json +18 -3
  141. package/kits/schemas/role-config.schema.1.json +6 -6
  142. package/kits/schemas/single-session.schema.1.json +6 -6
  143. package/kits/schemas/single-session.schema.2.json +6 -6
  144. package/kits/schemas/single-session.schema.3.json +6 -6
  145. package/kits/skills/.switches.json +12 -1
  146. package/kits/skills/ec-group/SKILL.md +234 -0
  147. package/kits/skills/ec-skill/SKILL.md +10 -1
  148. package/kits/skills/ec-skill/scripts/cli.mjs +15 -1
  149. package/kits/skills/scrapling-article-fetch/SKILL.md +128 -0
  150. package/kits/skills/scrapling-article-fetch/scripts/check_python_env.py +54 -0
  151. package/kits/skills/scrapling-article-fetch/scripts/render_markdown_reply.py +35 -0
  152. package/kits/skills/scrapling-article-fetch/scripts/run_pipeline.sh +16 -0
  153. package/kits/skills/scrapling-article-fetch/scripts/scrapling_fetch.py +344 -0
  154. package/kits/skills/thread-ops/SKILL.md +492 -0
  155. package/kits/skills/wechat-article-media-download/SKILL.md +65 -0
  156. package/kits/skills/wechat-article-media-download/scripts/download.py +163 -0
  157. package/kits/skills/wechat-article-reader/SKILL.md +97 -0
  158. package/kits/skills/wechat-article-reader/scripts/fetch.mjs +256 -0
  159. package/kits/templates/bootstrap-welcome.md +7 -7
  160. package/kits/templates/memory-consolidation/forget.md +5 -0
  161. package/kits/templates/memory-consolidation/generalize.md +5 -0
  162. package/kits/templates/memory-consolidation/profile.md +5 -0
  163. package/kits/templates/memory-consolidation/promote.md +5 -0
  164. package/kits/templates/message-fragments/item.md +2 -1
  165. package/kits/templates/message-fragments/thread-context.md +6 -0
  166. package/kits/templates/message-fragments/trigger-session.md +2 -0
  167. package/kits/templates/message-fragments/trigger-thread-check.md +2 -0
  168. package/kits/templates/roles/member.json +2 -0
  169. package/kits/templates/roles/visitor.json +2 -0
  170. package/kits/templates/system-fragments/bootstrap.md +66 -72
  171. package/kits/templates/system-fragments/channel.md +3 -6
  172. package/kits/templates/system-fragments/commands.md +2 -1
  173. package/kits/templates/system-fragments/managed-runtime.md +15 -0
  174. package/kits/templates/system-fragments/reply.md +3 -2
  175. package/kits/templates/system-fragments/session.md +5 -14
  176. package/package.json +3 -1
  177. package/kits/docs/evolcore/group-fs.md +0 -17
  178. package/kits/docs/venues/aun-group.md +0 -45
  179. package/kits/docs/venues/group.md +0 -43
@@ -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"![]({cover_url})\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())