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,1017 @@
|
|
|
1
|
+
"""Xiaoyuzhou runtime backend providers.
|
|
2
|
+
|
|
3
|
+
This module is loaded by ``openmd.backends.registry`` through the platform
|
|
4
|
+
extension naming convention. Providers here stay behind ``--enable-browser-
|
|
5
|
+
backends`` because both OpenCLI and Podwise rely on local browser/session or
|
|
6
|
+
authenticated CLI state. Importing the module does not read credentials, run
|
|
7
|
+
OpenCLI, run Podwise, download media, or submit paid processing jobs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
|
|
24
|
+
from openmd.core.opencli_output import load_opencli_json
|
|
25
|
+
from openmd.backends.base import (
|
|
26
|
+
BackendCapability,
|
|
27
|
+
BackendDescriptor,
|
|
28
|
+
BackendExtractRequest,
|
|
29
|
+
BackendHealth,
|
|
30
|
+
DiscoveryRequest,
|
|
31
|
+
DiscoveryResult,
|
|
32
|
+
)
|
|
33
|
+
from openmd.backends.executor import BackendProviderRegistry, DiscoveryProviderRegistry
|
|
34
|
+
from openmd.backends.registry import BackendRuntimeOptions
|
|
35
|
+
from openmd.core.models import ContentItem, TranscriptResult
|
|
36
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
37
|
+
from openmd.runtime.tempdirs import owned_temporary_directory
|
|
38
|
+
from openmd.runtime.interpreters import run_interruptibly
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
OPENCLI_XIAOYUZHOU_TRANSCRIPT_BACKEND = "opencli.xiaoyuzhou.transcript"
|
|
42
|
+
OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND = "opencli.xiaoyuzhou.podcast-episodes"
|
|
43
|
+
PODWISE_TRANSCRIPT_BACKEND = "podwise.transcript"
|
|
44
|
+
OPENCLI_WEB_READ_BACKEND = "opencli.web.read"
|
|
45
|
+
|
|
46
|
+
XIAOYUZHOU_CREDENTIALS_PATH = Path.home() / ".opencli" / "xiaoyuzhou.json"
|
|
47
|
+
XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS = frozenset(
|
|
48
|
+
{"uuid", "android_id", "oaid", "vaid", "aaid"}
|
|
49
|
+
)
|
|
50
|
+
XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS = frozenset({"idfa", "idfv"})
|
|
51
|
+
XIAOYUZHOU_IOS_REQUEST_METADATA_GROUPS = (
|
|
52
|
+
("user-agent",),
|
|
53
|
+
("os",),
|
|
54
|
+
("os-version",),
|
|
55
|
+
("manufacturer",),
|
|
56
|
+
("model",),
|
|
57
|
+
("market",),
|
|
58
|
+
("app-version",),
|
|
59
|
+
("app-buildno",),
|
|
60
|
+
("bundleid", "applicationid"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
PODWISE_EPISODE_RE = re.compile(r"https://podwise\.ai/dashboard/episodes/\d+")
|
|
64
|
+
DEFAULT_OPENCLI_TIMEOUT_SECONDS = 180
|
|
65
|
+
DEFAULT_PODWISE_SEARCH_TIMEOUT_SECONDS = 120
|
|
66
|
+
DEFAULT_PODWISE_GET_TIMEOUT_SECONDS = 180
|
|
67
|
+
DEFAULT_MIN_TRANSCRIPT_CHARS = 1
|
|
68
|
+
|
|
69
|
+
CommandRunner = Callable[..., tuple[int, str, str]]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class CredentialShapeCheck:
|
|
74
|
+
ok: bool
|
|
75
|
+
code: str
|
|
76
|
+
summary: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def register_backend_providers(
|
|
80
|
+
registry: BackendProviderRegistry,
|
|
81
|
+
options: BackendRuntimeOptions,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Register Xiaoyuzhou extraction providers behind explicit runtime gates."""
|
|
84
|
+
requested = options.backend_override
|
|
85
|
+
if not options.enable_browser_backends:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if requested in {None, OPENCLI_XIAOYUZHOU_TRANSCRIPT_BACKEND}:
|
|
89
|
+
registry.register(
|
|
90
|
+
OPENCLI_XIAOYUZHOU_TRANSCRIPT_BACKEND,
|
|
91
|
+
OpenCliXiaoyuzhouTranscriptProvider(),
|
|
92
|
+
)
|
|
93
|
+
if requested in {None, PODWISE_TRANSCRIPT_BACKEND}:
|
|
94
|
+
registry.register(PODWISE_TRANSCRIPT_BACKEND, PodwiseTranscriptProvider())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def register_discovery_providers(
|
|
98
|
+
registry: DiscoveryProviderRegistry,
|
|
99
|
+
options: BackendRuntimeOptions,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Register podcast episode discovery behind explicit browser/backend gates."""
|
|
102
|
+
requested = options.requested_discovery_provider
|
|
103
|
+
if not options.enable_browser_backends:
|
|
104
|
+
return
|
|
105
|
+
if requested in {None, OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND}:
|
|
106
|
+
registry.register(
|
|
107
|
+
OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND,
|
|
108
|
+
OpenCliXiaoyuzhouPodcastEpisodesProvider(),
|
|
109
|
+
platforms=("xiaoyuzhou",),
|
|
110
|
+
default_for_platforms=requested is None,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_opencli_xiaoyuzhou_transcript_args(episode_id: str, *, output_dir: str) -> list[str]:
|
|
115
|
+
return [
|
|
116
|
+
"opencli",
|
|
117
|
+
"xiaoyuzhou",
|
|
118
|
+
"transcript",
|
|
119
|
+
episode_id,
|
|
120
|
+
"--output",
|
|
121
|
+
output_dir,
|
|
122
|
+
"--json",
|
|
123
|
+
"true",
|
|
124
|
+
"--text",
|
|
125
|
+
"true",
|
|
126
|
+
"-f",
|
|
127
|
+
"json",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_opencli_xiaoyuzhou_podcast_episodes_args(
|
|
132
|
+
podcast_id: str,
|
|
133
|
+
*,
|
|
134
|
+
limit: int | None,
|
|
135
|
+
) -> list[str]:
|
|
136
|
+
command = [
|
|
137
|
+
"opencli",
|
|
138
|
+
"xiaoyuzhou",
|
|
139
|
+
"podcast-episodes",
|
|
140
|
+
podcast_id,
|
|
141
|
+
"-f",
|
|
142
|
+
"json",
|
|
143
|
+
]
|
|
144
|
+
if limit is not None:
|
|
145
|
+
command.extend(["--limit", str(max(0, limit))])
|
|
146
|
+
return command
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_opencli_web_read_args(url: str) -> list[str]:
|
|
150
|
+
return ["opencli", "web", "read", "--url", url, "--stdout"]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_podwise_search_args(title: str) -> list[str]:
|
|
154
|
+
return ["podwise", "search", "episode", title, "--limit", "5"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_podwise_get_transcript_args(podwise_episode_url: str) -> list[str]:
|
|
158
|
+
return ["podwise", "get", "transcript", podwise_episode_url]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class OpenCliXiaoyuzhouTranscriptProvider:
|
|
162
|
+
"""Fetch existing Xiaoyuzhou transcripts through OpenCLI credentials."""
|
|
163
|
+
|
|
164
|
+
name = OPENCLI_XIAOYUZHOU_TRANSCRIPT_BACKEND
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
runner: CommandRunner | None = None,
|
|
170
|
+
credential_path: Path | None = None,
|
|
171
|
+
timeout: int = DEFAULT_OPENCLI_TIMEOUT_SECONDS,
|
|
172
|
+
min_transcript_chars: int = DEFAULT_MIN_TRANSCRIPT_CHARS,
|
|
173
|
+
) -> None:
|
|
174
|
+
self._runner = runner or _run_command
|
|
175
|
+
self.credential_path = credential_path or XIAOYUZHOU_CREDENTIALS_PATH
|
|
176
|
+
self.timeout = timeout
|
|
177
|
+
self.min_transcript_chars = max(0, min_transcript_chars)
|
|
178
|
+
|
|
179
|
+
def descriptor(self) -> BackendDescriptor:
|
|
180
|
+
capability = BackendCapability(
|
|
181
|
+
platforms=("xiaoyuzhou",),
|
|
182
|
+
source_types=("audio",),
|
|
183
|
+
input_types=("single", "batch", "collection"),
|
|
184
|
+
source_levels=("native_caption", "third_party_extractor", "partial_extraction"),
|
|
185
|
+
requires_network=True,
|
|
186
|
+
requires_credentials=True,
|
|
187
|
+
optional_config=("opencli", "~/.opencli/xiaoyuzhou.json"),
|
|
188
|
+
)
|
|
189
|
+
return BackendDescriptor(
|
|
190
|
+
name=self.name,
|
|
191
|
+
layer="L3 browser/session fallback",
|
|
192
|
+
capabilities=(capability,),
|
|
193
|
+
description="Fetch Xiaoyuzhou transcript text through OpenCLI using local credentials.",
|
|
194
|
+
provider="opencli.xiaoyuzhou",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def health(self) -> BackendHealth:
|
|
198
|
+
if self._runner is not _run_command:
|
|
199
|
+
return BackendHealth(ok=True, dependencies={"opencli": "injected"})
|
|
200
|
+
if not _executable_available("opencli"):
|
|
201
|
+
return BackendHealth(
|
|
202
|
+
ok=False,
|
|
203
|
+
dependencies={"opencli": "missing"},
|
|
204
|
+
error_code="backend_dependency_missing",
|
|
205
|
+
error_summary="opencli executable is not available on PATH.",
|
|
206
|
+
)
|
|
207
|
+
return BackendHealth(ok=True, dependencies={"opencli": "available"})
|
|
208
|
+
|
|
209
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
210
|
+
item = request.item
|
|
211
|
+
if item.platform != "xiaoyuzhou":
|
|
212
|
+
return _failed(
|
|
213
|
+
self.name,
|
|
214
|
+
"unsupported_platform",
|
|
215
|
+
f"Xiaoyuzhou provider only supports xiaoyuzhou, got {item.platform}.",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
credential_check = check_xiaoyuzhou_credentials(self.credential_path)
|
|
219
|
+
if not credential_check.ok:
|
|
220
|
+
return _failed(
|
|
221
|
+
self.name,
|
|
222
|
+
credential_check.code,
|
|
223
|
+
credential_check.summary,
|
|
224
|
+
notes=[
|
|
225
|
+
"Skipped OpenCLI xiaoyuzhou transcript before making an API request.",
|
|
226
|
+
"No credential values or raw command streams were persisted by Openmd.",
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
episode_id = extract_xiaoyuzhou_episode_id(item)
|
|
231
|
+
if not episode_id:
|
|
232
|
+
return _failed(self.name, "invalid_input", "Could not extract a Xiaoyuzhou episode id.")
|
|
233
|
+
|
|
234
|
+
with owned_temporary_directory(prefix="openmd-xiaoyuzhou-") as tmp_dir:
|
|
235
|
+
command = build_opencli_xiaoyuzhou_transcript_args(episode_id, output_dir=tmp_dir)
|
|
236
|
+
exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
|
|
237
|
+
if exit_code != 0:
|
|
238
|
+
return _failed(
|
|
239
|
+
self.name,
|
|
240
|
+
classify_opencli_xiaoyuzhou_failure(exit_code, stderr or stdout),
|
|
241
|
+
stderr or stdout or f"OpenCLI exited with code {exit_code}.",
|
|
242
|
+
metadata={"exit_code": exit_code, "episode_id": episode_id},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
text = _read_opencli_transcript_text(Path(tmp_dir), stdout).strip()
|
|
246
|
+
if len("".join(text.split())) < self.min_transcript_chars:
|
|
247
|
+
return _failed(
|
|
248
|
+
self.name,
|
|
249
|
+
"empty_result",
|
|
250
|
+
"OpenCLI xiaoyuzhou transcript produced no usable text.",
|
|
251
|
+
metadata={"episode_id": episode_id},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
author = item.author or _reported_podcast(stdout)
|
|
255
|
+
if not author:
|
|
256
|
+
# Transcript output omits podcast metadata in current OpenCLI.
|
|
257
|
+
# Public episode metadata uses no browser and never starts media/ASR.
|
|
258
|
+
try:
|
|
259
|
+
code, episode_stdout, _ = self._runner(
|
|
260
|
+
["opencli", "xiaoyuzhou", "episode", episode_id, "-f", "json"],
|
|
261
|
+
timeout=min(self.timeout, 30),
|
|
262
|
+
)
|
|
263
|
+
if code == 0:
|
|
264
|
+
author = _reported_podcast(episode_stdout)
|
|
265
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
266
|
+
pass # Metadata unavailability must not discard a usable transcript.
|
|
267
|
+
|
|
268
|
+
return TranscriptResult(
|
|
269
|
+
text=text.rstrip() + "\n",
|
|
270
|
+
status="verified",
|
|
271
|
+
source_level="native_caption",
|
|
272
|
+
backend=self.name,
|
|
273
|
+
title=_reported_title(stdout) or item.title,
|
|
274
|
+
author=author,
|
|
275
|
+
published_at=item.published_at,
|
|
276
|
+
source_notes=[
|
|
277
|
+
"OpenCLI xiaoyuzhou transcript returned existing transcript text.",
|
|
278
|
+
"Credential shape was checked locally before the OpenCLI request.",
|
|
279
|
+
"Generated text was read from a temporary directory; raw command streams were not persisted.",
|
|
280
|
+
"No media download, ASR, or Podwise paid processing action was attempted.",
|
|
281
|
+
],
|
|
282
|
+
metadata={"episode_id": episode_id, "strategy": "opencli_transcript"},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class PodwiseTranscriptProvider:
|
|
287
|
+
"""Fetch already-processed Podwise transcripts for Xiaoyuzhou episodes."""
|
|
288
|
+
|
|
289
|
+
name = PODWISE_TRANSCRIPT_BACKEND
|
|
290
|
+
|
|
291
|
+
def __init__(
|
|
292
|
+
self,
|
|
293
|
+
*,
|
|
294
|
+
podwise_runner: CommandRunner | None = None,
|
|
295
|
+
opencli_runner: CommandRunner | None = None,
|
|
296
|
+
search_timeout: int = DEFAULT_PODWISE_SEARCH_TIMEOUT_SECONDS,
|
|
297
|
+
get_timeout: int = DEFAULT_PODWISE_GET_TIMEOUT_SECONDS,
|
|
298
|
+
web_timeout: int = DEFAULT_OPENCLI_TIMEOUT_SECONDS,
|
|
299
|
+
min_transcript_chars: int = DEFAULT_MIN_TRANSCRIPT_CHARS,
|
|
300
|
+
) -> None:
|
|
301
|
+
self._podwise_runner = podwise_runner or _run_command
|
|
302
|
+
self._opencli_runner = opencli_runner or _run_command
|
|
303
|
+
self.search_timeout = search_timeout
|
|
304
|
+
self.get_timeout = get_timeout
|
|
305
|
+
self.web_timeout = web_timeout
|
|
306
|
+
self.min_transcript_chars = max(0, min_transcript_chars)
|
|
307
|
+
|
|
308
|
+
def descriptor(self) -> BackendDescriptor:
|
|
309
|
+
capability = BackendCapability(
|
|
310
|
+
platforms=("xiaoyuzhou",),
|
|
311
|
+
source_types=("audio",),
|
|
312
|
+
input_types=("single", "batch", "collection"),
|
|
313
|
+
source_levels=("third_party_extractor", "partial_extraction"),
|
|
314
|
+
requires_network=True,
|
|
315
|
+
requires_credentials=True,
|
|
316
|
+
optional_config=("podwise", "opencli web read for title lookup"),
|
|
317
|
+
)
|
|
318
|
+
return BackendDescriptor(
|
|
319
|
+
name=self.name,
|
|
320
|
+
layer="L3 third-party transcript fallback",
|
|
321
|
+
capabilities=(capability,),
|
|
322
|
+
description="Fetch already-processed Podwise transcripts; never submits Podwise processing jobs.",
|
|
323
|
+
provider="podwise",
|
|
324
|
+
is_fallback=True,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def health(self) -> BackendHealth:
|
|
328
|
+
if self._podwise_runner is not _run_command or self._opencli_runner is not _run_command:
|
|
329
|
+
return BackendHealth(ok=True, dependencies={"podwise": "injected", "opencli": "injected"})
|
|
330
|
+
dependencies = {
|
|
331
|
+
"podwise": "available" if _executable_available("podwise") else "missing",
|
|
332
|
+
"opencli": "available" if _executable_available("opencli") else "missing",
|
|
333
|
+
}
|
|
334
|
+
ok = dependencies["podwise"] == "available"
|
|
335
|
+
return BackendHealth(
|
|
336
|
+
ok=ok,
|
|
337
|
+
dependencies=dependencies,
|
|
338
|
+
error_code=None if ok else "backend_dependency_missing",
|
|
339
|
+
error_summary=None if ok else "podwise executable is not available on PATH.",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
343
|
+
item = request.item
|
|
344
|
+
if item.platform != "xiaoyuzhou":
|
|
345
|
+
return _failed(
|
|
346
|
+
self.name,
|
|
347
|
+
"unsupported_platform",
|
|
348
|
+
f"Podwise Xiaoyuzhou fallback only supports xiaoyuzhou, got {item.platform}.",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
podwise_episode_url = extract_podwise_episode_url(item.url)
|
|
352
|
+
notes: list[str] = []
|
|
353
|
+
if podwise_episode_url is None:
|
|
354
|
+
lookup_result = self._find_podwise_episode_url(item.url)
|
|
355
|
+
if isinstance(lookup_result, TranscriptResult):
|
|
356
|
+
return lookup_result
|
|
357
|
+
podwise_episode_url = lookup_result
|
|
358
|
+
notes.append("Podwise episode URL was resolved through OpenCLI web read plus Podwise search.")
|
|
359
|
+
else:
|
|
360
|
+
notes.append("Input already contained a Podwise episode URL.")
|
|
361
|
+
|
|
362
|
+
command = build_podwise_get_transcript_args(podwise_episode_url)
|
|
363
|
+
exit_code, stdout, stderr = self._podwise_runner(command, timeout=self.get_timeout)
|
|
364
|
+
if exit_code != 0:
|
|
365
|
+
if _is_podwise_not_processed(stderr or stdout):
|
|
366
|
+
return _failed(
|
|
367
|
+
self.name,
|
|
368
|
+
"podwise_episode_not_processed",
|
|
369
|
+
(
|
|
370
|
+
"Podwise has not processed this episode yet. Openmd does not run "
|
|
371
|
+
"`podwise process` automatically because it can consume account credits."
|
|
372
|
+
),
|
|
373
|
+
notes=["Only Podwise search/get are allowed in this fallback path."],
|
|
374
|
+
metadata={"podwise_episode_url": podwise_episode_url},
|
|
375
|
+
)
|
|
376
|
+
return _failed(
|
|
377
|
+
self.name,
|
|
378
|
+
f"backend_exit_{exit_code}",
|
|
379
|
+
stderr or stdout or f"Podwise exited with code {exit_code}.",
|
|
380
|
+
metadata={"exit_code": exit_code, "podwise_episode_url": podwise_episode_url},
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
text = stdout.strip()
|
|
384
|
+
if len("".join(text.split())) < self.min_transcript_chars:
|
|
385
|
+
return _failed(
|
|
386
|
+
self.name,
|
|
387
|
+
"empty_result",
|
|
388
|
+
"Podwise transcript command returned no usable text.",
|
|
389
|
+
metadata={"podwise_episode_url": podwise_episode_url},
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return TranscriptResult(
|
|
393
|
+
text=text.rstrip() + "\n",
|
|
394
|
+
status="verified",
|
|
395
|
+
source_level="third_party_extractor",
|
|
396
|
+
backend=self.name,
|
|
397
|
+
title=item.title,
|
|
398
|
+
author=item.author,
|
|
399
|
+
published_at=item.published_at,
|
|
400
|
+
source_notes=[
|
|
401
|
+
*notes,
|
|
402
|
+
"Podwise transcript fallback was used for Xiaoyuzhou.",
|
|
403
|
+
"Only Podwise search/get were used; Podwise paid processing was not run.",
|
|
404
|
+
"Raw command streams were not persisted by Openmd.",
|
|
405
|
+
],
|
|
406
|
+
metadata={
|
|
407
|
+
"podwise_episode_url": podwise_episode_url,
|
|
408
|
+
"strategy": "podwise_already_processed_transcript",
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def _find_podwise_episode_url(self, xiaoyuzhou_url: str) -> str | TranscriptResult:
|
|
413
|
+
with owned_temporary_directory(prefix="openmd-xiaoyuzhou-web-") as tmp_dir:
|
|
414
|
+
exit_code, stdout, stderr = self._opencli_runner(
|
|
415
|
+
build_opencli_web_read_args(xiaoyuzhou_url),
|
|
416
|
+
timeout=self.web_timeout,
|
|
417
|
+
cwd=tmp_dir,
|
|
418
|
+
)
|
|
419
|
+
if exit_code != 0:
|
|
420
|
+
return _failed(
|
|
421
|
+
self.name,
|
|
422
|
+
"podwise_lookup_web_read_failed",
|
|
423
|
+
stderr or stdout or f"OpenCLI web read exited with code {exit_code}.",
|
|
424
|
+
metadata={"exit_code": exit_code},
|
|
425
|
+
)
|
|
426
|
+
title = (
|
|
427
|
+
extract_markdown_title(stdout)
|
|
428
|
+
or extract_table_title(stdout)
|
|
429
|
+
or extract_first_markdown_title(Path(tmp_dir))
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if not title:
|
|
433
|
+
return _failed(
|
|
434
|
+
self.name,
|
|
435
|
+
"podwise_lookup_failed",
|
|
436
|
+
"Could not derive a Podwise search title from the Xiaoyuzhou page.",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
exit_code, stdout, stderr = self._podwise_runner(
|
|
440
|
+
build_podwise_search_args(title),
|
|
441
|
+
timeout=self.search_timeout,
|
|
442
|
+
)
|
|
443
|
+
if exit_code != 0:
|
|
444
|
+
return _failed(
|
|
445
|
+
self.name,
|
|
446
|
+
f"podwise_search_exit_{exit_code}",
|
|
447
|
+
stderr or stdout or f"Podwise search exited with code {exit_code}.",
|
|
448
|
+
metadata={"exit_code": exit_code},
|
|
449
|
+
)
|
|
450
|
+
podwise_episode_url = extract_podwise_episode_url(stdout)
|
|
451
|
+
if not podwise_episode_url:
|
|
452
|
+
return _failed(
|
|
453
|
+
self.name,
|
|
454
|
+
"podwise_episode_not_found",
|
|
455
|
+
f"Podwise search did not return an episode URL for the derived title: {title}",
|
|
456
|
+
)
|
|
457
|
+
return podwise_episode_url
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class OpenCliXiaoyuzhouPodcastEpisodesProvider:
|
|
461
|
+
"""Discover Xiaoyuzhou podcast episodes through OpenCLI."""
|
|
462
|
+
|
|
463
|
+
name = OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND
|
|
464
|
+
|
|
465
|
+
def __init__(
|
|
466
|
+
self,
|
|
467
|
+
*,
|
|
468
|
+
runner: CommandRunner | None = None,
|
|
469
|
+
timeout: int = DEFAULT_OPENCLI_TIMEOUT_SECONDS,
|
|
470
|
+
) -> None:
|
|
471
|
+
self._runner = runner or _run_command
|
|
472
|
+
self.timeout = timeout
|
|
473
|
+
|
|
474
|
+
def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
|
|
475
|
+
if request.platform != "xiaoyuzhou":
|
|
476
|
+
return DiscoveryResult.failure(
|
|
477
|
+
provider=self.name,
|
|
478
|
+
error_code="unsupported_platform",
|
|
479
|
+
error_summary=f"Xiaoyuzhou discovery only supports xiaoyuzhou, got {request.platform}.",
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
podcast_id = extract_xiaoyuzhou_podcast_id(request.input_url)
|
|
483
|
+
if not podcast_id:
|
|
484
|
+
return DiscoveryResult.failure(
|
|
485
|
+
provider=self.name,
|
|
486
|
+
error_code="invalid_input",
|
|
487
|
+
error_summary="Could not extract a Xiaoyuzhou podcast id.",
|
|
488
|
+
metadata={"platform": request.platform},
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
command = build_opencli_xiaoyuzhou_podcast_episodes_args(
|
|
492
|
+
podcast_id,
|
|
493
|
+
limit=request.limit,
|
|
494
|
+
)
|
|
495
|
+
exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
|
|
496
|
+
if exit_code != 0:
|
|
497
|
+
return DiscoveryResult.failure(
|
|
498
|
+
provider=self.name,
|
|
499
|
+
error_code=f"backend_exit_{exit_code}",
|
|
500
|
+
error_summary=_sanitize_error(stderr or stdout or f"OpenCLI exited with code {exit_code}.")
|
|
501
|
+
or "OpenCLI xiaoyuzhou podcast-episodes failed.",
|
|
502
|
+
source_notes=[
|
|
503
|
+
"OpenCLI xiaoyuzhou podcast-episodes failed before item extraction.",
|
|
504
|
+
"Raw command streams were not persisted by Openmd.",
|
|
505
|
+
],
|
|
506
|
+
metadata={"exit_code": exit_code, "podcast_id": podcast_id},
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
items = parse_xiaoyuzhou_episode_items(stdout, podcast_id=podcast_id)
|
|
510
|
+
if request.limit is not None and not request.allow_all:
|
|
511
|
+
items = items[: max(0, request.limit)]
|
|
512
|
+
if not items:
|
|
513
|
+
return DiscoveryResult.failure(
|
|
514
|
+
provider=self.name,
|
|
515
|
+
error_code="empty_discovery_result",
|
|
516
|
+
error_summary="OpenCLI xiaoyuzhou podcast-episodes returned no episodes.",
|
|
517
|
+
metadata={"podcast_id": podcast_id},
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
return DiscoveryResult.success(
|
|
521
|
+
provider=self.name,
|
|
522
|
+
items=items,
|
|
523
|
+
source_notes=[
|
|
524
|
+
"OpenCLI xiaoyuzhou podcast-episodes returned episode manifest items.",
|
|
525
|
+
"Manifest discovery does not download media, run ASR, or fetch transcripts.",
|
|
526
|
+
],
|
|
527
|
+
metadata={
|
|
528
|
+
"podcast_id": podcast_id,
|
|
529
|
+
"normalized_url": f"https://www.xiaoyuzhoufm.com/podcast/{podcast_id}",
|
|
530
|
+
},
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def check_xiaoyuzhou_credentials(path: Path = XIAOYUZHOU_CREDENTIALS_PATH) -> CredentialShapeCheck:
|
|
535
|
+
"""Validate a local Xiaoyuzhou credential profile without exposing values."""
|
|
536
|
+
if not path.exists():
|
|
537
|
+
return CredentialShapeCheck(
|
|
538
|
+
False,
|
|
539
|
+
"xiaoyuzhou_credentials_missing",
|
|
540
|
+
"Missing Xiaoyuzhou credential file at ~/.opencli/xiaoyuzhou.json.",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
credentials = json.loads(path.read_text(encoding="utf-8"))
|
|
545
|
+
except (OSError, json.JSONDecodeError):
|
|
546
|
+
return CredentialShapeCheck(
|
|
547
|
+
False,
|
|
548
|
+
"xiaoyuzhou_credentials_invalid_json",
|
|
549
|
+
"Xiaoyuzhou credential file is not valid JSON.",
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
for field in ("access_token", "refresh_token", "device_id"):
|
|
553
|
+
value = credentials.get(field)
|
|
554
|
+
if not isinstance(value, str) or not value.strip():
|
|
555
|
+
return CredentialShapeCheck(
|
|
556
|
+
False,
|
|
557
|
+
"xiaoyuzhou_credentials_incomplete",
|
|
558
|
+
"Xiaoyuzhou credential profile is missing required authentication/device fields.",
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
device_id = credentials["device_id"].strip()
|
|
562
|
+
device_properties = credentials.get("device_properties")
|
|
563
|
+
if not isinstance(device_properties, str) or not device_properties.strip():
|
|
564
|
+
return CredentialShapeCheck(
|
|
565
|
+
False,
|
|
566
|
+
"xiaoyuzhou_device_properties_missing",
|
|
567
|
+
(
|
|
568
|
+
"Xiaoyuzhou device_properties is required; capture the real "
|
|
569
|
+
"device properties value and ensure its uuid matches device_id."
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
parsed_properties = json.loads(device_properties)
|
|
575
|
+
except json.JSONDecodeError:
|
|
576
|
+
return CredentialShapeCheck(
|
|
577
|
+
False,
|
|
578
|
+
"xiaoyuzhou_device_properties_invalid_json",
|
|
579
|
+
"Xiaoyuzhou device_properties must be a JSON string.",
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if not isinstance(parsed_properties, dict):
|
|
583
|
+
return CredentialShapeCheck(
|
|
584
|
+
False,
|
|
585
|
+
"xiaoyuzhou_device_properties_invalid_json",
|
|
586
|
+
"Xiaoyuzhou device_properties must decode to a JSON object.",
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
property_keys = set(parsed_properties.keys())
|
|
590
|
+
if XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS <= property_keys:
|
|
591
|
+
if parsed_properties.get("uuid") != device_id:
|
|
592
|
+
return CredentialShapeCheck(
|
|
593
|
+
False,
|
|
594
|
+
"xiaoyuzhou_device_mismatch",
|
|
595
|
+
"Xiaoyuzhou Android device_properties.uuid must match device_id.",
|
|
596
|
+
)
|
|
597
|
+
return CredentialShapeCheck(
|
|
598
|
+
True,
|
|
599
|
+
"ok",
|
|
600
|
+
"Xiaoyuzhou credential profile passed local shape checks.",
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
if XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS <= property_keys:
|
|
604
|
+
request_metadata = credentials.get("request_headers") or credentials.get("headers")
|
|
605
|
+
if not isinstance(request_metadata, dict) or not request_metadata:
|
|
606
|
+
return CredentialShapeCheck(
|
|
607
|
+
True,
|
|
608
|
+
"ok",
|
|
609
|
+
(
|
|
610
|
+
"Xiaoyuzhou credential profile passed local shape checks "
|
|
611
|
+
"for an OpenCLI-compatible iOS device profile."
|
|
612
|
+
),
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
missing_groups = [
|
|
616
|
+
group
|
|
617
|
+
for group in XIAOYUZHOU_IOS_REQUEST_METADATA_GROUPS
|
|
618
|
+
if _metadata_lookup(request_metadata, group) is None
|
|
619
|
+
]
|
|
620
|
+
if missing_groups:
|
|
621
|
+
return CredentialShapeCheck(
|
|
622
|
+
False,
|
|
623
|
+
"xiaoyuzhou_request_metadata_incomplete",
|
|
624
|
+
"Xiaoyuzhou iOS profile is missing required same-device request metadata.",
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
metadata_device_id = _metadata_lookup(request_metadata, ("x-jike-device-id",))
|
|
628
|
+
if metadata_device_id is not None and metadata_device_id != device_id:
|
|
629
|
+
return CredentialShapeCheck(
|
|
630
|
+
False,
|
|
631
|
+
"xiaoyuzhou_device_mismatch",
|
|
632
|
+
"Xiaoyuzhou same-device request metadata must match device_id.",
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
return CredentialShapeCheck(
|
|
636
|
+
True,
|
|
637
|
+
"ok",
|
|
638
|
+
"Xiaoyuzhou credential profile passed local shape checks.",
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
android_missing = sorted(XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS - property_keys)
|
|
642
|
+
ios_missing = sorted(XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS - property_keys)
|
|
643
|
+
if android_missing and ios_missing:
|
|
644
|
+
return CredentialShapeCheck(
|
|
645
|
+
False,
|
|
646
|
+
"xiaoyuzhou_device_properties_incomplete",
|
|
647
|
+
(
|
|
648
|
+
"Xiaoyuzhou device_properties must look like either Android "
|
|
649
|
+
f"({', '.join(sorted(XIAOYUZHOU_ANDROID_DEVICE_PROPERTY_KEYS))}) "
|
|
650
|
+
f"or iOS ({', '.join(sorted(XIAOYUZHOU_IOS_DEVICE_PROPERTY_KEYS))})."
|
|
651
|
+
),
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
return CredentialShapeCheck(
|
|
655
|
+
True,
|
|
656
|
+
"ok",
|
|
657
|
+
"Xiaoyuzhou credential profile passed local shape checks.",
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def extract_xiaoyuzhou_episode_id(item: ContentItem) -> str | None:
|
|
662
|
+
if item.id:
|
|
663
|
+
return item.id.strip()
|
|
664
|
+
parsed = urlparse(item.url)
|
|
665
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
666
|
+
if len(parts) >= 2 and parts[0] == "episode":
|
|
667
|
+
return parts[1]
|
|
668
|
+
if parts:
|
|
669
|
+
return parts[-1]
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def extract_xiaoyuzhou_podcast_id(input_value: str) -> str | None:
|
|
674
|
+
value = input_value.strip()
|
|
675
|
+
if not value:
|
|
676
|
+
return None
|
|
677
|
+
parsed = urlparse(value if "://" in value else f"https://www.xiaoyuzhoufm.com/podcast/{value}")
|
|
678
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
679
|
+
if len(parts) >= 2 and parts[0] == "podcast":
|
|
680
|
+
return parts[1]
|
|
681
|
+
if parts and parsed.netloc.endswith("xiaoyuzhoufm.com"):
|
|
682
|
+
return parts[-1]
|
|
683
|
+
if "://" not in value and "/" not in value:
|
|
684
|
+
return value
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def parse_xiaoyuzhou_episode_items(stdout: str, *, podcast_id: str) -> list[ContentItem]:
|
|
689
|
+
try:
|
|
690
|
+
payload = load_opencli_json(stdout.strip())
|
|
691
|
+
except json.JSONDecodeError:
|
|
692
|
+
return []
|
|
693
|
+
records = _episode_records_from_payload(payload)
|
|
694
|
+
items: list[ContentItem] = []
|
|
695
|
+
for record in records:
|
|
696
|
+
item = _episode_item_from_record(record, podcast_id=podcast_id)
|
|
697
|
+
if item is not None:
|
|
698
|
+
items.append(item)
|
|
699
|
+
return items
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _episode_records_from_payload(payload: Any) -> list[dict[str, Any]]:
|
|
703
|
+
if isinstance(payload, list):
|
|
704
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
705
|
+
if not isinstance(payload, dict):
|
|
706
|
+
return []
|
|
707
|
+
for key in ("episodes", "items", "data", "list", "rows"):
|
|
708
|
+
value = payload.get(key)
|
|
709
|
+
if isinstance(value, list):
|
|
710
|
+
return [item for item in value if isinstance(item, dict)]
|
|
711
|
+
if isinstance(value, dict):
|
|
712
|
+
nested = _episode_records_from_payload(value)
|
|
713
|
+
if nested:
|
|
714
|
+
return nested
|
|
715
|
+
return []
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _episode_item_from_record(record: dict[str, Any], *, podcast_id: str) -> ContentItem | None:
|
|
719
|
+
episode_id = _first_text(record, "eid", "episode_id", "id")
|
|
720
|
+
url = _first_text(record, "url", "episode_url", "link")
|
|
721
|
+
if not episode_id and url:
|
|
722
|
+
episode_id = extract_xiaoyuzhou_episode_id(
|
|
723
|
+
ContentItem(platform="xiaoyuzhou", input_type="single", url=url)
|
|
724
|
+
)
|
|
725
|
+
if not episode_id:
|
|
726
|
+
return None
|
|
727
|
+
episode_url = url or f"https://www.xiaoyuzhoufm.com/episode/{episode_id}"
|
|
728
|
+
return ContentItem(
|
|
729
|
+
platform="xiaoyuzhou",
|
|
730
|
+
input_type="collection",
|
|
731
|
+
url=episode_url,
|
|
732
|
+
id=episode_id,
|
|
733
|
+
title=_first_text(record, "title", "name"),
|
|
734
|
+
author=_first_text(record, "podcast_title", "author"),
|
|
735
|
+
published_at=_first_text(record, "published_at", "date", "pub_date"),
|
|
736
|
+
source_type="audio",
|
|
737
|
+
raw_meta={
|
|
738
|
+
"profile": "media",
|
|
739
|
+
"podcast_id": podcast_id,
|
|
740
|
+
"duration": _first_text(record, "duration"),
|
|
741
|
+
"plays": _first_text(record, "plays", "play_count"),
|
|
742
|
+
"caption_status": "unknown",
|
|
743
|
+
"discovery_provider": OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND,
|
|
744
|
+
},
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _first_text(record: dict[str, Any], *keys: str) -> str | None:
|
|
749
|
+
for key in keys:
|
|
750
|
+
value = record.get(key)
|
|
751
|
+
if value is None:
|
|
752
|
+
continue
|
|
753
|
+
text = str(value).strip()
|
|
754
|
+
if text:
|
|
755
|
+
return text
|
|
756
|
+
return None
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def extract_podwise_episode_url(text: str) -> str | None:
|
|
760
|
+
match = PODWISE_EPISODE_RE.search(text)
|
|
761
|
+
return match.group(0) if match else None
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def extract_markdown_title(markdown: str) -> str | None:
|
|
765
|
+
for line in markdown.splitlines():
|
|
766
|
+
text = line.strip()
|
|
767
|
+
if text.startswith("# "):
|
|
768
|
+
return text.removeprefix("# ").strip() or None
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def extract_table_title(stdout: str) -> str | None:
|
|
773
|
+
for line in stdout.splitlines():
|
|
774
|
+
if not line.startswith("|"):
|
|
775
|
+
continue
|
|
776
|
+
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
777
|
+
if not cells:
|
|
778
|
+
continue
|
|
779
|
+
title = cells[0]
|
|
780
|
+
if title and title.lower() != "title" and set(title) != {"-"}:
|
|
781
|
+
return title
|
|
782
|
+
return None
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def extract_first_markdown_title(directory: Path) -> str | None:
|
|
786
|
+
for markdown_file in sorted(directory.rglob("*.md")):
|
|
787
|
+
title = extract_markdown_title(
|
|
788
|
+
markdown_file.read_text(encoding="utf-8", errors="replace")
|
|
789
|
+
)
|
|
790
|
+
if title:
|
|
791
|
+
return title
|
|
792
|
+
return None
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def classify_opencli_xiaoyuzhou_failure(exit_code: int, message: object) -> str:
|
|
796
|
+
text = str(message or "").lower()
|
|
797
|
+
if exit_code == 78 and "xiaoyuzhou" in text and "credential" in text:
|
|
798
|
+
return "xiaoyuzhou_credentials_unavailable"
|
|
799
|
+
if "timed out" in text or "timeout" in text:
|
|
800
|
+
return "opencli_xiaoyuzhou_transcript_timeout"
|
|
801
|
+
if "credential" in text or "login required" in text or "unauthorized" in text:
|
|
802
|
+
return "xiaoyuzhou_credentials_unavailable"
|
|
803
|
+
return f"backend_exit_{exit_code}"
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _run_command(
|
|
807
|
+
command: list[str],
|
|
808
|
+
*,
|
|
809
|
+
timeout: int = 120,
|
|
810
|
+
cwd: str | Path | None = None,
|
|
811
|
+
) -> tuple[int, str, str]:
|
|
812
|
+
try:
|
|
813
|
+
completed = run_interruptibly(
|
|
814
|
+
[_resolve_executable(command[0]), *command[1:]],
|
|
815
|
+
cwd=cwd,
|
|
816
|
+
stdin=subprocess.DEVNULL,
|
|
817
|
+
capture_output=True,
|
|
818
|
+
text=True,
|
|
819
|
+
timeout=timeout,
|
|
820
|
+
check=False,
|
|
821
|
+
encoding="utf-8",
|
|
822
|
+
errors="replace",
|
|
823
|
+
)
|
|
824
|
+
except subprocess.TimeoutExpired as exc:
|
|
825
|
+
return 124, _to_text(exc.stdout), f"command timed out after {timeout} seconds."
|
|
826
|
+
except OSError as exc:
|
|
827
|
+
return 127, "", _sanitize_error(exc) or ""
|
|
828
|
+
return completed.returncode, completed.stdout or "", completed.stderr or ""
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _resolve_executable(executable: str) -> str:
|
|
832
|
+
if os.name == "nt" and not Path(executable).suffix:
|
|
833
|
+
for extension in (".cmd", ".bat", ".exe"):
|
|
834
|
+
resolved = shutil.which(f"{executable}{extension}")
|
|
835
|
+
if resolved:
|
|
836
|
+
return resolved
|
|
837
|
+
return shutil.which(executable) or executable
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _executable_available(executable: str) -> bool:
|
|
841
|
+
if shutil.which(executable):
|
|
842
|
+
return True
|
|
843
|
+
if os.name != "nt":
|
|
844
|
+
return False
|
|
845
|
+
return any(shutil.which(f"{executable}{extension}") for extension in (".cmd", ".bat", ".exe"))
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _reported_title(stdout: str) -> str | None:
|
|
849
|
+
try:
|
|
850
|
+
payload = load_opencli_json(stdout)
|
|
851
|
+
except json.JSONDecodeError:
|
|
852
|
+
return None
|
|
853
|
+
row = payload[0] if isinstance(payload, list) and payload else payload
|
|
854
|
+
title = row.get("title") if isinstance(row, dict) else None
|
|
855
|
+
return title.strip() or None if isinstance(title, str) else None
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _reported_podcast(stdout: str) -> str | None:
|
|
859
|
+
try:
|
|
860
|
+
payload = load_opencli_json(stdout)
|
|
861
|
+
except json.JSONDecodeError:
|
|
862
|
+
return None
|
|
863
|
+
row = payload[0] if isinstance(payload, list) and payload else payload
|
|
864
|
+
if not isinstance(row, dict):
|
|
865
|
+
return None
|
|
866
|
+
for key in ("podcast", "podcast_title", "podcastTitle", "author"):
|
|
867
|
+
value = row.get(key)
|
|
868
|
+
if isinstance(value, dict):
|
|
869
|
+
value = value.get("title")
|
|
870
|
+
if isinstance(value, str) and value.strip():
|
|
871
|
+
return value.strip()
|
|
872
|
+
return None
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _read_timed_transcript(directory: Path) -> str:
|
|
876
|
+
"""Render OpenCLI's segment JSON with timestamps; empty when segments are unusable."""
|
|
877
|
+
for path in sorted(directory.rglob("transcript.json")):
|
|
878
|
+
try:
|
|
879
|
+
segments = json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
880
|
+
except json.JSONDecodeError:
|
|
881
|
+
return ""
|
|
882
|
+
if not isinstance(segments, list):
|
|
883
|
+
return ""
|
|
884
|
+
lines = []
|
|
885
|
+
for segment in segments:
|
|
886
|
+
if not isinstance(segment, dict):
|
|
887
|
+
return ""
|
|
888
|
+
start, text = segment.get("startMs"), segment.get("text")
|
|
889
|
+
if not isinstance(start, (int, float)) or not isinstance(text, str):
|
|
890
|
+
return ""
|
|
891
|
+
seconds = int(start // 1000)
|
|
892
|
+
lines.append(f"[{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}] {text.strip()}")
|
|
893
|
+
return "\n".join(lines)
|
|
894
|
+
return ""
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _read_opencli_transcript_text(directory: Path, stdout: str) -> str:
|
|
898
|
+
timed = _read_timed_transcript(directory)
|
|
899
|
+
if timed:
|
|
900
|
+
return timed
|
|
901
|
+
text_files = sorted(directory.rglob("transcript.txt"))
|
|
902
|
+
if text_files:
|
|
903
|
+
return text_files[0].read_text(encoding="utf-8", errors="replace")
|
|
904
|
+
return _extract_text_from_stdout(stdout)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _extract_text_from_stdout(stdout: str) -> str:
|
|
908
|
+
stripped = stdout.strip()
|
|
909
|
+
if not stripped:
|
|
910
|
+
return ""
|
|
911
|
+
try:
|
|
912
|
+
payload = load_opencli_json(stripped)
|
|
913
|
+
except json.JSONDecodeError:
|
|
914
|
+
return stripped
|
|
915
|
+
return _text_from_json_payload(payload).strip()
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _text_from_json_payload(payload: Any) -> str:
|
|
919
|
+
if isinstance(payload, str):
|
|
920
|
+
return payload
|
|
921
|
+
if isinstance(payload, list):
|
|
922
|
+
lines = [_text_from_json_payload(item).strip() for item in payload]
|
|
923
|
+
return "\n".join(line for line in lines if line)
|
|
924
|
+
if not isinstance(payload, dict):
|
|
925
|
+
return ""
|
|
926
|
+
|
|
927
|
+
for key in ("text", "transcript", "markdown", "content"):
|
|
928
|
+
value = payload.get(key)
|
|
929
|
+
if isinstance(value, str) and value.strip():
|
|
930
|
+
return value
|
|
931
|
+
|
|
932
|
+
for key in ("segments", "snippets", "items", "lines"):
|
|
933
|
+
value = payload.get(key)
|
|
934
|
+
if isinstance(value, list):
|
|
935
|
+
lines = [_text_from_json_payload(item).strip() for item in value]
|
|
936
|
+
return "\n".join(line for line in lines if line)
|
|
937
|
+
return ""
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _metadata_lookup(metadata: object, names: tuple[str, ...]) -> str | None:
|
|
941
|
+
if not isinstance(metadata, dict):
|
|
942
|
+
return None
|
|
943
|
+
normalized = {str(key).lower(): value for key, value in metadata.items()}
|
|
944
|
+
for name in names:
|
|
945
|
+
value = normalized.get(name.lower())
|
|
946
|
+
if isinstance(value, str) and value.strip():
|
|
947
|
+
return value.strip()
|
|
948
|
+
return None
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _is_podwise_not_processed(message: object) -> bool:
|
|
952
|
+
text = str(message or "").lower()
|
|
953
|
+
return (
|
|
954
|
+
"not been processed yet" in text
|
|
955
|
+
or "not processed" in text
|
|
956
|
+
or "process this episode" in text
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _failed(
|
|
961
|
+
backend: str,
|
|
962
|
+
code: str,
|
|
963
|
+
error: object,
|
|
964
|
+
*,
|
|
965
|
+
notes: list[str] | None = None,
|
|
966
|
+
metadata: dict[str, Any] | None = None,
|
|
967
|
+
) -> TranscriptResult:
|
|
968
|
+
return TranscriptResult(
|
|
969
|
+
text="",
|
|
970
|
+
status="failed",
|
|
971
|
+
source_level="partial_extraction",
|
|
972
|
+
backend=backend,
|
|
973
|
+
source_notes=["backend returned structured failure", *(notes or [])],
|
|
974
|
+
error_code=code,
|
|
975
|
+
error_summary=_sanitize_error(error),
|
|
976
|
+
metadata=metadata or {},
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _sanitize_error(value: object, *, max_length: int = 240) -> str | None:
|
|
981
|
+
if value is None:
|
|
982
|
+
return None
|
|
983
|
+
text = str(value).replace("\r", " ").replace("\n", " ")
|
|
984
|
+
text = re.sub(r"(?i)\bauthorization\b\s+(?:bearer\s+)?[^,;\s]+", "[redacted]", text)
|
|
985
|
+
text = re.sub(r"(?i)\bbearer\s+[^,;\s]+", "[redacted]", text)
|
|
986
|
+
text = re.sub(
|
|
987
|
+
r"(?i)\b[A-Za-z0-9_-]*(?:token|authorization|cookie|headers?)[A-Za-z0-9_-]*\b"
|
|
988
|
+
r"\s*[:=]\s*[^,;\s]+",
|
|
989
|
+
"[redacted]",
|
|
990
|
+
text,
|
|
991
|
+
)
|
|
992
|
+
text = re.sub(
|
|
993
|
+
r"(?i)\b(?:raw|full)[-_ ]?(?:transcript|subtitle|html|response|api_response|media)\b"
|
|
994
|
+
r"\s*[:=]?\s*[^,;\s]*",
|
|
995
|
+
"[redacted]",
|
|
996
|
+
text,
|
|
997
|
+
)
|
|
998
|
+
sanitized = sanitize_error_summary(text, max_length=max_length) or ""
|
|
999
|
+
sanitized = re.sub(
|
|
1000
|
+
r"(?i)\b[A-Za-z0-9_-]*(?:token|authorization|cookie|headers?)[A-Za-z0-9_-]*\b",
|
|
1001
|
+
"[redacted]",
|
|
1002
|
+
sanitized,
|
|
1003
|
+
)
|
|
1004
|
+
sanitized = re.sub(
|
|
1005
|
+
r"(?i)\b(?:raw|full)[-_ ]?(?:transcript|subtitle|html|response|api_response|media)\b",
|
|
1006
|
+
"[redacted]",
|
|
1007
|
+
sanitized,
|
|
1008
|
+
)
|
|
1009
|
+
return sanitized
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _to_text(value: object) -> str:
|
|
1013
|
+
if value is None:
|
|
1014
|
+
return ""
|
|
1015
|
+
if isinstance(value, bytes):
|
|
1016
|
+
return value.decode("utf-8", errors="replace")
|
|
1017
|
+
return str(value)
|