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
openmd/core/secrets.py ADDED
@@ -0,0 +1,313 @@
1
+ """Small logging sanitizers for secrets and oversized raw artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from typing import Any
8
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
9
+
10
+
11
+ ALLOWED_RUNLOG_FIELDS: frozenset[str] = frozenset(
12
+ {
13
+ "input_url",
14
+ "platform",
15
+ "backend",
16
+ "status",
17
+ "source_type",
18
+ "source_level",
19
+ "output_path",
20
+ "package_id",
21
+ "package_operation",
22
+ "started_at",
23
+ "finished_at",
24
+ "item_count",
25
+ "success_count",
26
+ "failed_count",
27
+ "event_type",
28
+ "failure_class",
29
+ "guardrail_signals",
30
+ "resume_pointer",
31
+ "error_code",
32
+ "error_summary",
33
+ "extraction_contract_version",
34
+ "components",
35
+ "terminal_gap_count",
36
+ "route_trace",
37
+ "document_metadata",
38
+ "asr_execution",
39
+ "source_scope",
40
+ }
41
+ )
42
+
43
+ SENSITIVE_KEY_TERMS: tuple[str, ...] = (
44
+ "cookie",
45
+ "token",
46
+ "authorization",
47
+ "set-cookie",
48
+ "bearer",
49
+ "header",
50
+ "api_response",
51
+ "response_body",
52
+ "raw_html",
53
+ "html_dump",
54
+ "raw_subtitle",
55
+ "subtitle_body",
56
+ "raw_transcript",
57
+ "raw_media",
58
+ "media_path",
59
+ "stdout",
60
+ "stderr",
61
+ "xsec_token",
62
+ )
63
+
64
+ SENSITIVE_QUERY_KEY_TERMS: tuple[str, ...] = (
65
+ "access_token",
66
+ "refresh_token",
67
+ "id_token",
68
+ "token",
69
+ "code",
70
+ "authorization",
71
+ "auth",
72
+ "cookie",
73
+ "secret",
74
+ "signature",
75
+ "sig",
76
+ "credential",
77
+ "session",
78
+ "xsec_token",
79
+ )
80
+
81
+ SENSITIVE_TEXT_PATTERNS: tuple[re.Pattern[str], ...] = (
82
+ re.compile(r"(?is)<html\b.*?</html>"),
83
+ re.compile(r"(?i)\bauthorization\s*:\s*bearer\s+[^,;\s]+"),
84
+ re.compile(
85
+ r"(?i)\b(?:set-cookie|cookie|authorization|bearer|token|headers?)\b"
86
+ r"\s*[:=]\s*[^,;\s]+"
87
+ ),
88
+ re.compile(
89
+ r"(?i)\b(?:full api response|api response|raw transcript|raw subtitles?|"
90
+ r"raw html|html dump|media path|raw media|stdout|stderr)\b\s*[:=]\s*[^,;\n]*"
91
+ ),
92
+ )
93
+
94
+ URL_RE = re.compile(r"https?://[^\s<>'\")\]]+")
95
+
96
+
97
+ def is_sensitive_key(key: str) -> bool:
98
+ normalized = key.lower().replace("_", "-")
99
+ return any(term.replace("_", "-") in normalized for term in SENSITIVE_KEY_TERMS)
100
+
101
+
102
+ def is_sensitive_query_key(key: str) -> bool:
103
+ normalized = key.lower().replace("_", "-")
104
+ return any(
105
+ term.replace("_", "-") in normalized for term in SENSITIVE_QUERY_KEY_TERMS
106
+ )
107
+
108
+
109
+ def _sanitize_fragment(fragment: str) -> str:
110
+ if not fragment or "=" not in fragment:
111
+ return fragment
112
+ pairs = parse_qsl(fragment, keep_blank_values=True)
113
+ if not pairs:
114
+ return fragment
115
+ safe_pairs = [(k, v) for k, v in pairs if not is_sensitive_query_key(k)]
116
+ return urlencode(safe_pairs)
117
+
118
+
119
+ def sanitize_url(value: str) -> str:
120
+ try:
121
+ parsed = urlsplit(value)
122
+ except ValueError:
123
+ return value
124
+ if not parsed.scheme or not parsed.netloc:
125
+ return value
126
+ if not parsed.query and not parsed.fragment:
127
+ return value
128
+
129
+ safe_params = [
130
+ (key, param_value)
131
+ for key, param_value in parse_qsl(parsed.query, keep_blank_values=True)
132
+ if not is_sensitive_query_key(key)
133
+ ]
134
+ safe_fragment = _sanitize_fragment(parsed.fragment)
135
+ return urlunsplit(
136
+ (parsed.scheme, parsed.netloc, parsed.path, urlencode(safe_params), safe_fragment)
137
+ )
138
+
139
+
140
+ def sanitize_url_text(value: str) -> str:
141
+ return URL_RE.sub(lambda match: sanitize_url(match.group(0)), value)
142
+
143
+
144
+ def truncate_text(value: str, *, max_length: int = 240) -> str:
145
+ if len(value) <= max_length:
146
+ return value
147
+ return value[: max_length - 14].rstrip() + " ...[truncated]"
148
+
149
+
150
+ def sanitize_error_summary(value: Any, *, max_length: int = 240) -> str | None:
151
+ if value is None:
152
+ return None
153
+
154
+ text = sanitize_url_text(str(value).replace("\r", " ").replace("\n", " "))
155
+ for pattern in SENSITIVE_TEXT_PATTERNS:
156
+ text = pattern.sub("[redacted]", text)
157
+
158
+ # Remove bare sensitive words too, so summaries do not leak field names.
159
+ for term in (
160
+ "cookie",
161
+ "token",
162
+ "authorization",
163
+ "set-cookie",
164
+ "bearer",
165
+ "header",
166
+ "headers",
167
+ "xsec_token",
168
+ ):
169
+ text = re.sub(rf"(?i)\b{re.escape(term)}\b", "[redacted]", text)
170
+
171
+ return truncate_text(" ".join(text.split()), max_length=max_length)
172
+
173
+
174
+ def sanitize_for_logging(value: Any, *, key: str | None = None) -> Any:
175
+ if key and is_sensitive_key(key):
176
+ return "[redacted]"
177
+ if isinstance(value, dict):
178
+ sanitized: dict[str, Any] = {}
179
+ for child_key, child_value in value.items():
180
+ child_key_text = str(child_key)
181
+ if is_sensitive_key(child_key_text):
182
+ continue
183
+ sanitized[child_key_text] = sanitize_for_logging(
184
+ child_value, key=child_key_text
185
+ )
186
+ return sanitized
187
+ if isinstance(value, list):
188
+ return [sanitize_for_logging(item, key=key) for item in value[:20]]
189
+ if isinstance(value, tuple):
190
+ return [sanitize_for_logging(item, key=key) for item in value[:20]]
191
+ if isinstance(value, str):
192
+ if key in {"warnings", "terminal_reason"}:
193
+ return sanitize_error_summary(value)
194
+ return truncate_text(sanitize_url_text(value))
195
+ return value
196
+
197
+
198
+ def sanitize_runlog_entry(entry: dict[str, Any]) -> dict[str, Any]:
199
+ sanitized: dict[str, Any] = {}
200
+ for key in ALLOWED_RUNLOG_FIELDS:
201
+ if key not in entry:
202
+ continue
203
+ value = entry[key]
204
+ if key in {"error_code", "error_summary"}:
205
+ sanitized[key] = sanitize_error_summary(value)
206
+ elif key == "input_url" and isinstance(value, str):
207
+ # This locator is hashed again by compact receipts. Truncating it
208
+ # creates a second identity for the same discovered item.
209
+ sanitized[key] = sanitize_url(value)
210
+ elif key == "document_metadata":
211
+ sanitized[key] = sanitize_document_metadata(value)
212
+ elif key == "asr_execution":
213
+ sanitized[key] = sanitize_asr_execution(value)
214
+ elif key == "source_scope":
215
+ sanitized[key] = sanitize_source_scope(value)
216
+ elif isinstance(value, (dict, list, tuple)):
217
+ sanitized[key] = sanitize_for_logging(value, key=key)
218
+ elif isinstance(value, str):
219
+ sanitized[key] = truncate_text(sanitize_url_text(value))
220
+ else:
221
+ sanitized[key] = value
222
+ return sanitized
223
+
224
+
225
+ def sanitize_asr_execution(value: Any) -> dict[str, Any]:
226
+ """Publish numeric ASR metrics and bounded route labels, never source text."""
227
+ if not isinstance(value, dict):
228
+ return {}
229
+
230
+ def number(v: Any) -> bool:
231
+ return type(v) in (int, float) and math.isfinite(v) and v >= 0
232
+
233
+ keys = {"elapsed_seconds", "inference_seconds", "batch_size_requested", "batch_size_effective",
234
+ "batch_oom_retries", "torch_peak_allocated_bytes", "torch_peak_reserved_bytes"}
235
+ result = {key: value[key] for key in keys if key in value and number(value[key])}
236
+ for key in ("duration", "segment_count"):
237
+ if key in value and number(value[key]):
238
+ result[key] = value[key]
239
+ for key in ("model", "model_size", "device", "language", "detected_language"):
240
+ label = value.get(key)
241
+ if isinstance(label, str) and (re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", label)
242
+ or (key == "model" and label == "Qwen/Qwen3-ASR-1.7B")):
243
+ result[key] = label
244
+ backend = value.get("asr_backend")
245
+ if backend in {"asr.auto", "asr.qwen3", "asr.faster_whisper"}:
246
+ result["asr_backend"] = backend
247
+ route = value.get("asr_route")
248
+ if isinstance(route, dict):
249
+ result.update(sanitize_asr_execution({
250
+ "asr_backend": route.get("selected_backend"),
251
+ "detected_language": route.get("detected_language"),
252
+ }))
253
+ histogram = value.get("batch_size_histogram")
254
+ if isinstance(histogram, dict):
255
+ result["batch_size_histogram"] = {str(n): histogram[str(n)] for n in range(1, 33)
256
+ if str(n) in histogram and type(histogram[str(n)]) is int and histogram[str(n)] >= 0}
257
+ attempts = value.get("batch_oom_attempts")
258
+ if isinstance(attempts, list):
259
+ result["batch_oom_attempts"] = [v for v in attempts[:6] if type(v) is int and 1 <= v <= 32]
260
+ return result
261
+
262
+
263
+ def asr_execution_metrics(metadata: dict[str, Any]) -> dict[str, Any]:
264
+ worker = metadata.get("asr_execution")
265
+ return sanitize_asr_execution({**metadata, **(worker if isinstance(worker, dict) else {})})
266
+
267
+
268
+ def sanitize_source_scope(value: Any) -> dict[str, Any]:
269
+ """A small structural receipt; no text, signed URLs or arbitrary API data."""
270
+ if not isinstance(value, dict):
271
+ return {}
272
+ result: dict[str, Any] = {}
273
+ for key in ("engine", "scope"):
274
+ label = value.get(key)
275
+ if isinstance(label, str) and re.fullmatch(r"[a-z_]{1,40}", label):
276
+ result[key] = label
277
+ for key in ("topic_id", "post_id", "target_post_id"):
278
+ identity = value.get(key)
279
+ if type(identity) is int and identity > 0:
280
+ result[key] = identity
281
+ elif isinstance(identity, str) and re.fullmatch(r"\d{1,24}", identity):
282
+ result[key] = identity
283
+ if value.get("post_body_present") is True:
284
+ result["post_body_present"] = True
285
+ coverage = value.get("coverage")
286
+ if isinstance(coverage, dict):
287
+ safe: dict[str, Any] = {}
288
+ for key, metric in coverage.items():
289
+ if key not in {"duration_seconds", "expected_count", "fetched_count", "missing_count", "visible_count",
290
+ "expected_posts", "fetched_posts", "missing_posts", "initial_count", "request_count",
291
+ "sample_interval_seconds", "max_seconds", "sampled_seconds", "complete"}:
292
+ continue
293
+ if type(metric) is bool or (type(metric) in (int, float) and math.isfinite(metric) and metric >= 0):
294
+ safe[key] = metric
295
+ if safe:
296
+ result["coverage"] = safe
297
+ return result
298
+
299
+
300
+ def sanitize_document_metadata(value: Any) -> Any:
301
+ """Keep every page/tile receipt, but never retain recognized text or API bodies.
302
+
303
+ Generic log sanitization truncates lists to 20; document provenance needs
304
+ all pages. This restricted field contains only scalar metrics, identities,
305
+ routing and artifact paths produced by the local document/OCR providers.
306
+ """
307
+ if isinstance(value, dict):
308
+ return {str(k): sanitize_document_metadata(v) for k, v in value.items() if not is_sensitive_key(str(k)) and str(k) not in {"text", "content", "prompt", "raw_text", "snippet", "message", "messages"}}
309
+ if isinstance(value, (list, tuple)):
310
+ return [sanitize_document_metadata(v) for v in value]
311
+ if isinstance(value, str):
312
+ return truncate_text(sanitize_url_text(value))
313
+ return value
openmd/doctor_view.py ADDED
@@ -0,0 +1,68 @@
1
+ """Human-readable `openmd doctor`: what works now, what to install for more.
2
+
3
+ The JSON payload stays the machine contract (`--json`, or any non-TTY stdout);
4
+ this view only answers a new user's question in plain words.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import os
11
+ import shutil
12
+ from typing import Any
13
+
14
+
15
+ def _has(module: str) -> bool:
16
+ try:
17
+ return importlib.util.find_spec(module) is not None
18
+ except (ImportError, ValueError):
19
+ return False
20
+
21
+
22
+ def _browser_registered() -> bool | None:
23
+ if os.name != "nt":
24
+ return None
25
+ from openmd.browser_setup import host_files
26
+
27
+ return host_files()[0].is_file()
28
+
29
+
30
+ def _opencli_row() -> tuple[bool | None, str, str]:
31
+ from openmd.runtime.managed_tools import status
32
+
33
+ state = status()
34
+ label = "B 站字幕、公众号、X 等需要浏览器读取的平台"
35
+ if state["opencli"] in {"user", "managed"}:
36
+ return True, label + "(还需 Openmd 浏览器插件:openmd browser-setup)", ""
37
+ how = "首次处理这类链接时自动准备(约 20MB)" if state["bundled_node"] and state["auto_install"] else "pip install --upgrade openmd-cli"
38
+ return False, label, how + ";并运行 openmd browser-setup 装好浏览器插件"
39
+
40
+
41
+ def capability_rows(payload: dict[str, Any]) -> list[tuple[bool | None, str, str]]:
42
+ """Return (ready, ability, how-to-enable) rows; ready=None means not applicable."""
43
+ diagnostics = payload.get("profile_diagnostics") or {}
44
+ asr_ready = bool((diagnostics.get("asr_runtime") or {}).get("probe", {}).get("ready"))
45
+ documents = ((diagnostics.get("optional_profiles") or {}).get("documents") or {})
46
+ rows: list[tuple[bool | None, str, str]] = [
47
+ (_has("trafilatura") and _has("youtube_transcript_api"), "网页文章、YouTube 有字幕的视频", "pip install --upgrade openmd-cli"),
48
+ _opencli_row(),
49
+ (shutil.which("ffmpeg") is not None, "音视频处理(本机转录前置)", "winget install Gyan.FFmpeg"),
50
+ (asr_ready, "无字幕视频 / 播客本机转录", 'pip install "openmd-cli[asr]",再按 docs/tool-integration.md 准备模型'),
51
+ (bool(documents.get("ocr_dependency_available") or documents.get("markitdown_available")), "图片、PDF、Office 转文字", 'pip install "openmd-cli[document]"'),
52
+ (_browser_registered(), "浏览器插件一键保存", "openmd browser-setup"),
53
+ ]
54
+ for tool in ((diagnostics.get("tools") or {}).get("tools") or []):
55
+ rows.append((bool(tool.get("resolved")), f"可选工具 {tool.get('tool')}", "见 docs/tool-integration.md"))
56
+ return rows
57
+
58
+
59
+ def render(payload: dict[str, Any]) -> str:
60
+ rows = [row for row in capability_rows(payload) if row[0] is not None]
61
+ lines = ["Openmd 体检", ""]
62
+ for ready, ability, how in rows:
63
+ lines.append(f" {'✓' if ready else '·'} {ability}" + ("" if ready else f"\n → {how}"))
64
+ missing = sum(1 for ready, *_ in rows if not ready)
65
+ lines += ["", "日常使用:openmd \"链接\" 脚本 / Agent:openmd \"链接\" -o <目录>"]
66
+ if missing:
67
+ lines.append(f"{missing} 项可选能力未就绪;不装也能用,遇到对应内容时会提示。机器可读结果:openmd doctor --json")
68
+ return "\n".join(lines)