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,770 @@
|
|
|
1
|
+
"""Read-only CLI-backed platform adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from openmd.core.detector import extract_first_url
|
|
14
|
+
from openmd.core.models import ContentItem, TranscriptResult
|
|
15
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
16
|
+
|
|
17
|
+
from .base import PlatformAdapter
|
|
18
|
+
|
|
19
|
+
from openmd.runtime.tempdirs import owned_temporary_directory
|
|
20
|
+
from openmd.runtime.interpreters import run_interruptibly
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
BVID_RE = re.compile(r"(BV[0-9A-Za-z]+)")
|
|
24
|
+
X_STATUS_RE = re.compile(r"/status/(\d+)")
|
|
25
|
+
PODWISE_EPISODE_RE = re.compile(r"https://podwise\.ai/dashboard/episodes/\d+")
|
|
26
|
+
XIAOYUZHOU_CREDENTIALS_PATH = Path.home() / ".opencli" / "xiaoyuzhou.json"
|
|
27
|
+
XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS = frozenset(
|
|
28
|
+
{"uuid", "android_id", "oaid", "vaid", "aaid"}
|
|
29
|
+
)
|
|
30
|
+
XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS = frozenset({"idfa", "idfv"})
|
|
31
|
+
XIAOYUZHOU_IOS_REQUEST_HEADER_GROUPS = (
|
|
32
|
+
("user-agent",),
|
|
33
|
+
("os",),
|
|
34
|
+
("os-version",),
|
|
35
|
+
("manufacturer",),
|
|
36
|
+
("model",),
|
|
37
|
+
("market",),
|
|
38
|
+
("app-version",),
|
|
39
|
+
("app-buildno",),
|
|
40
|
+
("bundleid", "applicationid"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _source_type_for(platform: str) -> str:
|
|
45
|
+
if platform in {"bilibili", "youtube", "douyin", "tiktok", "xiaoe"}:
|
|
46
|
+
return "video"
|
|
47
|
+
if platform == "xiaoyuzhou":
|
|
48
|
+
return "audio"
|
|
49
|
+
if platform == "feishu":
|
|
50
|
+
return "document"
|
|
51
|
+
if platform == "xiaohongshu":
|
|
52
|
+
return "mixed"
|
|
53
|
+
return "article"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run_command(
|
|
57
|
+
command: list[str], *, timeout: int = 120, cwd: str | Path | None = None
|
|
58
|
+
) -> tuple[int, str, str]:
|
|
59
|
+
resolved = shutil.which(command[0]) or command[0]
|
|
60
|
+
completed = run_interruptibly(
|
|
61
|
+
[resolved, *command[1:]],
|
|
62
|
+
cwd=cwd,
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
check=False,
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
errors="replace",
|
|
69
|
+
)
|
|
70
|
+
return completed.returncode, completed.stdout or "", completed.stderr or ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run_opencli(
|
|
74
|
+
args: list[str], *, timeout: int = 120, cwd: str | Path | None = None
|
|
75
|
+
) -> tuple[int, str, str]:
|
|
76
|
+
# This adapter keeps stdout as backend text. OpenCLI 2.x defaults to a JSON
|
|
77
|
+
# envelope, so pin the table format instead of publishing the envelope as content.
|
|
78
|
+
if "-f" not in args and "--format" not in args:
|
|
79
|
+
args = [*args, "-f", "table"]
|
|
80
|
+
return _run_command(["opencli", *args], timeout=timeout, cwd=cwd)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run_podwise(
|
|
84
|
+
args: list[str], *, timeout: int = 180, cwd: str | Path | None = None
|
|
85
|
+
) -> tuple[int, str, str]:
|
|
86
|
+
return _run_command(["podwise", *args], timeout=timeout, cwd=cwd)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_bvid(url: str) -> str | None:
|
|
90
|
+
match = BVID_RE.search(url)
|
|
91
|
+
return match.group(1) if match else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_episode_id(url: str) -> str:
|
|
95
|
+
parsed = urlparse(url)
|
|
96
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
97
|
+
if len(parts) >= 2 and parts[0] == "episode":
|
|
98
|
+
return parts[1]
|
|
99
|
+
return url.rstrip("/").split("/")[-1]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_x_post_id(url: str) -> str | None:
|
|
103
|
+
parsed = urlparse(url)
|
|
104
|
+
match = X_STATUS_RE.search(parsed.path)
|
|
105
|
+
return match.group(1) if match else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _extract_x_username(url: str) -> str | None:
|
|
109
|
+
parsed = urlparse(url)
|
|
110
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
111
|
+
if not parts or parts[0] in {"i", "home", "explore", "search"}:
|
|
112
|
+
return None
|
|
113
|
+
return parts[0]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _extract_markdown_title(markdown: str) -> str | None:
|
|
117
|
+
for line in markdown.splitlines():
|
|
118
|
+
text = line.strip()
|
|
119
|
+
if text.startswith("# "):
|
|
120
|
+
return text.removeprefix("# ").strip() or None
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _extract_table_title(stdout: str) -> str | None:
|
|
125
|
+
for line in stdout.splitlines():
|
|
126
|
+
if not line.startswith("|"):
|
|
127
|
+
continue
|
|
128
|
+
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
129
|
+
if not cells:
|
|
130
|
+
continue
|
|
131
|
+
title = cells[0]
|
|
132
|
+
if title and title.lower() != "title" and set(title) != {"-"}:
|
|
133
|
+
return title
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _extract_first_markdown_title(directory: str | Path) -> str | None:
|
|
138
|
+
for markdown_file in sorted(Path(directory).rglob("*.md")):
|
|
139
|
+
title = _extract_markdown_title(
|
|
140
|
+
markdown_file.read_text(encoding="utf-8", errors="replace")
|
|
141
|
+
)
|
|
142
|
+
if title:
|
|
143
|
+
return title
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _extract_podwise_episode_url(text: str) -> str | None:
|
|
148
|
+
match = PODWISE_EPISODE_RE.search(text)
|
|
149
|
+
return match.group(0) if match else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_xiaoyuzhou_credentials_error(exit_code: int, stderr: str) -> bool:
|
|
153
|
+
stderr_lower = stderr.lower()
|
|
154
|
+
return exit_code == 78 and "xiaoyuzhou" in stderr_lower and "credential" in stderr_lower
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _header_lookup(headers: object, names: tuple[str, ...]) -> str | None:
|
|
158
|
+
if not isinstance(headers, dict):
|
|
159
|
+
return None
|
|
160
|
+
normalized = {str(key).lower(): value for key, value in headers.items()}
|
|
161
|
+
for name in names:
|
|
162
|
+
value = normalized.get(name.lower())
|
|
163
|
+
if isinstance(value, str) and value.strip():
|
|
164
|
+
return value.strip()
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _check_xiaoyuzhou_credentials(
|
|
169
|
+
path: Path = XIAOYUZHOU_CREDENTIALS_PATH,
|
|
170
|
+
) -> tuple[bool, str, str]:
|
|
171
|
+
"""Validate the local Xiaoyuzhou credential profile without exposing secrets."""
|
|
172
|
+
if not path.exists():
|
|
173
|
+
return (
|
|
174
|
+
False,
|
|
175
|
+
"xiaoyuzhou_credentials_missing",
|
|
176
|
+
"Missing Xiaoyuzhou credential file at ~/.opencli/xiaoyuzhou.json.",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
credentials = json.loads(path.read_text(encoding="utf-8"))
|
|
181
|
+
except (OSError, json.JSONDecodeError):
|
|
182
|
+
return (
|
|
183
|
+
False,
|
|
184
|
+
"xiaoyuzhou_credentials_invalid_json",
|
|
185
|
+
"Xiaoyuzhou credential file is not valid JSON.",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
for field in ("access_token", "refresh_token", "device_id"):
|
|
189
|
+
value = credentials.get(field)
|
|
190
|
+
if not isinstance(value, str) or not value.strip():
|
|
191
|
+
return (
|
|
192
|
+
False,
|
|
193
|
+
"xiaoyuzhou_credentials_incomplete",
|
|
194
|
+
f"Xiaoyuzhou credential file is missing required field: {field}.",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
device_id = credentials["device_id"].strip()
|
|
198
|
+
device_properties = credentials.get("device_properties")
|
|
199
|
+
if not isinstance(device_properties, str) or not device_properties.strip():
|
|
200
|
+
return (
|
|
201
|
+
False,
|
|
202
|
+
"xiaoyuzhou_device_properties_missing",
|
|
203
|
+
(
|
|
204
|
+
"Xiaoyuzhou device_properties is required; capture the real "
|
|
205
|
+
"x-jike-device-properties value when possible, and ensure its "
|
|
206
|
+
"uuid matches device_id."
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
parsed_properties = json.loads(device_properties)
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
return (
|
|
214
|
+
False,
|
|
215
|
+
"xiaoyuzhou_device_properties_invalid_json",
|
|
216
|
+
"Xiaoyuzhou device_properties must be a JSON string.",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if not isinstance(parsed_properties, dict):
|
|
220
|
+
return (
|
|
221
|
+
False,
|
|
222
|
+
"xiaoyuzhou_device_properties_invalid_json",
|
|
223
|
+
"Xiaoyuzhou device_properties must decode to a JSON object.",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
property_keys = set(parsed_properties.keys())
|
|
227
|
+
if XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS <= property_keys:
|
|
228
|
+
if parsed_properties.get("uuid") != device_id:
|
|
229
|
+
return (
|
|
230
|
+
False,
|
|
231
|
+
"xiaoyuzhou_device_mismatch",
|
|
232
|
+
"Xiaoyuzhou Android device_properties.uuid must match device_id.",
|
|
233
|
+
)
|
|
234
|
+
return True, "ok", "Xiaoyuzhou credential profile passed local shape checks."
|
|
235
|
+
|
|
236
|
+
if XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS <= property_keys:
|
|
237
|
+
request_headers = credentials.get("request_headers") or credentials.get("headers")
|
|
238
|
+
if not isinstance(request_headers, dict) or not request_headers:
|
|
239
|
+
return (
|
|
240
|
+
True,
|
|
241
|
+
"ok",
|
|
242
|
+
(
|
|
243
|
+
"Xiaoyuzhou credential profile passed local shape checks "
|
|
244
|
+
"for an OpenCLI-compatible iOS device profile."
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
missing_header_groups = [
|
|
249
|
+
"/".join(group)
|
|
250
|
+
for group in XIAOYUZHOU_IOS_REQUEST_HEADER_GROUPS
|
|
251
|
+
if _header_lookup(request_headers, group) is None
|
|
252
|
+
]
|
|
253
|
+
if missing_header_groups:
|
|
254
|
+
return (
|
|
255
|
+
False,
|
|
256
|
+
"xiaoyuzhou_request_headers_incomplete",
|
|
257
|
+
(
|
|
258
|
+
"Xiaoyuzhou iOS request_headers is missing fields: "
|
|
259
|
+
f"{', '.join(missing_header_groups)}."
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
header_device_id = _header_lookup(request_headers, ("x-jike-device-id",))
|
|
264
|
+
if header_device_id is not None and header_device_id != device_id:
|
|
265
|
+
return (
|
|
266
|
+
False,
|
|
267
|
+
"xiaoyuzhou_device_mismatch",
|
|
268
|
+
"Xiaoyuzhou request_headers x-jike-device-id must match device_id.",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return True, "ok", "Xiaoyuzhou credential profile passed local shape checks."
|
|
272
|
+
|
|
273
|
+
android_missing = sorted(XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS - property_keys)
|
|
274
|
+
ios_missing = sorted(XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS - property_keys)
|
|
275
|
+
if android_missing and ios_missing:
|
|
276
|
+
return (
|
|
277
|
+
False,
|
|
278
|
+
"xiaoyuzhou_device_properties_incomplete",
|
|
279
|
+
(
|
|
280
|
+
"Xiaoyuzhou device_properties must look like either Android "
|
|
281
|
+
f"({', '.join(sorted(XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS))}) "
|
|
282
|
+
f"or iOS ({', '.join(sorted(XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS))})."
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return True, "ok", "Xiaoyuzhou credential profile passed local shape checks."
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class OpenCliPlatformAdapter(PlatformAdapter):
|
|
290
|
+
"""Minimal real adapter for smoke-proven read-only CLI backends."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, platform_id: str, default_backend: str) -> None:
|
|
293
|
+
self.id = platform_id
|
|
294
|
+
self.default_backend = default_backend
|
|
295
|
+
|
|
296
|
+
def can_handle(self, input_value: str) -> bool:
|
|
297
|
+
from openmd.core.detector import detect_platform
|
|
298
|
+
|
|
299
|
+
return detect_platform(input_value) == self.id
|
|
300
|
+
|
|
301
|
+
def resolve_items(
|
|
302
|
+
self, input_value: str, *, limit: int | None = None, all: bool = False
|
|
303
|
+
) -> list[ContentItem]:
|
|
304
|
+
input_type = "collection" if limit is not None or all else "single"
|
|
305
|
+
return [
|
|
306
|
+
ContentItem(
|
|
307
|
+
platform=self.id,
|
|
308
|
+
input_type=input_type,
|
|
309
|
+
url=extract_first_url(input_value),
|
|
310
|
+
source_type=_source_type_for(self.id), # type: ignore[arg-type]
|
|
311
|
+
raw_meta={"limit": limit},
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
def extract_text(
|
|
316
|
+
self,
|
|
317
|
+
item: ContentItem,
|
|
318
|
+
*,
|
|
319
|
+
backend: str | None = None,
|
|
320
|
+
backend_chain: tuple[str, ...] | None = None,
|
|
321
|
+
allow_fallback: bool = True,
|
|
322
|
+
) -> TranscriptResult:
|
|
323
|
+
chain = backend_chain or (backend or self.default_backend,)
|
|
324
|
+
if not allow_fallback:
|
|
325
|
+
chain = chain[:1]
|
|
326
|
+
selected_backend = chain[0] if chain else self.default_backend
|
|
327
|
+
if selected_backend == "mock":
|
|
328
|
+
return TranscriptResult(
|
|
329
|
+
text="[00:00:00] Mock transcript for Openmd skeleton.\n",
|
|
330
|
+
status="verified",
|
|
331
|
+
source_level="third_party_extractor",
|
|
332
|
+
backend="mock",
|
|
333
|
+
source_notes=["local smoke check only"],
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if not selected_backend.startswith("opencli.") and selected_backend != "podwise.transcript":
|
|
337
|
+
return self._failed(
|
|
338
|
+
selected_backend,
|
|
339
|
+
"backend_not_implemented",
|
|
340
|
+
"Primary backend is registered but not wired in the V0 skeleton.",
|
|
341
|
+
notes=[f"backend_chain: {' -> '.join(chain)}"],
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
return self._extract_opencli_text(
|
|
346
|
+
item,
|
|
347
|
+
selected_backend,
|
|
348
|
+
allow_fallback=allow_fallback,
|
|
349
|
+
)
|
|
350
|
+
except FileNotFoundError as exc:
|
|
351
|
+
return self._failed(selected_backend, "backend_not_found", exc)
|
|
352
|
+
except subprocess.TimeoutExpired as exc:
|
|
353
|
+
return self._failed(selected_backend, "backend_timeout", exc)
|
|
354
|
+
except OSError as exc:
|
|
355
|
+
return self._failed(selected_backend, "backend_os_error", exc)
|
|
356
|
+
|
|
357
|
+
def _extract_opencli_text(
|
|
358
|
+
self,
|
|
359
|
+
item: ContentItem,
|
|
360
|
+
backend: str,
|
|
361
|
+
*,
|
|
362
|
+
allow_fallback: bool,
|
|
363
|
+
) -> TranscriptResult:
|
|
364
|
+
if self.id == "bilibili":
|
|
365
|
+
return self._bilibili(item, backend)
|
|
366
|
+
if self.id == "youtube":
|
|
367
|
+
return self._youtube(item, backend)
|
|
368
|
+
if self.id == "weixin":
|
|
369
|
+
return self._weixin(item, backend)
|
|
370
|
+
if self.id == "x":
|
|
371
|
+
return self._x(item, backend)
|
|
372
|
+
if self.id == "xiaoyuzhou":
|
|
373
|
+
return self._xiaoyuzhou(item, backend, allow_fallback=allow_fallback)
|
|
374
|
+
if self.id == "xiaohongshu":
|
|
375
|
+
return self._xiaohongshu(item, backend)
|
|
376
|
+
if self.id == "xiaoe":
|
|
377
|
+
return self._xiaoe(item, backend, allow_fallback=allow_fallback)
|
|
378
|
+
return self._failed(
|
|
379
|
+
backend,
|
|
380
|
+
"backend_not_implemented",
|
|
381
|
+
"OpenCLI adapter does not yet implement this platform.",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def _bilibili(self, item: ContentItem, backend: str) -> TranscriptResult:
|
|
385
|
+
bvid = _extract_bvid(item.url)
|
|
386
|
+
if not bvid:
|
|
387
|
+
return self._failed(backend, "invalid_input", "Bilibili BVID not found.")
|
|
388
|
+
exit_code, stdout, stderr = _run_opencli(["bilibili", "subtitle", bvid])
|
|
389
|
+
return self._result_from_stdout(
|
|
390
|
+
backend,
|
|
391
|
+
exit_code,
|
|
392
|
+
stdout,
|
|
393
|
+
stderr,
|
|
394
|
+
source_level="native_caption",
|
|
395
|
+
notes=["OpenCLI bilibili subtitle output is preserved as backend text."],
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def _youtube(self, item: ContentItem, backend: str) -> TranscriptResult:
|
|
399
|
+
exit_code, stdout, stderr = _run_opencli(
|
|
400
|
+
["youtube", "transcript", item.url, "--mode", "grouped"]
|
|
401
|
+
)
|
|
402
|
+
return self._result_from_stdout(
|
|
403
|
+
backend,
|
|
404
|
+
exit_code,
|
|
405
|
+
stdout,
|
|
406
|
+
stderr,
|
|
407
|
+
source_level="third_party_extractor",
|
|
408
|
+
notes=["OpenCLI youtube transcript grouped output."],
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def _weixin(self, item: ContentItem, backend: str) -> TranscriptResult:
|
|
412
|
+
with owned_temporary_directory(prefix="openmd-weixin-") as tmp_dir:
|
|
413
|
+
exit_code, stdout, stderr = _run_opencli(
|
|
414
|
+
[
|
|
415
|
+
"weixin",
|
|
416
|
+
"download",
|
|
417
|
+
"--url",
|
|
418
|
+
item.url,
|
|
419
|
+
"--output",
|
|
420
|
+
tmp_dir,
|
|
421
|
+
"--download-images",
|
|
422
|
+
"false",
|
|
423
|
+
"-f",
|
|
424
|
+
"json",
|
|
425
|
+
]
|
|
426
|
+
)
|
|
427
|
+
if exit_code != 0:
|
|
428
|
+
return self._backend_failure(backend, exit_code, stderr)
|
|
429
|
+
markdown_files = sorted(Path(tmp_dir).rglob("*.md"))
|
|
430
|
+
if not markdown_files:
|
|
431
|
+
return self._failed(
|
|
432
|
+
backend,
|
|
433
|
+
"empty_result",
|
|
434
|
+
"OpenCLI weixin download did not produce a Markdown file.",
|
|
435
|
+
)
|
|
436
|
+
text = markdown_files[0].read_text(encoding="utf-8", errors="replace")
|
|
437
|
+
return TranscriptResult(
|
|
438
|
+
text=text,
|
|
439
|
+
status="verified" if text.strip() else "failed",
|
|
440
|
+
source_level="native_text" if text.strip() else "partial_extraction",
|
|
441
|
+
backend=backend,
|
|
442
|
+
source_notes=["OpenCLI weixin download with --download-images false."],
|
|
443
|
+
error_code=None if text.strip() else "empty_result",
|
|
444
|
+
error_summary=None if text.strip() else "Downloaded Markdown was empty.",
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def _xiaoe(
|
|
448
|
+
self,
|
|
449
|
+
item: ContentItem,
|
|
450
|
+
backend: str,
|
|
451
|
+
*,
|
|
452
|
+
allow_fallback: bool,
|
|
453
|
+
) -> TranscriptResult:
|
|
454
|
+
if backend == "opencli.xiaoe.content":
|
|
455
|
+
return self._xiaoe_content(item, backend)
|
|
456
|
+
|
|
457
|
+
result = self._xiaoe_transcript(item, backend)
|
|
458
|
+
if result.has_usable_text or not allow_fallback:
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
fallback = self._xiaoe_content(item, "opencli.xiaoe.content")
|
|
462
|
+
if fallback.source_notes:
|
|
463
|
+
fallback.source_notes.insert(
|
|
464
|
+
0,
|
|
465
|
+
"OpenCLI xiaoe transcript failed; content extraction fallback was attempted.",
|
|
466
|
+
)
|
|
467
|
+
return fallback
|
|
468
|
+
|
|
469
|
+
def _xiaoe_transcript(self, item: ContentItem, backend: str) -> TranscriptResult:
|
|
470
|
+
with owned_temporary_directory(prefix="openmd-xiaoe-") as tmp_dir:
|
|
471
|
+
exit_code, _stdout, stderr = _run_opencli(
|
|
472
|
+
[
|
|
473
|
+
"xiaoe",
|
|
474
|
+
"transcript",
|
|
475
|
+
item.url,
|
|
476
|
+
"--out",
|
|
477
|
+
tmp_dir,
|
|
478
|
+
"--keep-artifacts",
|
|
479
|
+
"false",
|
|
480
|
+
"-f",
|
|
481
|
+
"json",
|
|
482
|
+
],
|
|
483
|
+
timeout=60 * 60,
|
|
484
|
+
)
|
|
485
|
+
if exit_code != 0:
|
|
486
|
+
return self._backend_failure(backend, exit_code, stderr)
|
|
487
|
+
|
|
488
|
+
markdown_files = sorted(Path(tmp_dir).rglob("*.md"))
|
|
489
|
+
if not markdown_files:
|
|
490
|
+
return self._failed(
|
|
491
|
+
backend,
|
|
492
|
+
"empty_result",
|
|
493
|
+
"OpenCLI xiaoe transcript did not produce a Markdown file.",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
text = markdown_files[0].read_text(encoding="utf-8", errors="replace")
|
|
497
|
+
return TranscriptResult(
|
|
498
|
+
text=text,
|
|
499
|
+
status="verified" if text.strip() else "failed",
|
|
500
|
+
source_level="media_asr" if text.strip() else "partial_extraction",
|
|
501
|
+
backend=backend,
|
|
502
|
+
source_notes=[
|
|
503
|
+
"OpenCLI xiaoe transcript with --keep-artifacts false.",
|
|
504
|
+
"Generated Markdown was read from a temporary output directory.",
|
|
505
|
+
],
|
|
506
|
+
error_code=None if text.strip() else "empty_result",
|
|
507
|
+
error_summary=None if text.strip() else "Generated Markdown was empty.",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def _xiaoe_content(self, item: ContentItem, backend: str) -> TranscriptResult:
|
|
511
|
+
exit_code, stdout, stderr = _run_opencli(
|
|
512
|
+
[
|
|
513
|
+
"xiaoe",
|
|
514
|
+
"content",
|
|
515
|
+
item.url,
|
|
516
|
+
"-f",
|
|
517
|
+
"json",
|
|
518
|
+
"--window",
|
|
519
|
+
"background",
|
|
520
|
+
"--site-session",
|
|
521
|
+
"persistent",
|
|
522
|
+
"--keep-tab",
|
|
523
|
+
"false",
|
|
524
|
+
],
|
|
525
|
+
timeout=300,
|
|
526
|
+
)
|
|
527
|
+
return self._result_from_stdout(
|
|
528
|
+
backend,
|
|
529
|
+
exit_code,
|
|
530
|
+
stdout,
|
|
531
|
+
stderr,
|
|
532
|
+
source_level="native_text",
|
|
533
|
+
notes=["OpenCLI xiaoe content extraction output."],
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def _x(self, item: ContentItem, backend: str) -> TranscriptResult:
|
|
537
|
+
post_id = _extract_x_post_id(item.url)
|
|
538
|
+
if post_id:
|
|
539
|
+
exit_code, stdout, stderr = _run_opencli(["twitter", "thread", post_id])
|
|
540
|
+
notes = ["OpenCLI twitter namespace is used for X thread output."]
|
|
541
|
+
else:
|
|
542
|
+
username = _extract_x_username(item.url)
|
|
543
|
+
if not username:
|
|
544
|
+
return self._failed(backend, "invalid_input", "X post ID or username not found.")
|
|
545
|
+
limit = str(item.raw_meta.get("limit") or 20)
|
|
546
|
+
exit_code, stdout, stderr = _run_opencli(["twitter", "tweets", username, "--limit", limit])
|
|
547
|
+
notes = ["OpenCLI twitter namespace is used for X user posts output."]
|
|
548
|
+
return self._result_from_stdout(
|
|
549
|
+
backend,
|
|
550
|
+
exit_code,
|
|
551
|
+
stdout,
|
|
552
|
+
stderr,
|
|
553
|
+
source_level="native_text",
|
|
554
|
+
notes=notes,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def _xiaoyuzhou(
|
|
558
|
+
self,
|
|
559
|
+
item: ContentItem,
|
|
560
|
+
backend: str,
|
|
561
|
+
*,
|
|
562
|
+
allow_fallback: bool,
|
|
563
|
+
) -> TranscriptResult:
|
|
564
|
+
if backend == "podwise.transcript":
|
|
565
|
+
return self._xiaoyuzhou_podwise(item)
|
|
566
|
+
|
|
567
|
+
credentials_ok, credentials_code, credentials_summary = _check_xiaoyuzhou_credentials()
|
|
568
|
+
if not credentials_ok:
|
|
569
|
+
if allow_fallback:
|
|
570
|
+
return self._xiaoyuzhou_podwise(
|
|
571
|
+
item,
|
|
572
|
+
extra_notes=[
|
|
573
|
+
credentials_summary,
|
|
574
|
+
"Skipped OpenCLI xiaoyuzhou transcript before making an API request.",
|
|
575
|
+
],
|
|
576
|
+
)
|
|
577
|
+
return self._failed(backend, credentials_code, credentials_summary)
|
|
578
|
+
|
|
579
|
+
episode_id = _extract_episode_id(item.url)
|
|
580
|
+
with owned_temporary_directory(prefix="openmd-xiaoyuzhou-") as tmp_dir:
|
|
581
|
+
exit_code, stdout, stderr = _run_opencli(
|
|
582
|
+
[
|
|
583
|
+
"xiaoyuzhou",
|
|
584
|
+
"transcript",
|
|
585
|
+
episode_id,
|
|
586
|
+
"--output",
|
|
587
|
+
tmp_dir,
|
|
588
|
+
"--json",
|
|
589
|
+
"false",
|
|
590
|
+
"--text",
|
|
591
|
+
"true",
|
|
592
|
+
"-f",
|
|
593
|
+
"json",
|
|
594
|
+
]
|
|
595
|
+
)
|
|
596
|
+
if exit_code != 0:
|
|
597
|
+
if allow_fallback and _is_xiaoyuzhou_credentials_error(exit_code, stderr):
|
|
598
|
+
return self._xiaoyuzhou_podwise(
|
|
599
|
+
item,
|
|
600
|
+
extra_notes=[
|
|
601
|
+
"OpenCLI xiaoyuzhou transcript credentials were unavailable.",
|
|
602
|
+
],
|
|
603
|
+
)
|
|
604
|
+
return self._backend_failure(backend, exit_code, stderr)
|
|
605
|
+
text_files = sorted(Path(tmp_dir).rglob("transcript.txt"))
|
|
606
|
+
text = text_files[0].read_text(encoding="utf-8", errors="replace") if text_files else stdout
|
|
607
|
+
return TranscriptResult(
|
|
608
|
+
text=text,
|
|
609
|
+
status="verified" if text.strip() else "failed",
|
|
610
|
+
source_level="native_caption" if text.strip() else "partial_extraction",
|
|
611
|
+
backend=backend,
|
|
612
|
+
source_notes=["OpenCLI xiaoyuzhou transcript text output."],
|
|
613
|
+
error_code=None if text.strip() else "empty_result",
|
|
614
|
+
error_summary=None if text.strip() else "Xiaoyuzhou transcript output was empty.",
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
def _xiaoyuzhou_podwise(
|
|
618
|
+
self,
|
|
619
|
+
item: ContentItem,
|
|
620
|
+
*,
|
|
621
|
+
extra_notes: list[str] | None = None,
|
|
622
|
+
) -> TranscriptResult:
|
|
623
|
+
podwise_url = _extract_podwise_episode_url(item.url)
|
|
624
|
+
notes = list(extra_notes or [])
|
|
625
|
+
if podwise_url is None:
|
|
626
|
+
podwise_url_result = self._find_podwise_episode_url(item.url)
|
|
627
|
+
if isinstance(podwise_url_result, TranscriptResult):
|
|
628
|
+
return podwise_url_result
|
|
629
|
+
podwise_url = podwise_url_result
|
|
630
|
+
|
|
631
|
+
exit_code, stdout, stderr = _run_podwise(
|
|
632
|
+
["get", "transcript", podwise_url],
|
|
633
|
+
timeout=180,
|
|
634
|
+
)
|
|
635
|
+
if exit_code != 0:
|
|
636
|
+
if "not been processed yet" in stderr.lower():
|
|
637
|
+
return self._failed(
|
|
638
|
+
"podwise.transcript",
|
|
639
|
+
"podwise_episode_not_processed",
|
|
640
|
+
(
|
|
641
|
+
"Podwise has not processed this episode yet. "
|
|
642
|
+
"Openmd does not run `podwise process` automatically because it consumes account credits."
|
|
643
|
+
),
|
|
644
|
+
)
|
|
645
|
+
return self._backend_failure("podwise.transcript", exit_code, stderr)
|
|
646
|
+
if not stdout.strip():
|
|
647
|
+
return self._failed(
|
|
648
|
+
"podwise.transcript",
|
|
649
|
+
"empty_result",
|
|
650
|
+
"Podwise transcript command returned empty stdout.",
|
|
651
|
+
)
|
|
652
|
+
notes.extend(
|
|
653
|
+
[
|
|
654
|
+
"Podwise transcript fallback was used for Xiaoyuzhou.",
|
|
655
|
+
f"podwise_episode: {podwise_url}",
|
|
656
|
+
]
|
|
657
|
+
)
|
|
658
|
+
return TranscriptResult(
|
|
659
|
+
text=stdout,
|
|
660
|
+
status="verified",
|
|
661
|
+
source_level="third_party_extractor",
|
|
662
|
+
backend="podwise.transcript",
|
|
663
|
+
source_notes=notes,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
def _find_podwise_episode_url(self, xiaoyuzhou_url: str) -> str | TranscriptResult:
|
|
667
|
+
with owned_temporary_directory(prefix="openmd-xiaoyuzhou-web-") as tmp_dir:
|
|
668
|
+
exit_code, stdout, stderr = _run_opencli(
|
|
669
|
+
["web", "read", "--url", xiaoyuzhou_url, "--stdout"],
|
|
670
|
+
cwd=tmp_dir,
|
|
671
|
+
)
|
|
672
|
+
if exit_code != 0:
|
|
673
|
+
return self._backend_failure("opencli.web.read", exit_code, stderr)
|
|
674
|
+
title = (
|
|
675
|
+
_extract_markdown_title(stdout)
|
|
676
|
+
or _extract_table_title(stdout)
|
|
677
|
+
or _extract_first_markdown_title(tmp_dir)
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
if not title:
|
|
681
|
+
return self._failed(
|
|
682
|
+
"podwise.transcript",
|
|
683
|
+
"podwise_lookup_failed",
|
|
684
|
+
"Could not derive a Podwise search title from the Xiaoyuzhou page.",
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
exit_code, stdout, stderr = _run_podwise(
|
|
688
|
+
["search", "episode", title, "--limit", "5"],
|
|
689
|
+
timeout=120,
|
|
690
|
+
)
|
|
691
|
+
if exit_code != 0:
|
|
692
|
+
return self._backend_failure("podwise.search", exit_code, stderr)
|
|
693
|
+
podwise_url = _extract_podwise_episode_url(stdout)
|
|
694
|
+
if not podwise_url:
|
|
695
|
+
return self._failed(
|
|
696
|
+
"podwise.transcript",
|
|
697
|
+
"podwise_episode_not_found",
|
|
698
|
+
f"Podwise search did not return an episode URL for title: {title}",
|
|
699
|
+
)
|
|
700
|
+
return podwise_url
|
|
701
|
+
|
|
702
|
+
def _xiaohongshu(self, item: ContentItem, backend: str) -> TranscriptResult:
|
|
703
|
+
parsed = urlparse(item.url)
|
|
704
|
+
if "/user/profile/" in parsed.path:
|
|
705
|
+
limit = str(item.raw_meta.get("limit") or 20)
|
|
706
|
+
args = ["xiaohongshu", "user", item.url, "--limit", limit, "-f", "json"]
|
|
707
|
+
notes = ["OpenCLI xiaohongshu user output with explicit limit."]
|
|
708
|
+
else:
|
|
709
|
+
args = ["xiaohongshu", "note", item.url, "-f", "json"]
|
|
710
|
+
notes = ["OpenCLI xiaohongshu note output."]
|
|
711
|
+
exit_code, stdout, stderr = _run_opencli(args)
|
|
712
|
+
return self._result_from_stdout(
|
|
713
|
+
backend,
|
|
714
|
+
exit_code,
|
|
715
|
+
stdout,
|
|
716
|
+
stderr,
|
|
717
|
+
source_level="native_text",
|
|
718
|
+
notes=notes,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
def _result_from_stdout(
|
|
722
|
+
self,
|
|
723
|
+
backend: str,
|
|
724
|
+
exit_code: int,
|
|
725
|
+
stdout: str,
|
|
726
|
+
stderr: str,
|
|
727
|
+
*,
|
|
728
|
+
source_level: str,
|
|
729
|
+
notes: list[str],
|
|
730
|
+
) -> TranscriptResult:
|
|
731
|
+
if exit_code != 0:
|
|
732
|
+
return self._backend_failure(backend, exit_code, stderr)
|
|
733
|
+
if not stdout.strip():
|
|
734
|
+
return self._failed(backend, "empty_result", "OpenCLI command returned empty stdout.")
|
|
735
|
+
return TranscriptResult(
|
|
736
|
+
text=stdout,
|
|
737
|
+
status="verified",
|
|
738
|
+
source_level=source_level, # type: ignore[arg-type]
|
|
739
|
+
backend=backend,
|
|
740
|
+
source_notes=notes,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
def _backend_failure(self, backend: str, exit_code: int, stderr: str) -> TranscriptResult:
|
|
744
|
+
return TranscriptResult(
|
|
745
|
+
text="",
|
|
746
|
+
status="failed",
|
|
747
|
+
source_level="partial_extraction",
|
|
748
|
+
backend=backend,
|
|
749
|
+
source_notes=["backend command failed without persisting raw stderr."],
|
|
750
|
+
error_code=f"backend_exit_{exit_code}",
|
|
751
|
+
error_summary=sanitize_error_summary(stderr) or "OpenCLI command failed.",
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
def _failed(
|
|
755
|
+
self,
|
|
756
|
+
backend: str,
|
|
757
|
+
code: str,
|
|
758
|
+
error: object,
|
|
759
|
+
*,
|
|
760
|
+
notes: list[str] | None = None,
|
|
761
|
+
) -> TranscriptResult:
|
|
762
|
+
return TranscriptResult(
|
|
763
|
+
text="",
|
|
764
|
+
status="failed",
|
|
765
|
+
source_level="partial_extraction",
|
|
766
|
+
backend=backend,
|
|
767
|
+
source_notes=["adapter returned structured failure", *(notes or [])],
|
|
768
|
+
error_code=code,
|
|
769
|
+
error_summary=sanitize_error_summary(error),
|
|
770
|
+
)
|