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