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