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,278 @@
1
+ """Weixin author-batch coverage report helpers.
2
+
3
+ The report consumes already-sanitized Openmd runlog JSONL rows. It does not
4
+ call the WeChat sidecar, read browser/session state, or persist raw exporter
5
+ responses.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections import Counter, defaultdict
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Iterable
15
+
16
+ from openmd.core.secrets import sanitize_error_summary, sanitize_url_text
17
+
18
+
19
+ WEIXIN_FALLBACK_BACKENDS: frozenset[str] = frozenset(
20
+ {
21
+ "wechat-article-exporter.download",
22
+ "opencli.weixin.download",
23
+ "opencli.web.read",
24
+ }
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class WeixinAuthorBatchReportResult:
30
+ report: dict[str, Any]
31
+ json_path: Path | None = None
32
+ markdown_path: Path | None = None
33
+
34
+
35
+ def load_runlog_entries(paths: Iterable[str | Path]) -> list[dict[str, Any]]:
36
+ entries: list[dict[str, Any]] = []
37
+ for path_value in paths:
38
+ path = Path(path_value)
39
+ with path.open("r", encoding="utf-8") as handle:
40
+ for line in handle:
41
+ stripped = line.strip()
42
+ if not stripped:
43
+ continue
44
+ payload = json.loads(stripped)
45
+ if isinstance(payload, dict):
46
+ entries.append(payload)
47
+ return entries
48
+
49
+
50
+ def build_weixin_author_batch_report(
51
+ entries: Iterable[dict[str, Any]],
52
+ *,
53
+ title: str = "Weixin author batch coverage report",
54
+ ) -> dict[str, Any]:
55
+ grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
56
+ ignored_rows = 0
57
+ for row in entries:
58
+ if row.get("platform") != "weixin" or row.get("event_type", "item") != "item":
59
+ ignored_rows += 1
60
+ continue
61
+ input_url = _safe_text(row.get("input_url"))
62
+ if not input_url:
63
+ ignored_rows += 1
64
+ continue
65
+ grouped[input_url].append(_sanitize_report_row(row))
66
+
67
+ item_reports = [_item_report(input_url, attempts) for input_url, attempts in grouped.items()]
68
+ item_reports.sort(key=lambda item: item["input_url"])
69
+
70
+ final_status_counts = Counter(item["final_status"] for item in item_reports)
71
+ final_failure_class_counts = Counter(
72
+ item["final_failure_class"] for item in item_reports if item.get("final_failure_class")
73
+ )
74
+ final_error_code_counts = Counter(
75
+ item["final_error_code"] for item in item_reports if item.get("final_error_code")
76
+ )
77
+ final_backend_counts = Counter(item["final_backend"] for item in item_reports if item.get("final_backend"))
78
+ all_error_code_counts: Counter[str] = Counter()
79
+ fallback_backend_counts: Counter[str] = Counter()
80
+ for item in item_reports:
81
+ for attempt_index, attempt in enumerate(item["attempts"]):
82
+ error_code = attempt.get("error_code")
83
+ backend = attempt.get("backend")
84
+ if error_code:
85
+ all_error_code_counts[str(error_code)] += 1
86
+ if attempt_index > 0 and backend in WEIXIN_FALLBACK_BACKENDS:
87
+ fallback_backend_counts[str(backend)] += 1
88
+
89
+ covered_items = [item for item in item_reports if item["covered"]]
90
+ recovered_items = [item for item in item_reports if item["recovered"]]
91
+ missing_items = [item for item in item_reports if item["missing"]]
92
+ fallback_attempt_items = [item for item in item_reports if item["fallback_attempt_count"] > 0]
93
+
94
+ return {
95
+ "title": title,
96
+ "summary": {
97
+ "total_items": len(item_reports),
98
+ "covered_count": len(covered_items),
99
+ "verified_count": final_status_counts.get("verified", 0),
100
+ "partial_count": final_status_counts.get("partial", 0),
101
+ "failed_count": final_status_counts.get("failed", 0),
102
+ "recovered_count": len(recovered_items),
103
+ "missing_count": len(missing_items),
104
+ "fallback_attempt_item_count": len(fallback_attempt_items),
105
+ "fallback_attempt_count": sum(item["fallback_attempt_count"] for item in item_reports),
106
+ "ignored_rows": ignored_rows,
107
+ },
108
+ "final_status_counts": dict(sorted(final_status_counts.items())),
109
+ "final_failure_class_counts": dict(sorted(final_failure_class_counts.items())),
110
+ "final_error_code_counts": dict(sorted(final_error_code_counts.items())),
111
+ "final_backend_counts": dict(sorted(final_backend_counts.items())),
112
+ "all_attempt_error_code_counts": dict(sorted(all_error_code_counts.items())),
113
+ "fallback_backend_counts": dict(sorted(fallback_backend_counts.items())),
114
+ "recovered_items": [_compact_item(item) for item in recovered_items],
115
+ "missing_items": [_compact_item(item) for item in missing_items],
116
+ "other_failed_items": [
117
+ _compact_item(item)
118
+ for item in item_reports
119
+ if item["final_status"] == "failed" and not item["missing"]
120
+ ],
121
+ "items": item_reports,
122
+ }
123
+
124
+
125
+ def write_weixin_author_batch_report(
126
+ entries: Iterable[dict[str, Any]],
127
+ out_dir: str | Path,
128
+ *,
129
+ title: str = "Weixin author batch coverage report",
130
+ write_json: bool = True,
131
+ write_markdown: bool = True,
132
+ ) -> WeixinAuthorBatchReportResult:
133
+ report = build_weixin_author_batch_report(entries, title=title)
134
+ output_dir = Path(out_dir)
135
+ output_dir.mkdir(parents=True, exist_ok=True)
136
+ json_path: Path | None = None
137
+ markdown_path: Path | None = None
138
+ if write_json:
139
+ json_path = output_dir / "weixin-author-batch-report.json"
140
+ json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
141
+ if write_markdown:
142
+ markdown_path = output_dir / "weixin-author-batch-report.md"
143
+ markdown_path.write_text(format_weixin_author_batch_report_markdown(report), encoding="utf-8")
144
+ return WeixinAuthorBatchReportResult(report=report, json_path=json_path, markdown_path=markdown_path)
145
+
146
+
147
+ def format_weixin_author_batch_report_markdown(report: dict[str, Any]) -> str:
148
+ summary = report.get("summary", {})
149
+ lines = [
150
+ f"# {report.get('title') or 'Weixin author batch coverage report'}",
151
+ "",
152
+ "## Summary",
153
+ "",
154
+ f"- Total items: {summary.get('total_items', 0)}",
155
+ f"- Covered: {summary.get('covered_count', 0)}",
156
+ f"- Verified: {summary.get('verified_count', 0)}",
157
+ f"- Partial: {summary.get('partial_count', 0)}",
158
+ f"- Failed: {summary.get('failed_count', 0)}",
159
+ f"- Recovered: {summary.get('recovered_count', 0)}",
160
+ f"- Missing/source unavailable: {summary.get('missing_count', 0)}",
161
+ f"- Fallback attempt items: {summary.get('fallback_attempt_item_count', 0)}",
162
+ f"- Fallback attempts: {summary.get('fallback_attempt_count', 0)}",
163
+ "",
164
+ ]
165
+ lines.extend(_counter_section("Final failure classes", report.get("final_failure_class_counts", {})))
166
+ lines.extend(_counter_section("Final error codes", report.get("final_error_code_counts", {})))
167
+ lines.extend(_counter_section("Fallback backends", report.get("fallback_backend_counts", {})))
168
+ lines.extend(_items_section("Recovered items", report.get("recovered_items", [])))
169
+ lines.extend(_items_section("Missing items", report.get("missing_items", [])))
170
+ lines.extend(_items_section("Other failed items", report.get("other_failed_items", [])))
171
+ lines.append("")
172
+ return "\n".join(lines)
173
+
174
+
175
+ def _item_report(input_url: str, attempts: list[dict[str, Any]]) -> dict[str, Any]:
176
+ final = attempts[-1]
177
+ final_status = _safe_text(final.get("status")) or "failed"
178
+ covered = final_status in {"verified", "partial"}
179
+ final_error_code = _safe_text(final.get("error_code"))
180
+ final_failure_class = _safe_text(final.get("failure_class"))
181
+ had_failed_attempt = any(_safe_text(attempt.get("status")) == "failed" for attempt in attempts[:-1])
182
+ recovered = covered and had_failed_attempt
183
+ missing = (
184
+ final_status == "failed"
185
+ and (
186
+ final_failure_class == "source_unavailable"
187
+ or _is_source_empty_error(final_error_code)
188
+ )
189
+ )
190
+ fallback_attempt_count = max(0, len(attempts) - 1)
191
+ return {
192
+ "input_url": sanitize_url_text(input_url),
193
+ "covered": covered,
194
+ "recovered": recovered,
195
+ "missing": missing,
196
+ "attempt_count": len(attempts),
197
+ "fallback_attempt_count": fallback_attempt_count,
198
+ "final_backend": _safe_text(final.get("backend")),
199
+ "final_status": final_status,
200
+ "final_source_level": _safe_text(final.get("source_level")),
201
+ "final_failure_class": final_failure_class,
202
+ "final_error_code": final_error_code,
203
+ "final_error_summary": sanitize_error_summary(final.get("error_summary")),
204
+ "output_path": _safe_text(final.get("output_path")),
205
+ "package_id": _safe_text(final.get("package_id")),
206
+ "package_operation": _safe_text(final.get("package_operation")),
207
+ "attempts": attempts,
208
+ }
209
+
210
+
211
+ def _sanitize_report_row(row: dict[str, Any]) -> dict[str, Any]:
212
+ return {
213
+ "backend": _safe_text(row.get("backend")),
214
+ "status": _safe_text(row.get("status")),
215
+ "source_level": _safe_text(row.get("source_level")),
216
+ "failure_class": _safe_text(row.get("failure_class")),
217
+ "error_code": _safe_text(row.get("error_code")),
218
+ "error_summary": sanitize_error_summary(row.get("error_summary")),
219
+ "output_path": _safe_text(row.get("output_path")),
220
+ "package_id": _safe_text(row.get("package_id")),
221
+ "package_operation": _safe_text(row.get("package_operation")),
222
+ }
223
+
224
+
225
+ def _compact_item(item: dict[str, Any]) -> dict[str, Any]:
226
+ return {
227
+ "input_url": item["input_url"],
228
+ "final_backend": item["final_backend"],
229
+ "final_status": item["final_status"],
230
+ "final_failure_class": item["final_failure_class"],
231
+ "final_error_code": item["final_error_code"],
232
+ "attempt_count": item["attempt_count"],
233
+ "fallback_attempt_count": item["fallback_attempt_count"],
234
+ "package_id": item.get("package_id") or None,
235
+ }
236
+
237
+
238
+ def _counter_section(title: str, values: dict[str, Any]) -> list[str]:
239
+ lines = [f"## {title}", ""]
240
+ if not values:
241
+ lines.extend(["- None", ""])
242
+ return lines
243
+ for key, value in values.items():
244
+ lines.append(f"- `{key}`: {value}")
245
+ lines.append("")
246
+ return lines
247
+
248
+
249
+ def _items_section(title: str, items: list[dict[str, Any]]) -> list[str]:
250
+ lines = [f"## {title}", ""]
251
+ if not items:
252
+ lines.extend(["- None", ""])
253
+ return lines
254
+ for item in items[:50]:
255
+ lines.append(
256
+ "- "
257
+ f"{item.get('input_url')} | "
258
+ f"backend={item.get('final_backend')} | "
259
+ f"status={item.get('final_status')} | "
260
+ f"failure_class={item.get('final_failure_class')} | "
261
+ f"error={item.get('final_error_code')} | "
262
+ f"attempts={item.get('attempt_count')}"
263
+ )
264
+ if len(items) > 50:
265
+ lines.append(f"- ... {len(items) - 50} more omitted from Markdown view")
266
+ lines.append("")
267
+ return lines
268
+
269
+
270
+ def _safe_text(value: Any) -> str:
271
+ if value is None:
272
+ return ""
273
+ return sanitize_url_text(str(value).strip())
274
+
275
+
276
+ def _is_source_empty_error(value: str) -> bool:
277
+ normalized = value.lower()
278
+ return "source_empty" in normalized or "empty_result" in normalized or "not_found" in normalized
@@ -0,0 +1,158 @@
1
+ """Xiaohongshu adapter declaring the multimodal extraction contract before runtime skip checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from urllib.parse import urlparse
8
+
9
+ from openmd.adapters.base import PlatformAdapter
10
+ from openmd.backends.xiaohongshu import has_required_signed_query, normalize_xiaohongshu_url, xiaohongshu_user_id
11
+ from openmd.backends.xiaohongshu_providers import XIAOHONGSHU_COMPOSITE_BACKEND
12
+ from openmd.core.detector import detect_platform, extract_first_url
13
+ from openmd.core.models import ContentItem, ExtractionComponent, TranscriptResult
14
+ from openmd.core.package_contract import XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION
15
+ from openmd.core.secrets import sanitize_url
16
+
17
+
18
+ _NOTE_ID_RE = re.compile(r"^[0-9a-f]{24}$", re.I)
19
+ _ALL_COMPONENTS = ["native_text", "image_ocr", "video_transcript"]
20
+
21
+
22
+ class XiaohongshuAdapter(PlatformAdapter):
23
+ def __init__(self, platform_id: str = "xiaohongshu", default_backend: str = XIAOHONGSHU_COMPOSITE_BACKEND) -> None:
24
+ self.id = platform_id
25
+ self.default_backend = default_backend
26
+
27
+ def can_handle(self, input_value: str) -> bool:
28
+ return detect_platform(input_value) == "xiaohongshu"
29
+
30
+ def resolve_items(
31
+ self,
32
+ input_value: str,
33
+ *,
34
+ limit: int | None = None,
35
+ all: bool = False,
36
+ ) -> list[ContentItem]:
37
+ target = normalize_xiaohongshu_url(extract_first_url(input_value))
38
+ note_id = _note_id_from_url(target)
39
+ is_short_note = _is_xhslink_url(target)
40
+ if note_id or is_short_note:
41
+ content_id = note_id or _stable_id(target)
42
+ profile_id = _profile_id_from_note_url(target)
43
+ raw_meta: dict[str, object] = {
44
+ "permission_scope": "user_accessible",
45
+ "content_id": content_id,
46
+ "media_kind": "mixed",
47
+ "extraction_contract_version": XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
48
+ "required_components": list(_ALL_COMPONENTS),
49
+ }
50
+ if profile_id:
51
+ raw_meta["profile_id"] = profile_id
52
+ if has_required_signed_query(target):
53
+ raw_meta["runtime_note_url"] = target
54
+ if is_short_note:
55
+ raw_meta["runtime_selector"] = target
56
+ raw_meta["identity_unresolved"] = True
57
+ return [
58
+ ContentItem(
59
+ platform=self.id,
60
+ input_type="single",
61
+ url=(
62
+ f"https://www.xiaohongshu.com/discovery/item/{note_id}"
63
+ if note_id
64
+ else sanitize_url(target)
65
+ ),
66
+ id=content_id,
67
+ source_type="mixed",
68
+ raw_meta=raw_meta,
69
+ )
70
+ ]
71
+ profile_id = xiaohongshu_user_id(target)
72
+ return [
73
+ ContentItem(
74
+ platform=self.id,
75
+ input_type="collection",
76
+ url=sanitize_url(target),
77
+ id=f"user-{profile_id}" if profile_id else _stable_id(target),
78
+ source_type="mixed",
79
+ raw_meta={
80
+ "permission_scope": "user_accessible",
81
+ "profile_id": profile_id,
82
+ "collection_selector": input_value.strip(),
83
+ "discovery_limit": limit,
84
+ "allow_all": all,
85
+ },
86
+ )
87
+ ]
88
+
89
+ def extract_text(
90
+ self,
91
+ item: ContentItem,
92
+ *,
93
+ backend: str | None = None,
94
+ backend_chain: tuple[str, ...] | None = None,
95
+ allow_fallback: bool = True,
96
+ ) -> TranscriptResult:
97
+ selected = (backend_chain or (backend or self.default_backend,))[0]
98
+ if selected == "mock":
99
+ return TranscriptResult(
100
+ text="## Native Note Text\n\nMock Xiaohongshu note.\n\n## Image OCR\n\n_Not applicable._\n\n## Video Transcript\n\n_Not applicable._\n",
101
+ status="verified",
102
+ source_level="native_text",
103
+ backend="mock",
104
+ title=item.title or item.id or "Mock Xiaohongshu note",
105
+ extraction_contract_version=XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
106
+ components=[
107
+ ExtractionComponent(
108
+ "native_text", "native_text", "verified", "native_text", "mock",
109
+ text_ref="index.md#native-note-text",
110
+ ),
111
+ ExtractionComponent(
112
+ "image_ocr", "image_ocr", "not_applicable", "partial_extraction", "mock",
113
+ text_ref="index.md#image-ocr",
114
+ ),
115
+ ExtractionComponent(
116
+ "video_transcript", "video_transcript", "not_applicable", "partial_extraction", "mock",
117
+ text_ref="index.md#video-transcript",
118
+ ),
119
+ ],
120
+ )
121
+ return TranscriptResult(
122
+ text="",
123
+ status="failed",
124
+ source_level="partial_extraction",
125
+ backend=selected,
126
+ error_code="backend_requires_runtime_gate",
127
+ error_summary="Enable the browser backend gate before running Xiaohongshu extraction.",
128
+ source_notes=[f"allow_fallback={allow_fallback}"],
129
+ extraction_contract_version=XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
130
+ )
131
+
132
+
133
+ def create_adapter(platform_id: str, _default_backend: str) -> PlatformAdapter:
134
+ return XiaohongshuAdapter(platform_id, XIAOHONGSHU_COMPOSITE_BACKEND)
135
+
136
+
137
+ def _note_id_from_url(value: str) -> str | None:
138
+ parsed = urlparse(value)
139
+ parts = [part for part in parsed.path.split("/") if part]
140
+ candidate = parts[-1] if parts else ""
141
+ return candidate.lower() if _NOTE_ID_RE.fullmatch(candidate) else None
142
+
143
+
144
+ def _profile_id_from_note_url(value: str) -> str | None:
145
+ parsed = urlparse(value)
146
+ parts = [part for part in parsed.path.split("/") if part]
147
+ if len(parts) >= 4 and parts[:2] == ["user", "profile"]:
148
+ return parts[2]
149
+ return None
150
+
151
+
152
+ def _is_xhslink_url(value: str) -> bool:
153
+ host = urlparse(value).netloc.lower()
154
+ return host == "xhslink.com" or host.endswith(".xhslink.com")
155
+
156
+
157
+ def _stable_id(value: str) -> str:
158
+ return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()[:12]
@@ -0,0 +1 @@
1
+ """Backend wrapper interfaces."""
@@ -0,0 +1,204 @@
1
+ """Backend framework primitives.
2
+
3
+ The V0 implementation only wires skeleton providers, but these dataclasses keep
4
+ the code aligned with docs/06-backend-framework.md: backends describe their
5
+ capabilities, report dependency health, and extract through one normalized
6
+ contract owned by Openmd.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Literal, Protocol
13
+
14
+ from openmd.core.models import (
15
+ ContentItem,
16
+ InputType,
17
+ SourceLevel,
18
+ SourceType,
19
+ TranscriptResult,
20
+ )
21
+
22
+
23
+ BackendLayer = str
24
+ DiscoveryStatus = Literal["ok", "failed"]
25
+ IdentityResolutionStatus = Literal["resolved", "failed"]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class BackendCapability:
30
+ platforms: tuple[str, ...]
31
+ source_types: tuple[SourceType, ...]
32
+ input_types: tuple[InputType, ...]
33
+ source_levels: tuple[SourceLevel, ...]
34
+ supports_batch: bool = False
35
+ supports_collection: bool = False
36
+ requires_network: bool = True
37
+ requires_credentials: bool = False
38
+ optional_config: tuple[str, ...] = ()
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class BackendDescriptor:
43
+ name: str
44
+ layer: BackendLayer
45
+ capabilities: tuple[BackendCapability, ...]
46
+ description: str = ""
47
+ provider: str | None = None
48
+ is_fallback: bool = False
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class BackendHealth:
53
+ ok: bool
54
+ dependencies: dict[str, str] = field(default_factory=dict)
55
+ warnings: tuple[str, ...] = ()
56
+ error_code: str | None = None
57
+ error_summary: str | None = None
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class BackendExtractRequest:
62
+ item: ContentItem
63
+ backend_chain: tuple[str, ...]
64
+ allow_fallback: bool = True
65
+
66
+ @property
67
+ def primary_backend(self) -> str:
68
+ return self.backend_chain[0] if self.backend_chain else "mock"
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ItemIdentityResolution:
73
+ item: ContentItem
74
+ status: IdentityResolutionStatus
75
+ provider: str
76
+ error_code: str | None = None
77
+ error_summary: str | None = None
78
+
79
+ @property
80
+ def ok(self) -> bool:
81
+ return self.status == "resolved" and self.error_code is None
82
+
83
+ @classmethod
84
+ def success(cls, item: ContentItem, *, provider: str) -> "ItemIdentityResolution":
85
+ return cls(item=item, status="resolved", provider=provider)
86
+
87
+ @classmethod
88
+ def failure(
89
+ cls,
90
+ item: ContentItem,
91
+ *,
92
+ provider: str,
93
+ error_code: str,
94
+ error_summary: str,
95
+ ) -> "ItemIdentityResolution":
96
+ return cls(
97
+ item=item,
98
+ status="failed",
99
+ provider=provider,
100
+ error_code=error_code,
101
+ error_summary=error_summary,
102
+ )
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class DiscoveryRequest:
107
+ """Request to resolve a collection/profile/playlist into extractable items."""
108
+
109
+ input_url: str
110
+ platform: str
111
+ limit: int | None = None
112
+ allow_all: bool = False
113
+ raw_meta: dict[str, Any] = field(default_factory=dict)
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class DiscoveryResult:
118
+ """Structured collection discovery result.
119
+
120
+ Discovery failures are expected platform/backend outcomes and should be
121
+ returned to the CLI instead of raised as command-level exceptions.
122
+ """
123
+
124
+ provider: str
125
+ status: DiscoveryStatus
126
+ items: list[ContentItem] = field(default_factory=list)
127
+ source_notes: list[str] = field(default_factory=list)
128
+ error_code: str | None = None
129
+ error_summary: str | None = None
130
+ metadata: dict[str, Any] = field(default_factory=dict)
131
+
132
+ @property
133
+ def ok(self) -> bool:
134
+ return self.status == "ok" and self.error_code is None
135
+
136
+ @classmethod
137
+ def success(
138
+ cls,
139
+ *,
140
+ provider: str,
141
+ items: list[ContentItem],
142
+ source_notes: list[str] | None = None,
143
+ metadata: dict[str, Any] | None = None,
144
+ ) -> "DiscoveryResult":
145
+ return cls(
146
+ provider=provider,
147
+ status="ok",
148
+ items=items,
149
+ source_notes=source_notes or [],
150
+ metadata=metadata or {},
151
+ )
152
+
153
+ @classmethod
154
+ def failure(
155
+ cls,
156
+ *,
157
+ provider: str,
158
+ error_code: str,
159
+ error_summary: str,
160
+ source_notes: list[str] | None = None,
161
+ metadata: dict[str, Any] | None = None,
162
+ ) -> "DiscoveryResult":
163
+ return cls(
164
+ provider=provider,
165
+ status="failed",
166
+ error_code=error_code,
167
+ error_summary=error_summary,
168
+ source_notes=source_notes or [],
169
+ metadata=metadata or {},
170
+ )
171
+
172
+
173
+ class BackendProvider(Protocol):
174
+ name: str
175
+
176
+ def descriptor(self) -> BackendDescriptor:
177
+ ...
178
+
179
+ def health(self) -> BackendHealth:
180
+ ...
181
+
182
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
183
+ ...
184
+
185
+
186
+ class DiscoveryProvider(Protocol):
187
+ name: str
188
+
189
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
190
+ ...
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class BackendCommandResult:
195
+ name: str
196
+ command: tuple[str, ...]
197
+ exit_code: int
198
+ stdout_summary: str = ""
199
+ stderr_summary: str = ""
200
+ error_summary: str | None = None
201
+
202
+ @property
203
+ def success(self) -> bool:
204
+ return self.exit_code == 0