openmd-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
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)
|