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,911 @@
|
|
|
1
|
+
"""Xiaohongshu OpenCLI runtime providers.
|
|
2
|
+
|
|
3
|
+
The providers in this module are loaded by ``openmd.backends.registry`` through
|
|
4
|
+
the platform extension naming convention. They stay inert unless the caller
|
|
5
|
+
explicitly enables browser backends, because OpenCLI may use a local browser
|
|
6
|
+
session to read signed Xiaohongshu pages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import parse_qsl, urlparse
|
|
18
|
+
|
|
19
|
+
from openmd.core.opencli_output import load_opencli_json
|
|
20
|
+
from openmd.backends.base import (
|
|
21
|
+
BackendCapability,
|
|
22
|
+
BackendDescriptor,
|
|
23
|
+
BackendExtractRequest,
|
|
24
|
+
BackendHealth,
|
|
25
|
+
DiscoveryRequest,
|
|
26
|
+
DiscoveryResult,
|
|
27
|
+
)
|
|
28
|
+
from openmd.backends.executor import BackendProviderRegistry, DiscoveryProviderRegistry
|
|
29
|
+
from openmd.backends.registry import BackendRuntimeOptions
|
|
30
|
+
from openmd.backends.youtube_discovery import _run_opencli
|
|
31
|
+
from openmd.core.detector import extract_first_url
|
|
32
|
+
from openmd.core.models import ContentItem, TranscriptResult
|
|
33
|
+
from openmd.core.package_contract import XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION
|
|
34
|
+
from openmd.core.secrets import sanitize_error_summary, sanitize_url, sanitize_url_text
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
OPENCLI_XIAOHONGSHU_NOTE_BACKEND = "opencli.xiaohongshu.note"
|
|
38
|
+
OPENCLI_XIAOHONGSHU_USER_DISCOVERY_BACKEND = "opencli.xiaohongshu.user"
|
|
39
|
+
OPENCLI_OPENMD_XIAOHONGSHU_CREATOR_NOTES_BACKEND = "opencli.openmd-xiaohongshu.creator-notes"
|
|
40
|
+
DEFAULT_XIAOHONGSHU_USER_LIMIT = 20
|
|
41
|
+
MAX_XIAOHONGSHU_USER_LIMIT = 50
|
|
42
|
+
MIN_NOTE_CHARS = 1
|
|
43
|
+
|
|
44
|
+
_SIGNED_QUERY_KEY = "xsec" + "_token"
|
|
45
|
+
_URL_CANDIDATE_RE = re.compile(r"https?://[^\s|,)\]\"']+")
|
|
46
|
+
_NOTE_PATH_MARKERS = ("/explore/", "/discovery/item/", "/item/")
|
|
47
|
+
_USER_PATH_MARKER = "/user/profile/"
|
|
48
|
+
|
|
49
|
+
OpenCliRunner = Callable[..., tuple[int, str, str]]
|
|
50
|
+
OpenCliInputRunner = Callable[..., tuple[int, str, str]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ParsedXiaohongshuNote:
|
|
55
|
+
text: str
|
|
56
|
+
title: str | None = None
|
|
57
|
+
author: str | None = None
|
|
58
|
+
published_at: str | None = None
|
|
59
|
+
note_id: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def register_backend_providers(
|
|
63
|
+
registry: BackendProviderRegistry,
|
|
64
|
+
options: BackendRuntimeOptions,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Register Xiaohongshu extraction only behind the browser backend gate."""
|
|
67
|
+
requested = options.backend_override
|
|
68
|
+
if options.enable_browser_backends and requested in {None, OPENCLI_XIAOHONGSHU_NOTE_BACKEND}:
|
|
69
|
+
registry.register(OPENCLI_XIAOHONGSHU_NOTE_BACKEND, OpenCliXiaohongshuNoteProvider())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def register_discovery_providers(
|
|
73
|
+
registry: DiscoveryProviderRegistry,
|
|
74
|
+
options: BackendRuntimeOptions,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Register bounded Xiaohongshu user discovery only behind the browser gate."""
|
|
77
|
+
requested = options.requested_discovery_provider
|
|
78
|
+
if options.enable_browser_backends and requested in {None, OPENCLI_XIAOHONGSHU_USER_DISCOVERY_BACKEND}:
|
|
79
|
+
registry.register(
|
|
80
|
+
OPENCLI_XIAOHONGSHU_USER_DISCOVERY_BACKEND,
|
|
81
|
+
OpenCliXiaohongshuUserDiscoveryProvider(),
|
|
82
|
+
platforms=("xiaohongshu",),
|
|
83
|
+
default_for_platforms=requested is None,
|
|
84
|
+
)
|
|
85
|
+
if options.enable_browser_backends and requested == OPENCLI_OPENMD_XIAOHONGSHU_CREATOR_NOTES_BACKEND:
|
|
86
|
+
registry.register(
|
|
87
|
+
OPENCLI_OPENMD_XIAOHONGSHU_CREATOR_NOTES_BACKEND,
|
|
88
|
+
OpenCliOpenmdXiaohongshuCreatorNotesProvider(),
|
|
89
|
+
platforms=("xiaohongshu",),
|
|
90
|
+
default_for_platforms=False,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_opencli_xiaohongshu_note_args(input_url: str) -> list[str]:
|
|
95
|
+
return ["opencli", "xiaohongshu", "note", input_url, "-f", "json"]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_opencli_xiaohongshu_user_args(input_url: str, *, limit: int | None = None) -> list[str]:
|
|
99
|
+
bounded_limit = bounded_xiaohongshu_user_limit(limit)
|
|
100
|
+
return [
|
|
101
|
+
"opencli",
|
|
102
|
+
"xiaohongshu",
|
|
103
|
+
"user",
|
|
104
|
+
input_url,
|
|
105
|
+
"--limit",
|
|
106
|
+
str(bounded_limit),
|
|
107
|
+
"-f",
|
|
108
|
+
"json",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_opencli_openmd_xiaohongshu_creator_notes_args(
|
|
113
|
+
input_value: str,
|
|
114
|
+
*,
|
|
115
|
+
selector_stdin: bool = False,
|
|
116
|
+
) -> list[str]:
|
|
117
|
+
"""Build the project-owned full profile discovery command with a query-free target."""
|
|
118
|
+
user_id = xiaohongshu_user_id(input_value)
|
|
119
|
+
target = (
|
|
120
|
+
f"https://www.xiaohongshu.com/user/profile/{user_id}"
|
|
121
|
+
if user_id
|
|
122
|
+
else sanitize_url(normalize_xiaohongshu_url(input_value))
|
|
123
|
+
)
|
|
124
|
+
command = [
|
|
125
|
+
"opencli",
|
|
126
|
+
"openmd-xiaohongshu",
|
|
127
|
+
"creator-notes",
|
|
128
|
+
"stdin-selector" if selector_stdin else target,
|
|
129
|
+
"--timeout",
|
|
130
|
+
"1800",
|
|
131
|
+
"--scroll_delay_ms",
|
|
132
|
+
"1500",
|
|
133
|
+
"--stable_rounds",
|
|
134
|
+
"4",
|
|
135
|
+
"-f",
|
|
136
|
+
"json",
|
|
137
|
+
]
|
|
138
|
+
if selector_stdin:
|
|
139
|
+
command.extend(
|
|
140
|
+
[
|
|
141
|
+
"--selector_stdin",
|
|
142
|
+
"true",
|
|
143
|
+
"--transient_note_urls",
|
|
144
|
+
"true",
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
return command
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def bounded_xiaohongshu_user_limit(limit: int | None) -> int:
|
|
151
|
+
if limit is None:
|
|
152
|
+
return DEFAULT_XIAOHONGSHU_USER_LIMIT
|
|
153
|
+
return min(MAX_XIAOHONGSHU_USER_LIMIT, max(0, int(limit)))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def normalize_xiaohongshu_url(input_value: str) -> str:
|
|
157
|
+
value = extract_first_url(str(input_value or "")).strip()
|
|
158
|
+
if value.startswith("xiaohongshu.com/") or value.startswith("www.xiaohongshu.com/"):
|
|
159
|
+
value = f"https://{value}"
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_xiaohongshu_user_url(input_value: str) -> bool:
|
|
164
|
+
parsed = urlparse(normalize_xiaohongshu_url(input_value))
|
|
165
|
+
return _host_is_xiaohongshu(parsed.netloc) and _USER_PATH_MARKER in parsed.path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def xiaohongshu_user_id(input_value: str) -> str | None:
|
|
169
|
+
parsed = urlparse(normalize_xiaohongshu_url(input_value))
|
|
170
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
171
|
+
if len(parts) == 3 and parts[:2] == ["user", "profile"]:
|
|
172
|
+
return parts[2]
|
|
173
|
+
value = str(input_value or "").strip()
|
|
174
|
+
if re.fullmatch(r"[A-Za-z0-9]{8,64}", value):
|
|
175
|
+
return value
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def is_xiaohongshu_note_url(input_value: str) -> bool:
|
|
180
|
+
parsed = urlparse(normalize_xiaohongshu_url(input_value))
|
|
181
|
+
if not _host_is_xiaohongshu(parsed.netloc):
|
|
182
|
+
return False
|
|
183
|
+
if parsed.netloc.lower().endswith("xhslink.com"):
|
|
184
|
+
return True
|
|
185
|
+
return any(marker in parsed.path for marker in _NOTE_PATH_MARKERS)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def has_required_signed_query(input_value: str) -> bool:
|
|
189
|
+
parsed = urlparse(normalize_xiaohongshu_url(input_value))
|
|
190
|
+
return any(key.lower() == _SIGNED_QUERY_KEY and value.strip() for key, value in parse_qsl(parsed.query))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class OpenCliXiaohongshuNoteProvider:
|
|
194
|
+
"""Extract one Xiaohongshu note through OpenCLI without persisting raw output."""
|
|
195
|
+
|
|
196
|
+
name = OPENCLI_XIAOHONGSHU_NOTE_BACKEND
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
runner: OpenCliRunner | None = None,
|
|
202
|
+
timeout: int = 120,
|
|
203
|
+
min_note_chars: int = MIN_NOTE_CHARS,
|
|
204
|
+
) -> None:
|
|
205
|
+
self._runner = runner or _run_opencli
|
|
206
|
+
self.timeout = timeout
|
|
207
|
+
self.min_note_chars = max(0, min_note_chars)
|
|
208
|
+
|
|
209
|
+
def descriptor(self) -> BackendDescriptor:
|
|
210
|
+
capability = BackendCapability(
|
|
211
|
+
platforms=("xiaohongshu",),
|
|
212
|
+
source_types=("mixed",),
|
|
213
|
+
input_types=("single", "collection"),
|
|
214
|
+
source_levels=("native_text", "third_party_extractor", "partial_extraction"),
|
|
215
|
+
requires_network=True,
|
|
216
|
+
requires_credentials=False,
|
|
217
|
+
)
|
|
218
|
+
return BackendDescriptor(
|
|
219
|
+
name=self.name,
|
|
220
|
+
layer="L3 browser/session fallback",
|
|
221
|
+
capabilities=(capability,),
|
|
222
|
+
description="Use OpenCLI Xiaohongshu note extraction for signed public note URLs.",
|
|
223
|
+
provider="opencli.xiaohongshu",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def health(self) -> BackendHealth:
|
|
227
|
+
if self._runner is not _run_opencli:
|
|
228
|
+
return BackendHealth(ok=True, dependencies={"opencli": "injected"})
|
|
229
|
+
if shutil.which("opencli") is None and shutil.which("opencli.cmd") is None:
|
|
230
|
+
return BackendHealth(
|
|
231
|
+
ok=False,
|
|
232
|
+
dependencies={"opencli": "missing"},
|
|
233
|
+
error_code="backend_dependency_missing",
|
|
234
|
+
error_summary="opencli executable is not available on PATH.",
|
|
235
|
+
)
|
|
236
|
+
return BackendHealth(ok=True, dependencies={"opencli": "available"})
|
|
237
|
+
|
|
238
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
239
|
+
item = request.item
|
|
240
|
+
url = normalize_xiaohongshu_url(item.url)
|
|
241
|
+
runtime_url = str(item.raw_meta.get("runtime_note_url") or "").strip()
|
|
242
|
+
command_url = runtime_url if has_required_signed_query(runtime_url) else url
|
|
243
|
+
metadata = {"platform": item.platform, "normalized_url": sanitize_url(url)}
|
|
244
|
+
|
|
245
|
+
if item.platform != "xiaohongshu":
|
|
246
|
+
return _failed(
|
|
247
|
+
self.name,
|
|
248
|
+
"unsupported_platform",
|
|
249
|
+
f"Xiaohongshu note provider only supports xiaohongshu, got {item.platform}.",
|
|
250
|
+
metadata=metadata,
|
|
251
|
+
)
|
|
252
|
+
if is_xiaohongshu_user_url(url):
|
|
253
|
+
return _failed(
|
|
254
|
+
self.name,
|
|
255
|
+
"xiaohongshu_user_requires_collect",
|
|
256
|
+
"Use collect with browser backends for bounded Xiaohongshu user discovery.",
|
|
257
|
+
metadata=metadata,
|
|
258
|
+
)
|
|
259
|
+
if not is_xiaohongshu_note_url(url):
|
|
260
|
+
return _failed(
|
|
261
|
+
self.name,
|
|
262
|
+
"invalid_input",
|
|
263
|
+
"Expected a Xiaohongshu note URL.",
|
|
264
|
+
metadata=metadata,
|
|
265
|
+
)
|
|
266
|
+
if not has_required_signed_query(command_url):
|
|
267
|
+
return _failed(
|
|
268
|
+
self.name,
|
|
269
|
+
"xiaohongshu_signed_url_required",
|
|
270
|
+
"Xiaohongshu note extraction requires a current signed share URL.",
|
|
271
|
+
source_notes=[
|
|
272
|
+
"OpenCLI Xiaohongshu note extraction was not run because the signed URL shape was missing.",
|
|
273
|
+
],
|
|
274
|
+
metadata=metadata,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
command = build_opencli_xiaohongshu_note_args(command_url)
|
|
278
|
+
exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
|
|
279
|
+
if exit_code != 0:
|
|
280
|
+
return _failed(
|
|
281
|
+
self.name,
|
|
282
|
+
classify_xiaohongshu_failure(stderr or stdout),
|
|
283
|
+
stderr or stdout or f"OpenCLI exited with code {exit_code}.",
|
|
284
|
+
source_notes=["OpenCLI Xiaohongshu note extraction returned a command failure."],
|
|
285
|
+
metadata={**metadata, "exit_code": exit_code},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
parsed = parse_opencli_xiaohongshu_note_output(stdout)
|
|
290
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
291
|
+
return _failed(
|
|
292
|
+
self.name,
|
|
293
|
+
"opencli_xiaohongshu_note_invalid_output",
|
|
294
|
+
exc,
|
|
295
|
+
source_notes=["OpenCLI Xiaohongshu note stdout had an unexpected shape."],
|
|
296
|
+
metadata=metadata,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if len("".join(parsed.text.split())) < self.min_note_chars:
|
|
300
|
+
return _failed(
|
|
301
|
+
self.name,
|
|
302
|
+
"opencli_xiaohongshu_note_empty",
|
|
303
|
+
"OpenCLI Xiaohongshu note extraction returned no usable text.",
|
|
304
|
+
metadata=metadata,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
result_metadata: dict[str, object] = {
|
|
308
|
+
**metadata,
|
|
309
|
+
"requires_browser_context": True,
|
|
310
|
+
}
|
|
311
|
+
if parsed.note_id:
|
|
312
|
+
result_metadata["note_id"] = parsed.note_id
|
|
313
|
+
return TranscriptResult(
|
|
314
|
+
text=parsed.text.rstrip() + "\n",
|
|
315
|
+
status="verified",
|
|
316
|
+
source_level="native_text",
|
|
317
|
+
backend=self.name,
|
|
318
|
+
title=parsed.title or item.title,
|
|
319
|
+
author=parsed.author or item.author,
|
|
320
|
+
published_at=parsed.published_at or item.published_at,
|
|
321
|
+
source_notes=[
|
|
322
|
+
"OpenCLI Xiaohongshu note extraction returned normalized note text.",
|
|
323
|
+
"Raw stdout/stderr and browser session data were not persisted.",
|
|
324
|
+
],
|
|
325
|
+
metadata=result_metadata,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class OpenCliXiaohongshuUserDiscoveryProvider:
|
|
330
|
+
"""Route bounded profiles to official OpenCLI and explicit all to the project plugin."""
|
|
331
|
+
|
|
332
|
+
name = OPENCLI_XIAOHONGSHU_USER_DISCOVERY_BACKEND
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
runner: OpenCliRunner | None = None,
|
|
338
|
+
timeout: int = 120,
|
|
339
|
+
full_timeout: int = 1900,
|
|
340
|
+
) -> None:
|
|
341
|
+
self._runner = runner or _run_opencli
|
|
342
|
+
self.timeout = timeout
|
|
343
|
+
self.full_timeout = full_timeout
|
|
344
|
+
|
|
345
|
+
def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
|
|
346
|
+
url = normalize_xiaohongshu_url(request.input_url)
|
|
347
|
+
metadata: dict[str, object] = {
|
|
348
|
+
"platform": request.platform,
|
|
349
|
+
"normalized_url": sanitize_url(url),
|
|
350
|
+
"limit": bounded_xiaohongshu_user_limit(request.limit),
|
|
351
|
+
"requires_browser_context": True,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if request.platform != "xiaohongshu" or not xiaohongshu_user_id(url):
|
|
355
|
+
return _discovery_failure(
|
|
356
|
+
self.name,
|
|
357
|
+
"xiaohongshu_user_input_not_supported",
|
|
358
|
+
"Input is not a supported Xiaohongshu user profile URL or user id.",
|
|
359
|
+
source_notes=["OpenCLI Xiaohongshu user discovery did not run because the input is unsupported."],
|
|
360
|
+
metadata=metadata,
|
|
361
|
+
)
|
|
362
|
+
if request.allow_all:
|
|
363
|
+
return OpenCliOpenmdXiaohongshuCreatorNotesProvider(
|
|
364
|
+
runner=self._runner,
|
|
365
|
+
timeout=self.full_timeout,
|
|
366
|
+
).discover(request)
|
|
367
|
+
if metadata["limit"] <= 0:
|
|
368
|
+
return _discovery_failure(
|
|
369
|
+
self.name,
|
|
370
|
+
"xiaohongshu_user_limit_required",
|
|
371
|
+
"Xiaohongshu user discovery requires a positive bounded limit.",
|
|
372
|
+
metadata=metadata,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
command = build_opencli_xiaohongshu_user_args(url, limit=request.limit)
|
|
376
|
+
exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
|
|
377
|
+
if exit_code != 0:
|
|
378
|
+
return _discovery_failure(
|
|
379
|
+
self.name,
|
|
380
|
+
classify_xiaohongshu_failure(stderr or stdout),
|
|
381
|
+
stderr or stdout or f"OpenCLI exited with code {exit_code}.",
|
|
382
|
+
source_notes=["OpenCLI Xiaohongshu user discovery returned a command failure."],
|
|
383
|
+
metadata={**metadata, "exit_code": exit_code},
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
items = parse_opencli_xiaohongshu_user_output(
|
|
388
|
+
stdout,
|
|
389
|
+
collection_url=url,
|
|
390
|
+
discovery_provider=self.name,
|
|
391
|
+
include_transient=True,
|
|
392
|
+
)[: int(metadata["limit"])]
|
|
393
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
394
|
+
return _discovery_failure(
|
|
395
|
+
self.name,
|
|
396
|
+
"opencli_xiaohongshu_user_invalid_output",
|
|
397
|
+
exc,
|
|
398
|
+
source_notes=["OpenCLI Xiaohongshu user discovery stdout had an unexpected shape."],
|
|
399
|
+
metadata=metadata,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if not items:
|
|
403
|
+
return _discovery_failure(
|
|
404
|
+
self.name,
|
|
405
|
+
"opencli_xiaohongshu_user_empty",
|
|
406
|
+
"OpenCLI Xiaohongshu user discovery returned no note items.",
|
|
407
|
+
source_notes=["OpenCLI Xiaohongshu user discovery returned no parseable note URLs."],
|
|
408
|
+
metadata=metadata,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
return DiscoveryResult.success(
|
|
412
|
+
provider=self.name,
|
|
413
|
+
items=items,
|
|
414
|
+
source_notes=[
|
|
415
|
+
"OpenCLI browser-context discovery resolved Xiaohongshu user notes.",
|
|
416
|
+
"Discovery stayed bounded by --limit and did not run an unbounded user crawl.",
|
|
417
|
+
"Raw stdout/stderr and browser session data were not persisted.",
|
|
418
|
+
],
|
|
419
|
+
metadata={**metadata, "item_count": len(items)},
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class OpenCliOpenmdXiaohongshuCreatorNotesProvider:
|
|
424
|
+
"""Full read-only creator discovery through the project-owned OpenCLI plugin."""
|
|
425
|
+
|
|
426
|
+
name = OPENCLI_OPENMD_XIAOHONGSHU_CREATOR_NOTES_BACKEND
|
|
427
|
+
|
|
428
|
+
def __init__(
|
|
429
|
+
self,
|
|
430
|
+
*,
|
|
431
|
+
runner: OpenCliRunner | None = None,
|
|
432
|
+
input_runner: OpenCliInputRunner | None = None,
|
|
433
|
+
timeout: int = 1900,
|
|
434
|
+
) -> None:
|
|
435
|
+
self._runner = runner or _run_opencli
|
|
436
|
+
self._input_runner = input_runner
|
|
437
|
+
self.timeout = timeout
|
|
438
|
+
|
|
439
|
+
def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
|
|
440
|
+
url = normalize_xiaohongshu_url(request.input_url)
|
|
441
|
+
canonical_profile = sanitize_url(url)
|
|
442
|
+
metadata: dict[str, object] = {
|
|
443
|
+
"platform": request.platform,
|
|
444
|
+
"normalized_url": canonical_profile,
|
|
445
|
+
"requires_browser_context": True,
|
|
446
|
+
"full_discovery": True,
|
|
447
|
+
}
|
|
448
|
+
if request.platform != "xiaohongshu" or not xiaohongshu_user_id(url):
|
|
449
|
+
return _discovery_failure(
|
|
450
|
+
self.name,
|
|
451
|
+
"xiaohongshu_user_input_not_supported",
|
|
452
|
+
"Input is not a supported Xiaohongshu creator profile URL or user id.",
|
|
453
|
+
metadata=metadata,
|
|
454
|
+
)
|
|
455
|
+
if not request.allow_all:
|
|
456
|
+
return _discovery_failure(
|
|
457
|
+
self.name,
|
|
458
|
+
"xiaohongshu_full_discovery_requires_all",
|
|
459
|
+
"The project-owned Xiaohongshu creator provider requires explicit --all.",
|
|
460
|
+
metadata=metadata,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
runtime_selector = url if has_required_signed_query(url) else None
|
|
464
|
+
command = build_opencli_openmd_xiaohongshu_creator_notes_args(
|
|
465
|
+
url,
|
|
466
|
+
selector_stdin=runtime_selector is not None,
|
|
467
|
+
)
|
|
468
|
+
if runtime_selector is not None:
|
|
469
|
+
input_runner = self._input_runner or (_run_opencli if self._runner is _run_opencli else None)
|
|
470
|
+
if input_runner is not None:
|
|
471
|
+
exit_code, stdout, stderr = input_runner(
|
|
472
|
+
command,
|
|
473
|
+
timeout=self.timeout,
|
|
474
|
+
stdin_text=runtime_selector,
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
|
|
478
|
+
else:
|
|
479
|
+
exit_code, stdout, stderr = self._runner(command, timeout=self.timeout)
|
|
480
|
+
if exit_code != 0:
|
|
481
|
+
return _discovery_failure(
|
|
482
|
+
self.name,
|
|
483
|
+
classify_xiaohongshu_failure(stderr or stdout),
|
|
484
|
+
stderr or stdout or f"OpenCLI exited with code {exit_code}.",
|
|
485
|
+
source_notes=["Project-owned Xiaohongshu full discovery returned a command failure."],
|
|
486
|
+
metadata={**metadata, "exit_code": exit_code},
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
items = parse_opencli_xiaohongshu_user_output(
|
|
491
|
+
stdout,
|
|
492
|
+
collection_url=canonical_profile,
|
|
493
|
+
discovery_provider=self.name,
|
|
494
|
+
include_transient=True,
|
|
495
|
+
include_runtime_note_url=True,
|
|
496
|
+
)
|
|
497
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
498
|
+
return _discovery_failure(
|
|
499
|
+
self.name,
|
|
500
|
+
"opencli_xiaohongshu_creator_notes_invalid_output",
|
|
501
|
+
exc,
|
|
502
|
+
metadata=metadata,
|
|
503
|
+
)
|
|
504
|
+
if not items:
|
|
505
|
+
return _discovery_failure(
|
|
506
|
+
self.name,
|
|
507
|
+
"opencli_xiaohongshu_creator_notes_empty",
|
|
508
|
+
"The project-owned Xiaohongshu creator plugin returned no parseable notes.",
|
|
509
|
+
metadata=metadata,
|
|
510
|
+
)
|
|
511
|
+
if runtime_selector is not None:
|
|
512
|
+
for item in items:
|
|
513
|
+
if not item.raw_meta.get("runtime_note_url"):
|
|
514
|
+
item.raw_meta["runtime_profile_selector"] = runtime_selector
|
|
515
|
+
|
|
516
|
+
pagination_metadata = {
|
|
517
|
+
key: items[0].raw_meta.get(key)
|
|
518
|
+
for key in (
|
|
519
|
+
"page_count",
|
|
520
|
+
"scroll_count",
|
|
521
|
+
"stable_round_count",
|
|
522
|
+
"empty_snapshot_count",
|
|
523
|
+
"duplicate_snapshot_count",
|
|
524
|
+
"anomaly_count",
|
|
525
|
+
"source_exhausted",
|
|
526
|
+
"explicit_exhaustion",
|
|
527
|
+
"exhaustion_reason",
|
|
528
|
+
"store_source",
|
|
529
|
+
)
|
|
530
|
+
if items[0].raw_meta.get(key) is not None
|
|
531
|
+
}
|
|
532
|
+
if (
|
|
533
|
+
pagination_metadata.get("source_exhausted") is not True
|
|
534
|
+
or pagination_metadata.get("explicit_exhaustion") is not True
|
|
535
|
+
):
|
|
536
|
+
return _discovery_failure(
|
|
537
|
+
self.name,
|
|
538
|
+
"xiaohongshu_source_not_explicitly_exhausted",
|
|
539
|
+
"The project-owned Xiaohongshu plugin returned rows without an explicit platform exhaustion signal.",
|
|
540
|
+
metadata={**metadata, **pagination_metadata},
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
return DiscoveryResult.success(
|
|
544
|
+
provider=self.name,
|
|
545
|
+
items=items,
|
|
546
|
+
source_notes=[
|
|
547
|
+
"The project-owned OpenCLI plugin read the authenticated Xiaohongshu profile store while scrolling.",
|
|
548
|
+
"Discovery stopped only after an explicit platform end marker; stable no-growth rounds are retryable and do not prove exhaustion.",
|
|
549
|
+
"Canonical note ids remain the durable identity; current per-note selectors cross only captured child stdout in memory.",
|
|
550
|
+
"Transient note selectors are removed by manifest/runlog/package allowlists before persistence.",
|
|
551
|
+
"Raw browser stores, cookies, headers, signed values, and command streams were not persisted.",
|
|
552
|
+
],
|
|
553
|
+
metadata={
|
|
554
|
+
**metadata,
|
|
555
|
+
"item_count": len(items),
|
|
556
|
+
**pagination_metadata,
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def parse_opencli_xiaohongshu_note_output(stdout: str) -> ParsedXiaohongshuNote:
|
|
562
|
+
text = str(stdout or "").strip()
|
|
563
|
+
if not text:
|
|
564
|
+
raise ValueError("OpenCLI Xiaohongshu note output was empty.")
|
|
565
|
+
|
|
566
|
+
try:
|
|
567
|
+
payload = load_opencli_json(text)
|
|
568
|
+
except json.JSONDecodeError:
|
|
569
|
+
return ParsedXiaohongshuNote(text=sanitize_url_text(text))
|
|
570
|
+
|
|
571
|
+
note = _first_note_dict(payload)
|
|
572
|
+
if note is None:
|
|
573
|
+
raise ValueError("OpenCLI Xiaohongshu note output did not contain a note object.")
|
|
574
|
+
|
|
575
|
+
title = _first_text(note, ("title", "note_title", "display_title", "name"))
|
|
576
|
+
body = _first_text(
|
|
577
|
+
note,
|
|
578
|
+
(
|
|
579
|
+
"markdown",
|
|
580
|
+
"content",
|
|
581
|
+
"desc",
|
|
582
|
+
"description",
|
|
583
|
+
"text",
|
|
584
|
+
"note",
|
|
585
|
+
),
|
|
586
|
+
)
|
|
587
|
+
if body is None:
|
|
588
|
+
body = _first_text(_nested_dict(note, ("data", "note_info", "note_card")) or {}, ("content", "desc", "text"))
|
|
589
|
+
if body is None:
|
|
590
|
+
body = ""
|
|
591
|
+
|
|
592
|
+
tags = _string_list(note.get("tags") or note.get("hashtags"))
|
|
593
|
+
text_parts: list[str] = []
|
|
594
|
+
if title:
|
|
595
|
+
text_parts.append(f"# {title.strip()}")
|
|
596
|
+
if body.strip():
|
|
597
|
+
text_parts.append(sanitize_url_text(body.strip()))
|
|
598
|
+
if tags:
|
|
599
|
+
text_parts.append("Tags: " + ", ".join(tags))
|
|
600
|
+
|
|
601
|
+
return ParsedXiaohongshuNote(
|
|
602
|
+
text="\n\n".join(part for part in text_parts if part).strip(),
|
|
603
|
+
title=title,
|
|
604
|
+
author=_extract_author(note),
|
|
605
|
+
published_at=_first_text(note, ("published_at", "publish_time", "created_at", "time")),
|
|
606
|
+
note_id=_first_text(note, ("id", "note_id", "noteId")),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def parse_opencli_xiaohongshu_user_output(
|
|
611
|
+
stdout: str,
|
|
612
|
+
*,
|
|
613
|
+
collection_url: str,
|
|
614
|
+
discovery_provider: str = OPENCLI_XIAOHONGSHU_USER_DISCOVERY_BACKEND,
|
|
615
|
+
include_transient: bool = False,
|
|
616
|
+
include_runtime_note_url: bool = True,
|
|
617
|
+
) -> list[ContentItem]:
|
|
618
|
+
text = str(stdout or "").strip()
|
|
619
|
+
if not text:
|
|
620
|
+
raise ValueError("OpenCLI Xiaohongshu user output was empty.")
|
|
621
|
+
|
|
622
|
+
payload = load_opencli_json(text)
|
|
623
|
+
rows = _candidate_rows(payload)
|
|
624
|
+
items: list[ContentItem] = []
|
|
625
|
+
seen: set[str] = set()
|
|
626
|
+
for row in rows:
|
|
627
|
+
runtime_note_url = _extract_note_url(row)
|
|
628
|
+
note_id = _first_text(row, ("id", "note_id", "noteId")) or (
|
|
629
|
+
_note_id_from_url(runtime_note_url) if runtime_note_url else None
|
|
630
|
+
)
|
|
631
|
+
note_url = _canonical_xiaohongshu_note_url(note_id)
|
|
632
|
+
if note_url is None or note_id is None or note_id in seen:
|
|
633
|
+
continue
|
|
634
|
+
seen.add(note_id)
|
|
635
|
+
title = _first_text(row, ("title", "note_title", "display_title", "field", "name"))
|
|
636
|
+
media_kind = _xiaohongshu_media_kind(row)
|
|
637
|
+
raw_meta: dict[str, Any] = {
|
|
638
|
+
"collection_url": sanitize_url(collection_url),
|
|
639
|
+
"discovery_provider": discovery_provider,
|
|
640
|
+
"media_kind": media_kind,
|
|
641
|
+
"process_status": "discovered",
|
|
642
|
+
"extraction_contract_version": XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
|
|
643
|
+
"required_components": _xiaohongshu_required_components(media_kind),
|
|
644
|
+
}
|
|
645
|
+
if include_transient and include_runtime_note_url:
|
|
646
|
+
raw_runtime_url = row.get("runtime_note_url")
|
|
647
|
+
signed_candidate = (
|
|
648
|
+
raw_runtime_url.strip()
|
|
649
|
+
if isinstance(raw_runtime_url, str) and raw_runtime_url.strip()
|
|
650
|
+
else runtime_note_url
|
|
651
|
+
)
|
|
652
|
+
if signed_candidate and has_required_signed_query(signed_candidate):
|
|
653
|
+
raw_meta["runtime_note_url"] = signed_candidate
|
|
654
|
+
if include_transient:
|
|
655
|
+
for key in (
|
|
656
|
+
"page_count",
|
|
657
|
+
"scroll_count",
|
|
658
|
+
"stable_round_count",
|
|
659
|
+
"empty_snapshot_count",
|
|
660
|
+
"duplicate_snapshot_count",
|
|
661
|
+
"anomaly_count",
|
|
662
|
+
"source_exhausted",
|
|
663
|
+
"explicit_exhaustion",
|
|
664
|
+
"exhaustion_reason",
|
|
665
|
+
"store_source",
|
|
666
|
+
):
|
|
667
|
+
value = row.get(key)
|
|
668
|
+
if value is not None:
|
|
669
|
+
raw_meta[key] = value
|
|
670
|
+
published_hint = _first_text(row, ("published_hint", "published_at", "publish_time", "created_at", "time"))
|
|
671
|
+
if published_hint:
|
|
672
|
+
raw_meta["published_hint"] = published_hint
|
|
673
|
+
items.append(
|
|
674
|
+
ContentItem(
|
|
675
|
+
platform="xiaohongshu",
|
|
676
|
+
input_type="collection",
|
|
677
|
+
url=note_url,
|
|
678
|
+
id=note_id,
|
|
679
|
+
title=title,
|
|
680
|
+
author=_extract_author(row),
|
|
681
|
+
published_at=published_hint,
|
|
682
|
+
source_type=media_kind,
|
|
683
|
+
raw_meta=raw_meta,
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
return items
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def classify_xiaohongshu_failure(message: object) -> str:
|
|
690
|
+
text = str(message or "").lower()
|
|
691
|
+
if "source_not_explicitly_exhausted" in text:
|
|
692
|
+
return "xiaohongshu_source_not_explicitly_exhausted"
|
|
693
|
+
if "timed out" in text or "timeout" in text:
|
|
694
|
+
return "opencli_xiaohongshu_timeout"
|
|
695
|
+
if "login" in text or "cookie" in text or "sign in" in text or "auth_challenge" in text:
|
|
696
|
+
return "xiaohongshu_browser_session_required"
|
|
697
|
+
if any(term in text for term in ("captcha", "verify", "risk", "blocked", "风控", "验证")):
|
|
698
|
+
return "xiaohongshu_request_blocked"
|
|
699
|
+
if "not found" in text or "404" in text:
|
|
700
|
+
return "xiaohongshu_not_found"
|
|
701
|
+
if "signed" in text or _SIGNED_QUERY_KEY in text:
|
|
702
|
+
return "xiaohongshu_signed_url_required"
|
|
703
|
+
return "opencli_xiaohongshu_failed"
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _host_is_xiaohongshu(host: str) -> bool:
|
|
707
|
+
normalized = host.lower()
|
|
708
|
+
return (
|
|
709
|
+
normalized == "xiaohongshu.com"
|
|
710
|
+
or normalized.endswith(".xiaohongshu.com")
|
|
711
|
+
or normalized == "xhslink.com"
|
|
712
|
+
or normalized.endswith(".xhslink.com")
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _first_note_dict(payload: object) -> dict[str, Any] | None:
|
|
717
|
+
if isinstance(payload, dict):
|
|
718
|
+
row_dict = _field_rows_to_dict(payload)
|
|
719
|
+
if row_dict is not None:
|
|
720
|
+
return row_dict
|
|
721
|
+
for key in ("data", "note", "note_info", "note_card", "result"):
|
|
722
|
+
value = payload.get(key)
|
|
723
|
+
if isinstance(value, dict):
|
|
724
|
+
nested = _first_note_dict(value)
|
|
725
|
+
if nested is not None:
|
|
726
|
+
return nested
|
|
727
|
+
return payload
|
|
728
|
+
if isinstance(payload, list):
|
|
729
|
+
row_dict = _field_rows_to_dict(payload)
|
|
730
|
+
if row_dict is not None:
|
|
731
|
+
return row_dict
|
|
732
|
+
for item in payload:
|
|
733
|
+
if isinstance(item, dict):
|
|
734
|
+
return item
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _field_rows_to_dict(payload: object) -> dict[str, Any] | None:
|
|
739
|
+
if isinstance(payload, dict):
|
|
740
|
+
payload = payload.get("rows") or payload.get("items")
|
|
741
|
+
if not isinstance(payload, list) or not payload:
|
|
742
|
+
return None
|
|
743
|
+
flattened: dict[str, Any] = {}
|
|
744
|
+
saw_field_row = False
|
|
745
|
+
for row in payload:
|
|
746
|
+
if not isinstance(row, dict):
|
|
747
|
+
return None
|
|
748
|
+
field = row.get("field")
|
|
749
|
+
value = row.get("value")
|
|
750
|
+
if field is None:
|
|
751
|
+
return None
|
|
752
|
+
saw_field_row = True
|
|
753
|
+
key = str(field).strip().lower().replace(" ", "_")
|
|
754
|
+
flattened[key] = value
|
|
755
|
+
return flattened if saw_field_row else None
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _candidate_rows(payload: object) -> list[dict[str, Any]]:
|
|
759
|
+
if isinstance(payload, list):
|
|
760
|
+
return [row for row in payload if isinstance(row, dict)]
|
|
761
|
+
if not isinstance(payload, dict):
|
|
762
|
+
raise ValueError("OpenCLI Xiaohongshu user output must be JSON object or list.")
|
|
763
|
+
|
|
764
|
+
for key in ("items", "notes", "list", "results"):
|
|
765
|
+
value = payload.get(key)
|
|
766
|
+
if isinstance(value, list):
|
|
767
|
+
return [row for row in value if isinstance(row, dict)]
|
|
768
|
+
|
|
769
|
+
data = payload.get("data")
|
|
770
|
+
if isinstance(data, list):
|
|
771
|
+
return [row for row in data if isinstance(row, dict)]
|
|
772
|
+
if isinstance(data, dict):
|
|
773
|
+
return _candidate_rows(data)
|
|
774
|
+
row_dict = _field_rows_to_dict(payload)
|
|
775
|
+
if row_dict is not None:
|
|
776
|
+
return [row_dict]
|
|
777
|
+
return [payload]
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _extract_note_url(row: dict[str, Any]) -> str | None:
|
|
781
|
+
for key in ("runtime_note_url", "url", "note_url", "share_url", "link", "href", "value"):
|
|
782
|
+
value = row.get(key)
|
|
783
|
+
if isinstance(value, str):
|
|
784
|
+
candidate = _first_xiaohongshu_url(value)
|
|
785
|
+
if candidate and is_xiaohongshu_note_url(candidate):
|
|
786
|
+
return candidate
|
|
787
|
+
for value in row.values():
|
|
788
|
+
if isinstance(value, str):
|
|
789
|
+
candidate = _first_xiaohongshu_url(value)
|
|
790
|
+
if candidate and is_xiaohongshu_note_url(candidate):
|
|
791
|
+
return candidate
|
|
792
|
+
return None
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _first_xiaohongshu_url(text: str) -> str | None:
|
|
796
|
+
for match in _URL_CANDIDATE_RE.finditer(text):
|
|
797
|
+
candidate = match.group(0).rstrip(".,,。;;!!))]")
|
|
798
|
+
if _host_is_xiaohongshu(urlparse(candidate).netloc):
|
|
799
|
+
return candidate
|
|
800
|
+
return None
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _first_text(mapping: dict[str, Any], keys: Iterable[str]) -> str | None:
|
|
804
|
+
for key in keys:
|
|
805
|
+
value = mapping.get(key)
|
|
806
|
+
if isinstance(value, str) and value.strip():
|
|
807
|
+
return sanitize_url_text(value.strip())
|
|
808
|
+
if value is not None and not isinstance(value, (dict, list, tuple)):
|
|
809
|
+
text = str(value).strip()
|
|
810
|
+
if text:
|
|
811
|
+
return sanitize_url_text(text)
|
|
812
|
+
return None
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _nested_dict(mapping: dict[str, Any], keys: Iterable[str]) -> dict[str, Any] | None:
|
|
816
|
+
for key in keys:
|
|
817
|
+
value = mapping.get(key)
|
|
818
|
+
if isinstance(value, dict):
|
|
819
|
+
return value
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _extract_author(mapping: dict[str, Any]) -> str | None:
|
|
824
|
+
author = _first_text(mapping, ("author", "nickname", "user_name", "username"))
|
|
825
|
+
if author:
|
|
826
|
+
return author
|
|
827
|
+
user = mapping.get("user") or mapping.get("author_info") or mapping.get("user_info")
|
|
828
|
+
if isinstance(user, dict):
|
|
829
|
+
return _first_text(user, ("nickname", "name", "username"))
|
|
830
|
+
return None
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _string_list(value: object) -> list[str]:
|
|
834
|
+
if not isinstance(value, list):
|
|
835
|
+
return []
|
|
836
|
+
tags: list[str] = []
|
|
837
|
+
for item in value:
|
|
838
|
+
if isinstance(item, str) and item.strip():
|
|
839
|
+
tags.append(item.strip())
|
|
840
|
+
elif isinstance(item, dict):
|
|
841
|
+
tag = _first_text(item, ("name", "tag", "text"))
|
|
842
|
+
if tag:
|
|
843
|
+
tags.append(tag)
|
|
844
|
+
return tags
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _note_id_from_url(input_value: str) -> str | None:
|
|
848
|
+
parsed = urlparse(input_value)
|
|
849
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
850
|
+
return parts[-1] if parts else None
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _canonical_xiaohongshu_note_url(note_id: str | None) -> str | None:
|
|
854
|
+
if not note_id or not re.fullmatch(r"[0-9a-fA-F]{24}", note_id):
|
|
855
|
+
return None
|
|
856
|
+
return f"https://www.xiaohongshu.com/discovery/item/{note_id.lower()}"
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _xiaohongshu_media_kind(row: dict[str, Any]) -> str:
|
|
860
|
+
value = _first_text(row, ("media_kind", "type", "note_type", "noteType")) or ""
|
|
861
|
+
normalized = value.lower().replace("-", "_")
|
|
862
|
+
if "video" in normalized:
|
|
863
|
+
return "video"
|
|
864
|
+
if any(term in normalized for term in ("image", "normal", "graphic", "note")):
|
|
865
|
+
return "image_text"
|
|
866
|
+
return "mixed"
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _xiaohongshu_required_components(media_kind: str) -> list[str]:
|
|
870
|
+
if media_kind == "video":
|
|
871
|
+
return ["native_text", "video_transcript"]
|
|
872
|
+
if media_kind == "image_text":
|
|
873
|
+
return ["native_text", "image_ocr"]
|
|
874
|
+
return ["native_text"]
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _failed(
|
|
878
|
+
backend: str,
|
|
879
|
+
code: str,
|
|
880
|
+
error: object,
|
|
881
|
+
*,
|
|
882
|
+
source_notes: list[str] | None = None,
|
|
883
|
+
metadata: dict[str, object] | None = None,
|
|
884
|
+
) -> TranscriptResult:
|
|
885
|
+
return TranscriptResult(
|
|
886
|
+
text="",
|
|
887
|
+
status="failed",
|
|
888
|
+
source_level="partial_extraction",
|
|
889
|
+
backend=backend,
|
|
890
|
+
source_notes=source_notes or ["OpenCLI Xiaohongshu provider returned structured failure."],
|
|
891
|
+
error_code=code,
|
|
892
|
+
error_summary=sanitize_error_summary(error),
|
|
893
|
+
metadata=metadata or {},
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _discovery_failure(
|
|
898
|
+
provider: str,
|
|
899
|
+
code: str,
|
|
900
|
+
error: object,
|
|
901
|
+
*,
|
|
902
|
+
source_notes: list[str] | None = None,
|
|
903
|
+
metadata: dict[str, object] | None = None,
|
|
904
|
+
) -> DiscoveryResult:
|
|
905
|
+
return DiscoveryResult.failure(
|
|
906
|
+
provider=provider,
|
|
907
|
+
error_code=code,
|
|
908
|
+
error_summary=sanitize_error_summary(error),
|
|
909
|
+
source_notes=source_notes or ["OpenCLI Xiaohongshu discovery returned structured failure."],
|
|
910
|
+
metadata=metadata or {},
|
|
911
|
+
)
|