openmd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,602 @@
1
+ """Douyin transcript provider with native text, transient ASR, and OCR fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import urllib.error
13
+ import urllib.request
14
+ import uuid
15
+ from collections.abc import Callable
16
+ from dataclasses import replace
17
+ from pathlib import Path
18
+ from typing import Any, Protocol
19
+
20
+ from openmd.backends.base import BackendCapability, BackendDescriptor, BackendExtractRequest, BackendHealth, ItemIdentityResolution
21
+ from openmd.backends.douyin_detail import DouyinDetailError, DouyinDetailProvider
22
+ from openmd.backends.executor import BackendProviderRegistry
23
+ from openmd.backends.registry import BackendRuntimeOptions
24
+ from openmd.core.models import ContentItem, TranscriptResult
25
+ from openmd.core.secrets import sanitize_error_summary
26
+ from openmd.runtime.tempdirs import owned_temporary_directory
27
+ from openmd.runtime.interpreters import run_interruptibly
28
+
29
+
30
+ DOUYIN_TRANSCRIPT_BACKEND = "douyin.transcript"
31
+ DOUYIN_NATIVE_TRANSCRIPT_BACKEND = "douyin.native-transcript"
32
+ DOUYIN_POST_TEXT_BACKEND = "douyin.post-text"
33
+ DOUYIN_ASR_BACKEND = "siliconflow.sensevoice"
34
+ DOUYIN_OCR_BACKEND = "rapidocr.douyin"
35
+ SILICONFLOW_URL = "https://api.siliconflow.cn/v1/audio/transcriptions"
36
+ SILICONFLOW_MODEL = "FunAudioLLM/SenseVoiceSmall"
37
+
38
+ CommandRunner = Callable[[list[str], int], tuple[int, str, str]]
39
+ OcrExtractor = Callable[[ContentItem], str]
40
+
41
+ _TIMESTAMP_PREFIX_RE = re.compile(r"^\[?\d{1,2}:\d{2}(?::\d{2})?\]?\s*")
42
+ _CHINESE_RE = re.compile(r"[\u4e00-\u9fff]")
43
+ _TEXT_TOKEN_RE = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]")
44
+ _SPACE_RE = re.compile(r"\s+")
45
+
46
+
47
+ class AsrClient(Protocol):
48
+ name: str
49
+
50
+ def is_available(self) -> bool:
51
+ ...
52
+
53
+ def transcribe(self, audio_path: Path, *, timeout: int) -> str:
54
+ ...
55
+
56
+
57
+ def register_backend_providers(
58
+ registry: BackendProviderRegistry,
59
+ options: BackendRuntimeOptions,
60
+ ) -> None:
61
+ requested = options.backend_override
62
+ if requested in {None, DOUYIN_TRANSCRIPT_BACKEND}:
63
+ registry.register(DOUYIN_TRANSCRIPT_BACKEND, DouyinTranscriptProvider(options))
64
+ if requested == DOUYIN_ASR_BACKEND and all((options.enable_browser_backends, options.enable_media_download_backends, options.enable_asr_backends, options.enable_cloud_backends)):
65
+ registry.register(DOUYIN_ASR_BACKEND, DouyinCloudTranscriptProvider(options, asr_client=SiliconFlowASRClient()))
66
+
67
+
68
+ class SiliconFlowASRClient:
69
+ name = DOUYIN_ASR_BACKEND
70
+
71
+ def is_available(self) -> bool:
72
+ return bool(os.getenv("SILICONFLOW_API_KEY"))
73
+
74
+ def transcribe(self, audio_path: Path, *, timeout: int) -> str:
75
+ api_key = os.getenv("SILICONFLOW_API_KEY")
76
+ if not api_key:
77
+ raise RuntimeError("SILICONFLOW_API_KEY is not set")
78
+ boundary = f"openmd-{uuid.uuid4().hex}"
79
+ body = _multipart_body(
80
+ boundary,
81
+ fields={"model": SILICONFLOW_MODEL},
82
+ file_field="file",
83
+ file_path=audio_path,
84
+ content_type="audio/mpeg",
85
+ )
86
+ request = urllib.request.Request(
87
+ SILICONFLOW_URL,
88
+ data=body,
89
+ method="POST",
90
+ headers={
91
+ "Authorization": f"Bearer {api_key}",
92
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
93
+ "Accept": "application/json",
94
+ },
95
+ )
96
+ try:
97
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - fixed HTTPS endpoint.
98
+ payload = json.loads(response.read().decode("utf-8", errors="replace"))
99
+ except urllib.error.HTTPError as exc:
100
+ raise RuntimeError(f"SiliconFlow ASR HTTP {exc.code}") from exc
101
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
102
+ raise RuntimeError(f"SiliconFlow ASR request failed: {sanitize_error_summary(exc)}") from exc
103
+ text = _asr_text_from_payload(payload)
104
+ if not _has_meaningful_text(text):
105
+ raise RuntimeError("SiliconFlow ASR returned no meaningful text")
106
+ return text
107
+
108
+
109
+ class DouyinTranscriptProvider:
110
+ """Own the native -> transient ASR -> hard-subtitle OCR ladder."""
111
+
112
+ name = DOUYIN_TRANSCRIPT_BACKEND
113
+
114
+ def __init__(
115
+ self,
116
+ options: BackendRuntimeOptions,
117
+ *,
118
+ runner: CommandRunner | None = None,
119
+ asr_client: AsrClient | None = None,
120
+ ocr_extractor: OcrExtractor | None = None,
121
+ detail_provider: DouyinDetailProvider | None = None,
122
+ asr_backend: Any | None = None,
123
+ ffmpeg_timeout: int = 600,
124
+ asr_timeout: int = 300,
125
+ ) -> None:
126
+ self._options = options
127
+ self._runner = runner or _run_command
128
+ self._asr_client = asr_client
129
+ self._asr_backend = asr_backend
130
+ self._detail_provider = detail_provider or DouyinDetailProvider()
131
+ self._resolved_items: dict[str, ContentItem] = {}
132
+ self._ocr_extractor_override = ocr_extractor
133
+ self._ffmpeg_timeout = ffmpeg_timeout
134
+ self._asr_timeout = asr_timeout
135
+ self._ocr_engine: Any | None = None
136
+
137
+ def descriptor(self) -> BackendDescriptor:
138
+ return BackendDescriptor(
139
+ name=self.name,
140
+ layer="transcript",
141
+ capabilities=(
142
+ BackendCapability(
143
+ platforms=("douyin",),
144
+ source_types=("video", "image_text"),
145
+ input_types=("single", "collection"),
146
+ source_levels=("native_text", "platform_transcript", "media_asr", "ocr", "partial_extraction"),
147
+ supports_batch=True,
148
+ supports_collection=True,
149
+ requires_network=True,
150
+ requires_credentials=False,
151
+ optional_config=(
152
+ "enable_media_download_backends",
153
+ "enable_asr_backends",
154
+ "enable_cloud_backends",
155
+ "SILICONFLOW_API_KEY",
156
+ ),
157
+ ),
158
+ ),
159
+ description="Douyin bounded detail, native captions, then temporary audio + local asr.auto; optional explicit cloud/OCR.",
160
+ provider="openmd.backends.douyin_providers",
161
+ )
162
+
163
+ def health(self) -> BackendHealth:
164
+ return BackendHealth(
165
+ ok=True,
166
+ dependencies={"ffmpeg": "ok" if shutil.which("ffmpeg") else "missing", "asr.auto": "local_runtime_lazy"},
167
+ warnings=("Detail needs browser permission; local audio ASR needs media and ASR permissions.",),
168
+ )
169
+
170
+ def resolve_item_identity(self, item: ContentItem) -> ItemIdentityResolution:
171
+ if not self._options.enable_browser_backends:
172
+ return ItemIdentityResolution.failure(item, provider=self.name, error_code="douyin_detail_browser_not_enabled", error_summary="Video share link resolution requires the browser backend gate.")
173
+ try:
174
+ resolved = self._detail_provider.resolve(item)
175
+ self._resolved_items[resolved.url] = resolved
176
+ return ItemIdentityResolution.success(resolved, provider=self._detail_provider.name)
177
+ except DouyinDetailError as exc:
178
+ return ItemIdentityResolution.failure(item, provider=self._detail_provider.name, error_code=exc.code, error_summary=str(exc))
179
+
180
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
181
+ item = request.item
182
+ if item.platform != "douyin":
183
+ return _failure(item, "invalid_input", "Douyin transcript provider only supports Douyin.")
184
+ item = self._resolved_items.pop(item.url, item)
185
+ metadata_detail = None
186
+ metadata_attempts = 0
187
+ metadata_errors: list[str] = []
188
+ if (
189
+ item.input_type == "collection" and self._options.enable_browser_backends
190
+ and not item.raw_meta.get("detail_resolved")
191
+ and not item.raw_meta.get("metadata_detail_attempted")
192
+ and _missing_metadata_fields(item.title, item.author, item.published_at, item.id)
193
+ and (item.raw_meta.get("play_url") or _native_transcript(item) or _image_post_author_text(item)[0])
194
+ ):
195
+ # Enrichment allows at most two bounded reads. Do not replace media,
196
+ # native captions, or image author text with a different source.
197
+ for _ in range(2):
198
+ metadata_attempts += 1
199
+ try:
200
+ metadata_detail = self._detail_provider.resolve(item)
201
+ item = replace(item, title=item.title if item.title and item.title != item.id else metadata_detail.title,
202
+ author=item.author or metadata_detail.author,
203
+ published_at=item.published_at or metadata_detail.published_at)
204
+ except DouyinDetailError as exc:
205
+ metadata_errors.append(exc.code)
206
+ if not _missing_metadata_fields(item.title, item.author, item.published_at, item.id):
207
+ break
208
+ item = replace(item, raw_meta={**item.raw_meta, "metadata_detail_attempted": True})
209
+ result = self._extract_content(item, metadata_detail=metadata_detail)
210
+ if item.input_type == "collection":
211
+ missing = _missing_metadata_fields(result.title, result.author, result.published_at, item.id)
212
+ if missing:
213
+ warning = "Douyin metadata incomplete: missing " + ", ".join(missing) + f"; bounded metadata detail attempts: {metadata_attempts}."
214
+ result = replace(result, source_notes=[*result.source_notes, warning], metadata={
215
+ **result.metadata,
216
+ "metadata_warnings": [warning],
217
+ "metadata_enrichment": {"detail_attempts": metadata_attempts, "missing_fields": missing, "detail_error_codes": metadata_errors},
218
+ })
219
+ return result
220
+
221
+ def _extract_content(self, item: ContentItem, *, metadata_detail: ContentItem | None = None) -> TranscriptResult:
222
+ native = _native_transcript(item)
223
+ author_text, author_text_source = _image_post_author_text(item)
224
+ needs_image_detail = item.source_type == "image_text" and not author_text.strip() and not item.raw_meta.get("detail_resolved")
225
+ if needs_image_detail or (item.source_type != "image_text" and not native and not item.raw_meta.get("play_url")):
226
+ if not self._options.enable_browser_backends:
227
+ if item.source_type == "image_text":
228
+ return _failure(item, "douyin_image_post_text_unavailable", "Image-post author text is unavailable; detail acquisition requires --enable-browser-backends.", failure_class="user_resolvable")
229
+ return _failure(item, "douyin_detail_browser_not_enabled", "Video detail acquisition requires --enable-browser-backends.", failure_class="user_resolvable")
230
+ try:
231
+ item = self._detail_provider.resolve(item)
232
+ except DouyinDetailError as exc:
233
+ return _failure(item, exc.code, str(exc), failure_class="source_unavailable")
234
+ native = _native_transcript(item)
235
+ author_text, author_text_source = _image_post_author_text(item)
236
+ if item.source_type == "image_text":
237
+ if not author_text.strip():
238
+ return _failure(item, "douyin_image_post_text_unavailable", "This image post returned no author text; display titles and IDs are not used as body text.", failure_class="source_unavailable")
239
+ return TranscriptResult(
240
+ text=author_text, status="verified", source_level="native_text", backend=DOUYIN_POST_TEXT_BACKEND,
241
+ title=item.title, author=item.author, published_at=item.published_at,
242
+ source_notes=["Image-post author text retained verbatim; no media, speech recognition, or OCR was used."],
243
+ metadata={"profile": "media", "content_id": item.id, "transcript_method": "image_post_text", "media_kind": "image_text", "content_kind": "author_text", "author_text_source": author_text_source, "scope": "image_post_author_text"},
244
+ )
245
+ if native:
246
+ return TranscriptResult(
247
+ text=native, status="verified", source_level="platform_transcript", backend=DOUYIN_NATIVE_TRANSCRIPT_BACKEND,
248
+ source_notes=["Native caption text retained in source order, including repeated speech and real timestamps.", "Untimed captions remain untimed; no artificial zero timestamps were added."],
249
+ title=item.title, author=item.author, published_at=item.published_at,
250
+ metadata={"profile": "media", "content_id": item.id, "transcript_method": "native", "scope": "video_captions", "coverage": {"duration_seconds": _safe_duration(item.raw_meta.get("duration")), "timing": "source_provided_or_untimed"}},
251
+ )
252
+ if not (self._options.enable_media_download_backends and self._options.enable_asr_backends):
253
+ return _failure(item, "douyin_media_asr_not_enabled", "No native captions were returned; use --enable-media-download --enable-asr.", failure_class="user_resolvable")
254
+ if self._asr_client is not None and not self._options.enable_cloud_backends:
255
+ return _failure(item, "douyin_cloud_backends_not_enabled", "Explicit cloud ASR requires --enable-cloud-backends before audio preparation.", failure_class="user_resolvable")
256
+ play_url = str(item.raw_meta.get("play_url") or "").strip()
257
+ if not play_url:
258
+ return _failure(item, "douyin_play_url_unavailable", "Video detail returned no transient playback URL.", failure_class="source_unavailable")
259
+ from openmd.runtime.asr import get_asr_backend
260
+
261
+ asr = self._asr_client or self._asr_backend or get_asr_backend("asr.auto")
262
+ try:
263
+ available = asr.is_available()
264
+ except Exception:
265
+ available = False
266
+ if not available:
267
+ return _failure(item, "douyin_asr_unavailable", "The selected ASR runtime or existing local models are unavailable; no model was downloaded.", failure_class="user_resolvable")
268
+ with owned_temporary_directory(prefix="openmd-douyin-transcript-") as temp_dir:
269
+ audio_path = Path(temp_dir) / "audio.mp3"
270
+ try:
271
+ self._extract_audio(play_url, audio_path)
272
+ except Exception:
273
+ if metadata_detail is not None:
274
+ # Reuse the already-read detail on an expired discovery URL;
275
+ # no second metadata read or repeated successful download.
276
+ return self.extract(BackendExtractRequest(metadata_detail, (self.name,), allow_fallback=False))
277
+ if self._options.enable_browser_backends and not item.raw_meta.get("detail_resolved") and not item.raw_meta.get("metadata_detail_attempted"):
278
+ try:
279
+ refreshed = self._detail_provider.resolve(item)
280
+ except DouyinDetailError as exc:
281
+ return _failure(item, exc.code, str(exc), failure_class="runtime_transient")
282
+ # The detail marker bounds refresh to one attempt. Native
283
+ # captions newly exposed by detail still take precedence.
284
+ return self.extract(BackendExtractRequest(refreshed, (self.name,), allow_fallback=False))
285
+ # Downloader diagnostics can include the complete signed URL.
286
+ # Persist a stable reason only, never raw stdout/stderr/argv.
287
+ return _failure(item, "douyin_media_url_unavailable", "Temporary audio acquisition failed: media URL may have expired or contain no accessible audio.", failure_class="runtime_transient")
288
+ try:
289
+ if self._asr_client is not None:
290
+ transcript = _format_transcript(asr.transcribe(audio_path, timeout=self._asr_timeout))
291
+ asr_result = TranscriptResult(text=transcript, status="verified", source_level="media_asr", backend=asr.name, metadata={"asr_model": SILICONFLOW_MODEL})
292
+ else:
293
+ asr_result = asr.transcribe(audio_path)
294
+ except Exception as exc:
295
+ no_speech_error = _asr_indicates_no_speech(str(exc)) or "no speech" in str(exc).lower()
296
+ asr_result = TranscriptResult(text="", status="failed", source_level="partial_extraction", backend=getattr(asr, "name", "asr.auto"), error_code="asr_no_speech" if no_speech_error else "asr_runtime_error")
297
+ if asr_result.has_usable_text:
298
+ return replace(asr_result, source_level="media_asr", title=item.title, author=item.author, published_at=item.published_at,
299
+ source_notes=[*asr_result.source_notes, "Temporary mono audio was transcribed and removed; playback URLs were not retained."],
300
+ metadata={**asr_result.metadata, "profile": "media", "content_id": item.id, "transcript_method": "asr", "media_retention": "none", "scope": "video_audio", "coverage": {"duration_seconds": _safe_duration(item.raw_meta.get("duration")), "audio_selection": "full_audio"}})
301
+ no_speech = not asr_result.text.strip() and asr_result.error_code in {None, "asr_empty", "asr_empty_text", "asr_no_speech", "no_speech", "empty_transcript"}
302
+ code = "douyin_no_speech" if no_speech else "douyin_asr_unavailable" if asr_result.error_code in {"asr_runtime_unavailable", "asr_dependency_missing", "asr_model_missing"} else "douyin_asr_failed"
303
+ if self._ocr_extractor_override is None and item.raw_meta.get("enable_ocr") is not True:
304
+ return _failure(item, code, "No usable speech transcript was produced; ASR detail code: " + str(asr_result.error_code or "empty_transcript"), failure_class="source_unavailable" if no_speech else "runtime_transient")
305
+ try:
306
+ ocr_text = self._extract_ocr(item)
307
+ except Exception:
308
+ return _failure(item, "douyin_ocr_failed", "ASR produced no transcript and sampled hard-subtitle OCR failed.", failure_class="source_unavailable")
309
+ if not _has_meaningful_text(ocr_text):
310
+ return _failure(item, "douyin_no_speech_or_hard_subtitle", "No usable speech or sampled hard-subtitle text was found.", failure_class="source_unavailable")
311
+ return TranscriptResult(text=ocr_text, status="partial", source_level="ocr", backend=DOUYIN_OCR_BACKEND,
312
+ title=item.title, author=item.author, published_at=item.published_at,
313
+ source_notes=["Sampled visible subtitles only; this is not a complete spoken transcript."],
314
+ metadata={"profile": "media", "content_id": item.id, "transcript_method": "ocr", "media_retention": "none", "scope": "sampled_hard_subtitles", "coverage": {"sample_interval_seconds": 2, "max_seconds": 300, "sampled_seconds": min(_safe_duration(item.raw_meta.get("duration")) or 60, 300), "crop": "lower_52_percent", "complete": False}})
315
+
316
+ def _extract_audio(self, play_url: str, output_path: Path) -> None:
317
+ ffmpeg = shutil.which("ffmpeg")
318
+ if not ffmpeg:
319
+ raise RuntimeError("ffmpeg is not available")
320
+ command = [
321
+ ffmpeg,
322
+ "-y",
323
+ "-hide_banner",
324
+ "-loglevel",
325
+ "error",
326
+ "-headers",
327
+ "User-Agent: Mozilla/5.0\r\nReferer: https://www.douyin.com/\r\n",
328
+ "-i",
329
+ play_url,
330
+ "-vn",
331
+ "-ac",
332
+ "1",
333
+ "-ar",
334
+ "16000",
335
+ "-b:a",
336
+ "48k",
337
+ str(output_path),
338
+ ]
339
+ exit_code, _stdout, stderr = self._runner(command, self._ffmpeg_timeout)
340
+ if exit_code != 0 or not output_path.is_file() or output_path.stat().st_size == 0:
341
+ raise RuntimeError(f"ffmpeg audio extraction failed: {sanitize_error_summary(stderr)}")
342
+
343
+ def _extract_ocr(self, item: ContentItem) -> str:
344
+ if self._ocr_extractor_override is not None:
345
+ return self._ocr_extractor_override(item)
346
+ from rapidocr_onnxruntime import RapidOCR
347
+
348
+ if self._ocr_engine is None:
349
+ self._ocr_engine = RapidOCR()
350
+ play_url = str(item.raw_meta.get("play_url") or "").strip()
351
+ duration = _safe_duration(item.raw_meta.get("duration"))
352
+ with owned_temporary_directory(prefix="openmd-douyin-ocr-") as temp_dir:
353
+ frames_dir = Path(temp_dir)
354
+ pattern = frames_dir / "frame_%05d.jpg"
355
+ ffmpeg = shutil.which("ffmpeg")
356
+ if not ffmpeg:
357
+ raise RuntimeError("ffmpeg is not available")
358
+ command = [
359
+ ffmpeg,
360
+ "-hide_banner",
361
+ "-loglevel",
362
+ "error",
363
+ "-headers",
364
+ "User-Agent: Mozilla/5.0\r\nReferer: https://www.douyin.com/\r\n",
365
+ "-t",
366
+ str(max(1, min(duration or 60, 300))),
367
+ "-i",
368
+ play_url,
369
+ "-vf",
370
+ "fps=0.5,crop=iw:ih*0.52:0:ih*0.45",
371
+ "-q:v",
372
+ "3",
373
+ str(pattern),
374
+ ]
375
+ exit_code, _stdout, stderr = self._runner(command, self._ffmpeg_timeout)
376
+ if exit_code != 0:
377
+ raise RuntimeError(f"ffmpeg frame extraction failed: {sanitize_error_summary(stderr)}")
378
+ segments: list[tuple[int, str]] = []
379
+ last = ""
380
+ for index, frame in enumerate(sorted(frames_dir.glob("frame_*.jpg"))):
381
+ text = _ocr_frame(self._ocr_engine, frame)
382
+ normalized = _SPACE_RE.sub("", text)
383
+ if not normalized:
384
+ continue
385
+ similarity = difflib.SequenceMatcher(None, normalized, last).ratio() if last else 0.0
386
+ if similarity < 0.82:
387
+ segments.append((index * 2, text))
388
+ last = normalized
389
+ return "\n".join(f"[{seconds // 60:02d}:{seconds % 60:02d}] {text}" for seconds, text in segments)
390
+
391
+
392
+ class DouyinCloudTranscriptProvider(DouyinTranscriptProvider):
393
+ """Cloud is reachable only through explicit backend selection and gates."""
394
+
395
+ name = DOUYIN_ASR_BACKEND
396
+
397
+
398
+ def _missing_metadata_fields(title: str | None, author: str | None, published_at: str | None, content_id: str | None) -> list[str]:
399
+ return [key for key, value in (("title", title if title != content_id else None), ("author", author), ("published_at", published_at)) if not value or not value.strip()]
400
+
401
+
402
+ def _image_post_author_text(item: ContentItem) -> tuple[str, str]:
403
+ if item.source_type != "image_text":
404
+ return "", ""
405
+ if "author_text" in item.raw_meta:
406
+ text = item.raw_meta["author_text"]
407
+ source = "detail_desc" if item.raw_meta.get("author_text_source") == "detail_desc" else "explicit_author_text"
408
+ return (text if isinstance(text, str) else ""), source
409
+ if item.raw_meta.get("detail_resolved"):
410
+ return "", ""
411
+ # Existing discovery stores the image description in title. Preserve that
412
+ # contract for already classified image posts, including short author text.
413
+ # Once detail has been read, only its actual description can supply a body.
414
+ title = item.title or ""
415
+ candidate = title.strip()
416
+ placeholders = {"untitled", "无标题", "抖音视频", "抖音作品", "douyin video", "douyin post"}
417
+ if not candidate or candidate == item.id or candidate.lower() in placeholders or re.fullmatch(r"\d{6,20}", candidate):
418
+ return "", ""
419
+ if candidate == item.url or re.fullmatch(r"(?i)(?:douyin[ -]*(?:video|post)|抖音(?:视频|作品))\s*[::#-]?\s*\d{6,20}", candidate):
420
+ return "", ""
421
+ return title, "legacy_image_title"
422
+
423
+
424
+ def _run_command(command: list[str], timeout: int) -> tuple[int, str, str]:
425
+ completed = run_interruptibly(
426
+ command,
427
+ capture_output=True,
428
+ text=True,
429
+ encoding="utf-8",
430
+ errors="replace",
431
+ timeout=timeout,
432
+ check=False,
433
+ )
434
+ return completed.returncode, completed.stdout, completed.stderr
435
+
436
+
437
+ def _native_transcript(item: ContentItem) -> str:
438
+ # Choose one native source; never concatenate alternative caption tracks.
439
+ for key in ("transcript_candidates", "subtitles", "caption_info", "captionInfo"):
440
+ candidates = item.raw_meta.get(key)
441
+ if not candidates:
442
+ continue
443
+ lines: list[str] = []
444
+ def visit(candidate: object) -> None:
445
+ if isinstance(candidate, list):
446
+ for segment in candidate:
447
+ visit(segment)
448
+ return
449
+ if isinstance(candidate, str):
450
+ # Caption download locators are not caption bodies. Keep them
451
+ # transient and let audio ASR supply the requested transcript.
452
+ if re.fullmatch(r"https?://\S+", candidate.strip(), re.IGNORECASE):
453
+ return
454
+ lines.extend(line.strip() for line in candidate.replace("\r\n", "\n").splitlines() if line.strip())
455
+ return
456
+ if not isinstance(candidate, dict):
457
+ return
458
+ for nested in ("segments", "sentences", "captions", "subtitles", "items", "list"):
459
+ if isinstance(candidate.get(nested), list):
460
+ visit(candidate[nested])
461
+ return
462
+ text = str(candidate.get("text") or candidate.get("content") or candidate.get("utterance") or "").strip()
463
+ if not text or not _TEXT_TOKEN_RE.search(text):
464
+ return
465
+ start = candidate.get("start", candidate.get("start_time"))
466
+ if candidate.get("start_ms") is not None:
467
+ try:
468
+ start = float(candidate["start_ms"]) / 1000
469
+ except (ValueError, TypeError):
470
+ start = None
471
+ anchor = ""
472
+ if start is not None and not _TIMESTAMP_PREFIX_RE.match(text):
473
+ try:
474
+ seconds = max(0, int(float(start)))
475
+ anchor = f"[{seconds // 3600:02d}:{seconds // 60 % 60:02d}:{seconds % 60:02d}] "
476
+ except (ValueError, TypeError, OverflowError):
477
+ pass
478
+ lines.append(anchor + text)
479
+ visit(candidates)
480
+ if lines:
481
+ return "\n".join(lines) + "\n"
482
+ return ""
483
+
484
+
485
+ def _format_transcript(text: str) -> str:
486
+ # Cloud text has no reliable timestamps; preserve wording without inventing anchors.
487
+ cleaned = re.sub(r"<\|[^|]+?\|>", "", text)
488
+ return cleaned.replace("\r\n", "\n").replace("\r", "\n").strip() + ("\n" if cleaned.strip() else "")
489
+
490
+
491
+ def _has_meaningful_text(value: str) -> bool:
492
+ tokens = _TEXT_TOKEN_RE.findall(value or "")
493
+ return len(tokens) >= 2
494
+
495
+
496
+ def _asr_indicates_no_speech(error_summary: str | None) -> bool:
497
+ value = str(error_summary or "").lower()
498
+ return "no meaningful text" in value or "no meaningful speech" in value
499
+
500
+
501
+ def _safe_duration(value: object) -> int:
502
+ try:
503
+ return max(0, int(float(str(value or 0).rstrip("s"))))
504
+ except (TypeError, ValueError):
505
+ return 0
506
+
507
+
508
+ def _ocr_frame(ocr: Any, image_path: Path) -> str:
509
+ result, _elapsed = ocr(str(image_path))
510
+ if not result:
511
+ return ""
512
+ items: list[tuple[int, float, str]] = []
513
+ for box, text, score in result:
514
+ if float(score) < 0.55:
515
+ continue
516
+ cleaned = re.sub(r"[^\u4e00-\u9fffA-Za-z0-9%,。!?、:;()《》“”+&./:_()' -]", "", str(text or ""))
517
+ cleaned = _SPACE_RE.sub(" ", cleaned).strip()
518
+ if not cleaned or not (_CHINESE_RE.search(cleaned) or re.search(r"[A-Za-z]{2,}", cleaned)):
519
+ continue
520
+ xs = [float(point[0]) for point in box]
521
+ ys = [float(point[1]) for point in box]
522
+ items.append((int(round(min(ys) / 28)), min(xs), cleaned))
523
+ items.sort(key=lambda item: (item[0], item[1]))
524
+ return "".join(text for _line, _x, text in items)
525
+
526
+
527
+ def _failure(
528
+ item: ContentItem,
529
+ code: str,
530
+ summary: object,
531
+ *,
532
+ failure_class: str = "code_resolvable",
533
+ notes: list[str] | None = None,
534
+ ) -> TranscriptResult:
535
+ return TranscriptResult(
536
+ text="",
537
+ status="failed",
538
+ source_level="partial_extraction",
539
+ backend=DOUYIN_TRANSCRIPT_BACKEND,
540
+ source_notes=["Douyin transcript provider returned a structured failure.", *(notes or [])],
541
+ error_code=code,
542
+ error_summary=sanitize_error_summary(summary),
543
+ title=item.title,
544
+ author=item.author,
545
+ published_at=item.published_at,
546
+ metadata={
547
+ "profile": "media",
548
+ "content_id": item.id,
549
+ "failure_class": failure_class,
550
+ "media_retention": "none",
551
+ },
552
+ )
553
+
554
+
555
+ def _multipart_body(
556
+ boundary: str,
557
+ *,
558
+ fields: dict[str, str],
559
+ file_field: str,
560
+ file_path: Path,
561
+ content_type: str,
562
+ ) -> bytes:
563
+ chunks: list[bytes] = []
564
+ for name, value in fields.items():
565
+ chunks.extend(
566
+ [
567
+ f"--{boundary}\r\n".encode(),
568
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
569
+ value.encode("utf-8"),
570
+ b"\r\n",
571
+ ]
572
+ )
573
+ chunks.extend(
574
+ [
575
+ f"--{boundary}\r\n".encode(),
576
+ (
577
+ f'Content-Disposition: form-data; name="{file_field}"; filename="{file_path.name}"\r\n'
578
+ f"Content-Type: {content_type}\r\n\r\n"
579
+ ).encode(),
580
+ file_path.read_bytes(),
581
+ b"\r\n",
582
+ f"--{boundary}--\r\n".encode(),
583
+ ]
584
+ )
585
+ return b"".join(chunks)
586
+
587
+
588
+ def _asr_text_from_payload(payload: object) -> str:
589
+ if not isinstance(payload, dict):
590
+ return ""
591
+ text = str(payload.get("text") or payload.get("transcription") or "").strip()
592
+ data = payload.get("data")
593
+ if not text and isinstance(data, dict):
594
+ text = str(data.get("text") or "").strip()
595
+ segments = payload.get("segments")
596
+ if not text and isinstance(segments, list):
597
+ text = "\n".join(
598
+ str(segment.get("text") or "").strip()
599
+ for segment in segments
600
+ if isinstance(segment, dict) and str(segment.get("text") or "").strip()
601
+ )
602
+ return text