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,510 @@
1
+ """Discovery wrappers for WeChat Official Account article batches.
2
+
3
+ These wrappers parse sanitized article-list data from an explicitly enabled
4
+ local/private ``wechat-article-exporter`` style sidecar. They never start a
5
+ service, open a browser, log in, import cookies, or persist raw exporter/API
6
+ output by themselves.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from collections.abc import Callable, Iterable
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+ from urllib.error import HTTPError, URLError
19
+ from urllib.parse import quote, urlencode, urlparse, urlunparse
20
+ from urllib.request import Request as UrlRequest
21
+ from urllib.request import urlopen
22
+
23
+ from openmd.backends.base import DiscoveryRequest, DiscoveryResult
24
+ from openmd.backends.weixin import normalize_weixin_url
25
+ from openmd.core.models import ContentItem
26
+ from openmd.core.secrets import sanitize_error_summary
27
+ from openmd.runtime.interpreters import run_interruptibly
28
+
29
+
30
+ WECHAT_ARTICLE_EXPORTER_DISCOVERY_BACKEND = "wechat-article-exporter"
31
+ WECHAT_ARTICLE_EXPORTER_HTTP_DISCOVERY_BACKEND = "wechat-article-exporter.http"
32
+ DEFAULT_EXPORTER_TIMEOUT_SECONDS = 180
33
+ DEFAULT_EXPORTER_BASE_URL = "http://127.0.0.1:3000"
34
+ EXPORTER_BASE_URL_ENV = "OPENMD_WECHAT_ARTICLE_EXPORTER_BASE_URL"
35
+ EXPORTER_AUTH_KEY_ENV = "OPENMD_WECHAT_ARTICLE_EXPORTER_AUTH_KEY"
36
+ WEIXIN_SELECTOR_SCHEMES: frozenset[str] = frozenset(
37
+ {"weixin", "wechat", "wechat-official", "wx-official"}
38
+ )
39
+ CommandRunner = Callable[[list[str], int], tuple[int, str, str]]
40
+ HttpJsonFetcher = Callable[[str, dict[str, str], int], tuple[int, str]]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ParsedWeixinArticle:
45
+ url: str
46
+ title: str | None = None
47
+ author: str | None = None
48
+ published_at: str | None = None
49
+ article_id: str | None = None
50
+
51
+
52
+ class WechatArticleExporterDiscoveryProvider:
53
+ """Parse article list JSON from an explicitly enabled local exporter."""
54
+
55
+ name = WECHAT_ARTICLE_EXPORTER_DISCOVERY_BACKEND
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ runner: CommandRunner | None = None,
61
+ timeout: int = DEFAULT_EXPORTER_TIMEOUT_SECONDS,
62
+ ) -> None:
63
+ self._runner = runner or _run_command
64
+ self.timeout = timeout
65
+
66
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
67
+ command = build_wechat_article_exporter_args(
68
+ request.input_url,
69
+ limit=request.limit,
70
+ )
71
+ exit_code, stdout, stderr = self._runner(command, self.timeout)
72
+ if exit_code != 0:
73
+ return DiscoveryResult.failure(
74
+ provider=self.name,
75
+ error_code=_exit_error_code(exit_code),
76
+ error_summary=sanitize_error_summary(stderr or stdout)
77
+ or "wechat-article-exporter discovery failed.",
78
+ source_notes=["exporter command failed without persisting raw stdout/stderr"],
79
+ metadata={"platform": request.platform, "limit": request.limit},
80
+ )
81
+
82
+ try:
83
+ parsed = parse_wechat_article_exporter_output(stdout)
84
+ except ValueError as exc:
85
+ return DiscoveryResult.failure(
86
+ provider=self.name,
87
+ error_code="invalid_discovery_output",
88
+ error_summary=sanitize_error_summary(exc),
89
+ source_notes=["exporter stdout was invalid JSON and was not persisted"],
90
+ metadata={"platform": request.platform, "limit": request.limit},
91
+ )
92
+ if request.limit is not None and not request.allow_all:
93
+ parsed = parsed[: max(0, request.limit)]
94
+ if not parsed:
95
+ return DiscoveryResult.failure(
96
+ provider=self.name,
97
+ error_code="empty_result",
98
+ error_summary="wechat-article-exporter returned no article URLs.",
99
+ source_notes=["exporter stdout was parsed in memory and was not persisted"],
100
+ metadata={"platform": request.platform, "limit": request.limit},
101
+ )
102
+
103
+ return DiscoveryResult.success(
104
+ provider=self.name,
105
+ items=[_item_from_article(article, request, provider=self.name) for article in parsed],
106
+ source_notes=[
107
+ "wechat-article-exporter JSON was parsed in memory.",
108
+ "No browser/login/service lifecycle action was attempted by Openmd.",
109
+ ],
110
+ metadata={
111
+ "platform": request.platform,
112
+ "limit": request.limit,
113
+ "normalized_url": request.input_url,
114
+ },
115
+ )
116
+
117
+
118
+ class WechatArticleExporterHttpDiscoveryProvider:
119
+ """Discover account articles through a running ``wechat-article-exporter`` API.
120
+
121
+ The provider only calls read-only public API endpoints on an already-running
122
+ sidecar. The sidecar owns any login/session/cookie state; Openmd receives
123
+ sanitized account/article JSON and never logs or persists auth headers.
124
+ """
125
+
126
+ name = WECHAT_ARTICLE_EXPORTER_HTTP_DISCOVERY_BACKEND
127
+
128
+ def __init__(
129
+ self,
130
+ *,
131
+ base_url: str | None = None,
132
+ auth_key: str | None = None,
133
+ fetch_json: HttpJsonFetcher | None = None,
134
+ timeout: int = DEFAULT_EXPORTER_TIMEOUT_SECONDS,
135
+ page_size: int = 20,
136
+ ) -> None:
137
+ self.base_url = _normalize_base_url(base_url or os.environ.get(EXPORTER_BASE_URL_ENV) or DEFAULT_EXPORTER_BASE_URL)
138
+ self.auth_key = auth_key if auth_key is not None else os.environ.get(EXPORTER_AUTH_KEY_ENV)
139
+ self._fetch_json = fetch_json or _fetch_json
140
+ self.timeout = timeout
141
+ self.page_size = min(20, max(1, page_size))
142
+
143
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
144
+ if not self.auth_key:
145
+ return DiscoveryResult.failure(
146
+ provider=self.name,
147
+ error_code="auth_key_required",
148
+ error_summary=(
149
+ "Set OPENMD_WECHAT_ARTICLE_EXPORTER_AUTH_KEY after logging in to "
150
+ "the local wechat-article-exporter sidecar."
151
+ ),
152
+ source_notes=[
153
+ "Openmd did not attempt login, cookie import, or browser automation.",
154
+ "The sidecar must already be running and authenticated by the operator.",
155
+ ],
156
+ metadata={"platform": request.platform, "limit": request.limit, "sidecar_base_url": self.base_url},
157
+ )
158
+
159
+ fakeid, account_metadata, account_error = self._resolve_fakeid(request.input_url)
160
+ if account_error is not None:
161
+ return account_error
162
+ if not fakeid:
163
+ return DiscoveryResult.failure(
164
+ provider=self.name,
165
+ error_code="account_not_found",
166
+ error_summary="wechat-article-exporter could not resolve an account fakeid for the selector.",
167
+ source_notes=["No raw account lookup response was persisted."],
168
+ metadata={"platform": request.platform, "limit": request.limit, "sidecar_base_url": self.base_url},
169
+ )
170
+
171
+ articles: list[ParsedWeixinArticle] = []
172
+ begin = 0
173
+ target_count = request.limit if request.limit is not None and not request.allow_all else None
174
+ while True:
175
+ size = self.page_size if target_count is None else min(self.page_size, max(0, target_count - len(articles)))
176
+ if size <= 0:
177
+ break
178
+ payload, error = self._get_json(
179
+ "/api/public/v1/article",
180
+ {"fakeid": fakeid, "begin": str(begin), "size": str(size)},
181
+ )
182
+ if error is not None:
183
+ return error
184
+
185
+ parsed = parse_wechat_article_exporter_payload(payload)
186
+ if not parsed:
187
+ break
188
+ articles.extend(parsed)
189
+ if target_count is not None and len(articles) >= target_count:
190
+ break
191
+ if len(parsed) < size:
192
+ break
193
+ begin += size
194
+
195
+ if target_count is not None:
196
+ articles = articles[: max(0, target_count)]
197
+ if not articles:
198
+ return DiscoveryResult.failure(
199
+ provider=self.name,
200
+ error_code="empty_result",
201
+ error_summary="wechat-article-exporter returned no article URLs.",
202
+ source_notes=["Article-list JSON was parsed in memory and was not persisted."],
203
+ metadata={
204
+ "platform": request.platform,
205
+ "limit": request.limit,
206
+ "sidecar_base_url": self.base_url,
207
+ **account_metadata,
208
+ },
209
+ )
210
+
211
+ return DiscoveryResult.success(
212
+ provider=self.name,
213
+ items=[_item_from_article(article, request, provider=self.name) for article in articles],
214
+ source_notes=[
215
+ "wechat-article-exporter HTTP API was parsed in memory.",
216
+ "Openmd called read-only account/article endpoints on an already-running sidecar.",
217
+ "Openmd did not start/stop the sidecar, log in, import cookies, or persist auth headers.",
218
+ ],
219
+ metadata={
220
+ "platform": request.platform,
221
+ "limit": request.limit,
222
+ "normalized_url": request.input_url,
223
+ "sidecar_base_url": self.base_url,
224
+ **account_metadata,
225
+ },
226
+ )
227
+
228
+ def _resolve_fakeid(self, selector: str) -> tuple[str | None, dict[str, Any], DiscoveryResult | None]:
229
+ parsed = urlparse(selector.strip())
230
+ if parsed.scheme in WEIXIN_SELECTOR_SCHEMES and parsed.netloc in {"fakeid", "biz"}:
231
+ fakeid = parsed.path.strip("/") or parsed.query.strip()
232
+ return fakeid or None, {"account_selector_type": parsed.netloc}, None
233
+ if parsed.scheme in WEIXIN_SELECTOR_SCHEMES and parsed.netloc == "account":
234
+ keyword = parsed.path.strip("/") or parsed.query.strip()
235
+ return self._search_account(keyword)
236
+ if _looks_like_weixin_article(normalize_weixin_url(selector)):
237
+ payload, error = self._get_json("/api/public/v1/accountbyurl", {"url": normalize_weixin_url(selector)})
238
+ if error is not None:
239
+ return None, {}, error
240
+ return _fakeid_from_account_payload(payload), {"account_selector_type": "article_url"}, None
241
+ if parsed.scheme in WEIXIN_SELECTOR_SCHEMES:
242
+ keyword = parsed.netloc if parsed.netloc not in {"author", "official", "oa"} else parsed.path.strip("/")
243
+ if not keyword and parsed.netloc:
244
+ keyword = parsed.netloc
245
+ return self._search_account(keyword)
246
+ return self._search_account(selector.strip())
247
+
248
+ def _search_account(self, keyword: str) -> tuple[str | None, dict[str, Any], DiscoveryResult | None]:
249
+ if not keyword:
250
+ return None, {}, DiscoveryResult.failure(
251
+ provider=self.name,
252
+ error_code="account_selector_empty",
253
+ error_summary="Weixin account selector is empty.",
254
+ metadata={"sidecar_base_url": self.base_url},
255
+ )
256
+ payload, error = self._get_json("/api/public/v1/account", {"keyword": keyword, "begin": "0", "size": "5"})
257
+ if error is not None:
258
+ return None, {}, error
259
+ account = _best_account(payload, keyword)
260
+ if account is None:
261
+ return None, {"account_keyword": keyword}, None
262
+ fakeid = _string_field(account, "fakeid")
263
+ metadata = {
264
+ "account_keyword": keyword,
265
+ "account_nickname": _string_field(account, "nickname"),
266
+ "account_alias": _string_field(account, "alias"),
267
+ }
268
+ return fakeid, metadata, None
269
+
270
+ def _get_json(self, path: str, query: dict[str, str]) -> tuple[object | None, DiscoveryResult | None]:
271
+ url = _build_url(self.base_url, path, query)
272
+ status, body = self._fetch_json(url, {"X-Auth-Key": self.auth_key or ""}, self.timeout)
273
+ if status < 200 or status >= 300:
274
+ return None, DiscoveryResult.failure(
275
+ provider=self.name,
276
+ error_code=_http_error_code(status),
277
+ error_summary=sanitize_error_summary(body) or f"wechat-article-exporter HTTP {status}",
278
+ source_notes=["HTTP error body was sanitized and not persisted."],
279
+ metadata={"sidecar_base_url": self.base_url},
280
+ )
281
+ try:
282
+ payload = json.loads(body)
283
+ except json.JSONDecodeError:
284
+ return None, DiscoveryResult.failure(
285
+ provider=self.name,
286
+ error_code="invalid_discovery_output",
287
+ error_summary="wechat-article-exporter did not return valid JSON.",
288
+ source_notes=["Invalid response body was not persisted."],
289
+ metadata={"sidecar_base_url": self.base_url},
290
+ )
291
+ base_resp = payload.get("base_resp") if isinstance(payload, dict) else None
292
+ if isinstance(base_resp, dict) and int(base_resp.get("ret") or 0) != 0:
293
+ return None, DiscoveryResult.failure(
294
+ provider=self.name,
295
+ error_code=_api_error_code(base_resp),
296
+ error_summary=sanitize_error_summary(base_resp.get("err_msg")) or "wechat-article-exporter API error.",
297
+ source_notes=["API error payload was sanitized and not persisted."],
298
+ metadata={"sidecar_base_url": self.base_url},
299
+ )
300
+ return payload, None
301
+
302
+
303
+ def build_wechat_article_exporter_args(selector: str, *, limit: int | None) -> list[str]:
304
+ command = [
305
+ "wechat-article-exporter",
306
+ "articles",
307
+ "--selector",
308
+ selector,
309
+ "--format",
310
+ "json",
311
+ ]
312
+ if limit is not None:
313
+ command.extend(["--limit", str(max(0, limit))])
314
+ return command
315
+
316
+
317
+ def parse_wechat_article_exporter_output(stdout: str) -> list[ParsedWeixinArticle]:
318
+ try:
319
+ payload = json.loads(stdout)
320
+ except json.JSONDecodeError as exc:
321
+ raise ValueError("wechat-article-exporter did not return valid JSON") from exc
322
+
323
+ return parse_wechat_article_exporter_payload(payload)
324
+
325
+
326
+ def parse_wechat_article_exporter_payload(payload: object) -> list[ParsedWeixinArticle]:
327
+ rows = list(_candidate_rows(payload))
328
+ articles: list[ParsedWeixinArticle] = []
329
+ for row in rows:
330
+ if not isinstance(row, dict):
331
+ continue
332
+ article = _parse_article_row(row)
333
+ if article is not None:
334
+ articles.append(article)
335
+ return articles
336
+
337
+
338
+ def _candidate_rows(payload: object) -> Iterable[object]:
339
+ if isinstance(payload, list):
340
+ return payload
341
+ if not isinstance(payload, dict):
342
+ return ()
343
+ for key in ("articles", "items", "list", "data", "records", "results"):
344
+ value = payload.get(key)
345
+ if isinstance(value, list):
346
+ return value
347
+ if isinstance(value, dict):
348
+ nested = _candidate_rows(value)
349
+ nested_list = list(nested)
350
+ if nested_list:
351
+ return nested_list
352
+ return ()
353
+
354
+
355
+ def _parse_article_row(row: dict[str, Any]) -> ParsedWeixinArticle | None:
356
+ url = _first_string(row, "url", "link", "article_url", "articleUrl", "content_url", "contentUrl")
357
+ if not url:
358
+ return None
359
+ normalized = normalize_weixin_url(url)
360
+ if not _looks_like_weixin_article(normalized):
361
+ return None
362
+ return ParsedWeixinArticle(
363
+ url=normalized,
364
+ title=_first_string(row, "title", "name"),
365
+ author=_first_string(row, "author", "account", "nickname", "author_name", "authorName"),
366
+ published_at=_first_string(row, "published_at", "publish_time", "publishTime", "date", "time"),
367
+ article_id=_first_string(row, "id", "article_id", "articleId", "msgid", "mid"),
368
+ )
369
+
370
+
371
+ def _first_string(row: dict[str, Any], *keys: str) -> str | None:
372
+ for key in keys:
373
+ value = row.get(key)
374
+ if isinstance(value, str) and value.strip():
375
+ return value.strip()
376
+ if isinstance(value, int):
377
+ return str(value)
378
+ return None
379
+
380
+
381
+ def _string_field(row: dict[str, Any], key: str) -> str | None:
382
+ value = row.get(key)
383
+ if isinstance(value, str) and value.strip():
384
+ return value.strip()
385
+ if isinstance(value, int):
386
+ return str(value)
387
+ return None
388
+
389
+
390
+ def _best_account(payload: object, keyword: str) -> dict[str, Any] | None:
391
+ candidates = [row for row in _candidate_rows(payload) if isinstance(row, dict)]
392
+ if not candidates:
393
+ return None
394
+ normalized_keyword = keyword.strip().lower()
395
+ for row in candidates:
396
+ for key in ("nickname", "alias"):
397
+ value = _string_field(row, key)
398
+ if value and value.strip().lower() == normalized_keyword:
399
+ return row
400
+ return candidates[0]
401
+
402
+
403
+ def _fakeid_from_account_payload(payload: object) -> str | None:
404
+ account = _best_account(payload, "")
405
+ if account is None:
406
+ return None
407
+ return _string_field(account, "fakeid")
408
+
409
+
410
+ def _looks_like_weixin_article(url: str) -> bool:
411
+ parsed = urlparse(url)
412
+ return (
413
+ parsed.scheme in {"http", "https"}
414
+ and parsed.netloc.lower() == "mp.weixin.qq.com"
415
+ and (parsed.path == "/s" or parsed.path.startswith("/s/"))
416
+ )
417
+
418
+
419
+ def _normalize_base_url(value: str) -> str:
420
+ parsed = urlparse(value.strip())
421
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
422
+ return DEFAULT_EXPORTER_BASE_URL
423
+ return urlunparse((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "", ""))
424
+
425
+
426
+ def _build_url(base_url: str, path: str, query: dict[str, str]) -> str:
427
+ safe_path = "/" + path.strip("/")
428
+ return f"{base_url}{safe_path}?{urlencode(query, quote_via=quote)}"
429
+
430
+
431
+ def _fetch_json(url: str, headers: dict[str, str], timeout: int) -> tuple[int, str]:
432
+ request = UrlRequest(url, headers={key: value for key, value in headers.items() if value})
433
+ try:
434
+ with urlopen(request, timeout=timeout) as response:
435
+ body = response.read().decode(response.headers.get_content_charset() or "utf-8", errors="replace")
436
+ return response.status, body
437
+ except HTTPError as exc:
438
+ body = exc.read().decode("utf-8", errors="replace")
439
+ return exc.code, body
440
+ except URLError as exc:
441
+ return 599, sanitize_error_summary(exc) or "wechat-article-exporter sidecar is unreachable."
442
+
443
+
444
+ def _http_error_code(status: int) -> str:
445
+ if status == 401 or status == 403:
446
+ return "auth_required"
447
+ if status == 404:
448
+ return "sidecar_endpoint_not_found"
449
+ if status == 599:
450
+ return "sidecar_unreachable"
451
+ return f"sidecar_http_{status}"
452
+
453
+
454
+ def _api_error_code(base_resp: dict[str, Any]) -> str:
455
+ ret = int(base_resp.get("ret") or -1)
456
+ if ret in {-1, 200003}:
457
+ return "auth_required"
458
+ return f"api_ret_{ret}"
459
+
460
+
461
+ def _item_from_article(
462
+ article: ParsedWeixinArticle,
463
+ request: DiscoveryRequest,
464
+ *,
465
+ provider: str,
466
+ ) -> ContentItem:
467
+ return ContentItem(
468
+ platform="weixin",
469
+ input_type="single",
470
+ url=article.url,
471
+ id=article.article_id,
472
+ title=article.title,
473
+ author=article.author,
474
+ published_at=article.published_at,
475
+ source_type="article",
476
+ raw_meta={
477
+ "profile": "article",
478
+ "permission_scope": "user_accessible",
479
+ "collection_url": request.input_url,
480
+ "discovery_provider": provider,
481
+ "discovery_limit": request.limit,
482
+ },
483
+ )
484
+
485
+
486
+ def _run_command(command: list[str], timeout: int) -> tuple[int, str, str]:
487
+ resolved = shutil.which(command[0]) or command[0]
488
+ try:
489
+ completed = run_interruptibly(
490
+ [resolved, *command[1:]],
491
+ capture_output=True,
492
+ text=True,
493
+ timeout=timeout,
494
+ check=False,
495
+ encoding="utf-8",
496
+ errors="replace",
497
+ )
498
+ except FileNotFoundError:
499
+ return 127, "", "wechat-article-exporter executable is not available on PATH."
500
+ except subprocess.TimeoutExpired:
501
+ return 124, "", f"wechat-article-exporter timed out after {timeout}s."
502
+ return completed.returncode, completed.stdout or "", completed.stderr or ""
503
+
504
+
505
+ def _exit_error_code(exit_code: int) -> str:
506
+ if exit_code == 127:
507
+ return "discovery_tool_missing"
508
+ if exit_code == 124:
509
+ return "discovery_timeout"
510
+ return f"discovery_exit_{exit_code}"