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,876 @@
1
+ """Composite Xiaohongshu native text, ordered image OCR, and video transcript provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import json
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass, replace
13
+ from pathlib import Path, PurePosixPath
14
+ from urllib.parse import urlparse
15
+
16
+ from openmd.core.opencli_output import load_opencli_json
17
+ from openmd.adapters.screenshot.backends.rapidocr import RapidOCRBackend
18
+ from openmd.backends.base import (
19
+ BackendCapability,
20
+ BackendDescriptor,
21
+ BackendExtractRequest,
22
+ BackendHealth,
23
+ ItemIdentityResolution,
24
+ )
25
+ from openmd.backends.douyin_providers import AsrClient, SiliconFlowASRClient
26
+ from openmd.backends.executor import BackendProviderRegistry
27
+ from openmd.backends.registry import BackendRuntimeOptions
28
+ from openmd.backends.xiaohongshu import (
29
+ classify_xiaohongshu_failure,
30
+ normalize_xiaohongshu_url,
31
+ xiaohongshu_user_id,
32
+ )
33
+ from openmd.backends.youtube_discovery import (
34
+ _is_windows_batch_file,
35
+ _resolve_executable,
36
+ _windows_batch_command_line,
37
+ )
38
+ from openmd.core.models import ContentItem, ExtractionComponent, TranscriptResult
39
+ from openmd.core.package_contract import XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION
40
+ from openmd.core.secrets import sanitize_error_summary, sanitize_url
41
+ from openmd.runtime.tempdirs import owned_temporary_directory
42
+ from openmd.runtime.interpreters import run_interruptibly
43
+
44
+
45
+ XIAOHONGSHU_COMPOSITE_BACKEND = "xiaohongshu.composite"
46
+ XIAOHONGSHU_NATIVE_BACKEND = "opencli.openmd-xiaohongshu.note-assets"
47
+ XIAOHONGSHU_IMAGE_OCR_BACKEND = "ocr.rapidocr.xiaohongshu"
48
+ XIAOHONGSHU_HARD_SUBTITLE_BACKEND = "ocr.rapidocr.xiaohongshu-video"
49
+
50
+ AssetRunner = Callable[[list[str], int, str | None], tuple[int, str, str]]
51
+ CommandRunner = Callable[[list[str], int], tuple[int, str, str]]
52
+ ImageOcrExtractor = Callable[[Path, int], str]
53
+ HardSubtitleExtractor = Callable[[Path], str]
54
+
55
+ _TEXT_TOKEN_RE = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]")
56
+ _TIMESTAMP_PREFIX_RE = re.compile(r"^\[?\d{1,2}:\d{2}(?::\d{2})?\]?\s*")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class XiaohongshuAssetBundle:
61
+ note_id: str
62
+ title: str
63
+ author: str
64
+ native_text: str
65
+ tags: list[str]
66
+ media_kind: str
67
+ native_transcript: str
68
+ image_count: int
69
+ video_count: int
70
+ image_paths: list[Path | None]
71
+ video_paths: list[Path | None]
72
+ image_errors: list[str | None]
73
+ video_errors: list[str | None]
74
+
75
+
76
+ def register_backend_providers(
77
+ registry: BackendProviderRegistry,
78
+ options: BackendRuntimeOptions,
79
+ ) -> None:
80
+ requested = options.backend_override
81
+ if options.enable_browser_backends and requested in {None, XIAOHONGSHU_COMPOSITE_BACKEND}:
82
+ registry.register(XIAOHONGSHU_COMPOSITE_BACKEND, XiaohongshuCompositeProvider(options))
83
+
84
+
85
+ def build_opencli_xiaohongshu_note_assets_args(
86
+ input_value: str,
87
+ *,
88
+ output_dir: Path,
89
+ profile_id: str | None,
90
+ download: bool,
91
+ selector_stdin: bool = False,
92
+ command_timeout_seconds: int = 900,
93
+ ) -> list[str]:
94
+ safe_target = sanitize_url(normalize_xiaohongshu_url(input_value))
95
+ target = "stdin-selector" if selector_stdin and _is_xhslink_selector(safe_target) else safe_target
96
+ command = [
97
+ "opencli",
98
+ "openmd-xiaohongshu",
99
+ "note-assets",
100
+ target,
101
+ ]
102
+ if selector_stdin:
103
+ command.extend(["--selector_stdin", "true"])
104
+ if profile_id:
105
+ command.extend(["--creator", profile_id])
106
+ command.extend(
107
+ [
108
+ "--output",
109
+ str(output_dir),
110
+ "--download",
111
+ "true" if download else "false",
112
+ "--timeout",
113
+ str(max(1, int(command_timeout_seconds))),
114
+ "-f",
115
+ "json",
116
+ ]
117
+ )
118
+ return command
119
+
120
+
121
+ def _is_xhslink_selector(value: str) -> bool:
122
+ try:
123
+ host = urlparse(str(value or "")).netloc.lower()
124
+ except ValueError:
125
+ return False
126
+ return host == "xhslink.com" or host.endswith(".xhslink.com")
127
+
128
+
129
+ class XiaohongshuCompositeProvider:
130
+ """Extract one note into three explicit aggregate components."""
131
+
132
+ name = XIAOHONGSHU_COMPOSITE_BACKEND
133
+
134
+ def __init__(
135
+ self,
136
+ options: BackendRuntimeOptions,
137
+ *,
138
+ runner: AssetRunner | None = None,
139
+ command_runner: CommandRunner | None = None,
140
+ image_ocr: ImageOcrExtractor | None = None,
141
+ asr_client: AsrClient | None = None,
142
+ hard_subtitle_ocr: HardSubtitleExtractor | None = None,
143
+ opencli_timeout: int = 900,
144
+ ffmpeg_timeout: int = 600,
145
+ asr_timeout: int = 300,
146
+ ) -> None:
147
+ self._options = options
148
+ self._runner = runner or _run_asset_command
149
+ self._command_runner = command_runner or _run_command
150
+ self._image_ocr_override = image_ocr
151
+ self._asr_client = asr_client or SiliconFlowASRClient()
152
+ self._hard_subtitle_override = hard_subtitle_ocr
153
+ self._opencli_timeout = opencli_timeout
154
+ self._ffmpeg_timeout = ffmpeg_timeout
155
+ self._asr_timeout = asr_timeout
156
+
157
+ def descriptor(self) -> BackendDescriptor:
158
+ return BackendDescriptor(
159
+ name=self.name,
160
+ layer="composite browser/local extraction",
161
+ capabilities=(
162
+ BackendCapability(
163
+ platforms=("xiaohongshu",),
164
+ source_types=("mixed", "image_text", "video"),
165
+ input_types=("single", "collection"),
166
+ source_levels=("native_text", "platform_transcript", "media_asr", "ocr", "partial_extraction"),
167
+ requires_network=True,
168
+ requires_credentials=True,
169
+ optional_config=(
170
+ "enable_browser_backends",
171
+ "enable_media_download_backends",
172
+ "enable_asr_backends",
173
+ "enable_cloud_backends",
174
+ "SILICONFLOW_API_KEY",
175
+ ),
176
+ ),
177
+ ),
178
+ description="Resolve native note text and temporary media through the project plugin, then local OCR or explicitly authorized cloud ASR.",
179
+ provider="openmd.backends.xiaohongshu_providers",
180
+ )
181
+
182
+ def health(self) -> BackendHealth:
183
+ return BackendHealth(
184
+ ok=True,
185
+ dependencies={
186
+ "opencli": "injected" if self._runner is not _run_asset_command else "available" if shutil.which("opencli") or shutil.which("opencli.cmd") else "missing",
187
+ "ffmpeg": "available" if shutil.which("ffmpeg") else "missing",
188
+ self._asr_client.name: "available" if self._asr_client.is_available() else "missing",
189
+ },
190
+ )
191
+
192
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
193
+ item = request.item
194
+ if item.platform != "xiaohongshu":
195
+ return _command_failure(item, "unsupported_platform", "Xiaohongshu composite provider only supports xiaohongshu.")
196
+ profile_id = _profile_id_for_item(item)
197
+ selector = _runtime_selector_for_item(item)
198
+ download = self._options.enable_media_download_backends
199
+ with owned_temporary_directory(prefix="openmd-xiaohongshu-assets-") as temp_dir:
200
+ temp_root = Path(temp_dir)
201
+ command = build_opencli_xiaohongshu_note_assets_args(
202
+ item.url,
203
+ output_dir=temp_root,
204
+ profile_id=profile_id,
205
+ download=download,
206
+ selector_stdin=bool(selector),
207
+ command_timeout_seconds=self._opencli_timeout,
208
+ )
209
+ exit_code, stdout, stderr = self._runner(command, self._opencli_timeout, selector)
210
+ if exit_code != 0:
211
+ return _command_failure(
212
+ item,
213
+ classify_xiaohongshu_failure(stderr or stdout),
214
+ stderr or stdout or f"OpenCLI exited with code {exit_code}.",
215
+ )
216
+ try:
217
+ bundle = parse_opencli_xiaohongshu_note_assets(stdout, temp_root=temp_root)
218
+ except (json.JSONDecodeError, ValueError) as exc:
219
+ return _command_failure(
220
+ item,
221
+ "opencli_xiaohongshu_note_assets_invalid_output",
222
+ exc,
223
+ )
224
+ return self._build_result(item, bundle, download=download)
225
+
226
+ def resolve_item_identity(self, item: ContentItem) -> ItemIdentityResolution:
227
+ selector = _runtime_selector_for_item(item)
228
+ if not selector:
229
+ return ItemIdentityResolution.failure(
230
+ item,
231
+ provider=self.name,
232
+ error_code="xiaohongshu_identity_selector_missing",
233
+ error_summary="Xiaohongshu durable identity resolution requires a runtime selector.",
234
+ )
235
+ with owned_temporary_directory(prefix="openmd-xiaohongshu-identity-") as temp_dir:
236
+ command = build_opencli_xiaohongshu_note_assets_args(
237
+ item.url,
238
+ output_dir=Path(temp_dir),
239
+ profile_id=_profile_id_for_item(item),
240
+ download=False,
241
+ selector_stdin=True,
242
+ command_timeout_seconds=self._opencli_timeout,
243
+ )
244
+ exit_code, stdout, stderr = self._runner(
245
+ command,
246
+ self._opencli_timeout,
247
+ selector,
248
+ )
249
+ if exit_code != 0:
250
+ return ItemIdentityResolution.failure(
251
+ item,
252
+ provider=self.name,
253
+ error_code=classify_xiaohongshu_failure(stderr or stdout),
254
+ error_summary=sanitize_error_summary(stderr or stdout)
255
+ or "Xiaohongshu identity resolution failed.",
256
+ )
257
+ try:
258
+ bundle = parse_opencli_xiaohongshu_note_assets(
259
+ stdout,
260
+ temp_root=Path(temp_dir),
261
+ )
262
+ except (json.JSONDecodeError, ValueError) as exc:
263
+ return ItemIdentityResolution.failure(
264
+ item,
265
+ provider=self.name,
266
+ error_code="xiaohongshu_identity_invalid_output",
267
+ error_summary=sanitize_error_summary(exc)
268
+ or "Xiaohongshu identity output was invalid.",
269
+ )
270
+
271
+ note_id = bundle.note_id.strip().lower()
272
+ if not note_id:
273
+ return ItemIdentityResolution.failure(
274
+ item,
275
+ provider=self.name,
276
+ error_code="xiaohongshu_identity_missing_note_id",
277
+ error_summary="Xiaohongshu identity output did not contain a note ID.",
278
+ )
279
+ media_kind = _normalized_media_kind(bundle.media_kind)
280
+ raw_meta = dict(item.raw_meta)
281
+ raw_meta.update(
282
+ {
283
+ "content_id": note_id,
284
+ "media_kind": media_kind,
285
+ "profile": "media" if media_kind in {"video", "mixed"} else "document",
286
+ "extraction_contract_version": XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
287
+ "required_components": _required_components_for_media_kind(media_kind),
288
+ "identity_unresolved": False,
289
+ }
290
+ )
291
+ resolved = replace(
292
+ item,
293
+ id=note_id,
294
+ url=f"https://www.xiaohongshu.com/discovery/item/{note_id}",
295
+ title=bundle.title or item.title,
296
+ author=bundle.author or item.author,
297
+ source_type=_source_type_for_media_kind(media_kind),
298
+ raw_meta=raw_meta,
299
+ )
300
+ return ItemIdentityResolution.success(resolved, provider=self.name)
301
+
302
+ def _build_result(
303
+ self,
304
+ item: ContentItem,
305
+ bundle: XiaohongshuAssetBundle,
306
+ *,
307
+ download: bool,
308
+ ) -> TranscriptResult:
309
+ native_section, native_component = _native_component(bundle)
310
+ image_section, image_component = self._image_component(bundle, download=download)
311
+ video_section, video_component = self._video_component(bundle, download=download)
312
+ components = [native_component, image_component, video_component]
313
+ text = (
314
+ "## Native Note Text\n\n"
315
+ f"{native_section}\n\n"
316
+ "## Image OCR\n\n"
317
+ f"{image_section}\n\n"
318
+ "## Video Transcript\n\n"
319
+ f"{video_section}\n"
320
+ )
321
+ usable = any(component.status == "verified" for component in components)
322
+ blocking = any(component.status in {"failed", "retryable", "partial", "auth_challenge"} for component in components)
323
+ terminal_gap = any(component.status == "unavailable" for component in components)
324
+ status = "failed" if not usable else "partial" if blocking or terminal_gap else "verified"
325
+ source_level = _primary_source_level(components)
326
+ return TranscriptResult(
327
+ text=text,
328
+ status=status,
329
+ source_level=source_level,
330
+ backend=self.name,
331
+ title=bundle.title or item.title,
332
+ author=bundle.author or item.author,
333
+ published_at=item.published_at,
334
+ source_notes=[
335
+ "Project-owned OpenCLI resolved note metadata and downloaded only into a caller-owned temporary directory.",
336
+ "Ordered image OCR, native transcript, ASR, and hard-subtitle OCR provenance remain separate components.",
337
+ "Signed note URLs, CDN URLs, cookies, headers, raw browser stores, and temporary media were not persisted.",
338
+ ],
339
+ metadata={
340
+ "profile": "media" if bundle.media_kind in {"video", "mixed"} else "document",
341
+ "content_id": bundle.note_id or item.id,
342
+ "media_kind": bundle.media_kind,
343
+ "image_count": bundle.image_count,
344
+ "video_count": bundle.video_count,
345
+ "media_retention": "none",
346
+ "temp_cleanup": True,
347
+ },
348
+ extraction_contract_version=XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
349
+ components=components,
350
+ )
351
+
352
+ def _image_component(
353
+ self,
354
+ bundle: XiaohongshuAssetBundle,
355
+ *,
356
+ download: bool,
357
+ ) -> tuple[str, ExtractionComponent]:
358
+ if bundle.media_kind not in {"image_text", "mixed"} or bundle.image_count <= 0:
359
+ return "_Not applicable._", _not_applicable("image_ocr", "image_ocr", XIAOHONGSHU_IMAGE_OCR_BACKEND, "index.md#image-ocr")
360
+ if not download:
361
+ return "_Unavailable until media download is enabled._", _retryable(
362
+ "image_ocr", "image_ocr", XIAOHONGSHU_IMAGE_OCR_BACKEND,
363
+ "xiaohongshu_image_download_not_enabled", "Enable media download to OCR note images.", "index.md#image-ocr",
364
+ )
365
+ sections: list[str] = []
366
+ success = 0
367
+ retryable_codes: list[str] = []
368
+ for index in range(1, bundle.image_count + 1):
369
+ path = bundle.image_paths[index - 1]
370
+ text = ""
371
+ if path is None:
372
+ retryable_codes.append(bundle.image_errors[index - 1] or "xiaohongshu_image_asset_missing")
373
+ else:
374
+ try:
375
+ text = self._image_ocr(path, index)
376
+ except Exception as exc:
377
+ retryable_codes.append(_retryable_code(exc, prefix="xiaohongshu_image_ocr"))
378
+ text = ""
379
+ sections.extend([f"### Image {index}", "", text.strip() or "_OCR unavailable or empty._"])
380
+ if _has_meaningful_text(text):
381
+ success += 1
382
+ if retryable_codes:
383
+ component = _retryable(
384
+ "image_ocr", "image_ocr", XIAOHONGSHU_IMAGE_OCR_BACKEND,
385
+ retryable_codes[0],
386
+ f"Image OCR requires retry for {len(retryable_codes)} of {bundle.image_count} ordered slots.",
387
+ "index.md#image-ocr",
388
+ failure_class="runtime_transient",
389
+ )
390
+ elif success == bundle.image_count:
391
+ component = ExtractionComponent(
392
+ "image_ocr", "image_ocr", "verified", "ocr", XIAOHONGSHU_IMAGE_OCR_BACKEND,
393
+ text_ref="index.md#image-ocr",
394
+ )
395
+ elif success > 0:
396
+ component = ExtractionComponent(
397
+ "image_ocr", "image_ocr", "partial", "ocr", XIAOHONGSHU_IMAGE_OCR_BACKEND,
398
+ text_ref="index.md#image-ocr",
399
+ warnings=[f"OCR produced text for {success} of {bundle.image_count} images."],
400
+ error_code="xiaohongshu_image_ocr_partial",
401
+ failure_class="runtime_transient",
402
+ )
403
+ else:
404
+ component = _unavailable(
405
+ "image_ocr", "image_ocr", XIAOHONGSHU_IMAGE_OCR_BACKEND,
406
+ "xiaohongshu_image_ocr_empty", "No meaningful OCR text was available from the ordered images.", "index.md#image-ocr",
407
+ )
408
+ return "\n\n".join(sections), component
409
+
410
+ def _video_component(
411
+ self,
412
+ bundle: XiaohongshuAssetBundle,
413
+ *,
414
+ download: bool,
415
+ ) -> tuple[str, ExtractionComponent]:
416
+ if bundle.media_kind not in {"video", "mixed"} or bundle.video_count <= 0:
417
+ return "_Not applicable._", _not_applicable("video_transcript", "video_transcript", XIAOHONGSHU_NATIVE_BACKEND, "index.md#video-transcript")
418
+ native = _format_transcript(bundle.native_transcript)
419
+ if _has_meaningful_text(native):
420
+ return native, ExtractionComponent(
421
+ "video_transcript", "video_transcript", "verified", "platform_transcript", XIAOHONGSHU_NATIVE_BACKEND,
422
+ text_ref="index.md#video-transcript",
423
+ )
424
+ if not download:
425
+ return "_Unavailable until media download is enabled._", _retryable(
426
+ "video_transcript", "video_transcript", self._asr_client.name,
427
+ "xiaohongshu_video_download_not_enabled", "Enable media download for video transcription.", "index.md#video-transcript",
428
+ )
429
+ video_path = next((path for path in bundle.video_paths if path is not None), None)
430
+ if video_path is None:
431
+ code = next((code for code in bundle.video_errors if code), "xiaohongshu_video_asset_missing")
432
+ return "_Video asset download requires retry._", _retryable(
433
+ "video_transcript", "video_transcript", self._asr_client.name,
434
+ code, "The note exposed video metadata but no temporary video was downloaded.", "index.md#video-transcript",
435
+ failure_class="runtime_transient",
436
+ )
437
+ if not self._options.enable_asr_backends:
438
+ return "_Unavailable until ASR is enabled._", _retryable(
439
+ "video_transcript", "video_transcript", self._asr_client.name,
440
+ "xiaohongshu_asr_not_enabled", "Enable ASR to transcribe videos without native subtitles.", "index.md#video-transcript",
441
+ )
442
+ if not self._options.enable_cloud_backends:
443
+ return "_Unavailable until cloud ASR is authorized._", _retryable(
444
+ "video_transcript", "video_transcript", self._asr_client.name,
445
+ "xiaohongshu_cloud_backends_not_enabled",
446
+ "Enable --enable-cloud-backends before preparing audio for cloud ASR.",
447
+ "index.md#video-transcript",
448
+ )
449
+ if not self._asr_client.is_available():
450
+ return "_ASR backend unavailable._", _retryable(
451
+ "video_transcript", "video_transcript", self._asr_client.name,
452
+ "xiaohongshu_asr_backend_unavailable", "The configured ASR backend is unavailable.", "index.md#video-transcript",
453
+ )
454
+ asr_error = ""
455
+ retryable_code = ""
456
+ explicit_no_speech = False
457
+ try:
458
+ audio_path = video_path.with_suffix(".mp3")
459
+ self._extract_audio(video_path, audio_path)
460
+ asr_text = _format_transcript(self._asr_client.transcribe(audio_path, timeout=self._asr_timeout))
461
+ if _has_meaningful_text(asr_text):
462
+ return asr_text, ExtractionComponent(
463
+ "video_transcript", "video_transcript", "verified", "media_asr", self._asr_client.name,
464
+ text_ref="index.md#video-transcript",
465
+ )
466
+ asr_error = "ASR returned no meaningful speech."
467
+ explicit_no_speech = True
468
+ except Exception as exc: # noqa: BLE001 - hard-subtitle OCR is the approved fallback.
469
+ asr_error = sanitize_error_summary(exc)
470
+ if _is_explicit_no_speech(asr_error):
471
+ explicit_no_speech = True
472
+ else:
473
+ retryable_code = _retryable_code(exc, prefix="xiaohongshu_video_asr")
474
+ try:
475
+ hard_text = self._hard_subtitle_ocr(video_path)
476
+ except Exception as exc: # noqa: BLE001 - return structured terminal gap.
477
+ hard_text = ""
478
+ asr_error = f"{asr_error}; hard subtitle OCR failed: {sanitize_error_summary(exc)}"
479
+ retryable_code = retryable_code or _retryable_code(exc, prefix="xiaohongshu_hard_subtitle_ocr")
480
+ if _has_meaningful_text(hard_text):
481
+ return hard_text.strip() + "\n", ExtractionComponent(
482
+ "video_transcript", "video_transcript", "verified", "ocr", XIAOHONGSHU_HARD_SUBTITLE_BACKEND,
483
+ text_ref="index.md#video-transcript",
484
+ warnings=[f"ASR did not produce usable text: {asr_error}"],
485
+ )
486
+ if retryable_code:
487
+ return "_Video transcript extraction requires retry._", _retryable(
488
+ "video_transcript", "video_transcript", XIAOHONGSHU_HARD_SUBTITLE_BACKEND,
489
+ retryable_code, asr_error or "Video transcript extraction failed transiently.", "index.md#video-transcript",
490
+ failure_class="runtime_transient",
491
+ )
492
+ return "_No usable speech or hard-subtitle text._", _unavailable(
493
+ "video_transcript", "video_transcript", XIAOHONGSHU_HARD_SUBTITLE_BACKEND,
494
+ "xiaohongshu_video_transcript_unavailable", asr_error or "No usable speech or hard-subtitle text was found.", "index.md#video-transcript",
495
+ )
496
+
497
+ def _image_ocr(self, path: Path, index: int) -> str:
498
+ if self._image_ocr_override is not None:
499
+ return self._image_ocr_override(path, index)
500
+ result = RapidOCRBackend().recognize(str(path), title=f"Image {index}")
501
+ if result.status == "failed" and result.error_code not in {None, "ocr_empty_text"}:
502
+ raise RuntimeError(result.error_code)
503
+ return _strip_generated_title(result.text)
504
+
505
+ def _extract_audio(self, video_path: Path, audio_path: Path) -> None:
506
+ ffmpeg = shutil.which("ffmpeg") or "ffmpeg"
507
+ command = [
508
+ ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-i", str(video_path),
509
+ "-vn", "-ac", "1", "-ar", "16000", "-b:a", "48k", str(audio_path),
510
+ ]
511
+ exit_code, _stdout, stderr = self._command_runner(command, self._ffmpeg_timeout)
512
+ if exit_code != 0 or not audio_path.is_file() or audio_path.stat().st_size == 0:
513
+ raise RuntimeError(f"ffmpeg audio extraction failed: {sanitize_error_summary(stderr)}")
514
+
515
+ def _hard_subtitle_ocr(self, video_path: Path) -> str:
516
+ if self._hard_subtitle_override is not None:
517
+ return self._hard_subtitle_override(video_path)
518
+ ffmpeg = shutil.which("ffmpeg") or "ffmpeg"
519
+ with owned_temporary_directory(prefix="openmd-xiaohongshu-frames-") as frames_dir:
520
+ pattern = Path(frames_dir) / "frame_%05d.jpg"
521
+ command = [
522
+ ffmpeg, "-hide_banner", "-loglevel", "error", "-i", str(video_path),
523
+ "-vf", "fps=0.5,crop=iw:ih*0.52:0:ih*0.45", "-q:v", "3", str(pattern),
524
+ ]
525
+ exit_code, _stdout, stderr = self._command_runner(command, self._ffmpeg_timeout)
526
+ if exit_code != 0:
527
+ raise RuntimeError(f"ffmpeg frame extraction failed: {sanitize_error_summary(stderr)}")
528
+ segments: list[str] = []
529
+ previous = ""
530
+ for index, frame in enumerate(sorted(Path(frames_dir).glob("frame_*.jpg")), start=1):
531
+ text = self._image_ocr(frame, index).strip()
532
+ normalized = re.sub(r"\s+", "", text)
533
+ if not normalized:
534
+ continue
535
+ similarity = difflib.SequenceMatcher(None, normalized, previous).ratio() if previous else 0.0
536
+ if similarity < 0.82:
537
+ segments.append(text)
538
+ previous = normalized
539
+ return "\n".join(segments)
540
+
541
+
542
+ def parse_opencli_xiaohongshu_note_assets(stdout: str, *, temp_root: Path) -> XiaohongshuAssetBundle:
543
+ payload = load_opencli_json(str(stdout or "").strip())
544
+ rows = payload if isinstance(payload, list) else payload.get("items") if isinstance(payload, dict) else None
545
+ if not isinstance(rows, list):
546
+ raise ValueError("Xiaohongshu note-assets output must be JSON rows.")
547
+ note = next((row for row in rows if isinstance(row, dict) and row.get("row_type") == "note"), None)
548
+ if not isinstance(note, dict):
549
+ raise ValueError("Xiaohongshu note-assets output had no note metadata row.")
550
+ image_count = max(0, int(note.get("image_count") or 0))
551
+ video_count = max(0, int(note.get("video_count") or 0))
552
+ image_paths: list[Path | None] = [None] * image_count
553
+ video_paths: list[Path | None] = [None] * video_count
554
+ image_errors: list[str | None] = ["xiaohongshu_image_asset_missing"] * image_count
555
+ video_errors: list[str | None] = ["xiaohongshu_video_asset_missing"] * video_count
556
+ asset_rows = sorted(
557
+ (row for row in rows if isinstance(row, dict) and row.get("row_type") == "asset"),
558
+ key=lambda row: int(row.get("asset_index") or 0),
559
+ )
560
+ for row in asset_rows:
561
+ if not isinstance(row, dict) or row.get("row_type") != "asset":
562
+ continue
563
+ asset_type = str(row.get("asset_type") or "")
564
+ asset_index = max(0, int(row.get("asset_index") or 0))
565
+ slot = asset_index - 1 if asset_type == "image" else asset_index - image_count - 1
566
+ slots = image_paths if asset_type == "image" else video_paths
567
+ errors = image_errors if asset_type == "image" else video_errors
568
+ if slot < 0 or slot >= len(slots):
569
+ continue
570
+ if row.get("asset_status") != "success":
571
+ errors[slot] = str(row.get("asset_error_code") or f"xiaohongshu_{asset_type}_download_failed")
572
+ continue
573
+ local_path = _safe_local_asset_path(temp_root, str(row.get("local_path") or ""))
574
+ if local_path is None or not local_path.is_file():
575
+ errors[slot] = f"xiaohongshu_{asset_type}_asset_missing"
576
+ continue
577
+ slots[slot] = local_path
578
+ errors[slot] = None
579
+ return XiaohongshuAssetBundle(
580
+ note_id=str(note.get("note_id") or ""),
581
+ title=str(note.get("title") or ""),
582
+ author=str(note.get("author") or ""),
583
+ native_text=str(note.get("native_text") or ""),
584
+ tags=[str(value) for value in note.get("tags", []) if isinstance(value, str)] if isinstance(note.get("tags"), list) else [],
585
+ media_kind=str(note.get("media_kind") or "text"),
586
+ native_transcript=str(note.get("native_transcript") or ""),
587
+ image_count=image_count,
588
+ video_count=video_count,
589
+ image_paths=image_paths,
590
+ video_paths=video_paths,
591
+ image_errors=image_errors,
592
+ video_errors=video_errors,
593
+ )
594
+
595
+
596
+ def _profile_id_for_item(item: ContentItem) -> str | None:
597
+ for value in (
598
+ item.raw_meta.get("profile_id"),
599
+ item.raw_meta.get("collection_url"),
600
+ item.raw_meta.get("profile_url"),
601
+ ):
602
+ profile_id = xiaohongshu_user_id(str(value or ""))
603
+ if profile_id:
604
+ return profile_id
605
+ return None
606
+
607
+
608
+ def _runtime_selector_for_item(item: ContentItem) -> str | None:
609
+ for key in ("runtime_note_url", "runtime_selector", "runtime_profile_selector"):
610
+ value = str(item.raw_meta.get(key) or "").strip()
611
+ if value:
612
+ return value
613
+ parsed = normalize_xiaohongshu_url(item.url)
614
+ host = urlparse(parsed).netloc.lower()
615
+ if host == "xhslink.com" or host.endswith(".xhslink.com"):
616
+ return parsed
617
+ return None
618
+
619
+
620
+ def _normalized_media_kind(value: str) -> str:
621
+ normalized = str(value or "").strip().lower().replace("-", "_")
622
+ if normalized in {"text", "image_text", "video", "mixed"}:
623
+ return normalized
624
+ return "mixed"
625
+
626
+
627
+ def _required_components_for_media_kind(media_kind: str) -> list[str]:
628
+ if media_kind == "text":
629
+ return ["native_text"]
630
+ if media_kind == "image_text":
631
+ return ["native_text", "image_ocr"]
632
+ if media_kind == "video":
633
+ return ["native_text", "video_transcript"]
634
+ return ["native_text", "image_ocr", "video_transcript"]
635
+
636
+
637
+ def _source_type_for_media_kind(media_kind: str) -> str:
638
+ if media_kind == "text":
639
+ return "article"
640
+ if media_kind in {"image_text", "video"}:
641
+ return media_kind
642
+ return "mixed"
643
+
644
+
645
+ def _native_component(bundle: XiaohongshuAssetBundle) -> tuple[str, ExtractionComponent]:
646
+ parts = [bundle.native_text.strip()]
647
+ if bundle.tags:
648
+ parts.append("Tags: " + ", ".join(bundle.tags))
649
+ text = "\n\n".join(part for part in parts if part).strip()
650
+ if not text and bundle.title:
651
+ text = "_The creator title is preserved in the document heading; no separate body text was present._"
652
+ if _has_meaningful_text(text) or bundle.title:
653
+ return text, ExtractionComponent(
654
+ "native_text", "native_text", "verified", "native_text", XIAOHONGSHU_NATIVE_BACKEND,
655
+ text_ref="index.md#native-note-text",
656
+ )
657
+ return "_Native note text unavailable._", _unavailable(
658
+ "native_text", "native_text", XIAOHONGSHU_NATIVE_BACKEND,
659
+ "xiaohongshu_native_text_unavailable", "No creator-authored title, body, or tags were available.", "index.md#native-note-text",
660
+ )
661
+
662
+
663
+ def _not_applicable(component_id: str, kind: str, backend: str, text_ref: str) -> ExtractionComponent:
664
+ return ExtractionComponent(component_id, kind, "not_applicable", "partial_extraction", backend, text_ref=text_ref)
665
+
666
+
667
+ def _retryable(
668
+ component_id: str, kind: str, backend: str, code: str, warning: str, text_ref: str,
669
+ *, failure_class: str = "user_resolvable",
670
+ ) -> ExtractionComponent:
671
+ return ExtractionComponent(
672
+ component_id, kind, "retryable", "partial_extraction", backend,
673
+ text_ref=text_ref, warnings=[warning], error_code=code, failure_class=failure_class,
674
+ )
675
+
676
+
677
+ def _unavailable(
678
+ component_id: str, kind: str, backend: str, code: str, reason: str, text_ref: str,
679
+ ) -> ExtractionComponent:
680
+ return ExtractionComponent(
681
+ component_id, kind, "unavailable", "partial_extraction", backend,
682
+ text_ref=text_ref, warnings=[reason], terminal_reason=reason,
683
+ error_code=code, failure_class="source_unavailable",
684
+ )
685
+
686
+
687
+ def _primary_source_level(components: list[ExtractionComponent]) -> str:
688
+ for component in components:
689
+ if component.status == "verified" and component.id == "native_text":
690
+ return "native_text"
691
+ for component in components:
692
+ if component.status == "verified":
693
+ return component.source_level
694
+ return "partial_extraction"
695
+
696
+
697
+ def _safe_local_asset_path(root: Path, value: str) -> Path | None:
698
+ normalized = value.replace("\\", "/")
699
+ pure = PurePosixPath(normalized)
700
+ if not normalized or pure.is_absolute() or ".." in pure.parts:
701
+ return None
702
+ candidate = (root / Path(*pure.parts)).resolve()
703
+ try:
704
+ candidate.relative_to(root.resolve())
705
+ except ValueError:
706
+ return None
707
+ return candidate
708
+
709
+
710
+ def _format_transcript(text: str) -> str:
711
+ lines: list[str] = []
712
+ for raw in str(text or "").replace("\r\n", "\n").replace("\r", "\n").splitlines():
713
+ line = _TIMESTAMP_PREFIX_RE.sub("", raw).strip()
714
+ if _has_meaningful_text(line):
715
+ lines.append(f"[00:00:00] {line}")
716
+ if not lines and _has_meaningful_text(text):
717
+ lines = [f"[00:00:00] {str(text).strip()}"]
718
+ return "\n".join(lines).strip() + ("\n" if lines else "")
719
+
720
+
721
+ def _has_meaningful_text(value: str) -> bool:
722
+ return len(_TEXT_TOKEN_RE.findall(str(value or ""))) >= 2
723
+
724
+
725
+ def _strip_generated_title(text: str) -> str:
726
+ lines = str(text or "").splitlines()
727
+ if lines and lines[0].startswith("# "):
728
+ lines = lines[1:]
729
+ return "\n".join(lines).strip()
730
+
731
+
732
+ def _is_explicit_no_speech(value: str) -> bool:
733
+ text = str(value or "").lower()
734
+ return any(term in text for term in ("no meaningful speech", "no meaningful text", "no speech"))
735
+
736
+
737
+ def _retryable_code(error: object, *, prefix: str) -> str:
738
+ text = str(error or "").lower()
739
+ if "not available" in text or "not found" in text or "missing" in text:
740
+ return f"{prefix}_dependency_missing"
741
+ if "timeout" in text or "timed out" in text:
742
+ return f"{prefix}_timeout"
743
+ if "http" in text or any(code in text for code in ("403", "404", "429", "500", "502", "503")):
744
+ return f"{prefix}_http_error"
745
+ return f"{prefix}_retryable"
746
+
747
+
748
+ def _run_command(command: list[str], timeout: int) -> tuple[int, str, str]:
749
+ completed = run_interruptibly(
750
+ command,
751
+ capture_output=True,
752
+ text=True,
753
+ encoding="utf-8",
754
+ errors="replace",
755
+ timeout=timeout,
756
+ check=False,
757
+ )
758
+ return completed.returncode, completed.stdout, completed.stderr
759
+
760
+
761
+ def _run_asset_command(
762
+ command: list[str],
763
+ timeout: int,
764
+ selector: str | None,
765
+ ) -> tuple[int, str, str]:
766
+ resolved = [_resolve_executable(command[0]), *command[1:]]
767
+ popen_args: list[str] | str = resolved
768
+ if _is_windows_batch_file(resolved[0]):
769
+ popen_args = _windows_batch_command_line(resolved)
770
+ try:
771
+ completed = run_interruptibly(
772
+ popen_args,
773
+ input=selector or "",
774
+ capture_output=True,
775
+ text=True,
776
+ encoding="utf-8",
777
+ errors="replace",
778
+ timeout=timeout,
779
+ check=False,
780
+ )
781
+ except subprocess.TimeoutExpired:
782
+ return 124, "", f"OpenCLI note-assets timed out after {timeout} seconds."
783
+ except OSError as exc:
784
+ return 127, "", sanitize_error_summary(exc)
785
+ return completed.returncode, completed.stdout, completed.stderr
786
+
787
+
788
+ def _command_failure(
789
+ item: ContentItem,
790
+ code: str,
791
+ summary: object,
792
+ ) -> TranscriptResult:
793
+ if _is_terminal_source_unavailable_code(code):
794
+ reason = sanitize_error_summary(summary) or "The Xiaohongshu note is unavailable."
795
+ components = [
796
+ _unavailable(
797
+ component_id,
798
+ component_id,
799
+ XIAOHONGSHU_COMPOSITE_BACKEND,
800
+ code,
801
+ reason,
802
+ f"index.md#{component_id.replace('_', '-')}",
803
+ )
804
+ for component_id in ("native_text", "image_ocr", "video_transcript")
805
+ ]
806
+ text = (
807
+ "## Native Note Text\n\n_Source unavailable._\n\n"
808
+ "## Image OCR\n\n_Source unavailable._\n\n"
809
+ "## Video Transcript\n\n_Source unavailable._\n"
810
+ )
811
+ media_kind = str(item.raw_meta.get("media_kind") or "mixed")
812
+ return TranscriptResult(
813
+ text=text,
814
+ status="partial",
815
+ source_level="partial_extraction",
816
+ backend=XIAOHONGSHU_COMPOSITE_BACKEND,
817
+ source_notes=[
818
+ "Xiaohongshu reported a durable terminal source gap; no raw response or transient locator was persisted."
819
+ ],
820
+ error_code=code,
821
+ error_summary=reason,
822
+ title=item.title or item.id or "Unavailable Xiaohongshu note",
823
+ author=item.author,
824
+ published_at=item.published_at,
825
+ metadata={
826
+ "profile": "media" if media_kind in {"video", "mixed"} else "document",
827
+ "media_kind": media_kind,
828
+ "media_retention": "none",
829
+ "temp_cleanup": True,
830
+ },
831
+ extraction_contract_version=XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
832
+ components=components,
833
+ )
834
+
835
+ auth = "auth" in code or "session" in code
836
+ status = "auth_challenge" if auth else "failed"
837
+ failure_class = "user_resolvable" if auth else "runtime_transient"
838
+ components = [
839
+ ExtractionComponent(
840
+ component_id,
841
+ component_id,
842
+ status,
843
+ "partial_extraction",
844
+ XIAOHONGSHU_COMPOSITE_BACKEND,
845
+ text_ref=f"index.md#{component_id.replace('_', '-')}",
846
+ warnings=[sanitize_error_summary(summary)],
847
+ error_code=code,
848
+ failure_class=failure_class,
849
+ )
850
+ for component_id in ("native_text", "image_ocr", "video_transcript")
851
+ ]
852
+ return TranscriptResult(
853
+ text="",
854
+ status="failed",
855
+ source_level="partial_extraction",
856
+ backend=XIAOHONGSHU_COMPOSITE_BACKEND,
857
+ source_notes=["Xiaohongshu composite provider returned a structured command failure."],
858
+ error_code=code,
859
+ error_summary=sanitize_error_summary(summary),
860
+ title=item.title,
861
+ author=item.author,
862
+ published_at=item.published_at,
863
+ metadata={"media_retention": "none", "temp_cleanup": True},
864
+ extraction_contract_version=XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
865
+ components=components,
866
+ )
867
+
868
+
869
+ def _is_terminal_source_unavailable_code(code: str) -> bool:
870
+ normalized = str(code or "").strip().lower()
871
+ return normalized in {
872
+ "xiaohongshu_not_found",
873
+ "xiaohongshu_removed",
874
+ "xiaohongshu_deleted",
875
+ "xiaohongshu_source_unavailable",
876
+ }