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,868 @@
|
|
|
1
|
+
"""Douyin bounded runtime discovery provider.
|
|
2
|
+
|
|
3
|
+
This platform extension is intentionally narrow: it only registers a read-only,
|
|
4
|
+
bounded OpenCLI discovery provider when browser-context backends are explicitly
|
|
5
|
+
enabled. It does not download media, run ASR, resolve short links, or perform
|
|
6
|
+
account actions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import parse_qs, urlparse
|
|
19
|
+
|
|
20
|
+
from openmd.core.opencli_output import load_opencli_json
|
|
21
|
+
from openmd.backends.base import (
|
|
22
|
+
BackendCapability,
|
|
23
|
+
BackendDescriptor,
|
|
24
|
+
BackendExtractRequest,
|
|
25
|
+
BackendHealth,
|
|
26
|
+
DiscoveryRequest,
|
|
27
|
+
DiscoveryResult,
|
|
28
|
+
)
|
|
29
|
+
from openmd.backends.executor import BackendProviderRegistry, DiscoveryProviderRegistry
|
|
30
|
+
from openmd.backends.douyin_detail import douyin_author, douyin_published_at
|
|
31
|
+
from openmd.backends.registry import BackendRuntimeOptions
|
|
32
|
+
from openmd.backends.youtube_discovery import _run_opencli
|
|
33
|
+
from openmd.core.detector import extract_first_url
|
|
34
|
+
from openmd.core.models import ContentItem, TranscriptResult
|
|
35
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
DOUYIN_OPENCLI_USER_VIDEOS_BACKEND = "opencli.douyin.user-videos"
|
|
39
|
+
DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND = "opencli.openmd-douyin.creator-videos"
|
|
40
|
+
DOUYIN_METADATA_BACKEND = "douyin.metadata"
|
|
41
|
+
DEFAULT_OPENCLI_DOUYIN_LIMIT = 20
|
|
42
|
+
MAX_OPENCLI_DOUYIN_LIMIT = 20
|
|
43
|
+
|
|
44
|
+
_SEC_UID_RE = re.compile(r"^[A-Za-z0-9_.-]{6,}$")
|
|
45
|
+
_AWEME_ID_RE = re.compile(r"^\d{6,}$")
|
|
46
|
+
_URL_CANDIDATE_RE = re.compile(r"https?://[^\s|,)\]]+")
|
|
47
|
+
_SENSITIVE_DOUYIN_TERMS = (
|
|
48
|
+
"raw_response",
|
|
49
|
+
"raw_stdout",
|
|
50
|
+
"raw_stderr",
|
|
51
|
+
"play_url",
|
|
52
|
+
"download_url",
|
|
53
|
+
"top_comments",
|
|
54
|
+
"transcript_candidates",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
OpenCliRunner = Callable[..., tuple[int, str, str]]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def register_backend_providers(
|
|
61
|
+
registry: BackendProviderRegistry,
|
|
62
|
+
options: BackendRuntimeOptions,
|
|
63
|
+
) -> None:
|
|
64
|
+
requested = options.backend_override
|
|
65
|
+
if requested in {None, DOUYIN_METADATA_BACKEND}:
|
|
66
|
+
registry.register(DOUYIN_METADATA_BACKEND, DouyinMetadataProvider())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register_discovery_providers(
|
|
70
|
+
registry: DiscoveryProviderRegistry,
|
|
71
|
+
options: BackendRuntimeOptions,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Register Douyin OpenCLI discovery only behind the browser runtime gate."""
|
|
74
|
+
requested = options.requested_discovery_provider
|
|
75
|
+
if options.enable_browser_backends and requested in {None, DOUYIN_OPENCLI_USER_VIDEOS_BACKEND}:
|
|
76
|
+
registry.register(
|
|
77
|
+
DOUYIN_OPENCLI_USER_VIDEOS_BACKEND,
|
|
78
|
+
OpenCliDouyinUserVideosProvider(),
|
|
79
|
+
platforms=("douyin",),
|
|
80
|
+
default_for_platforms=requested is None,
|
|
81
|
+
)
|
|
82
|
+
if options.enable_browser_backends and requested == DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND:
|
|
83
|
+
registry.register(
|
|
84
|
+
DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND,
|
|
85
|
+
OpenCliOpenmdDouyinCreatorVideosProvider(),
|
|
86
|
+
platforms=("douyin",),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DouyinMetadataProvider:
|
|
91
|
+
"""Package bounded Douyin discovery metadata without downloading media."""
|
|
92
|
+
|
|
93
|
+
name = DOUYIN_METADATA_BACKEND
|
|
94
|
+
|
|
95
|
+
def descriptor(self) -> BackendDescriptor:
|
|
96
|
+
return BackendDescriptor(
|
|
97
|
+
name=self.name,
|
|
98
|
+
layer="local",
|
|
99
|
+
capabilities=(
|
|
100
|
+
BackendCapability(
|
|
101
|
+
platforms=("douyin",),
|
|
102
|
+
source_types=("video",),
|
|
103
|
+
input_types=("single", "collection"),
|
|
104
|
+
source_levels=("third_party_extractor",),
|
|
105
|
+
requires_network=False,
|
|
106
|
+
requires_credentials=False,
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
description="Create a read-only Douyin metadata package from discovered items.",
|
|
110
|
+
provider="openmd.backends.douyin",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def health(self) -> BackendHealth:
|
|
114
|
+
return BackendHealth(ok=True, dependencies={self.name: "ok"})
|
|
115
|
+
|
|
116
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
117
|
+
item = request.item
|
|
118
|
+
if item.platform != "douyin":
|
|
119
|
+
return _metadata_failure(
|
|
120
|
+
"invalid_input",
|
|
121
|
+
f"Douyin metadata provider only supports douyin, got {item.platform}.",
|
|
122
|
+
item=item,
|
|
123
|
+
)
|
|
124
|
+
if not item.id and not item.title:
|
|
125
|
+
return _metadata_failure(
|
|
126
|
+
"douyin_metadata_missing",
|
|
127
|
+
"Douyin metadata package requires a discovered video id or title.",
|
|
128
|
+
item=item,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
title = item.title or item.id or "Douyin video"
|
|
132
|
+
lines = [
|
|
133
|
+
f"# {title}",
|
|
134
|
+
"",
|
|
135
|
+
"## Source",
|
|
136
|
+
"",
|
|
137
|
+
"- Platform: douyin",
|
|
138
|
+
f"- URL: {item.url}",
|
|
139
|
+
]
|
|
140
|
+
if item.id:
|
|
141
|
+
lines.append(f"- Video ID: {item.id}")
|
|
142
|
+
if item.author:
|
|
143
|
+
lines.append(f"- Author: {item.author}")
|
|
144
|
+
if item.published_at:
|
|
145
|
+
lines.append(f"- Published: {item.published_at}")
|
|
146
|
+
|
|
147
|
+
safe_fields = _safe_metadata_fields(item.raw_meta)
|
|
148
|
+
if safe_fields:
|
|
149
|
+
lines.extend(["", "## Discovered metadata", ""])
|
|
150
|
+
for key, value in safe_fields.items():
|
|
151
|
+
lines.append(f"- {key}: {value}")
|
|
152
|
+
|
|
153
|
+
lines.extend(
|
|
154
|
+
[
|
|
155
|
+
"",
|
|
156
|
+
"## Acquisition note",
|
|
157
|
+
"",
|
|
158
|
+
(
|
|
159
|
+
"This package contains bounded read-only Douyin discovery metadata only. "
|
|
160
|
+
"Openmd did not download media, capture comments, run ASR, bypass anti-bot "
|
|
161
|
+
"controls, or mutate any account state."
|
|
162
|
+
),
|
|
163
|
+
"",
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return TranscriptResult(
|
|
168
|
+
text="\n".join(lines),
|
|
169
|
+
status="verified",
|
|
170
|
+
source_level="third_party_extractor",
|
|
171
|
+
backend=self.name,
|
|
172
|
+
source_notes=[
|
|
173
|
+
"Douyin package contains bounded discovery metadata only.",
|
|
174
|
+
"No media download, comments capture, ASR, anti-bot bypass, or account mutation was attempted.",
|
|
175
|
+
],
|
|
176
|
+
title=title,
|
|
177
|
+
metadata={
|
|
178
|
+
"profile": "media",
|
|
179
|
+
"metadata_only": True,
|
|
180
|
+
"content_id": item.id,
|
|
181
|
+
"collection_url": safe_fields.get("collection_url"),
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _safe_metadata_fields(raw_meta: dict[str, Any]) -> dict[str, str]:
|
|
187
|
+
allowed = (
|
|
188
|
+
"caption_status",
|
|
189
|
+
"process_status",
|
|
190
|
+
"duration",
|
|
191
|
+
"like_count",
|
|
192
|
+
"collection_url",
|
|
193
|
+
"discovery_provider",
|
|
194
|
+
)
|
|
195
|
+
safe: dict[str, str] = {}
|
|
196
|
+
for key in allowed:
|
|
197
|
+
value = raw_meta.get(key)
|
|
198
|
+
if value is None or value == "":
|
|
199
|
+
continue
|
|
200
|
+
safe[key] = sanitize_error_summary(str(value))
|
|
201
|
+
return safe
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _metadata_failure(
|
|
205
|
+
code: str,
|
|
206
|
+
error: object,
|
|
207
|
+
*,
|
|
208
|
+
item: ContentItem,
|
|
209
|
+
) -> TranscriptResult:
|
|
210
|
+
return TranscriptResult(
|
|
211
|
+
text="",
|
|
212
|
+
status="failed",
|
|
213
|
+
source_level="partial_extraction",
|
|
214
|
+
backend=DOUYIN_METADATA_BACKEND,
|
|
215
|
+
source_notes=["Douyin metadata provider returned structured failure."],
|
|
216
|
+
error_code=code,
|
|
217
|
+
error_summary=sanitize_error_summary(error),
|
|
218
|
+
title=item.title or item.id or "Douyin video",
|
|
219
|
+
metadata={"profile": "media", "content_id": item.id},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def normalize_douyin_collection_url(input_value: str) -> str | None:
|
|
224
|
+
"""Return a safe normalized Douyin user URL or short-link URL for diagnostics."""
|
|
225
|
+
sec_uid = douyin_user_target(input_value)
|
|
226
|
+
if sec_uid:
|
|
227
|
+
return f"https://www.douyin.com/user/{sec_uid}"
|
|
228
|
+
|
|
229
|
+
value = extract_first_url(input_value).strip()
|
|
230
|
+
parsed = _parse_url(value)
|
|
231
|
+
host = parsed.netloc.lower()
|
|
232
|
+
if _is_douyin_short_host(host):
|
|
233
|
+
parts = _path_parts(parsed.path)
|
|
234
|
+
if not parts:
|
|
235
|
+
return None
|
|
236
|
+
return f"https://v.douyin.com/{parts[0]}/"
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def douyin_user_target(input_value: str) -> str | None:
|
|
241
|
+
"""Extract a Douyin ``sec_uid`` for bounded user-video discovery."""
|
|
242
|
+
value = extract_first_url(input_value).strip()
|
|
243
|
+
if _looks_like_sec_uid(value):
|
|
244
|
+
return value
|
|
245
|
+
|
|
246
|
+
parsed = _parse_url(value)
|
|
247
|
+
host = parsed.netloc.lower()
|
|
248
|
+
if not (_is_douyin_host(host) or _is_iesdouyin_host(host)):
|
|
249
|
+
return None
|
|
250
|
+
if _is_douyin_short_host(host):
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
query = parse_qs(parsed.query)
|
|
254
|
+
for key in ("sec_uid", "secUid"):
|
|
255
|
+
values = query.get(key)
|
|
256
|
+
if values:
|
|
257
|
+
candidate = values[0].strip()
|
|
258
|
+
if _looks_like_sec_uid(candidate):
|
|
259
|
+
return candidate
|
|
260
|
+
|
|
261
|
+
parts = _path_parts(parsed.path)
|
|
262
|
+
for marker in ("user", "share/user"):
|
|
263
|
+
marker_parts = marker.split("/")
|
|
264
|
+
if parts[: len(marker_parts)] == marker_parts and len(parts) > len(marker_parts):
|
|
265
|
+
candidate = parts[len(marker_parts)]
|
|
266
|
+
if _looks_like_sec_uid(candidate):
|
|
267
|
+
return candidate
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def build_opencli_douyin_user_videos_args(sec_uid: str, *, limit: int | None = None) -> list[str]:
|
|
272
|
+
"""Build a bounded, read-only OpenCLI Douyin discovery command."""
|
|
273
|
+
return [
|
|
274
|
+
"opencli",
|
|
275
|
+
"douyin",
|
|
276
|
+
"user-videos",
|
|
277
|
+
sec_uid,
|
|
278
|
+
"--limit",
|
|
279
|
+
str(_bounded_limit(limit)),
|
|
280
|
+
"--with_comments",
|
|
281
|
+
"false",
|
|
282
|
+
"-f",
|
|
283
|
+
"json",
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_opencli_openmd_douyin_creator_videos_args(input_value: str) -> list[str]:
|
|
288
|
+
"""Build the project-owned full creator discovery command."""
|
|
289
|
+
return [
|
|
290
|
+
"opencli",
|
|
291
|
+
"openmd-douyin",
|
|
292
|
+
"creator-videos",
|
|
293
|
+
extract_first_url(input_value).strip(),
|
|
294
|
+
"--timeout",
|
|
295
|
+
"1800",
|
|
296
|
+
"--page_delay_ms",
|
|
297
|
+
"800",
|
|
298
|
+
"--request_timeout_ms",
|
|
299
|
+
"30000",
|
|
300
|
+
"-f",
|
|
301
|
+
"json",
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def classify_douyin_input_failure(input_value: str, *, platform: str = "douyin") -> str:
|
|
306
|
+
"""Classify unsupported Douyin/TikTok input before any browser command runs."""
|
|
307
|
+
if platform == "tiktok":
|
|
308
|
+
return "tiktok_not_supported"
|
|
309
|
+
|
|
310
|
+
value = extract_first_url(input_value).strip()
|
|
311
|
+
parsed = _parse_url(value)
|
|
312
|
+
host = parsed.netloc.lower()
|
|
313
|
+
parts = _path_parts(parsed.path)
|
|
314
|
+
|
|
315
|
+
if _is_tiktok_host(host):
|
|
316
|
+
return "tiktok_not_supported"
|
|
317
|
+
if _is_douyin_short_host(host):
|
|
318
|
+
return "douyin_short_link_resolution_required"
|
|
319
|
+
if _is_douyin_host(host) or _is_iesdouyin_host(host):
|
|
320
|
+
if parts[:1] == ["video"] or "modal_id" in parse_qs(parsed.query):
|
|
321
|
+
return "douyin_single_video_not_supported_for_discovery"
|
|
322
|
+
return "douyin_user_sec_uid_not_found"
|
|
323
|
+
return "unsupported_platform"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def classify_douyin_discovery_failure(message: object, *, exit_code: int | None = None) -> str:
|
|
327
|
+
"""Map OpenCLI/browser failures to stable Douyin discovery error codes."""
|
|
328
|
+
text = str(message or "").lower()
|
|
329
|
+
if exit_code == 127 or "not found" in text and "opencli" in text:
|
|
330
|
+
return "backend_dependency_missing"
|
|
331
|
+
if "timed out" in text or "timeout" in text:
|
|
332
|
+
return "opencli_douyin_user_videos_timeout"
|
|
333
|
+
if any(term in text for term in ("login", "sign in", "cookie", "session", "authorization")):
|
|
334
|
+
return "douyin_auth_or_browser_session_required"
|
|
335
|
+
if any(term in text for term in ("captcha", "verify", "risk", "blocked", "风控", "验证")):
|
|
336
|
+
return "douyin_request_blocked"
|
|
337
|
+
if "sec_uid" in text or "user not found" in text or "unavailable" in text:
|
|
338
|
+
return "douyin_user_not_found"
|
|
339
|
+
if exit_code is not None:
|
|
340
|
+
return f"backend_exit_{exit_code}"
|
|
341
|
+
return "opencli_douyin_user_videos_failed"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def parse_opencli_douyin_user_videos(stdout: str) -> list[ContentItem]:
|
|
345
|
+
"""Keep transient acquisition fields in memory; manifests omit them."""
|
|
346
|
+
if not stdout.strip():
|
|
347
|
+
return []
|
|
348
|
+
payload = load_opencli_json(stdout)
|
|
349
|
+
rows = _json_video_rows(payload)
|
|
350
|
+
items: list[ContentItem] = []
|
|
351
|
+
seen: set[str] = set()
|
|
352
|
+
for row in rows:
|
|
353
|
+
item = _content_item_from_row(row, include_transient=True)
|
|
354
|
+
if item is None or not item.id or item.id in seen:
|
|
355
|
+
continue
|
|
356
|
+
seen.add(item.id)
|
|
357
|
+
items.append(item)
|
|
358
|
+
return items
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def parse_opencli_openmd_douyin_creator_videos(stdout: str) -> list[ContentItem]:
|
|
362
|
+
"""Parse full creator rows while retaining private acquisition fields in memory."""
|
|
363
|
+
if not stdout.strip():
|
|
364
|
+
return []
|
|
365
|
+
payload = load_opencli_json(stdout)
|
|
366
|
+
rows = _json_video_rows(payload)
|
|
367
|
+
items: list[ContentItem] = []
|
|
368
|
+
seen: set[str] = set()
|
|
369
|
+
for row in rows:
|
|
370
|
+
item = _content_item_from_row(row, include_transient=True)
|
|
371
|
+
if item is None or not item.id or item.id in seen:
|
|
372
|
+
continue
|
|
373
|
+
seen.add(item.id)
|
|
374
|
+
items.append(item)
|
|
375
|
+
return items
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class OpenCliDouyinUserVideosProvider:
|
|
379
|
+
"""Bounded OpenCLI fallback for public Douyin user-video discovery."""
|
|
380
|
+
|
|
381
|
+
name = DOUYIN_OPENCLI_USER_VIDEOS_BACKEND
|
|
382
|
+
|
|
383
|
+
def __init__(self, *, runner: OpenCliRunner | None = None, timeout: int = 120) -> None:
|
|
384
|
+
self._runner = runner or _run_opencli
|
|
385
|
+
self.timeout = timeout
|
|
386
|
+
|
|
387
|
+
def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
|
|
388
|
+
normalized_url = normalize_douyin_collection_url(request.input_url)
|
|
389
|
+
sec_uid = douyin_user_target(request.input_url)
|
|
390
|
+
metadata: dict[str, Any] = {
|
|
391
|
+
"platform": request.platform,
|
|
392
|
+
"normalized_url": normalized_url,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if request.platform == "douyin" and not sec_uid and _is_douyin_short_host(_parse_url(extract_first_url(request.input_url)).netloc.lower()):
|
|
396
|
+
try:
|
|
397
|
+
resolved_url = self._resolve_author_share(request.input_url)
|
|
398
|
+
sec_uid = douyin_user_target(resolved_url)
|
|
399
|
+
if not sec_uid:
|
|
400
|
+
raise ValueError("not an author selector")
|
|
401
|
+
normalized_url = f"https://www.douyin.com/user/{sec_uid}"
|
|
402
|
+
metadata["normalized_url"] = normalized_url
|
|
403
|
+
except Exception:
|
|
404
|
+
return self._failure(error_code="douyin_author_share_resolution_failed", error_summary="The shared link did not resolve to an accessible Douyin author profile.", metadata=metadata)
|
|
405
|
+
|
|
406
|
+
if request.platform != "douyin" or not sec_uid:
|
|
407
|
+
return self._failure(
|
|
408
|
+
error_code=classify_douyin_input_failure(request.input_url, platform=request.platform),
|
|
409
|
+
error_summary=_input_error_summary(request.input_url, platform=request.platform),
|
|
410
|
+
source_notes=[
|
|
411
|
+
"OpenCLI Douyin user-videos discovery did not run because the input is unsupported.",
|
|
412
|
+
"Short links are not resolved by Openmd in this safe lane.",
|
|
413
|
+
],
|
|
414
|
+
metadata=metadata,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if request.allow_all:
|
|
418
|
+
return self._failure(
|
|
419
|
+
error_code="douyin_discovery_all_not_supported",
|
|
420
|
+
error_summary="OpenCLI Douyin user-videos discovery requires a bounded --limit; --all is not supported.",
|
|
421
|
+
source_notes=["OpenCLI Douyin discovery did not run unbounded collection discovery."],
|
|
422
|
+
metadata={**metadata, "sec_uid": sec_uid},
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
limit = _bounded_limit(request.limit)
|
|
426
|
+
command = build_opencli_douyin_user_videos_args(sec_uid, limit=limit)
|
|
427
|
+
metadata = {
|
|
428
|
+
**metadata,
|
|
429
|
+
"sec_uid": sec_uid,
|
|
430
|
+
"limit": limit,
|
|
431
|
+
"requires_browser_context": True,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
|
|
436
|
+
except FileNotFoundError as exc:
|
|
437
|
+
return self._failure(
|
|
438
|
+
error_code="backend_dependency_missing",
|
|
439
|
+
error_summary=exc,
|
|
440
|
+
metadata=metadata,
|
|
441
|
+
)
|
|
442
|
+
except subprocess.TimeoutExpired as exc:
|
|
443
|
+
return self._failure(
|
|
444
|
+
error_code="opencli_douyin_user_videos_timeout",
|
|
445
|
+
error_summary=exc,
|
|
446
|
+
metadata=metadata,
|
|
447
|
+
)
|
|
448
|
+
except OSError as exc:
|
|
449
|
+
return self._failure(
|
|
450
|
+
error_code="backend_os_error",
|
|
451
|
+
error_summary=exc,
|
|
452
|
+
metadata=metadata,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
if exit_code != 0:
|
|
456
|
+
signal = stderr or stdout or f"OpenCLI exited with code {exit_code}."
|
|
457
|
+
return self._failure(
|
|
458
|
+
error_code=classify_douyin_discovery_failure(signal, exit_code=exit_code),
|
|
459
|
+
error_summary=signal,
|
|
460
|
+
source_notes=["OpenCLI Douyin user-videos returned a command failure."],
|
|
461
|
+
metadata={**metadata, "exit_code": exit_code},
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
items = parse_opencli_douyin_user_videos(stdout)
|
|
466
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
467
|
+
return self._failure(
|
|
468
|
+
error_code="opencli_douyin_user_videos_invalid_json",
|
|
469
|
+
error_summary=exc,
|
|
470
|
+
source_notes=["OpenCLI Douyin user-videos stdout was not valid JSON rows."],
|
|
471
|
+
metadata=metadata,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
items = [
|
|
475
|
+
replace(
|
|
476
|
+
item,
|
|
477
|
+
raw_meta={
|
|
478
|
+
**item.raw_meta,
|
|
479
|
+
"collection_url": normalized_url or request.input_url,
|
|
480
|
+
"discovery_provider": self.name,
|
|
481
|
+
"sec_uid": sec_uid,
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
for item in items
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
items = items[:limit]
|
|
488
|
+
if not items:
|
|
489
|
+
return self._failure(
|
|
490
|
+
error_code="opencli_douyin_user_videos_empty",
|
|
491
|
+
error_summary="OpenCLI Douyin user-videos returned no parseable video rows.",
|
|
492
|
+
metadata=metadata,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
return DiscoveryResult.success(
|
|
496
|
+
provider=self.name,
|
|
497
|
+
items=items,
|
|
498
|
+
source_notes=[
|
|
499
|
+
"OpenCLI Douyin user-videos discovery only; item extraction was not attempted.",
|
|
500
|
+
"Discovery stayed bounded by --limit and requested comments=false.",
|
|
501
|
+
"No media download, ASR, anti-bot bypass, or account mutation was attempted.",
|
|
502
|
+
"Raw stdout/stderr, play URLs, and comments were not persisted by this provider.",
|
|
503
|
+
],
|
|
504
|
+
metadata={**metadata, "item_count": len(items)},
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def _resolve_author_share(self, input_value: str) -> str:
|
|
508
|
+
"""Resolve one share URL using an owned tab, reading only its URL."""
|
|
509
|
+
session = "openmd-douyin-author-" + uuid.uuid4().hex
|
|
510
|
+
try:
|
|
511
|
+
code, _stdout, _stderr = self._runner(["opencli", "browser", "open", extract_first_url(input_value), "--session", session, "--window", "background"], timeout=self.timeout)
|
|
512
|
+
if code:
|
|
513
|
+
raise ValueError("navigation failed")
|
|
514
|
+
code, stdout, _stderr = self._runner(["opencli", "browser", "eval", "JSON.stringify({url: location.href})", "--session", session, "--window", "background"], timeout=self.timeout)
|
|
515
|
+
if code:
|
|
516
|
+
raise ValueError("URL read failed")
|
|
517
|
+
payload = load_opencli_json(stdout)
|
|
518
|
+
if isinstance(payload, str):
|
|
519
|
+
payload = json.loads(payload)
|
|
520
|
+
if not isinstance(payload, dict):
|
|
521
|
+
raise ValueError("invalid URL projection")
|
|
522
|
+
return str(payload.get("url") or "")
|
|
523
|
+
finally:
|
|
524
|
+
try:
|
|
525
|
+
self._runner(["opencli", "browser", "close", "--session", session, "--window", "background"], timeout=min(self.timeout, 30))
|
|
526
|
+
except Exception:
|
|
527
|
+
pass
|
|
528
|
+
|
|
529
|
+
def _failure(
|
|
530
|
+
self,
|
|
531
|
+
*,
|
|
532
|
+
error_code: str,
|
|
533
|
+
error_summary: object,
|
|
534
|
+
source_notes: list[str] | None = None,
|
|
535
|
+
metadata: dict[str, Any] | None = None,
|
|
536
|
+
) -> DiscoveryResult:
|
|
537
|
+
return DiscoveryResult.failure(
|
|
538
|
+
provider=self.name,
|
|
539
|
+
error_code=error_code,
|
|
540
|
+
error_summary=_safe_douyin_error(error_summary),
|
|
541
|
+
source_notes=source_notes or ["OpenCLI Douyin discovery returned structured failure."],
|
|
542
|
+
metadata=metadata or {},
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class OpenCliOpenmdDouyinCreatorVideosProvider:
|
|
547
|
+
"""Full read-only creator discovery through the project-owned OpenCLI plugin."""
|
|
548
|
+
|
|
549
|
+
name = DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND
|
|
550
|
+
|
|
551
|
+
def __init__(self, *, runner: OpenCliRunner | None = None, timeout: int = 1900) -> None:
|
|
552
|
+
self._runner = runner or _run_opencli
|
|
553
|
+
self.timeout = timeout
|
|
554
|
+
|
|
555
|
+
def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
|
|
556
|
+
normalized_url = normalize_douyin_collection_url(request.input_url)
|
|
557
|
+
metadata: dict[str, Any] = {
|
|
558
|
+
"platform": request.platform,
|
|
559
|
+
"normalized_url": normalized_url,
|
|
560
|
+
"requires_browser_context": True,
|
|
561
|
+
"full_discovery": True,
|
|
562
|
+
}
|
|
563
|
+
if request.platform != "douyin":
|
|
564
|
+
return self._failure(
|
|
565
|
+
error_code="unsupported_platform",
|
|
566
|
+
error_summary="The full creator provider only supports Douyin.",
|
|
567
|
+
metadata=metadata,
|
|
568
|
+
)
|
|
569
|
+
if not request.allow_all:
|
|
570
|
+
return self._failure(
|
|
571
|
+
error_code="douyin_full_discovery_requires_all",
|
|
572
|
+
error_summary="The full creator provider requires the explicit --all collection contract.",
|
|
573
|
+
metadata=metadata,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
command = build_opencli_openmd_douyin_creator_videos_args(request.input_url)
|
|
577
|
+
try:
|
|
578
|
+
exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
|
|
579
|
+
except FileNotFoundError as exc:
|
|
580
|
+
return self._failure(
|
|
581
|
+
error_code="backend_dependency_missing",
|
|
582
|
+
error_summary=exc,
|
|
583
|
+
metadata=metadata,
|
|
584
|
+
)
|
|
585
|
+
except subprocess.TimeoutExpired as exc:
|
|
586
|
+
return self._failure(
|
|
587
|
+
error_code="opencli_douyin_creator_videos_timeout",
|
|
588
|
+
error_summary=exc,
|
|
589
|
+
metadata=metadata,
|
|
590
|
+
)
|
|
591
|
+
except OSError as exc:
|
|
592
|
+
return self._failure(
|
|
593
|
+
error_code="backend_os_error",
|
|
594
|
+
error_summary=exc,
|
|
595
|
+
metadata=metadata,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
if exit_code != 0:
|
|
599
|
+
signal = stderr or stdout or f"OpenCLI exited with code {exit_code}."
|
|
600
|
+
return self._failure(
|
|
601
|
+
error_code=classify_douyin_discovery_failure(signal, exit_code=exit_code),
|
|
602
|
+
error_summary=signal,
|
|
603
|
+
metadata={**metadata, "exit_code": exit_code},
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
items = parse_opencli_openmd_douyin_creator_videos(stdout)
|
|
608
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
609
|
+
return self._failure(
|
|
610
|
+
error_code="opencli_douyin_creator_videos_invalid_json",
|
|
611
|
+
error_summary=exc,
|
|
612
|
+
metadata=metadata,
|
|
613
|
+
)
|
|
614
|
+
if not items:
|
|
615
|
+
return self._failure(
|
|
616
|
+
error_code="opencli_douyin_creator_videos_empty",
|
|
617
|
+
error_summary="The full creator plugin returned no parseable videos.",
|
|
618
|
+
metadata=metadata,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
collection_url = normalized_url or request.input_url
|
|
622
|
+
image_post_count = sum(item.source_type == "image_text" for item in items)
|
|
623
|
+
items = [
|
|
624
|
+
replace(
|
|
625
|
+
item,
|
|
626
|
+
raw_meta={
|
|
627
|
+
**item.raw_meta,
|
|
628
|
+
"collection_url": collection_url,
|
|
629
|
+
"discovery_provider": self.name,
|
|
630
|
+
"source_exhausted": True,
|
|
631
|
+
},
|
|
632
|
+
)
|
|
633
|
+
for item in items
|
|
634
|
+
]
|
|
635
|
+
pagination_metadata = {
|
|
636
|
+
key: items[0].raw_meta.get(key)
|
|
637
|
+
for key in (
|
|
638
|
+
"page_count",
|
|
639
|
+
"empty_page_count",
|
|
640
|
+
"duplicate_page_count",
|
|
641
|
+
"pagination_retry_count",
|
|
642
|
+
"source_exhausted",
|
|
643
|
+
)
|
|
644
|
+
if items[0].raw_meta.get(key) is not None
|
|
645
|
+
}
|
|
646
|
+
return DiscoveryResult.success(
|
|
647
|
+
provider=self.name,
|
|
648
|
+
items=items,
|
|
649
|
+
source_notes=[
|
|
650
|
+
"The project-owned OpenCLI plugin paginated Douyin until has_more=false.",
|
|
651
|
+
"Video and image-text works were classified explicitly; image posts may preserve spoken audio or creator text.",
|
|
652
|
+
"Playback URLs and native transcript candidates remain transient in ContentItem.raw_meta.",
|
|
653
|
+
"The persisted discovery manifest excludes transient acquisition fields.",
|
|
654
|
+
],
|
|
655
|
+
metadata={
|
|
656
|
+
**metadata,
|
|
657
|
+
"item_count": len(items),
|
|
658
|
+
"video_count": len(items) - image_post_count,
|
|
659
|
+
"image_post_count": image_post_count,
|
|
660
|
+
"source_exhausted": True,
|
|
661
|
+
**pagination_metadata,
|
|
662
|
+
},
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
def _failure(
|
|
666
|
+
self,
|
|
667
|
+
*,
|
|
668
|
+
error_code: str,
|
|
669
|
+
error_summary: object,
|
|
670
|
+
metadata: dict[str, Any],
|
|
671
|
+
) -> DiscoveryResult:
|
|
672
|
+
return DiscoveryResult.failure(
|
|
673
|
+
provider=self.name,
|
|
674
|
+
error_code=error_code,
|
|
675
|
+
error_summary=_safe_douyin_error(error_summary),
|
|
676
|
+
source_notes=["Full Douyin creator discovery returned a structured failure."],
|
|
677
|
+
metadata=metadata,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _json_video_rows(payload: Any) -> list[dict[str, Any]]:
|
|
682
|
+
if isinstance(payload, list):
|
|
683
|
+
return [row for row in payload if isinstance(row, dict)]
|
|
684
|
+
if isinstance(payload, dict):
|
|
685
|
+
if _row_has_video_reference(payload):
|
|
686
|
+
return [payload]
|
|
687
|
+
for key in ("items", "videos", "data", "rows", "aweme_list", "awemeList"):
|
|
688
|
+
value = payload.get(key)
|
|
689
|
+
rows = _json_video_rows(value)
|
|
690
|
+
if rows:
|
|
691
|
+
return rows
|
|
692
|
+
return []
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _content_item_from_row(
|
|
696
|
+
row: dict[str, Any],
|
|
697
|
+
*,
|
|
698
|
+
include_transient: bool = False,
|
|
699
|
+
) -> ContentItem | None:
|
|
700
|
+
normalized = {_normalize_key(key): value for key, value in row.items()}
|
|
701
|
+
aweme_id = _first_present(normalized, "aweme_id", "awemeid", "video_id", "id")
|
|
702
|
+
video_url = _canonical_douyin_video_url(_first_present(normalized, "url", "video_url", "share_url"))
|
|
703
|
+
if video_url is None and aweme_id:
|
|
704
|
+
video_url = _canonical_douyin_video_url(f"https://www.douyin.com/video/{aweme_id}")
|
|
705
|
+
if aweme_id is None and video_url:
|
|
706
|
+
aweme_id = _aweme_id_from_url(video_url)
|
|
707
|
+
if video_url is None or aweme_id is None:
|
|
708
|
+
return None
|
|
709
|
+
|
|
710
|
+
title = _first_present(normalized, "title", "desc", "description", "caption")
|
|
711
|
+
author = douyin_author(normalized)
|
|
712
|
+
raw_meta: dict[str, Any] = {
|
|
713
|
+
"caption_status": "unknown",
|
|
714
|
+
"process_status": "discovered",
|
|
715
|
+
}
|
|
716
|
+
media_kind = _first_present(normalized, "media_kind") or "video"
|
|
717
|
+
source_type = "image_text" if media_kind == "image_text" else "video"
|
|
718
|
+
raw_meta["media_kind"] = media_kind
|
|
719
|
+
for key in ("aweme_type", "image_count"):
|
|
720
|
+
value = row.get(key)
|
|
721
|
+
if value is not None:
|
|
722
|
+
raw_meta[key] = value
|
|
723
|
+
duration = _first_present(normalized, "duration_s", "duration")
|
|
724
|
+
if duration:
|
|
725
|
+
raw_meta["duration"] = duration
|
|
726
|
+
digg_count = _first_present(normalized, "digg_count", "like_count", "likes")
|
|
727
|
+
if digg_count:
|
|
728
|
+
raw_meta["like_count"] = digg_count
|
|
729
|
+
if include_transient:
|
|
730
|
+
play_url = _first_present(normalized, "play_url")
|
|
731
|
+
if play_url:
|
|
732
|
+
raw_meta["play_url"] = play_url
|
|
733
|
+
transcript_candidates = row.get("transcript_candidates")
|
|
734
|
+
if isinstance(transcript_candidates, list):
|
|
735
|
+
raw_meta["transcript_candidates"] = [
|
|
736
|
+
candidate
|
|
737
|
+
for candidate in transcript_candidates
|
|
738
|
+
if isinstance(candidate, dict)
|
|
739
|
+
]
|
|
740
|
+
for key in (
|
|
741
|
+
"page_count",
|
|
742
|
+
"empty_page_count",
|
|
743
|
+
"duplicate_page_count",
|
|
744
|
+
"pagination_retry_count",
|
|
745
|
+
"source_exhausted",
|
|
746
|
+
):
|
|
747
|
+
value = row.get(key)
|
|
748
|
+
if value is not None:
|
|
749
|
+
raw_meta[key] = value
|
|
750
|
+
published_at = douyin_published_at(normalized)
|
|
751
|
+
|
|
752
|
+
return ContentItem(
|
|
753
|
+
platform="douyin",
|
|
754
|
+
input_type="collection",
|
|
755
|
+
url=video_url,
|
|
756
|
+
id=aweme_id,
|
|
757
|
+
title=title,
|
|
758
|
+
author=author,
|
|
759
|
+
published_at=published_at,
|
|
760
|
+
source_type=source_type,
|
|
761
|
+
raw_meta=raw_meta,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _row_has_video_reference(row: dict[str, Any]) -> bool:
|
|
766
|
+
normalized = {_normalize_key(key): value for key, value in row.items()}
|
|
767
|
+
aweme_id = _first_present(normalized, "aweme_id", "awemeid", "video_id", "id")
|
|
768
|
+
if aweme_id and _AWEME_ID_RE.match(aweme_id):
|
|
769
|
+
return True
|
|
770
|
+
return any(_canonical_douyin_video_url(str(value or "")) for value in row.values())
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _canonical_douyin_video_url(value: str | None) -> str | None:
|
|
774
|
+
if not value:
|
|
775
|
+
return None
|
|
776
|
+
for candidate in _video_url_candidates(value):
|
|
777
|
+
parsed = _parse_url(candidate)
|
|
778
|
+
host = parsed.netloc.lower()
|
|
779
|
+
parts = _path_parts(parsed.path)
|
|
780
|
+
if (_is_douyin_host(host) or _is_iesdouyin_host(host)) and parts[:1] == ["video"] and len(parts) >= 2:
|
|
781
|
+
aweme_id = parts[1]
|
|
782
|
+
if _AWEME_ID_RE.match(aweme_id):
|
|
783
|
+
return f"https://www.douyin.com/video/{aweme_id}"
|
|
784
|
+
return None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _aweme_id_from_url(value: str) -> str | None:
|
|
788
|
+
parsed = _parse_url(value)
|
|
789
|
+
parts = _path_parts(parsed.path)
|
|
790
|
+
if parts[:1] == ["video"] and len(parts) >= 2 and _AWEME_ID_RE.match(parts[1]):
|
|
791
|
+
return parts[1]
|
|
792
|
+
return None
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _video_url_candidates(value: str) -> list[str]:
|
|
796
|
+
candidates = [match.group(0) for match in _URL_CANDIDATE_RE.finditer(value)]
|
|
797
|
+
if not candidates:
|
|
798
|
+
candidates.append(value)
|
|
799
|
+
return candidates
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _input_error_summary(input_value: str, *, platform: str) -> str:
|
|
803
|
+
code = classify_douyin_input_failure(input_value, platform=platform)
|
|
804
|
+
if code == "tiktok_not_supported":
|
|
805
|
+
return "TikTok runtime provider is not implemented in this V0 lane."
|
|
806
|
+
if code == "douyin_short_link_resolution_required":
|
|
807
|
+
return "Douyin short links must be resolved to a user sec_uid/profile URL before bounded discovery can run."
|
|
808
|
+
if code == "douyin_single_video_not_supported_for_discovery":
|
|
809
|
+
return "Douyin single-video metadata extraction is not implemented in this safe discovery lane."
|
|
810
|
+
if code == "douyin_user_sec_uid_not_found":
|
|
811
|
+
return "Douyin user sec_uid was not found in the input URL."
|
|
812
|
+
return "Input is not a supported Douyin user/profile URL."
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _safe_douyin_error(value: object) -> str:
|
|
816
|
+
summary = sanitize_error_summary(value) or ""
|
|
817
|
+
for term in _SENSITIVE_DOUYIN_TERMS:
|
|
818
|
+
summary = re.sub(rf"(?i)\b{re.escape(term)}\b", "[redacted]", summary)
|
|
819
|
+
return summary
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _bounded_limit(limit: int | None) -> int:
|
|
823
|
+
if limit is None:
|
|
824
|
+
return DEFAULT_OPENCLI_DOUYIN_LIMIT
|
|
825
|
+
return max(0, min(limit, MAX_OPENCLI_DOUYIN_LIMIT))
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _looks_like_sec_uid(value: str) -> bool:
|
|
829
|
+
return _SEC_UID_RE.match(value) is not None and "://" not in value and "/" not in value
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _parse_url(value: str) -> Any:
|
|
833
|
+
return urlparse(value if "://" in value else f"https://{value}")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _path_parts(path: str) -> list[str]:
|
|
837
|
+
return [part for part in path.split("/") if part]
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _is_douyin_host(host: str) -> bool:
|
|
841
|
+
return host == "douyin.com" or host.endswith(".douyin.com")
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _is_iesdouyin_host(host: str) -> bool:
|
|
845
|
+
return host == "iesdouyin.com" or host.endswith(".iesdouyin.com")
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _is_douyin_short_host(host: str) -> bool:
|
|
849
|
+
return host == "v.douyin.com"
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _is_tiktok_host(host: str) -> bool:
|
|
853
|
+
return host == "tiktok.com" or host.endswith(".tiktok.com")
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _normalize_key(key: object) -> str:
|
|
857
|
+
return str(key or "").strip().lower().replace(" ", "_").replace("-", "_")
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _first_present(row: dict[str, Any], *keys: str) -> str | None:
|
|
861
|
+
for key in keys:
|
|
862
|
+
value = row.get(key)
|
|
863
|
+
if value is None:
|
|
864
|
+
continue
|
|
865
|
+
text = str(value).strip()
|
|
866
|
+
if text:
|
|
867
|
+
return text
|
|
868
|
+
return None
|