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,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\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"
|