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,546 @@
1
+ """Backend chain executor for normalized extraction.
2
+
3
+ The executor owns chain semantics: try the selected backend first, then continue
4
+ to fallbacks only when the previous provider returned a structured failure.
5
+ Individual providers receive a single-backend request so they do not perform
6
+ their own hidden fallback loops.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import replace
12
+ from collections.abc import Callable
13
+
14
+ from openmd.adapters.base import PlatformAdapter
15
+ from openmd.core.content_validation import validate_extraction_content
16
+ from openmd.core.models import ContentItem, TranscriptResult
17
+ from openmd.core.secrets import sanitize_error_summary
18
+
19
+ from .base import (
20
+ BackendCapability,
21
+ BackendDescriptor,
22
+ BackendExtractRequest,
23
+ BackendHealth,
24
+ BackendProvider,
25
+ DiscoveryProvider,
26
+ DiscoveryRequest,
27
+ DiscoveryResult,
28
+ ItemIdentityResolution,
29
+ )
30
+
31
+
32
+ class BackendProviderRegistry:
33
+ """Provider lookup with an explicit safe skeleton mode.
34
+
35
+ The default Openmd CLI uses skeleton mode so backend names in a chain remain
36
+ route declarations until a real provider is intentionally registered.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ providers: dict[str, BackendProvider] | None = None,
42
+ *,
43
+ allow_skeleton_fallback: bool = False,
44
+ ) -> None:
45
+ self._providers: dict[str, BackendProvider] = {"mock": MockBackendProvider()}
46
+ self._providers.update(providers or {})
47
+ self.allow_skeleton_fallback = allow_skeleton_fallback
48
+
49
+ def register(self, name: str, provider: BackendProvider) -> None:
50
+ self._providers[name] = provider
51
+
52
+ def provider_for(self, backend: str) -> BackendProvider | None:
53
+ provider = self._providers.get(backend)
54
+ if provider is not None:
55
+ return provider
56
+ if self.allow_skeleton_fallback:
57
+ return SkeletonBackendProvider(backend)
58
+ return None
59
+
60
+
61
+ class DiscoveryProviderRegistry:
62
+ """Discovery provider lookup with safe non-network defaults."""
63
+
64
+ def __init__(
65
+ self,
66
+ providers: dict[str, DiscoveryProvider] | None = None,
67
+ *,
68
+ platform_defaults: dict[str, str] | None = None,
69
+ allow_skeleton_fallback: bool = True,
70
+ ) -> None:
71
+ self._providers: dict[str, DiscoveryProvider] = {
72
+ "skeleton.discovery": SkeletonDiscoveryProvider(),
73
+ "mock.discovery": MockDiscoveryProvider(),
74
+ }
75
+ self._providers.update(providers or {})
76
+ self._platform_defaults: dict[str, str] = dict(platform_defaults or {})
77
+ self.allow_skeleton_fallback = allow_skeleton_fallback
78
+
79
+ def register(
80
+ self,
81
+ name: str,
82
+ provider: DiscoveryProvider,
83
+ *,
84
+ platforms: tuple[str, ...] = (),
85
+ default_for_platforms: bool = True,
86
+ ) -> None:
87
+ self._providers[name] = provider
88
+ if default_for_platforms:
89
+ for platform in platforms:
90
+ self._platform_defaults[platform] = name
91
+
92
+ def provider_for(
93
+ self,
94
+ request: DiscoveryRequest,
95
+ *,
96
+ requested_provider: str | None = None,
97
+ ) -> DiscoveryProvider | None:
98
+ name = requested_provider or self._platform_defaults.get(request.platform)
99
+ if name:
100
+ provider = self._providers.get(name)
101
+ if provider is not None:
102
+ return provider
103
+ if self.allow_skeleton_fallback:
104
+ return self._providers["skeleton.discovery"]
105
+ return None
106
+
107
+
108
+ class SkeletonBackendProvider:
109
+ """Safe placeholder that never calls network, accounts, commands, ASR, or OCR."""
110
+
111
+ def __init__(self, name: str) -> None:
112
+ self.name = name
113
+
114
+ def descriptor(self) -> BackendDescriptor:
115
+ return BackendDescriptor(
116
+ name=self.name,
117
+ layer="skeleton",
118
+ capabilities=(),
119
+ description="Safe placeholder provider; real extraction is not implemented.",
120
+ provider="openmd.skeleton",
121
+ is_fallback=True,
122
+ )
123
+
124
+ def health(self) -> BackendHealth:
125
+ return BackendHealth(ok=True, warnings=("skeleton provider only",))
126
+
127
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
128
+ backend = request.primary_backend
129
+ return _failed_result(
130
+ request.item,
131
+ backend,
132
+ "backend_not_implemented",
133
+ "Skeleton provider only; no external command, network, account, ASR, or OCR action was attempted.",
134
+ notes=[
135
+ "skeleton provider only; real extraction is not implemented",
136
+ "no external command/network/account/asr/ocr action was attempted",
137
+ ],
138
+ )
139
+
140
+
141
+ class MockBackendProvider:
142
+ """Deterministic local smoke provider for tests and CLI skeleton checks."""
143
+
144
+ name = "mock"
145
+
146
+ def descriptor(self) -> BackendDescriptor:
147
+ capability = BackendCapability(
148
+ platforms=("*",),
149
+ source_types=("article", "video", "audio", "image_text", "mixed", "document"),
150
+ input_types=("single", "batch", "collection", "generic"),
151
+ source_levels=("third_party_extractor", "platform_transcript"),
152
+ requires_network=False,
153
+ )
154
+ return BackendDescriptor(
155
+ name=self.name,
156
+ layer="mock",
157
+ capabilities=(capability,),
158
+ description="Local deterministic backend for smoke checks.",
159
+ provider="openmd.mock",
160
+ )
161
+
162
+ def health(self) -> BackendHealth:
163
+ return BackendHealth(ok=True, dependencies={"mock": "ok"})
164
+
165
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
166
+ return TranscriptResult(
167
+ text="[00:00:00] Mock transcript for Openmd skeleton.\n",
168
+ status="verified",
169
+ # The video fixture must satisfy the same source contract as real
170
+ # providers. Its backend/notes still identify this as synthetic.
171
+ source_level="platform_transcript" if request.item.platform == "douyin" else "third_party_extractor",
172
+ backend=request.primary_backend,
173
+ source_notes=["local smoke check only"],
174
+ )
175
+
176
+
177
+ def create_safe_backend_provider_registry() -> BackendProviderRegistry:
178
+ """Create the default provider registry used by CLI skeleton execution."""
179
+ return BackendProviderRegistry(allow_skeleton_fallback=True)
180
+
181
+
182
+ class SkeletonDiscoveryProvider:
183
+ """Safe placeholder that never discovers through network, browser, CLI, ASR, or OCR."""
184
+
185
+ name = "skeleton.discovery"
186
+
187
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
188
+ return _failed_discovery_result(
189
+ self.name,
190
+ "discovery_not_implemented",
191
+ (
192
+ "Skeleton discovery provider only; no external command, network, "
193
+ "browser, account/session, yt-dlp, ASR, or OCR action was attempted."
194
+ ),
195
+ notes=[
196
+ "skeleton discovery provider only; real collection discovery is not implemented",
197
+ "no external command/network/browser/account/session/yt-dlp/asr/ocr action was attempted",
198
+ ],
199
+ metadata={
200
+ "platform": request.platform,
201
+ "limit": request.limit,
202
+ "allow_all": request.allow_all,
203
+ },
204
+ )
205
+
206
+
207
+ class MockDiscoveryProvider:
208
+ """Deterministic local discovery provider for tests and CLI smoke checks."""
209
+
210
+ name = "mock.discovery"
211
+
212
+ def __init__(
213
+ self,
214
+ *,
215
+ items: list[ContentItem] | None = None,
216
+ default_count: int = 2,
217
+ ) -> None:
218
+ self._items = items
219
+ self.default_count = default_count
220
+
221
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
222
+ if self._items is not None:
223
+ items = list(self._items)
224
+ if request.limit is not None and not request.allow_all:
225
+ items = items[: max(0, request.limit)]
226
+ else:
227
+ count = self.default_count
228
+ if request.limit is not None and not request.allow_all:
229
+ count = min(count, max(0, request.limit))
230
+ items = [
231
+ ContentItem(
232
+ platform=request.platform,
233
+ input_type="single",
234
+ url=f"https://www.youtube.com/watch?v=openmd-mock-{index + 1}",
235
+ id=f"openmd-mock-{index + 1}",
236
+ title=f"Openmd mock video {index + 1}",
237
+ author="Openmd mock",
238
+ source_type="video",
239
+ raw_meta={
240
+ "collection_url": request.input_url,
241
+ "discovery_limit": request.limit,
242
+ "discovery_provider": self.name,
243
+ },
244
+ )
245
+ for index in range(count)
246
+ ]
247
+ return DiscoveryResult.success(
248
+ provider=self.name,
249
+ items=items,
250
+ source_notes=["mock discovery provider returned deterministic local items"],
251
+ metadata={
252
+ "platform": request.platform,
253
+ "limit": request.limit,
254
+ "allow_all": request.allow_all,
255
+ },
256
+ )
257
+
258
+
259
+ def create_safe_discovery_provider_registry() -> DiscoveryProviderRegistry:
260
+ """Create the default discovery registry used by CLI collect skeleton execution."""
261
+ return DiscoveryProviderRegistry(allow_skeleton_fallback=True)
262
+
263
+
264
+ class AdapterBackendProvider:
265
+ """Opt-in bridge from platform adapter to backend provider contract.
266
+
267
+ This is intentionally not used by the default CLI path because some adapters
268
+ can call real local tools. Register it only when a real provider is being
269
+ explicitly tested or enabled.
270
+ """
271
+
272
+ def __init__(self, adapter: PlatformAdapter, *, name: str = "adapter") -> None:
273
+ self.adapter = adapter
274
+ self.name = name
275
+
276
+ def descriptor(self) -> BackendDescriptor:
277
+ capability = BackendCapability(
278
+ platforms=(self.adapter.id,),
279
+ source_types=("article", "video", "audio", "image_text", "mixed", "document"),
280
+ input_types=("single", "batch", "collection", "generic"),
281
+ source_levels=(
282
+ "native_text",
283
+ "native_caption",
284
+ "auto_caption",
285
+ "third_party_extractor",
286
+ "media_asr",
287
+ "live_capture_asr",
288
+ "partial_extraction",
289
+ ),
290
+ )
291
+ return BackendDescriptor(
292
+ name=self.name,
293
+ layer="adapter",
294
+ capabilities=(capability,),
295
+ description="Adapter-backed skeleton provider.",
296
+ provider=self.adapter.id,
297
+ )
298
+
299
+ def health(self) -> BackendHealth:
300
+ return BackendHealth(ok=True, dependencies={"adapter": self.adapter.id})
301
+
302
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
303
+ backend = request.primary_backend
304
+ return self.adapter.extract_text(
305
+ request.item,
306
+ backend=backend,
307
+ backend_chain=(backend,),
308
+ allow_fallback=False,
309
+ )
310
+
311
+
312
+ class BackendExecutor:
313
+ """Execute a backend chain until one provider returns usable text."""
314
+
315
+ def __init__(
316
+ self,
317
+ providers: dict[str, BackendProvider] | None = None,
318
+ *,
319
+ provider_registry: BackendProviderRegistry | None = None,
320
+ default_provider: BackendProvider | None = None,
321
+ result_enricher: Callable[[ContentItem, TranscriptResult], TranscriptResult] | None = None,
322
+ ) -> None:
323
+ self._provider_registry = provider_registry or BackendProviderRegistry(providers)
324
+ if provider_registry is not None:
325
+ for name, provider in (providers or {}).items():
326
+ self._provider_registry.register(name, provider)
327
+ self._default_provider = default_provider
328
+ self._result_enricher = result_enricher
329
+
330
+ def extract(
331
+ self,
332
+ item: ContentItem,
333
+ backend_chain: tuple[str, ...],
334
+ *,
335
+ allow_fallback: bool = True,
336
+ ) -> TranscriptResult:
337
+ return self.execute(
338
+ BackendExtractRequest(
339
+ item=item,
340
+ backend_chain=backend_chain,
341
+ allow_fallback=allow_fallback,
342
+ )
343
+ )
344
+
345
+ def resolve_item_identity(
346
+ self,
347
+ item: ContentItem,
348
+ backend_chain: tuple[str, ...],
349
+ ) -> ItemIdentityResolution:
350
+ """Resolve optional durable identity before skip-existing checks."""
351
+ if not bool(item.raw_meta.get("identity_unresolved")):
352
+ return ItemIdentityResolution.success(item, provider="identity.already_resolved")
353
+
354
+ for backend in backend_chain:
355
+ provider = self._provider_for(backend)
356
+ resolver = getattr(provider, "resolve_item_identity", None) if provider else None
357
+ if not callable(resolver):
358
+ continue
359
+ try:
360
+ resolved = resolver(item)
361
+ except Exception as exc: # pragma: no cover - provider boundary safety net
362
+ return ItemIdentityResolution.failure(
363
+ item,
364
+ provider=getattr(provider, "name", backend),
365
+ error_code="identity_resolution_provider_error",
366
+ error_summary=sanitize_error_summary(exc) or "identity resolution failed",
367
+ )
368
+ if isinstance(resolved, ItemIdentityResolution):
369
+ return resolved
370
+ if isinstance(resolved, ContentItem):
371
+ return ItemIdentityResolution.success(
372
+ resolved,
373
+ provider=getattr(provider, "name", backend),
374
+ )
375
+ return ItemIdentityResolution.failure(
376
+ item,
377
+ provider=getattr(provider, "name", backend),
378
+ error_code="identity_resolution_invalid_result",
379
+ error_summary="Identity resolver returned an unsupported result.",
380
+ )
381
+
382
+ return ItemIdentityResolution.failure(
383
+ item,
384
+ provider="identity.not_available",
385
+ error_code="identity_resolver_not_available",
386
+ error_summary="No selected backend can resolve the durable item identity.",
387
+ )
388
+
389
+ def execute(self, request: BackendExtractRequest) -> TranscriptResult:
390
+ chain = request.backend_chain if request.allow_fallback else request.backend_chain[:1]
391
+ if not chain:
392
+ return _failed_result(request.item, "not_selected", "backend_chain_empty")
393
+
394
+ attempt_notes: list[str] = [f"backend_chain: {' -> '.join(chain)}"]
395
+ attempts: list[dict[str, str]] = []
396
+ last_result: TranscriptResult | None = None
397
+ current_item = request.item
398
+
399
+ for backend in chain:
400
+ provider = self._provider_for(backend)
401
+ if provider is None:
402
+ result = _failed_result(request.item, backend, "backend_not_registered")
403
+ else:
404
+ result = self._extract_one(provider, current_item, backend)
405
+
406
+ # Engine detection is evidence for every later fallback. Generic
407
+ # article readers cannot replace a known missing forum body with UI.
408
+ if result.metadata.get("engine") == "discourse" or result.metadata.get("site_engine") == "discourse":
409
+ current_item = replace(current_item, raw_meta={**current_item.raw_meta, "site_engine": "discourse"})
410
+
411
+ from openmd.routing.loader import route_trace
412
+
413
+ result = replace(result, metadata={**result.metadata, **route_trace(request.item.platform, result.backend)})
414
+
415
+ if result.has_usable_text or (result.status == "partial" and result.metadata.get("terminal_partial") is True):
416
+ if self._result_enricher is not None:
417
+ result = self._result_enricher(request.item, result)
418
+ return _with_attempt_notes(result, attempt_notes)
419
+
420
+ # A later "tool not installed" must not hide an earlier verdict about
421
+ # the source itself (error page, challenge, no subtitles).
422
+ if last_result is None or not _is_tool_unavailable(result) or _is_tool_unavailable(last_result):
423
+ last_result = result
424
+ attempts.append({"backend": backend, "error_code": result.error_code or result.status})
425
+ attempt_notes.append(
426
+ f"fallback_attempt: {backend} failed with {result.error_code or result.status}"
427
+ )
428
+
429
+ if last_result is not None:
430
+ last_result = replace(last_result, metadata={**last_result.metadata, "fallback_attempts": attempts})
431
+ return _with_attempt_notes(last_result, attempt_notes) if last_result else _failed_result(
432
+ request.item,
433
+ chain[-1],
434
+ "backend_chain_empty",
435
+ )
436
+
437
+ def _provider_for(self, backend: str) -> BackendProvider | None:
438
+ return self._provider_registry.provider_for(backend) or self._default_provider
439
+
440
+ def _extract_one(
441
+ self,
442
+ provider: BackendProvider,
443
+ item: ContentItem,
444
+ backend: str,
445
+ ) -> TranscriptResult:
446
+ try:
447
+ result = provider.extract(
448
+ BackendExtractRequest(
449
+ item=item,
450
+ backend_chain=(backend,),
451
+ allow_fallback=False,
452
+ )
453
+ )
454
+ return validate_extraction_content(result, item)
455
+ except Exception as exc: # pragma: no cover - final provider safety net
456
+ return _failed_result(item, backend, "backend_provider_error", exc)
457
+
458
+
459
+ def _failed_result(
460
+ item: ContentItem,
461
+ backend: str,
462
+ code: str,
463
+ error: object | None = None,
464
+ *,
465
+ notes: list[str] | None = None,
466
+ ) -> TranscriptResult:
467
+ return TranscriptResult(
468
+ text="",
469
+ status="failed",
470
+ source_level="partial_extraction",
471
+ backend=backend,
472
+ source_notes=["backend executor returned structured failure", *(notes or [])],
473
+ error_code=code,
474
+ error_summary=sanitize_error_summary(error or code),
475
+ metadata={"platform": item.platform},
476
+ )
477
+
478
+
479
+ _TOOL_UNAVAILABLE_CODES = frozenset({
480
+ "backend_exit_127", "backend_exit_69", "tool_not_installed", "backend_dependency_missing", "backend_not_registered",
481
+ "backend_not_implemented", "dependency_missing", "not_installed",
482
+ })
483
+
484
+
485
+ def _is_tool_unavailable(result: TranscriptResult) -> bool:
486
+ return str(result.error_code or "") in _TOOL_UNAVAILABLE_CODES
487
+
488
+
489
+ def _with_attempt_notes(result: TranscriptResult, attempt_notes: list[str]) -> TranscriptResult:
490
+ return replace(result, source_notes=[*result.source_notes, *attempt_notes])
491
+
492
+
493
+ class DiscoveryExecutor:
494
+ """Resolve collection inputs into extractable ContentItem records."""
495
+
496
+ def __init__(
497
+ self,
498
+ providers: dict[str, DiscoveryProvider] | None = None,
499
+ *,
500
+ provider_registry: DiscoveryProviderRegistry | None = None,
501
+ requested_provider: str | None = None,
502
+ ) -> None:
503
+ self._provider_registry = provider_registry or DiscoveryProviderRegistry(providers)
504
+ if provider_registry is not None:
505
+ for name, provider in (providers or {}).items():
506
+ self._provider_registry.register(name, provider)
507
+ self._requested_provider = requested_provider
508
+
509
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
510
+ provider = self._provider_registry.provider_for(
511
+ request,
512
+ requested_provider=self._requested_provider,
513
+ )
514
+ if provider is None:
515
+ return _failed_discovery_result(
516
+ "not_selected",
517
+ "discovery_provider_not_registered",
518
+ f"No discovery provider registered for platform: {request.platform}.",
519
+ metadata={"platform": request.platform, "limit": request.limit},
520
+ )
521
+ try:
522
+ return provider.discover(request)
523
+ except Exception as exc: # pragma: no cover - final provider safety net
524
+ return _failed_discovery_result(
525
+ getattr(provider, "name", "unknown.discovery"),
526
+ "discovery_provider_error",
527
+ exc,
528
+ metadata={"platform": request.platform, "limit": request.limit},
529
+ )
530
+
531
+
532
+ def _failed_discovery_result(
533
+ provider: str,
534
+ code: str,
535
+ error: object,
536
+ *,
537
+ notes: list[str] | None = None,
538
+ metadata: dict[str, object] | None = None,
539
+ ) -> DiscoveryResult:
540
+ return DiscoveryResult.failure(
541
+ provider=provider,
542
+ error_code=code,
543
+ error_summary=sanitize_error_summary(error),
544
+ source_notes=["discovery executor returned structured failure", *(notes or [])],
545
+ metadata=dict(metadata or {}),
546
+ )