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