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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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}"
|