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,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
|
+
)
|