openmd-cli 0.1.0__py3-none-any.whl

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 (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,767 @@
1
+ """Weixin/WeChat Official Account article providers.
2
+
3
+ ``weixin.article.local`` is an HTTP-first local parser for public article
4
+ HTML. Browser/session fallbacks are registered separately and only after the
5
+ caller enables browser backends.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html as html_mod
11
+ import json
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import tempfile
16
+ from collections.abc import Callable
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+ from urllib.error import HTTPError, URLError
20
+ from urllib.parse import quote, urlencode, urlparse, urlunparse
21
+ from urllib.request import Request, urlopen
22
+
23
+ from openmd.core.opencli_output import load_opencli_json
24
+ from openmd.backends.base import (
25
+ BackendCapability,
26
+ BackendDescriptor,
27
+ BackendExtractRequest,
28
+ BackendHealth,
29
+ )
30
+ from openmd.backends.executor import BackendProviderRegistry, DiscoveryProviderRegistry
31
+ from openmd.backends.registry import BackendRuntimeOptions
32
+ from openmd.backends.web_article import OPENCLI_WEB_READ_BACKEND, OpenCliWebReadProvider
33
+ from openmd.core.models import TranscriptResult
34
+ from openmd.core.secrets import sanitize_error_summary
35
+ from openmd.runtime.tempdirs import owned_temporary_directory
36
+ from openmd.runtime.interpreters import run_interruptibly
37
+
38
+
39
+ WEIXIN_LOCAL_BACKEND = "weixin.article.local"
40
+ WECHAT_ARTICLE_EXPORTER_DOWNLOAD_BACKEND = "wechat-article-exporter.download"
41
+ OPENCLI_WEIXIN_DOWNLOAD_BACKEND = "opencli.weixin.download"
42
+ DEFAULT_MIN_WEIXIN_CHARS = 20
43
+ DEFAULT_WECHAT_ARTICLE_EXPORTER_BASE_URL = "http://127.0.0.1:3000"
44
+ WECHAT_ARTICLE_EXPORTER_BASE_URL_ENV = "OPENMD_WECHAT_ARTICLE_EXPORTER_BASE_URL"
45
+
46
+ HtmlFetcher = Callable[[str, int], str]
47
+ CommandRunner = Callable[[list[str], int], tuple[int, str, str]]
48
+ TextFetcher = Callable[[str, int], tuple[int, str]]
49
+
50
+
51
+ def register_backend_providers(
52
+ registry: BackendProviderRegistry,
53
+ options: BackendRuntimeOptions,
54
+ ) -> None:
55
+ """Register Weixin extraction providers behind explicit runtime gates."""
56
+ requested = options.backend_override
57
+ if options.enable_network_backends and requested in {None, WEIXIN_LOCAL_BACKEND}:
58
+ registry.register(WEIXIN_LOCAL_BACKEND, WeixinArticleLocalProvider())
59
+
60
+ if options.enable_browser_backends and requested in {None, WECHAT_ARTICLE_EXPORTER_DOWNLOAD_BACKEND}:
61
+ registry.register(WECHAT_ARTICLE_EXPORTER_DOWNLOAD_BACKEND, WechatArticleExporterDownloadProvider())
62
+
63
+ if options.enable_browser_backends and requested in {None, OPENCLI_WEIXIN_DOWNLOAD_BACKEND}:
64
+ registry.register(OPENCLI_WEIXIN_DOWNLOAD_BACKEND, OpenCliWeixinDownloadProvider())
65
+ if options.enable_browser_backends and requested in {None, OPENCLI_WEB_READ_BACKEND}:
66
+ registry.register(OPENCLI_WEB_READ_BACKEND, OpenCliWebReadProvider())
67
+
68
+
69
+ def register_discovery_providers(
70
+ registry: DiscoveryProviderRegistry,
71
+ options: BackendRuntimeOptions,
72
+ ) -> None:
73
+ """Register Weixin account/article discovery behind explicit browser gates."""
74
+ if not options.enable_browser_backends:
75
+ return
76
+ requested = options.requested_discovery_provider
77
+ from openmd.adapters.weixin.discovery import (
78
+ WECHAT_ARTICLE_EXPORTER_DISCOVERY_BACKEND,
79
+ WECHAT_ARTICLE_EXPORTER_HTTP_DISCOVERY_BACKEND,
80
+ WechatArticleExporterDiscoveryProvider,
81
+ WechatArticleExporterHttpDiscoveryProvider,
82
+ )
83
+ requested = _normalize_weixin_discovery_backend(requested)
84
+
85
+ if requested in {None, WECHAT_ARTICLE_EXPORTER_DISCOVERY_BACKEND}:
86
+ registry.register(
87
+ WECHAT_ARTICLE_EXPORTER_DISCOVERY_BACKEND,
88
+ WechatArticleExporterDiscoveryProvider(),
89
+ platforms=("weixin",),
90
+ default_for_platforms=False,
91
+ )
92
+ if requested in {None, WECHAT_ARTICLE_EXPORTER_HTTP_DISCOVERY_BACKEND}:
93
+ registry.register(
94
+ WECHAT_ARTICLE_EXPORTER_HTTP_DISCOVERY_BACKEND,
95
+ WechatArticleExporterHttpDiscoveryProvider(),
96
+ platforms=("weixin",),
97
+ default_for_platforms=requested is None,
98
+ )
99
+
100
+
101
+ def _normalize_weixin_discovery_backend(value: str | None) -> str | None:
102
+ if value in {
103
+ "wechat-article-exporter-api",
104
+ "wechat-article-exporter-http",
105
+ "weixin-exporter-api",
106
+ "weixin-exporter-http",
107
+ "weixin-http",
108
+ }:
109
+ return "wechat-article-exporter.http"
110
+ return value
111
+
112
+
113
+ class WeixinArticleLocalProvider:
114
+ """HTTP-first parser for public ``mp.weixin.qq.com/s`` articles."""
115
+
116
+ name = WEIXIN_LOCAL_BACKEND
117
+
118
+ def __init__(
119
+ self,
120
+ *,
121
+ fetcher: HtmlFetcher | None = None,
122
+ timeout: int = 30,
123
+ min_article_chars: int = DEFAULT_MIN_WEIXIN_CHARS,
124
+ ) -> None:
125
+ self._fetcher = fetcher or _fetch_html
126
+ self.timeout = timeout
127
+ self.min_article_chars = max(0, min_article_chars)
128
+
129
+ def descriptor(self) -> BackendDescriptor:
130
+ capability = BackendCapability(
131
+ platforms=("weixin",),
132
+ source_types=("article",),
133
+ input_types=("single", "batch"),
134
+ source_levels=("native_text", "partial_extraction"),
135
+ requires_network=True,
136
+ requires_credentials=False,
137
+ optional_config=("beautifulsoup4", "markdownify"),
138
+ )
139
+ return BackendDescriptor(
140
+ name=self.name,
141
+ layer="L1 specialized extractor",
142
+ capabilities=(capability,),
143
+ description="HTTP-first Weixin article HTML parser; no browser, cloud API, media, or ASR.",
144
+ provider="openmd.backends.weixin",
145
+ )
146
+
147
+ def health(self) -> BackendHealth:
148
+ try:
149
+ import bs4 # type: ignore[import-not-found] # noqa: F401
150
+ except ModuleNotFoundError:
151
+ return BackendHealth(
152
+ ok=False,
153
+ dependencies={"beautifulsoup4": "missing"},
154
+ error_code="backend_dependency_missing",
155
+ error_summary="Install beautifulsoup4 or use a browser fallback backend.",
156
+ )
157
+ return BackendHealth(ok=True, dependencies={"beautifulsoup4": "available"})
158
+
159
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
160
+ item = request.item
161
+ if item.platform != "weixin":
162
+ return _failed(
163
+ self.name,
164
+ "unsupported_platform",
165
+ f"Weixin article provider only supports weixin, got {item.platform}.",
166
+ )
167
+
168
+ url = normalize_weixin_url(item.url)
169
+ if not _is_weixin_article_url(url):
170
+ return _failed(self.name, "invalid_input", "Expected an mp.weixin.qq.com article URL.")
171
+
172
+ try:
173
+ from bs4 import BeautifulSoup # type: ignore[import-not-found]
174
+ except ModuleNotFoundError:
175
+ return _failed(
176
+ self.name,
177
+ "backend_dependency_missing",
178
+ "beautifulsoup4 is not installed; install it or enable browser fallback.",
179
+ )
180
+
181
+ try:
182
+ html = self._fetcher(url, self.timeout)
183
+ except Exception as exc:
184
+ return _failed(self.name, "weixin_fetch_failed", exc)
185
+
186
+ soup = BeautifulSoup(html, "html.parser")
187
+ content_el = soup.select_one("#js_content")
188
+ if content_el is None:
189
+ from openmd.core.content_validation import weixin_error_page_reason
190
+
191
+ for script in soup(["script", "style"]):
192
+ script.decompose()
193
+ verdict = weixin_error_page_reason(soup.get_text("\n"))
194
+ if verdict == "error":
195
+ return _failed(self.name, "weixin_error_page", "公众号返回了错误页,没有正文;链接可能失效、已删除或已过期。")
196
+ if verdict == "challenge":
197
+ return _failed(self.name, "browser_challenge_page", "公众号要求完成环境验证,未取得正文。")
198
+ return _failed(
199
+ self.name,
200
+ "weixin_source_empty",
201
+ "HTTP fetch did not expose #js_content; classify as source-empty unless a gated fallback recovers content.",
202
+ )
203
+
204
+ metadata = _extract_metadata(soup, html)
205
+ content_html, code_blocks = _process_content(soup)
206
+ markdown = _html_to_markdown(content_html, code_blocks).strip()
207
+ if len("".join(markdown.split())) < self.min_article_chars:
208
+ return _failed(self.name, "weixin_source_empty", "Parsed Weixin article body was empty.")
209
+
210
+ return TranscriptResult(
211
+ text=markdown.rstrip() + "\n",
212
+ status="verified",
213
+ source_level="native_text",
214
+ backend=self.name,
215
+ title=metadata.get("title") or item.title,
216
+ author=metadata.get("author") or item.author,
217
+ published_at=metadata.get("published_at") or item.published_at,
218
+ source_notes=[
219
+ "Weixin article HTML was fetched over HTTP and parsed locally.",
220
+ "#js_content was converted to Markdown; data-src image URLs were preserved.",
221
+ "Weixin code-snippet blocks were converted to fenced code blocks where detected.",
222
+ "No browser, cloud API, media download, or ASR action was attempted.",
223
+ ],
224
+ metadata={"strategy": "http_first", "normalized_url": url},
225
+ )
226
+
227
+
228
+ class OpenCliWeixinDownloadProvider:
229
+ """Browser/session fallback for Weixin articles through OpenCLI."""
230
+
231
+ name = OPENCLI_WEIXIN_DOWNLOAD_BACKEND
232
+
233
+ def __init__(self, *, runner: CommandRunner | None = None, timeout: int = 180) -> None:
234
+ self._runner = runner or _run_command
235
+ self.timeout = timeout
236
+
237
+ def descriptor(self) -> BackendDescriptor:
238
+ capability = BackendCapability(
239
+ platforms=("weixin",),
240
+ source_types=("article",),
241
+ input_types=("single", "batch"),
242
+ source_levels=("third_party_extractor", "partial_extraction"),
243
+ requires_network=True,
244
+ )
245
+ return BackendDescriptor(
246
+ name=self.name,
247
+ layer="L3 browser/session fallback",
248
+ capabilities=(capability,),
249
+ description="Use OpenCLI weixin download and read generated Markdown from a temp directory.",
250
+ provider="opencli.weixin",
251
+ is_fallback=True,
252
+ )
253
+
254
+ def health(self) -> BackendHealth:
255
+ if self._runner is not _run_command:
256
+ return BackendHealth(ok=True, dependencies={"opencli": "injected"})
257
+ if shutil.which("opencli") is None and shutil.which("opencli.cmd") is None:
258
+ return BackendHealth(
259
+ ok=False,
260
+ dependencies={"opencli": "missing"},
261
+ error_code="backend_dependency_missing",
262
+ error_summary="opencli executable is not available on PATH.",
263
+ )
264
+ return BackendHealth(ok=True, dependencies={"opencli": "available"})
265
+
266
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
267
+ item = request.item
268
+ with owned_temporary_directory(prefix="openmd-weixin-") as tmp_dir:
269
+ command = [
270
+ "opencli",
271
+ "weixin",
272
+ "download",
273
+ "--url",
274
+ item.url,
275
+ "--output",
276
+ tmp_dir,
277
+ "--download-images",
278
+ "false",
279
+ "-f",
280
+ "json",
281
+ "--window",
282
+ "background",
283
+ "--keep-tab",
284
+ "false",
285
+ ]
286
+ exit_code, stdout, stderr = self._runner(command, self.timeout)
287
+ if exit_code != 0:
288
+ return _failed(self.name, f"backend_exit_{exit_code}", stderr or stdout)
289
+
290
+ markdown_files = sorted(Path(tmp_dir).rglob("*.md"))
291
+ if not markdown_files:
292
+ return _failed(self.name, "empty_result", "OpenCLI weixin download produced no Markdown file.")
293
+
294
+ text = markdown_files[0].read_text(encoding="utf-8", errors="replace").strip()
295
+ if not text:
296
+ return _failed(self.name, "empty_result", "OpenCLI weixin download produced empty Markdown.")
297
+
298
+ metadata = _opencli_stdout_metadata(stdout)
299
+ return TranscriptResult(
300
+ text=text.rstrip() + "\n",
301
+ status="verified",
302
+ source_level="third_party_extractor",
303
+ backend=self.name,
304
+ title=metadata.get("title") or _markdown_title(text) or item.title,
305
+ author=metadata.get("author") or item.author,
306
+ published_at=metadata.get("publish_time") or item.published_at,
307
+ source_notes=[
308
+ "OpenCLI weixin download returned Markdown via browser/session fallback.",
309
+ "--download-images false was used; Openmd owns optional asset localization.",
310
+ "Generated Markdown was read from a temporary directory and raw stdout/stderr was not persisted.",
311
+ ],
312
+ )
313
+
314
+
315
+ class WechatArticleExporterDownloadProvider:
316
+ """Read article Markdown through a running ``wechat-article-exporter`` sidecar."""
317
+
318
+ name = WECHAT_ARTICLE_EXPORTER_DOWNLOAD_BACKEND
319
+
320
+ def __init__(
321
+ self,
322
+ *,
323
+ base_url: str | None = None,
324
+ fetch_text: TextFetcher | None = None,
325
+ timeout: int = 60,
326
+ min_article_chars: int = DEFAULT_MIN_WEIXIN_CHARS,
327
+ ) -> None:
328
+ import os
329
+
330
+ self.base_url = _normalize_exporter_base_url(
331
+ base_url or os.environ.get(WECHAT_ARTICLE_EXPORTER_BASE_URL_ENV) or DEFAULT_WECHAT_ARTICLE_EXPORTER_BASE_URL
332
+ )
333
+ self._fetch_text = fetch_text or _fetch_text
334
+ self.timeout = timeout
335
+ self.min_article_chars = max(0, min_article_chars)
336
+
337
+ def descriptor(self) -> BackendDescriptor:
338
+ capability = BackendCapability(
339
+ platforms=("weixin",),
340
+ source_types=("article",),
341
+ input_types=("single", "batch"),
342
+ source_levels=("third_party_extractor", "partial_extraction"),
343
+ requires_network=True,
344
+ requires_credentials=False,
345
+ )
346
+ return BackendDescriptor(
347
+ name=self.name,
348
+ layer="L3 local sidecar extractor",
349
+ capabilities=(capability,),
350
+ description="Use a running wechat-article-exporter public download API to return Markdown.",
351
+ provider="wechat-article-exporter",
352
+ is_fallback=True,
353
+ )
354
+
355
+ def health(self) -> BackendHealth:
356
+ return BackendHealth(
357
+ ok=True,
358
+ dependencies={"wechat-article-exporter": "configured"},
359
+ warnings=("sidecar availability is checked at extraction time",),
360
+ )
361
+
362
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
363
+ item = request.item
364
+ if item.platform != "weixin":
365
+ return _failed(
366
+ self.name,
367
+ "unsupported_platform",
368
+ f"WechatArticleExporterDownloadProvider only supports weixin, got {item.platform}.",
369
+ )
370
+ url = normalize_weixin_url(item.url)
371
+ if not _is_weixin_article_url(url):
372
+ return _failed(self.name, "invalid_input", "Expected an mp.weixin.qq.com article URL.")
373
+
374
+ endpoint = _build_exporter_download_url(self.base_url, url)
375
+ status, text = self._fetch_text(endpoint, self.timeout)
376
+ if status == 204:
377
+ return _failed(
378
+ self.name,
379
+ "weixin_source_empty",
380
+ "wechat-article-exporter returned HTTP 204 with no article content.",
381
+ )
382
+ if status < 200 or status >= 300:
383
+ return _failed(self.name, _exporter_http_error_code(status), text)
384
+ markdown = _clean_exporter_markdown(text)
385
+ if _looks_like_exporter_empty_shell(markdown):
386
+ return _failed(
387
+ self.name,
388
+ "weixin_source_empty",
389
+ "wechat-article-exporter returned a fixed empty Markdown shell.",
390
+ )
391
+ if len("".join(markdown.split())) < self.min_article_chars:
392
+ return _failed(self.name, "weixin_source_empty", "wechat-article-exporter returned empty Markdown.")
393
+ return TranscriptResult(
394
+ text=markdown.rstrip() + "\n",
395
+ status="verified",
396
+ source_level="third_party_extractor",
397
+ backend=self.name,
398
+ title=_markdown_title(markdown) or item.title,
399
+ author=item.author,
400
+ published_at=item.published_at,
401
+ source_notes=[
402
+ "wechat-article-exporter public download API returned Markdown.",
403
+ "Openmd called a read-only endpoint on an already-running sidecar.",
404
+ "No browser/login/service lifecycle action or credential import was attempted by Openmd.",
405
+ ],
406
+ metadata={"strategy": "wechat_article_exporter_download", "sidecar_base_url": self.base_url},
407
+ )
408
+
409
+
410
+ def normalize_weixin_url(raw: str) -> str:
411
+ value = str(raw or "").strip()
412
+ if not value:
413
+ return value
414
+ if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
415
+ value = value[1:-1].strip()
416
+ if value.startswith("<") and value.endswith(">"):
417
+ value = value[1:-1].strip()
418
+ value = re.sub(r"\\+([:/&?=#%])", r"\1", value)
419
+ value = html_mod.unescape(value)
420
+ if value.startswith("mp.weixin.qq.com/") or value.startswith("//mp.weixin.qq.com/"):
421
+ value = "https://" + value.lstrip("/")
422
+ parsed = urlparse(value)
423
+ if parsed.scheme in {"http", "https"} and (parsed.hostname or "").lower() == "mp.weixin.qq.com":
424
+ value = urlunparse(("https", "mp.weixin.qq.com", parsed.path, parsed.params, parsed.query, parsed.fragment))
425
+ return value
426
+
427
+
428
+ def _is_weixin_article_url(url: str) -> bool:
429
+ parsed = urlparse(url)
430
+ return (
431
+ parsed.scheme in {"http", "https"}
432
+ and parsed.netloc.lower() == "mp.weixin.qq.com"
433
+ and (parsed.path == "/s" or parsed.path.startswith("/s/"))
434
+ )
435
+
436
+
437
+ def _fetch_html(url: str, timeout: int) -> str:
438
+ request = Request(
439
+ url,
440
+ headers={
441
+ "User-Agent": (
442
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
443
+ "(KHTML, like Gecko) Chrome/125.0 Safari/537.36"
444
+ ),
445
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
446
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
447
+ "Referer": "https://mp.weixin.qq.com/",
448
+ },
449
+ )
450
+ with urlopen(request, timeout=timeout) as response:
451
+ content = response.read()
452
+ encoding = response.headers.get_content_charset() or "utf-8"
453
+ return content.decode(encoding, errors="replace")
454
+
455
+
456
+ def _extract_metadata(soup: object, raw_html: str) -> dict[str, str]:
457
+ title_el = soup.select_one("#activity-name") # type: ignore[attr-defined]
458
+ author_el = soup.select_one("#js_name") # type: ignore[attr-defined]
459
+ return {
460
+ "title": title_el.get_text(strip=True) if title_el else "",
461
+ "author": author_el.get_text(strip=True) if author_el else "",
462
+ "published_at": _extract_publish_time(raw_html),
463
+ }
464
+
465
+
466
+ def _extract_publish_time(raw_html: str) -> str:
467
+ patterns = (
468
+ r"create_time\s*:\s*JsDecode\('([^']+)'\)",
469
+ r"create_time\s*:\s*'(\d+)'",
470
+ r'create_time\s*[:=]\s*["\']?(\d+)["\']?',
471
+ )
472
+ for pattern in patterns:
473
+ match = re.search(pattern, raw_html)
474
+ if not match:
475
+ continue
476
+ value = match.group(1)
477
+ if value.isdigit():
478
+ return _format_timestamp(int(value))
479
+ return value
480
+ return ""
481
+
482
+
483
+ def _format_timestamp(timestamp: int) -> str:
484
+ china_tz = timezone(timedelta(hours=8))
485
+ return datetime.fromtimestamp(timestamp, tz=china_tz).strftime("%Y-%m-%d %H:%M:%S")
486
+
487
+
488
+ def _process_content(soup: object) -> tuple[str, list[dict[str, str]]]:
489
+ content_el = soup.select_one("#js_content") # type: ignore[attr-defined]
490
+ if content_el is None:
491
+ return "", []
492
+
493
+ for img in content_el.find_all("img"):
494
+ data_src = img.get("data-src") or img.get("data-original") or img.get("data-backsrc")
495
+ if data_src:
496
+ img["src"] = _absolute_image_url(str(data_src))
497
+
498
+ code_blocks: list[dict[str, str]] = []
499
+ for element in content_el.select(".code-snippet__fix"):
500
+ for line_index in element.select(".code-snippet__line-index"):
501
+ line_index.decompose()
502
+
503
+ pre = element.select_one("pre[data-lang]")
504
+ language = str(pre.get("data-lang", "")) if pre else ""
505
+ lines: list[str] = []
506
+ for code_tag in element.find_all("code"):
507
+ text = code_tag.get_text()
508
+ if re.match(r"^[ce]?ounter\(line", text):
509
+ continue
510
+ lines.append(text)
511
+ if not lines:
512
+ lines.append(element.get_text("\n"))
513
+
514
+ placeholder = f"OPENMD-CODEBLOCK-PLACEHOLDER-{len(code_blocks)}"
515
+ code_blocks.append({"language": language, "code": "\n".join(lines).strip("\n")})
516
+ replacement = soup.new_tag("p") # type: ignore[attr-defined]
517
+ replacement.string = placeholder
518
+ element.replace_with(replacement)
519
+
520
+ for selector in ("script", "style", ".qr_code_pc", ".reward_area", ".rich_media_tool"):
521
+ for tag in content_el.select(selector):
522
+ tag.decompose()
523
+
524
+ return str(content_el), code_blocks
525
+
526
+
527
+ def _html_to_markdown(content_html: str, code_blocks: list[dict[str, str]]) -> str:
528
+ try:
529
+ import markdownify # type: ignore[import-not-found]
530
+
531
+ markdown = markdownify.markdownify(
532
+ content_html,
533
+ heading_style="ATX",
534
+ bullets="-",
535
+ convert=[
536
+ "p",
537
+ "h1",
538
+ "h2",
539
+ "h3",
540
+ "h4",
541
+ "h5",
542
+ "h6",
543
+ "strong",
544
+ "em",
545
+ "a",
546
+ "img",
547
+ "ul",
548
+ "ol",
549
+ "li",
550
+ "blockquote",
551
+ "br",
552
+ "hr",
553
+ "table",
554
+ "thead",
555
+ "tbody",
556
+ "tr",
557
+ "th",
558
+ "td",
559
+ "pre",
560
+ "code",
561
+ ],
562
+ )
563
+ except ModuleNotFoundError:
564
+ markdown = _simple_html_to_markdown(content_html)
565
+
566
+ for index, block in enumerate(code_blocks):
567
+ placeholder = f"OPENMD-CODEBLOCK-PLACEHOLDER-{index}"
568
+ fenced = f"\n```{block.get('language', '')}\n{block.get('code', '')}\n```\n"
569
+ markdown = markdown.replace(placeholder, fenced)
570
+ markdown = markdown.replace("\u00a0", " ")
571
+ markdown = re.sub(r"\n{4,}", "\n\n\n", markdown)
572
+ markdown = re.sub(r"[ \t]+$", "", markdown, flags=re.MULTILINE)
573
+ return markdown
574
+
575
+
576
+ def _simple_html_to_markdown(content_html: str) -> str:
577
+ from bs4 import BeautifulSoup # type: ignore[import-not-found]
578
+
579
+ soup = BeautifulSoup(content_html, "html.parser")
580
+ for img in soup.find_all("img"):
581
+ src = img.get("src")
582
+ if src:
583
+ img.replace_with(soup.new_string(f"\n![{img.get('alt', '')}]({src})\n"))
584
+ for link in soup.find_all("a"):
585
+ href = link.get("href")
586
+ text = link.get_text(strip=True)
587
+ if href and text:
588
+ link.replace_with(soup.new_string(f"[{text}]({href})"))
589
+ lines = [line.strip() for line in soup.get_text("\n").splitlines()]
590
+ return "\n\n".join(line for line in lines if line)
591
+
592
+
593
+ def _absolute_image_url(url: str) -> str:
594
+ if url.startswith("//"):
595
+ return f"https:{url}"
596
+ return url
597
+
598
+
599
+ def _run_command(command: list[str], timeout: int) -> tuple[int, str, str]:
600
+ resolved = shutil.which(command[0]) or command[0]
601
+ try:
602
+ completed = run_interruptibly(
603
+ [resolved, *command[1:]],
604
+ capture_output=True,
605
+ text=True,
606
+ timeout=timeout,
607
+ check=False,
608
+ encoding="utf-8",
609
+ errors="replace",
610
+ )
611
+ except FileNotFoundError as exc:
612
+ return 127, "", sanitize_error_summary(exc) or "command not found"
613
+ except subprocess.TimeoutExpired as exc:
614
+ return (
615
+ 124,
616
+ exc.stdout if isinstance(exc.stdout, str) else "",
617
+ f"command timed out after {timeout}s",
618
+ )
619
+ return completed.returncode, completed.stdout or "", completed.stderr or ""
620
+
621
+
622
+ def _normalize_exporter_base_url(value: str) -> str:
623
+ parsed = urlparse(value.strip())
624
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
625
+ return DEFAULT_WECHAT_ARTICLE_EXPORTER_BASE_URL
626
+ return urlunparse((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "", ""))
627
+
628
+
629
+ def _build_exporter_download_url(base_url: str, article_url: str) -> str:
630
+ query = urlencode({"url": article_url, "format": "markdown"}, quote_via=quote)
631
+ return f"{base_url}/api/public/v1/download?{query}"
632
+
633
+
634
+ def _fetch_text(url: str, timeout: int) -> tuple[int, str]:
635
+ request = Request(url)
636
+ try:
637
+ with urlopen(request, timeout=timeout) as response:
638
+ text = response.read().decode(response.headers.get_content_charset() or "utf-8", errors="replace")
639
+ return response.status, text
640
+ except HTTPError as exc:
641
+ body = exc.read().decode("utf-8", errors="replace")
642
+ return exc.code, body
643
+ except URLError as exc:
644
+ return 599, sanitize_error_summary(exc) or "wechat-article-exporter sidecar is unreachable."
645
+
646
+
647
+ def _exporter_http_error_code(status: int) -> str:
648
+ if status == 404:
649
+ return "sidecar_endpoint_not_found"
650
+ if status == 599:
651
+ return "sidecar_unreachable"
652
+ return f"sidecar_http_{status}"
653
+
654
+
655
+ def _clean_exporter_markdown(markdown: str) -> str:
656
+ """Remove Weixin/exporter chrome noise while preserving article order."""
657
+ lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
658
+ lines = _drop_leading_css_lines(lines)
659
+ lines = [line for line in lines if not _is_weixin_reader_chrome_line(line)]
660
+ cleaned = "\n".join(lines).strip()
661
+ cleaned = re.sub(r"\n{4,}", "\n\n\n", cleaned)
662
+ return cleaned
663
+
664
+
665
+ def _drop_leading_css_lines(lines: list[str]) -> list[str]:
666
+ start = 0
667
+ while start < len(lines) and not lines[start].strip():
668
+ start += 1
669
+ if start >= len(lines):
670
+ return []
671
+ first = lines[start].strip()
672
+ if not _looks_like_css_noise(first):
673
+ return lines[start:]
674
+
675
+ end = start
676
+ while end < len(lines):
677
+ stripped = lines[end].strip()
678
+ if not stripped:
679
+ end += 1
680
+ break
681
+ if not _looks_like_css_noise(stripped):
682
+ break
683
+ end += 1
684
+ return lines[end:]
685
+
686
+
687
+ def _looks_like_css_noise(line: str) -> bool:
688
+ stripped = line.strip()
689
+ if len(stripped) < 12:
690
+ return False
691
+ return "{" in stripped and "}" in stripped and any(
692
+ token in stripped
693
+ for token in (
694
+ "max-width",
695
+ "margin:",
696
+ "display:",
697
+ "height:",
698
+ "width:",
699
+ "#js_",
700
+ "#js\\_",
701
+ ".wx_",
702
+ ".wx\\_",
703
+ )
704
+ )
705
+
706
+
707
+ def _is_weixin_reader_chrome_line(line: str) -> bool:
708
+ stripped = line.strip()
709
+ return stripped in {
710
+ "在小说阅读器读本章",
711
+ "去阅读",
712
+ "在小说阅读器中沉浸阅读",
713
+ }
714
+
715
+
716
+ def _looks_like_exporter_empty_shell(markdown: str) -> bool:
717
+ compact = "".join(markdown.split())
718
+ if not compact:
719
+ return True
720
+ if len(markdown.encode("utf-8", errors="ignore")) <= 420 and not re.search(r"[\u4e00-\u9fffA-Za-z0-9]{20}", compact):
721
+ return True
722
+ lower = markdown.lower()
723
+ return "cgidatanew" in lower and "# article" not in lower and "## " not in lower
724
+
725
+
726
+ def _opencli_stdout_metadata(stdout: str) -> dict[str, str]:
727
+ try:
728
+ payload = load_opencli_json(stdout)
729
+ except json.JSONDecodeError:
730
+ return {}
731
+ if isinstance(payload, list) and payload and isinstance(payload[0], dict):
732
+ return {key: str(value) for key, value in payload[0].items() if value is not None}
733
+ if isinstance(payload, dict):
734
+ return {key: str(value) for key, value in payload.items() if value is not None}
735
+ return {}
736
+
737
+
738
+ def _markdown_title(markdown: str) -> str | None:
739
+ for line in markdown.splitlines():
740
+ stripped = line.strip()
741
+ if stripped.startswith("# "):
742
+ return stripped.removeprefix("# ").strip() or None
743
+ return None
744
+
745
+
746
+ def _failed(backend: str, code: str, error: object) -> TranscriptResult:
747
+ return TranscriptResult(
748
+ text="",
749
+ status="failed",
750
+ source_level="partial_extraction",
751
+ backend=backend,
752
+ source_notes=["weixin article provider returned structured failure"],
753
+ error_code=code,
754
+ error_summary=sanitize_error_summary(error),
755
+ metadata={"failure_class": _weixin_failure_class(code)},
756
+ )
757
+
758
+
759
+ def _weixin_failure_class(code: str) -> str:
760
+ normalized = code.lower()
761
+ if "auth" in normalized or "permission" in normalized:
762
+ return "user_resolvable"
763
+ if "source_empty" in normalized or "empty_result" in normalized or "not_found" in normalized:
764
+ return "source_unavailable"
765
+ if "unreachable" in normalized or "http_5" in normalized or "fetch_failed" in normalized or "backend_exit" in normalized:
766
+ return "runtime_transient"
767
+ return "code_resolvable"