openmd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )