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,940 @@
1
+ """Xiaoe-specific opt-in runtime backend providers.
2
+
3
+ The providers in this module are loaded through the runtime extension naming
4
+ convention and are registered only after ``--enable-browser-backends``. They
5
+ wrap existing read-only OpenCLI Xiaoe transcript/content flows without adding
6
+ batch transcript, course crawling, media download, ASR, or permission bypass.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import shutil
14
+ import tempfile
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from openmd.core.content_validation import validate_extraction_content
21
+ from openmd.core.opencli_output import load_opencli_json
22
+ from openmd.core.models import ContentItem, TranscriptResult
23
+ from openmd.core.secrets import sanitize_error_summary
24
+
25
+ from .base import (
26
+ BackendCapability,
27
+ BackendDescriptor,
28
+ BackendExtractRequest,
29
+ BackendHealth,
30
+ DiscoveryRequest,
31
+ DiscoveryResult,
32
+ )
33
+ from .executor import DiscoveryProviderRegistry
34
+ from .youtube_discovery import _run_opencli
35
+
36
+ from openmd.runtime.tempdirs import owned_temporary_directory
37
+
38
+
39
+ OPENCLI_XIAOE_TRANSCRIPT_BACKEND = "opencli.xiaoe.transcript"
40
+ OPENCLI_XIAOE_CONTENT_BACKEND = "opencli.xiaoe.content"
41
+ OPENCLI_XIAOE_CATALOG_DISCOVERY_BACKEND = "opencli.xiaoe.catalog"
42
+ XIAOE_TRANSCRIPT_TIMEOUT = 60 * 60
43
+ XIAOE_CONTENT_TIMEOUT = 300
44
+ XIAOE_CATALOG_TIMEOUT = 300
45
+
46
+ XIAOE_RESOURCE_TYPE_LABELS: dict[int, str] = {
47
+ 1: "图文",
48
+ 2: "直播",
49
+ 3: "音频",
50
+ 4: "视频",
51
+ 6: "专栏",
52
+ 8: "大专栏",
53
+ }
54
+ XIAOE_CONTAINER_RESOURCE_TYPES = frozenset({6, 8})
55
+ XIAOE_TEXT_RESOURCE_TYPES = frozenset({1})
56
+ XIAOE_AUDIO_RESOURCE_TYPES = frozenset({3})
57
+ XIAOE_VIDEO_RESOURCE_TYPES = frozenset({2, 4})
58
+
59
+ XIAOE_ERROR_CODE_ALIASES: dict[str, str] = {
60
+ "argumenterror": "invalid_argument",
61
+ "invalidargument": "invalid_argument",
62
+ "emptyresulterror": "empty_result",
63
+ "emptyresult": "empty_result",
64
+ "emptycontent": "empty_content",
65
+ "commandexecutionerror": "command_execution_failed",
66
+ "commandexecutionfailed": "command_execution_failed",
67
+ "authrequired": "session_auth_required",
68
+ "authenticationrequired": "session_auth_required",
69
+ "sessionexpired": "session_expired",
70
+ "permissiondenied": "permission_denied",
71
+ "notfound": "source_not_found",
72
+ "resourcenotfound": "source_not_found",
73
+ }
74
+
75
+ _XIAOE_ERROR_NAME_RE = re.compile(
76
+ r"\b(?:ArgumentError|EmptyResultError|CommandExecutionError|"
77
+ r"AuthRequired|SessionExpired|PermissionDenied|NotFound)\b",
78
+ re.IGNORECASE,
79
+ )
80
+
81
+ OpenCliRunner = Callable[..., tuple[int, str, str]]
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class XiaoeContentPayload:
86
+ """The small, normalized subset accepted from ``xiaoe content -f json``."""
87
+
88
+ title: str | None
89
+ content: str
90
+ content_length: int
91
+ image_count: int
92
+ content_selector: str | None = None
93
+
94
+
95
+ def register_backend_providers(registry: Any, options: Any) -> None:
96
+ """Register Xiaoe extraction providers under the explicit browser gate."""
97
+ if not getattr(options, "enable_browser_backends", False):
98
+ return
99
+
100
+ requested = getattr(options, "backend_override", None)
101
+ if requested in {None, OPENCLI_XIAOE_TRANSCRIPT_BACKEND}:
102
+ registry.register(
103
+ OPENCLI_XIAOE_TRANSCRIPT_BACKEND,
104
+ OpenCliXiaoeTranscriptProvider(
105
+ allow_local_asr=bool(getattr(options, "enable_asr_backends", False)),
106
+ ),
107
+ )
108
+ if requested in {None, OPENCLI_XIAOE_CONTENT_BACKEND}:
109
+ registry.register(OPENCLI_XIAOE_CONTENT_BACKEND, OpenCliXiaoeContentProvider())
110
+
111
+
112
+ def register_discovery_providers(
113
+ registry: DiscoveryProviderRegistry,
114
+ options: Any,
115
+ ) -> None:
116
+ """Register Xiaoe catalog discovery under the explicit browser gate."""
117
+ if not getattr(options, "enable_browser_backends", False):
118
+ return
119
+
120
+ requested = getattr(options, "requested_discovery_provider", None)
121
+ if requested in {None, OPENCLI_XIAOE_CATALOG_DISCOVERY_BACKEND}:
122
+ registry.register(
123
+ OPENCLI_XIAOE_CATALOG_DISCOVERY_BACKEND,
124
+ OpenCliXiaoeCatalogProvider(),
125
+ platforms=("xiaoe",),
126
+ default_for_platforms=requested is None,
127
+ )
128
+
129
+
130
+ def build_opencli_xiaoe_transcript_args(url: str, *, output_dir: str) -> list[str]:
131
+ """Build the bounded OpenCLI Xiaoe transcript command."""
132
+ return [
133
+ "opencli",
134
+ "xiaoe",
135
+ "transcript",
136
+ url,
137
+ "--provider",
138
+ "tingwu",
139
+ "--out",
140
+ output_dir,
141
+ "--keep-artifacts",
142
+ "true",
143
+ "-f",
144
+ "json",
145
+ ]
146
+
147
+
148
+ def build_opencli_xiaoe_catalog_args(url: str) -> list[str]:
149
+ """Build the bounded read-only OpenCLI Xiaoe catalog command."""
150
+ return [
151
+ "opencli",
152
+ "xiaoe",
153
+ "catalog",
154
+ url,
155
+ "-f",
156
+ "json",
157
+ "--window",
158
+ "background",
159
+ "--site-session",
160
+ "persistent",
161
+ "--keep-tab",
162
+ "false",
163
+ ]
164
+
165
+
166
+ def build_opencli_xiaoe_content_args(url: str) -> list[str]:
167
+ """Build the OpenCLI Xiaoe text/content fallback command."""
168
+ return [
169
+ "opencli",
170
+ "xiaoe",
171
+ "content",
172
+ url,
173
+ "-f",
174
+ "json",
175
+ "--window",
176
+ "background",
177
+ "--site-session",
178
+ "persistent",
179
+ "--keep-tab",
180
+ "false",
181
+ ]
182
+
183
+
184
+ class OpenCliXiaoeTranscriptProvider:
185
+ """Read Xiaoe transcript Markdown generated by OpenCLI in a temp directory."""
186
+
187
+ name = OPENCLI_XIAOE_TRANSCRIPT_BACKEND
188
+
189
+ def __init__(
190
+ self,
191
+ *,
192
+ runner: OpenCliRunner | None = None,
193
+ timeout: int = XIAOE_TRANSCRIPT_TIMEOUT,
194
+ allow_local_asr: bool = False,
195
+ local_asr: Any | None = None,
196
+ ) -> None:
197
+ self._runner = runner or _run_opencli
198
+ self.timeout = timeout
199
+ self.allow_local_asr = allow_local_asr
200
+ self._local_asr = local_asr
201
+
202
+ def descriptor(self) -> BackendDescriptor:
203
+ capability = BackendCapability(
204
+ platforms=("xiaoe",),
205
+ source_types=("video",),
206
+ input_types=("single",),
207
+ source_levels=("media_asr",),
208
+ requires_network=True,
209
+ requires_credentials=False,
210
+ optional_config=(
211
+ "opencli",
212
+ "browser_session",
213
+ "user_accessible_session",
214
+ "timeout",
215
+ ),
216
+ )
217
+ return BackendDescriptor(
218
+ name=self.name,
219
+ layer="L2 browser/session extractor",
220
+ capabilities=(capability,),
221
+ description=(
222
+ "Fetch Xiaoe transcript text through OpenCLI transcript output; "
223
+ "Openmd does not own or store credentials, but execution may require "
224
+ "an already user-accessible browser session. OpenCLI downloads the item's "
225
+ "media and transcribes it with Tingwu; a failed Tingwu upload is retried "
226
+ "once from the retained audio. English audio moves to local ASR under "
227
+ "--enable-asr. No batch transcript is attempted."
228
+ ),
229
+ provider="opencli.xiaoe",
230
+ )
231
+
232
+ def health(self) -> BackendHealth:
233
+ return _opencli_health(self._runner)
234
+
235
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
236
+ item = request.item
237
+ platform_failure = _validate_xiaoe_item(item, self.name)
238
+ if platform_failure is not None:
239
+ return platform_failure
240
+
241
+ with owned_temporary_directory(prefix="openmd-xiaoe-") as tmp_dir:
242
+ args = build_opencli_xiaoe_transcript_args(item.url, output_dir=tmp_dir)
243
+ exit_code, stdout, stderr = self._runner(args, timeout=self.timeout)
244
+ retained_dir: Path | None = None
245
+ if exit_code != 0:
246
+ retained_dir = _retained_tingwu_audio_dir(stderr or stdout)
247
+ if retained_dir is None:
248
+ return _backend_failure(self.name, item, exit_code, stderr or stdout)
249
+ exit_code, stdout, stderr = self._runner(
250
+ [
251
+ *args,
252
+ "--audio-file",
253
+ str(retained_dir / "normalized.mp3"),
254
+ *(["--title", item.title] if item.title else []),
255
+ ],
256
+ timeout=self.timeout,
257
+ )
258
+ if exit_code != 0:
259
+ return _backend_failure(self.name, item, exit_code, stderr or stdout)
260
+
261
+ artifact_dirs = (retained_dir, _reported_temp_dir(stdout))
262
+ text, title = _read_first_markdown(tmp_dir)
263
+ if title:
264
+ # Package and Markdown renderers add the title themselves.
265
+ text = text.strip().removeprefix(f"# {title}").strip()
266
+ if not text.strip():
267
+ _remove_xiaoe_temp_dirs(*artifact_dirs)
268
+ return _failed(
269
+ self.name,
270
+ item,
271
+ "empty_result",
272
+ "OpenCLI xiaoe transcript did not produce a non-empty Markdown file.",
273
+ )
274
+
275
+ language_notes: list[str] = []
276
+ language_metadata: dict[str, Any] = {}
277
+ if is_predominantly_english(text):
278
+ language_metadata["detected_language"] = "en"
279
+ local = self._transcribe_english_locally(artifact_dirs) if self.allow_local_asr else None
280
+ if local is not None:
281
+ text = local.text
282
+ language_metadata["transcript_provider"] = local.backend
283
+ language_notes.append(
284
+ f"English audio: replaced the Tingwu transcript with {local.backend}, which scored far better on English."
285
+ )
286
+ elif self.allow_local_asr:
287
+ language_notes.append("English audio: local ASR failed, so this is the Tingwu transcript, which is weak on English.")
288
+ else:
289
+ language_notes.append("English audio: this is the Tingwu transcript, which is weak on English; rerun with --enable-asr for local ASR.")
290
+ _remove_xiaoe_temp_dirs(*artifact_dirs)
291
+
292
+ result = TranscriptResult(
293
+ text=text,
294
+ status="verified",
295
+ source_level="media_asr",
296
+ backend=self.name,
297
+ title=title,
298
+ source_notes=[
299
+ "OpenCLI xiaoe transcript ran with --provider tingwu; its temporary artifacts were removed after use.",
300
+ "Generated Markdown was read from a temporary output directory and cleaned up.",
301
+ "OpenCLI downloaded this item's media and transcribed it with Tingwu; no batch-transcript, full-course crawl, or permission bypass was attempted.",
302
+ *(["The first Tingwu upload failed; retried once from the retained audio."] if retained_dir else []),
303
+ *language_notes,
304
+ ],
305
+ metadata={
306
+ "artifact_scope": "temporary_markdown",
307
+ "tingwu_upload_retries": 1 if retained_dir else 0,
308
+ **language_metadata,
309
+ },
310
+ )
311
+ return validate_extraction_content(result)
312
+
313
+ def _transcribe_english_locally(self, artifact_dirs: tuple[Path | None, ...]) -> TranscriptResult | None:
314
+ audio = next(
315
+ (
316
+ directory / "normalized.mp3"
317
+ for directory in artifact_dirs
318
+ if directory is not None and _is_xiaoe_temp_dir(directory) and (directory / "normalized.mp3").is_file()
319
+ ),
320
+ None,
321
+ )
322
+ if audio is None:
323
+ return None
324
+ backend = self._local_asr
325
+ if backend is None:
326
+ from openmd.runtime.asr import get_asr_backend
327
+
328
+ backend = get_asr_backend("asr.auto")
329
+ result = backend.transcribe(audio)
330
+ return result if result.status == "verified" and result.text.strip() else None
331
+
332
+
333
+ class OpenCliXiaoeCatalogProvider:
334
+ """Discover Xiaoe course catalog entries through OpenCLI."""
335
+
336
+ name = OPENCLI_XIAOE_CATALOG_DISCOVERY_BACKEND
337
+
338
+ def __init__(
339
+ self,
340
+ *,
341
+ runner: OpenCliRunner | None = None,
342
+ timeout: int = XIAOE_CATALOG_TIMEOUT,
343
+ ) -> None:
344
+ self._runner = runner or _run_opencli
345
+ self.timeout = timeout
346
+
347
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
348
+ if request.platform != "xiaoe":
349
+ return DiscoveryResult.failure(
350
+ provider=self.name,
351
+ error_code="unsupported_platform",
352
+ error_summary=f"Xiaoe catalog discovery only supports xiaoe, got {request.platform}.",
353
+ )
354
+
355
+ args = build_opencli_xiaoe_catalog_args(request.input_url)
356
+ exit_code, stdout, stderr = self._runner(args, timeout=self.timeout)
357
+ if exit_code != 0:
358
+ error_code, error_summary = _map_xiaoe_error(stderr or stdout, exit_code)
359
+ return DiscoveryResult.failure(
360
+ provider=self.name,
361
+ error_code=error_code,
362
+ error_summary=error_summary,
363
+ source_notes=[
364
+ "OpenCLI xiaoe catalog failed before item extraction.",
365
+ "Raw browser/session state and command streams were not persisted by Openmd.",
366
+ ],
367
+ metadata={"exit_code": exit_code},
368
+ )
369
+
370
+ items = parse_xiaoe_catalog_items(stdout)
371
+ if request.limit is not None and not request.allow_all:
372
+ items = items[: max(0, request.limit)]
373
+ if not items:
374
+ return DiscoveryResult.failure(
375
+ provider=self.name,
376
+ error_code="empty_discovery_result",
377
+ error_summary="OpenCLI xiaoe catalog returned no catalog items.",
378
+ )
379
+
380
+ return DiscoveryResult.success(
381
+ provider=self.name,
382
+ items=items,
383
+ source_notes=[
384
+ "OpenCLI xiaoe catalog returned manifest items.",
385
+ "Catalog discovery does not run transcript extraction, batch-transcript, media download, or ASR.",
386
+ ],
387
+ metadata={"normalized_url": request.input_url},
388
+ )
389
+
390
+
391
+ class OpenCliXiaoeContentProvider:
392
+ """Fetch Xiaoe page/article text through OpenCLI content as fallback."""
393
+
394
+ name = OPENCLI_XIAOE_CONTENT_BACKEND
395
+
396
+ def __init__(
397
+ self,
398
+ *,
399
+ runner: OpenCliRunner | None = None,
400
+ timeout: int = XIAOE_CONTENT_TIMEOUT,
401
+ ) -> None:
402
+ self._runner = runner or _run_opencli
403
+ self.timeout = timeout
404
+
405
+ def descriptor(self) -> BackendDescriptor:
406
+ capability = BackendCapability(
407
+ platforms=("xiaoe",),
408
+ source_types=("article", "image_text", "mixed", "video"),
409
+ input_types=("single",),
410
+ source_levels=("native_text",),
411
+ requires_network=True,
412
+ requires_credentials=False,
413
+ optional_config=(
414
+ "opencli",
415
+ "browser_session",
416
+ "user_accessible_session",
417
+ "timeout",
418
+ ),
419
+ )
420
+ return BackendDescriptor(
421
+ name=self.name,
422
+ layer="L2 browser/session extractor",
423
+ capabilities=(capability,),
424
+ description=(
425
+ "Fetch Xiaoe graphic/text content through OpenCLI content fallback. "
426
+ "Openmd does not own or store credentials; a user-accessible browser "
427
+ "session may still be required."
428
+ ),
429
+ provider="opencli.xiaoe",
430
+ is_fallback=True,
431
+ )
432
+
433
+ def health(self) -> BackendHealth:
434
+ return _opencli_health(self._runner)
435
+
436
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
437
+ item = request.item
438
+ platform_failure = _validate_xiaoe_item(item, self.name)
439
+ if platform_failure is not None:
440
+ return platform_failure
441
+
442
+ args = build_opencli_xiaoe_content_args(item.url)
443
+ exit_code, stdout, stderr = self._runner(args, timeout=self.timeout)
444
+ if exit_code != 0:
445
+ return _backend_failure(self.name, item, exit_code, stderr or stdout)
446
+ if not stdout.strip():
447
+ return _failed(
448
+ self.name,
449
+ item,
450
+ "empty_result",
451
+ "OpenCLI xiaoe content returned empty stdout.",
452
+ )
453
+
454
+ content_payload = parse_xiaoe_content_result(stdout)
455
+ if content_payload is None:
456
+ typed_error = _extract_xiaoe_error_details(stdout)
457
+ if typed_error is not None:
458
+ error_code, error_summary = _map_xiaoe_error(typed_error, None)
459
+ return _failed(
460
+ self.name,
461
+ item,
462
+ error_code,
463
+ error_summary,
464
+ )
465
+ return _failed(
466
+ self.name,
467
+ item,
468
+ "invalid_content_response",
469
+ "OpenCLI xiaoe content did not return the expected JSON content record.",
470
+ )
471
+
472
+ if not content_payload.content.strip():
473
+ return _failed(
474
+ self.name,
475
+ item,
476
+ "empty_content",
477
+ "OpenCLI xiaoe content returned an empty content field.",
478
+ metadata={
479
+ "content_length": content_payload.content_length,
480
+ "image_count": content_payload.image_count,
481
+ },
482
+ )
483
+
484
+ content_length_mismatch = (
485
+ content_payload.content_length > 0
486
+ and content_payload.content_length != _javascript_string_length(content_payload.content)
487
+ )
488
+ source_notes = [
489
+ "OpenCLI xiaoe content extraction output was normalized from JSON.",
490
+ "Only title, content, content_length, image_count, and content_selector were retained.",
491
+ "Fallback path only; no batch-transcript, media download, ASR, or permission bypass was attempted.",
492
+ ]
493
+ if content_length_mismatch:
494
+ source_notes.append(
495
+ "Declared content_length differs from the returned content length; result is marked partial."
496
+ )
497
+
498
+ result = TranscriptResult(
499
+ text=content_payload.content,
500
+ status="partial" if content_length_mismatch else "verified",
501
+ source_level="native_text",
502
+ backend=self.name,
503
+ title=content_payload.title,
504
+ source_notes=source_notes,
505
+ metadata={
506
+ "content_length": content_payload.content_length,
507
+ "image_count": content_payload.image_count,
508
+ "content_selector": content_payload.content_selector,
509
+ },
510
+ )
511
+ return validate_extraction_content(result)
512
+
513
+
514
+ def _opencli_health(runner: OpenCliRunner) -> BackendHealth:
515
+ if runner is not _run_opencli:
516
+ return BackendHealth(ok=True, dependencies={"opencli": "injected"})
517
+ if shutil.which("opencli") is None and shutil.which("opencli.cmd") is None:
518
+ return BackendHealth(
519
+ ok=False,
520
+ dependencies={"opencli": "missing"},
521
+ error_code="backend_dependency_missing",
522
+ error_summary="opencli executable is not available on PATH.",
523
+ )
524
+ return BackendHealth(ok=True, dependencies={"opencli": "available"})
525
+
526
+
527
+ def _validate_xiaoe_item(item: ContentItem, backend: str) -> TranscriptResult | None:
528
+ platform_failure = _require_xiaoe_item(item, backend)
529
+ if platform_failure is not None:
530
+ return platform_failure
531
+ if not item.url.strip():
532
+ resource_id = item.id or item.raw_meta.get("resource_id")
533
+ return _failed(
534
+ backend,
535
+ item,
536
+ "resource_url_unavailable",
537
+ (
538
+ "Xiaoe catalog item has no canonical URL and cannot be sent to an "
539
+ f"extractor (resource_id={resource_id or 'unknown'})."
540
+ ),
541
+ metadata={"resource_id": resource_id},
542
+ )
543
+ if item.raw_meta.get("container") is True:
544
+ return _failed(
545
+ backend,
546
+ item,
547
+ "container_not_extractable",
548
+ "Xiaoe column/container records must be discovered before extracting lessons.",
549
+ metadata={"resource_id": item.id or item.raw_meta.get("resource_id")},
550
+ )
551
+ return None
552
+
553
+
554
+ def _require_xiaoe_item(item: ContentItem, backend: str) -> TranscriptResult | None:
555
+ if item.platform == "xiaoe":
556
+ return None
557
+ return _failed(
558
+ backend,
559
+ item,
560
+ "unsupported_platform",
561
+ f"OpenCLI Xiaoe backend only supports xiaoe, got {item.platform}.",
562
+ )
563
+
564
+
565
+ def _read_first_markdown(directory: str | Path) -> tuple[str, str | None]:
566
+ for markdown_file in sorted(Path(directory).rglob("*.md")):
567
+ text = markdown_file.read_text(encoding="utf-8", errors="replace")
568
+ return text, _extract_markdown_title(text)
569
+ return "", None
570
+
571
+
572
+ def _extract_markdown_title(markdown: str) -> str | None:
573
+ for line in markdown.splitlines():
574
+ text = line.strip()
575
+ if text.startswith("# "):
576
+ return text.removeprefix("# ").strip() or None
577
+ return None
578
+
579
+
580
+ def parse_xiaoe_catalog_items(stdout: str) -> list[ContentItem]:
581
+ records = _catalog_records_from_stdout(stdout)
582
+ items: list[ContentItem] = []
583
+ for record in records:
584
+ item = _catalog_item_from_record(record)
585
+ if item is not None:
586
+ items.append(item)
587
+ return items
588
+
589
+
590
+ def _catalog_records_from_stdout(stdout: str) -> list[dict[str, Any]]:
591
+ import json
592
+
593
+ try:
594
+ payload = load_opencli_json(stdout.strip())
595
+ except json.JSONDecodeError:
596
+ return []
597
+ if isinstance(payload, list):
598
+ return [item for item in payload if isinstance(item, dict)]
599
+ if not isinstance(payload, dict):
600
+ return []
601
+ for key in ("items", "catalog", "data", "rows", "list"):
602
+ value = payload.get(key)
603
+ if isinstance(value, list):
604
+ return [item for item in value if isinstance(item, dict)]
605
+ if isinstance(value, dict):
606
+ nested = _catalog_records_from_payload(value)
607
+ if nested:
608
+ return nested
609
+ return []
610
+
611
+
612
+ def _catalog_records_from_payload(payload: dict[str, Any]) -> list[dict[str, Any]]:
613
+ for key in ("items", "catalog", "data", "rows", "list"):
614
+ value = payload.get(key)
615
+ if isinstance(value, list):
616
+ return [item for item in value if isinstance(item, dict)]
617
+ if isinstance(value, dict):
618
+ nested = _catalog_records_from_payload(value)
619
+ if nested:
620
+ return nested
621
+ return []
622
+
623
+
624
+ def _catalog_item_from_record(record: dict[str, Any]) -> ContentItem | None:
625
+ url = _first_text(record, "url", "link", "href", "canonical_url") or ""
626
+ resource_id = _first_text(
627
+ record,
628
+ "resource_id",
629
+ "resourceId",
630
+ "chapter_id",
631
+ "chapterId",
632
+ "id",
633
+ "rid",
634
+ )
635
+ if not url and not resource_id:
636
+ return None
637
+ raw_resource_type = _first_value(
638
+ record,
639
+ "resource_type",
640
+ "resourceType",
641
+ "chapter_type",
642
+ "chapterType",
643
+ "type",
644
+ )
645
+ resource_label = _first_text(record, "resource_label", "resourceLabel", "label")
646
+ resource_kind, source_type, is_container = _classify_xiaoe_resource_type(
647
+ raw_resource_type
648
+ )
649
+ resource_label = resource_label or _xiaoe_resource_type_label(raw_resource_type)
650
+ title = _first_text(record, "title", "name")
651
+ return ContentItem(
652
+ platform="xiaoe",
653
+ input_type="collection",
654
+ url=url,
655
+ id=resource_id,
656
+ title=title,
657
+ source_type=source_type,
658
+ raw_meta={
659
+ "profile": (
660
+ "media"
661
+ if source_type in {"video", "audio"}
662
+ else "document"
663
+ if source_type == "image_text"
664
+ else "article"
665
+ ),
666
+ "chapter": _first_text(record, "chapter", "ch"),
667
+ "no": _first_text(record, "no", "index"),
668
+ "status": _first_text(record, "status"),
669
+ "resource_id": resource_id,
670
+ "resource_type": raw_resource_type,
671
+ "resource_label": resource_label or None,
672
+ "resource_kind": resource_kind,
673
+ "container": is_container,
674
+ "canonical_url": url or None,
675
+ "canonical_url_available": bool(url),
676
+ "catalog_type": resource_label or None,
677
+ "caption_status": "unknown",
678
+ "discovery_provider": OPENCLI_XIAOE_CATALOG_DISCOVERY_BACKEND,
679
+ },
680
+ )
681
+
682
+
683
+ def _first_value(record: dict[str, Any], *keys: str) -> Any:
684
+ for key in keys:
685
+ value = record.get(key)
686
+ if value is None:
687
+ continue
688
+ if isinstance(value, str):
689
+ if value.strip():
690
+ return value.strip()
691
+ continue
692
+ return value
693
+ return None
694
+
695
+
696
+ def _first_text(record: dict[str, Any], *keys: str) -> str | None:
697
+ value = _first_value(record, *keys)
698
+ if value is None:
699
+ return None
700
+ text = str(value).strip()
701
+ return text or None
702
+
703
+
704
+ def _resource_type_number(value: Any) -> int | None:
705
+ if isinstance(value, bool) or value is None:
706
+ return None
707
+ if isinstance(value, int):
708
+ return value
709
+ if isinstance(value, str) and value.strip().isdigit():
710
+ return int(value.strip())
711
+ return None
712
+
713
+
714
+ def _xiaoe_resource_type_label(value: Any) -> str | None:
715
+ resource_type_number = _resource_type_number(value)
716
+ if resource_type_number in XIAOE_RESOURCE_TYPE_LABELS:
717
+ return XIAOE_RESOURCE_TYPE_LABELS[resource_type_number]
718
+ if value is None:
719
+ return None
720
+ text = str(value).strip()
721
+ return text or None
722
+
723
+
724
+ def _classify_xiaoe_resource_type(value: Any) -> tuple[str, str, bool]:
725
+ resource_type_number = _resource_type_number(value)
726
+ if resource_type_number in XIAOE_CONTAINER_RESOURCE_TYPES:
727
+ return "container", "mixed", True
728
+ if resource_type_number in XIAOE_TEXT_RESOURCE_TYPES:
729
+ return "image_text", "image_text", False
730
+ if resource_type_number in XIAOE_AUDIO_RESOURCE_TYPES:
731
+ return "audio", "audio", False
732
+ if resource_type_number in XIAOE_VIDEO_RESOURCE_TYPES:
733
+ return "live" if resource_type_number == 2 else "video", "video", False
734
+
735
+ normalized = str(value or "").strip().lower()
736
+ if normalized in {"图文", "article", "text", "graphic", "image_text"}:
737
+ return "image_text", "image_text", False
738
+ if normalized in {"直播", "live", "live_stream", "livestream"}:
739
+ return "live", "video", False
740
+ if normalized in {"音频", "audio", "podcast"}:
741
+ return "audio", "audio", False
742
+ if normalized in {"视频", "video", "vod"}:
743
+ return "video", "video", False
744
+ if normalized in {"专栏", "大专栏", "column", "series", "container"}:
745
+ return "container", "mixed", True
746
+ return "unknown", "mixed", False
747
+
748
+
749
+ def parse_xiaoe_content_result(stdout: str) -> XiaoeContentPayload | None:
750
+ """Extract only the normalized content fields from an OpenCLI JSON response."""
751
+ try:
752
+ payload = load_opencli_json(stdout.strip())
753
+ except (TypeError, ValueError):
754
+ return None
755
+
756
+ record = _find_xiaoe_content_record(payload)
757
+ if record is None:
758
+ return None
759
+ content = record.get("content")
760
+ if not isinstance(content, str):
761
+ return None
762
+ return XiaoeContentPayload(
763
+ title=_first_text(record, "title", "name"),
764
+ content=content,
765
+ content_length=_non_negative_int(record.get("content_length"), len(content)),
766
+ image_count=_non_negative_int(record.get("image_count"), 0),
767
+ content_selector=_first_text(record, "content_selector"),
768
+ )
769
+
770
+
771
+ def _find_xiaoe_content_record(payload: Any) -> dict[str, Any] | None:
772
+ if isinstance(payload, dict):
773
+ if "content" in payload or "content_length" in payload or "image_count" in payload:
774
+ return payload
775
+ for key in ("data", "result", "items", "rows", "list"):
776
+ nested = _find_xiaoe_content_record(payload.get(key))
777
+ if nested is not None:
778
+ return nested
779
+ elif isinstance(payload, list):
780
+ for value in payload:
781
+ nested = _find_xiaoe_content_record(value)
782
+ if nested is not None:
783
+ return nested
784
+ return None
785
+
786
+
787
+ def _non_negative_int(value: Any, fallback: int) -> int:
788
+ if isinstance(value, bool):
789
+ return fallback
790
+ try:
791
+ normalized = int(value)
792
+ except (TypeError, ValueError):
793
+ return fallback
794
+ return normalized if normalized >= 0 else fallback
795
+
796
+
797
+ def _javascript_string_length(value: str) -> int:
798
+ """Match JavaScript's UTF-16 ``String.length`` for content metadata."""
799
+ return len(value.encode("utf-16-le")) // 2
800
+
801
+
802
+ def _extract_xiaoe_error_details(value: Any) -> tuple[str | None, str | None] | None:
803
+ text = str(value or "").strip()
804
+ if not text:
805
+ return None
806
+ try:
807
+ payload = json.loads(text)
808
+ except (TypeError, ValueError):
809
+ payload = None
810
+
811
+ candidates: list[dict[str, Any]] = []
812
+ if isinstance(payload, dict):
813
+ candidates.append(payload)
814
+ for key in ("error", "failure", "data", "result"):
815
+ nested = payload.get(key)
816
+ if isinstance(nested, dict):
817
+ candidates.append(nested)
818
+ for candidate in candidates:
819
+ error_code = _first_text(
820
+ candidate,
821
+ "error_code",
822
+ "errorCode",
823
+ "code",
824
+ "name",
825
+ "type",
826
+ )
827
+ error_message = _first_text(
828
+ candidate,
829
+ "error_summary",
830
+ "errorSummary",
831
+ "message",
832
+ "detail",
833
+ )
834
+ if error_code or error_message:
835
+ return error_code, error_message
836
+
837
+ match = _XIAOE_ERROR_NAME_RE.search(text)
838
+ if match:
839
+ return match.group(0), text
840
+ return None
841
+
842
+
843
+ def _map_xiaoe_error(
844
+ error: tuple[str | None, str | None] | str,
845
+ exit_code: int | None,
846
+ ) -> tuple[str, str]:
847
+ if isinstance(error, tuple):
848
+ raw_code, raw_summary = error
849
+ else:
850
+ details = _extract_xiaoe_error_details(error)
851
+ raw_code, raw_summary = details or (None, str(error))
852
+ normalized_code = re.sub(r"[^a-z0-9]+", "", str(raw_code or "").lower())
853
+ mapped_code = XIAOE_ERROR_CODE_ALIASES.get(normalized_code)
854
+ if mapped_code is None:
855
+ mapped_code = str(raw_code or "").strip().lower().replace(" ", "_")
856
+ if not mapped_code:
857
+ mapped_code = f"backend_exit_{exit_code}" if exit_code is not None else "backend_failed"
858
+ summary = sanitize_error_summary(
859
+ raw_summary or f"OpenCLI xiaoe command failed with code {mapped_code}."
860
+ ) or "OpenCLI xiaoe command failed."
861
+ return mapped_code, summary
862
+
863
+
864
+ def is_predominantly_english(text: str) -> bool:
865
+ """True when Latin letters clearly outnumber CJK characters in a transcript."""
866
+ latin = len(re.findall(r"[A-Za-z]", text))
867
+ cjk = len(re.findall(r"[\u4e00-\u9fff]", text))
868
+ return latin >= 200 and cjk < latin * 0.05
869
+
870
+
871
+ def _retained_tingwu_audio_dir(error: object) -> Path | None:
872
+ """Return the retained audio directory when Tingwu rejected one upload."""
873
+ message = str(error)
874
+ if "Tingwu server reported upload/transcription failure" not in message:
875
+ return None
876
+ match = re.search(r"temp dir: ([^)\"\r\n]+)", message)
877
+ if match is None:
878
+ return None
879
+ directory = Path(match.group(1).strip().replace("\\\\", "\\"))
880
+ if not _is_xiaoe_temp_dir(directory) or not (directory / "normalized.mp3").is_file():
881
+ return None
882
+ return directory
883
+
884
+
885
+ def _reported_temp_dir(stdout: str) -> Path | None:
886
+ match = re.search(r'"tempDir"\s*:\s*"([^"]+)"', stdout)
887
+ return Path(match.group(1).replace("\\\\", "\\")) if match else None
888
+
889
+
890
+ def _is_xiaoe_temp_dir(directory: Path) -> bool:
891
+ return (
892
+ directory.name.startswith("xiaoe-to-md-")
893
+ and directory.resolve().parent == Path(tempfile.gettempdir()).resolve()
894
+ )
895
+
896
+
897
+ def _remove_xiaoe_temp_dirs(*directories: Path | None) -> None:
898
+ for directory in directories:
899
+ if directory is not None and _is_xiaoe_temp_dir(directory):
900
+ shutil.rmtree(directory, ignore_errors=True)
901
+
902
+
903
+ def _backend_failure(
904
+ backend: str,
905
+ item: ContentItem,
906
+ exit_code: int,
907
+ error: object,
908
+ ) -> TranscriptResult:
909
+ code, summary = _map_xiaoe_error(error, exit_code)
910
+ if exit_code == 127 and code.startswith("backend_exit_"):
911
+ code = "backend_dependency_missing"
912
+ return _failed(
913
+ backend,
914
+ item,
915
+ code,
916
+ summary or f"OpenCLI command exited with code {exit_code}.",
917
+ metadata={"exit_code": exit_code},
918
+ notes=["backend command failed without persisting raw stdout/stderr."],
919
+ )
920
+
921
+
922
+ def _failed(
923
+ backend: str,
924
+ item: ContentItem,
925
+ code: str,
926
+ error: object,
927
+ *,
928
+ metadata: dict[str, object] | None = None,
929
+ notes: list[str] | None = None,
930
+ ) -> TranscriptResult:
931
+ return TranscriptResult(
932
+ text="",
933
+ status="failed",
934
+ source_level="partial_extraction",
935
+ backend=backend,
936
+ source_notes=["Xiaoe runtime provider returned structured failure.", *(notes or [])],
937
+ error_code=code,
938
+ error_summary=sanitize_error_summary(error),
939
+ metadata={"platform": item.platform, **dict(metadata or {})},
940
+ )