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/interactive.py ADDED
@@ -0,0 +1,453 @@
1
+ """Single-link workflow with human prompts and a flat Markdown destination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import io
8
+ import json
9
+ import re
10
+ import shutil
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+ from uuid import uuid4
15
+
16
+ from openmd.core.filename import safe_filename
17
+ from openmd.core.package_validator import validate_package_or_raise
18
+ from openmd.core.preferences import data_directory, load_preferences, save_preferences
19
+ from openmd.core.secrets import sanitize_error_summary
20
+ from openmd.runtime.tempdirs import owned_mkdtemp
21
+
22
+
23
+ _GATE_OPTIONS = {"network": "enable_network_backends", "browser": "enable_browser_backends", "media_download": "enable_media_download", "asr": "enable_asr"}
24
+ _GATE_LABELS = {"network": "读取公开网络", "browser": "使用浏览器读取", "media_download": "必要时下载媒体", "asr": "在本机转录", "cloud": "已明确允许的云服务"}
25
+
26
+
27
+ def is_interactive() -> bool:
28
+ return sys.stdin.isatty() and sys.stdout.isatty()
29
+
30
+
31
+ def _ask(message: str) -> str:
32
+ return input(message).strip()
33
+
34
+
35
+ def _say(args: argparse.Namespace, message: str) -> None:
36
+ print(message, file=sys.stderr if getattr(args, "json", False) else sys.stdout)
37
+
38
+
39
+ def _prompt(args: argparse.Namespace, message: str) -> str:
40
+ if getattr(args, "json", False):
41
+ print(message, file=sys.stderr, end="", flush=True)
42
+ return _ask("")
43
+ return _ask(message)
44
+
45
+
46
+ def display_backend(name: object) -> str:
47
+ """User-facing route name: the built-in browser reader is part of Openmd."""
48
+ text = str(name or "")
49
+ return "browser." + text[len("opencli."):] if text.startswith("opencli.") else text
50
+
51
+
52
+ def _preview(args: argparse.Namespace, url: str) -> None:
53
+ if not getattr(args, "verbose", False):
54
+ return
55
+ from openmd.adapters.registry import create_default_registry
56
+ from openmd.core.detector import detect_platform
57
+ source = detect_platform(url)
58
+ chain = create_default_registry().backend_chain(source, args.backend)
59
+ _say(args, f"来源:{source};候选工具:{' → '.join(display_backend(b) for b in chain)}")
60
+
61
+
62
+ def _authorize(args: argparse.Namespace, url: str, *, tty: bool) -> bool:
63
+ # Open-source ease of use (user decision 2026-11-15): a non-interactive run
64
+ # that names a destination gets the same daily gates as the terminal flow.
65
+ # Explicit capability flags stay a narrow grant; cloud is never implied.
66
+ _preview(args, url)
67
+ explicit = [gate for gate, option in _GATE_OPTIONS.items() if getattr(args, option, False)]
68
+ if getattr(args, "enable_cloud_backends", False):
69
+ explicit.append("cloud")
70
+ if explicit:
71
+ # Explicit flags are the user's narrow grant; typing them is the confirmation.
72
+ return True
73
+ from openmd.adapters.registry import create_default_registry
74
+ from openmd.core.detector import detect_platform
75
+ from openmd.routing.loader import load_catalog
76
+ catalog = load_catalog()
77
+ chain = create_default_registry().backend_chain(detect_platform(url), args.backend)
78
+ needed = {gate for backend in chain for gate in catalog.capabilities.get(backend, {}).get("gates", [])}
79
+ # Daily interactive use (user decision 2026-11-07): public network, browser session reads,
80
+ # media download and local ASR start without any prompt. Cloud is never implied.
81
+ if getattr(args, "video", "note") in {"text", "file"}:
82
+ needed |= {"network", "media_download", "asr"} # asking for the video text is the grant
83
+ requested = [gate for gate in ("network", "browser", "media_download", "asr") if gate in needed]
84
+ for gate in requested:
85
+ setattr(args, _GATE_OPTIONS[gate], True)
86
+ return True
87
+
88
+
89
+ def _noninteractive_requirements(args: argparse.Namespace, url: str) -> str | None:
90
+ if args.save_to or args.out:
91
+ return None
92
+ return f"非交互运行需要保存目录(-o 或 --save-to),例如:\nopenmd {url} -o <目录>"
93
+
94
+
95
+ class _StagingWorkspace:
96
+ def __init__(self) -> None:
97
+ self.path = Path(owned_mkdtemp(prefix="openmd-staging-"))
98
+ self.keep = False
99
+
100
+ def __enter__(self) -> Path:
101
+ return self.path
102
+
103
+ def __exit__(self, *_exc) -> None:
104
+ if not self.keep:
105
+ shutil.rmtree(self.path)
106
+
107
+
108
+ def _relocate_references(root: Path, old_root: Path, new_root: Path, *, published: dict[Path, Path] | None = None, drop_unpublished: bool = False) -> None:
109
+ """Rewrite only structured local pointers, never source Markdown prose."""
110
+ old = str(old_root)
111
+ mapping = {str(before): str(after) for before, after in (published or {}).items()}
112
+
113
+ def mapped(text: str) -> str | None:
114
+ for before, after in mapping.items():
115
+ if text == before:
116
+ return after
117
+ for separator in ("\\", "/"):
118
+ if text.startswith(before + separator):
119
+ return after + text[len(before):]
120
+ return None
121
+
122
+ def transform(value: object, *, key: str = "") -> object:
123
+ if isinstance(value, str):
124
+ replacement = mapped(value)
125
+ if replacement is not None:
126
+ return replacement
127
+ if key in {"artifact_ref", "output_path"} and not Path(value).is_absolute():
128
+ replacement = mapped(str(old_root / value))
129
+ if replacement is not None:
130
+ return replacement
131
+ if drop_unpublished and (old_root / value).exists():
132
+ return None
133
+ if value == old:
134
+ return str(new_root)
135
+ for prefix in (old + "\\", old + "/"):
136
+ if value.startswith(prefix):
137
+ if key in {"artifact_ref", "output_path"} and drop_unpublished and not published:
138
+ return None
139
+ return str(new_root) + value[len(old):]
140
+ return value
141
+ if isinstance(value, list):
142
+ return [transform(v, key=key) for v in value]
143
+ if isinstance(value, dict):
144
+ return {k: transform(v, key=k) for k, v in value.items()}
145
+ return value
146
+
147
+ for path in root.rglob("*"):
148
+ if path.suffix == ".json":
149
+ value = json.loads(path.read_text(encoding="utf-8"))
150
+ path.write_text(json.dumps(transform(value), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
151
+ elif path.suffix == ".jsonl":
152
+ values = [transform(json.loads(line)) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
153
+ path.write_text("".join(json.dumps(v, ensure_ascii=False) + "\n" for v in values), encoding="utf-8")
154
+
155
+
156
+ def _preserve_records(stage: Path, *, published: dict[Path, Path] | None = None) -> Path:
157
+ target = data_directory() / "runs" / uuid4().hex
158
+ target.mkdir(parents=True, exist_ok=False)
159
+ if (stage / "runlog.jsonl").is_file():
160
+ shutil.copyfile(stage / "runlog.jsonl", target / "runlog.jsonl")
161
+ if (stage / "runs").is_dir():
162
+ shutil.copytree(stage / "runs", target / "runs")
163
+ # These directories only exist when the caller explicitly retained media.
164
+ for name in ("raw-media", "raw"):
165
+ if (stage / name).is_dir():
166
+ shutil.copytree(stage / name, target / name)
167
+ _relocate_references(target, stage, target, published=published, drop_unpublished=True)
168
+ return target
169
+
170
+
171
+ def _preserve_failure_log(stage: Path) -> Path | None:
172
+ if not (stage / "runlog.jsonl").is_file():
173
+ return None
174
+ return _preserve_records(stage) / "runlog.jsonl"
175
+
176
+
177
+ def _daily_filename(title: str) -> str:
178
+ value = safe_filename(title, max_length=100)
179
+ if re.fullmatch(r"(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])", value.split(".", 1)[0], re.I):
180
+ value = "_" + value
181
+ return value
182
+
183
+
184
+ def _save(stage: Path, destination: Path, *, title: str, package: bool = False, source: Path | None = None) -> Path:
185
+ destination = destination.expanduser().resolve()
186
+ if destination.exists() and not destination.is_dir():
187
+ raise ValueError("save_directory_required: 请输入保存文件夹")
188
+ destination.mkdir(parents=True, exist_ok=True)
189
+ if package:
190
+ target = destination / f"{_daily_filename(title)}-{uuid4().hex[:8]}"
191
+
192
+ def ignore_records(directory: str, names: list[str]) -> list[str]:
193
+ if Path(directory) == stage:
194
+ return [name for name in names if name in {".openmd-compact.lock", ".openmd-compact.guard", ".openmd-owner.json", "runlog.jsonl", "runs"}]
195
+ return []
196
+
197
+ shutil.copytree(stage, target, ignore=ignore_records)
198
+ _relocate_references(target, stage, target)
199
+ for path in target.rglob("*.openmd"):
200
+ validate_package_or_raise(path)
201
+ return target
202
+ if source is None:
203
+ matches = list(stage.rglob("*.md"))
204
+ if len(matches) != 1:
205
+ raise ValueError("go_single_markdown_required: 本次产生多份文件,请显式选择 --package 保存")
206
+ source = matches[0]
207
+ if source.suffix != ".md" or not source.resolve().is_relative_to(stage.resolve()):
208
+ raise ValueError("go_single_markdown_required: 未找到本次提取的 Markdown")
209
+ base = _daily_filename(title)
210
+ for attempt in range(100):
211
+ target = destination / f"{base}{'-' + uuid4().hex[:6] if attempt else ''}.md"
212
+ try:
213
+ handle = target.open("xb")
214
+ except FileExistsError:
215
+ continue
216
+ try:
217
+ with handle:
218
+ assets = source.parent / "assets"
219
+ if assets.is_dir():
220
+ asset_target = destination / f"{target.stem}.assets-{uuid4().hex[:6]}"
221
+ shutil.copytree(assets, asset_target)
222
+ text = source.read_text(encoding="utf-8")
223
+ text = re.sub(r"(!\[[^\]]*\]\(<?)assets/", lambda m: m.group(1) + asset_target.name + "/", text)
224
+ handle.write(text.encode("utf-8"))
225
+ else:
226
+ with source.open("rb") as original:
227
+ shutil.copyfileobj(original, handle)
228
+ return target
229
+ except BaseException:
230
+ target.unlink(missing_ok=True)
231
+ raise
232
+ raise OSError("save_name_collision: 无法分配新文件名,请换一个保存目录")
233
+
234
+
235
+ def _source_summary(stage: Path, result) -> tuple[str, list[dict[str, object]]]:
236
+ from openmd.core.renderer import source_level_label
237
+
238
+ runlog = stage / "runlog.jsonl"
239
+ rows = [json.loads(line) for line in runlog.read_text(encoding="utf-8").splitlines() if line.strip()] if runlog.is_file() else []
240
+ levels = [str(row["source_level"]) for row in rows if row.get("event_type") == "item" and row.get("source_level")]
241
+ if not levels:
242
+ levels = list(result.source_level_counts)
243
+ labels = list(dict.fromkeys(source_level_label(level) for level in levels))
244
+ return " / ".join(labels), rows
245
+
246
+
247
+ def _failure_hint(rows: list[dict[str, object]]) -> str:
248
+ codes = " ".join(str(row.get("error_code") or "") for row in rows).lower()
249
+ if "browser_challenge_page" in codes:
250
+ return "被站点验证页拦截,未取得目标正文;请在浏览器中确认原链接可访问后重试。"
251
+ if "weixin_error_page" in codes:
252
+ return "公众号返回了错误页(链接失效、已删除或已过期),没有正文;请在微信或浏览器中打开确认。"
253
+ if "content_navigation_shell" in codes:
254
+ return "页面只有导航或图片,没有正文;链接可能不存在(软 404)或需要登录,请在浏览器中打开确认。"
255
+ attempts = [a for row in rows if isinstance(row.get("route_trace"), dict) for a in row["route_trace"].get("fallback_attempts") or []]
256
+ opencli_missing = any(str(a.get("backend", "")).startswith("opencli.") and a.get("error_code") in {"backend_exit_127", "backend_not_found"} for a in attempts)
257
+ content_verdict = any(word in codes for word in ("weixin_error_page", "not_found", "404"))
258
+ if opencli_missing and not content_verdict:
259
+ return "这个链接要用浏览器读取组件,但自动准备没有成功;检查网络(或设置 OPENMD_NPM_REGISTRY 使用 npm 镜像)后重试,运行 openmd doctor 查看详情。"
260
+ opencli_codes = {str(a.get("error_code")) for a in attempts if str(a.get("backend", "")).startswith("opencli.")}
261
+ if "backend_exit_69" in opencli_codes and not content_verdict:
262
+ return "这个链接要借用浏览器读取:请先运行 openmd browser-setup 装好 Openmd 浏览器插件,保持 Chrome / Edge 打开后重试。"
263
+ if "backend_exit_77" in opencli_codes and not content_verdict:
264
+ return "这个链接需要登录:请先在 Chrome / Edge 里登录该网站,再重试。"
265
+ if any(word in codes for word in ("model_missing", "asr_model", "worker")):
266
+ return "本机转录环境或模型尚未就绪;运行 openmd doctor --profile media 查看缺项。"
267
+ if any(word in codes for word in ("auth", "login", "cookie", "browser", "permission")):
268
+ return "当前读取方式需要浏览器或现有登录状态;确认浏览器可访问原链接后重试。"
269
+ if "backend_not_implemented" in codes:
270
+ return "当前读取路线尚未实现;可加 --verbose 查看具体路线。"
271
+ if any(word in codes for word in ("dependency", "not_installed", "not_registered")):
272
+ return "所需工具尚未就绪;运行 openmd doctor --profile tools 查看缺项。"
273
+ if any(word in codes for word in ("timeout", "network", "connection")):
274
+ return "连接超时或网络不可达;确认原链接可以打开后重试。"
275
+ return "没有取得可保存的正文;可加 --verbose 查看具体工具错误,或确认原链接能正常打开。"
276
+
277
+
278
+ def issue_context(url: str, rows: list[dict[str, object]]) -> str:
279
+ """One pasteable line for the "链接提取失败" issue form; no paths, no secrets."""
280
+ import platform as host
281
+
282
+ from openmd import __version__
283
+ from openmd.core.detector import detect_platform
284
+
285
+ tried = []
286
+ for row in rows:
287
+ attempts = (row.get("route_trace") or {}).get("fallback_attempts") if isinstance(row.get("route_trace"), dict) else None
288
+ if attempts:
289
+ tried += [f"{display_backend(a.get('backend'))}={a.get('error_code')}" for a in attempts]
290
+ elif row.get("event_type") == "item" or row.get("error_code"):
291
+ tried.append(f"{display_backend(row.get('backend'))}={row.get('error_code') or row.get('status')}")
292
+ # Sanitize each attempt: one joined string would hit the summary length cap and lose the tail.
293
+ route = ", ".join(dict.fromkeys(str(sanitize_error_summary(part)) for part in tried)) or "无工具记录"
294
+ return f"openmd {__version__} · {host.system()} {host.release()} · Python {host.python_version()} · 平台 {detect_platform(url)} · {route}"
295
+
296
+
297
+ def _emit_saved(args: argparse.Namespace, *, target: Path, outputs: list[str], records: Path | None) -> None:
298
+ if getattr(args, "json", False):
299
+ print(json.dumps({"ok": True, "command": "go", "saved_to": str(target), "outputs": outputs, "records": str(records) if records else None}, ensure_ascii=True))
300
+ else:
301
+ print(f"已保存:{target}")
302
+ if getattr(args, "verbose", False) and records:
303
+ print(f"运行记录:{records}")
304
+
305
+
306
+ def _attach_video(args: argparse.Namespace, url: str, markdown: Path) -> None:
307
+ from openmd.post_video import append_video_section, applies_to
308
+
309
+ mode = getattr(args, "video", "note") or "note"
310
+ if not applies_to(url) or not getattr(args, "enable_network_backends", False):
311
+ return
312
+ if mode in {"text", "file"} and not (getattr(args, "enable_media_download", False) and getattr(args, "enable_asr", False)):
313
+ mode = "note" # video text needs explicit media download plus local ASR
314
+ if mode != "note":
315
+ _say(args, "正在处理帖子里的视频(下载音频并在本机转文字)…")
316
+ try:
317
+ append_video_section(markdown, url, mode)
318
+ except OSError as exc:
319
+ print(f"视频部分未完成:{sanitize_error_summary(exc)}", file=sys.stderr)
320
+
321
+
322
+ def cmd_go(args: argparse.Namespace) -> int:
323
+ from openmd.cli import _emit_result, _process_input, _start_compact_receipt
324
+ tty = is_interactive()
325
+ if any(getattr(args, option, False) for option in ("all", "overwrite", "update", "new_version")) or args.package_root:
326
+ print("openmd go: 批量与更新操作请使用 ingest/batch/collect 及明确输出目录", file=sys.stderr)
327
+ return 2
328
+ try:
329
+ url = args.url or (_prompt(args, "输入链接或本地文件:") if tty else "")
330
+ if url == "-":
331
+ if tty:
332
+ print("openmd go: 请直接粘贴链接", file=sys.stderr)
333
+ return 2
334
+ url = sys.stdin.readline().strip()
335
+ if not url:
336
+ print("openmd go: 需要一个链接或本地文件", file=sys.stderr)
337
+ return 2
338
+ from openmd.core.input_validation import INPUT_HINT, network_preflight, valid_daily_input
339
+
340
+ if not valid_daily_input(url):
341
+ print(INPUT_HINT, file=sys.stderr)
342
+ return 2
343
+ if not tty:
344
+ requirements = _noninteractive_requirements(args, url)
345
+ if requirements:
346
+ print(requirements, file=sys.stderr)
347
+ return 2
348
+ if not _authorize(args, url, tty=tty):
349
+ _say(args, "已取消。")
350
+ return 0
351
+ if args.backend != "mock" and getattr(args, "enable_network_backends", False) and url.startswith(("http://", "https://")):
352
+ failure = network_preflight(url)
353
+ if failure:
354
+ print(failure, file=sys.stderr)
355
+ return 1
356
+ workspace = _StagingWorkspace()
357
+ with workspace as temporary:
358
+ stage = Path(temporary)
359
+ requested_destination = args.save_to or args.out
360
+ run_args = argparse.Namespace(**vars(args))
361
+ run_args.out = str(stage)
362
+ run_args.url = url
363
+ run_args.package_root = None
364
+ run_args.runlog = None
365
+ run_args.command = "go"
366
+ start_error = _start_compact_receipt(run_args, "go")
367
+ if start_error:
368
+ raise ValueError(start_error)
369
+ try:
370
+ result = _process_input(url, run_args, command="go")
371
+ if run_args.receipt:
372
+ with contextlib.redirect_stdout(io.StringIO()):
373
+ receipt_code = _emit_result(run_args, result)
374
+ if receipt_code and result.ok:
375
+ raise ValueError("go_receipt_failed")
376
+ source_label, rows = _source_summary(stage, result)
377
+ if not result.ok or not result.outputs:
378
+ print(f"处理失败:{_failure_hint(rows)}", file=sys.stderr)
379
+ print(f"提 issue 请附上:{issue_context(url, rows)}", file=sys.stderr)
380
+ if getattr(args, "verbose", False):
381
+ for row in rows[-3:]:
382
+ print(f"{display_backend(row.get('backend'))}: {row.get('error_code')};{sanitize_error_summary(row.get('error_summary'))}", file=sys.stderr)
383
+ retained = _preserve_failure_log(stage)
384
+ if retained:
385
+ print(f"错误记录:{retained}", file=sys.stderr)
386
+ if getattr(args, "json", False):
387
+ print(json.dumps({"ok": False, "command": "go", "error": sanitize_error_summary(result.message) or "extraction_failed", "records": str(retained) if retained else None}, ensure_ascii=True))
388
+ return 1
389
+ for output in result.outputs:
390
+ if Path(output).is_dir():
391
+ validate_package_or_raise(Path(output))
392
+ first = Path(result.outputs[0])
393
+ if not args.package and len(result.outputs) != 1:
394
+ workspace.keep = True
395
+ raise ValueError(f"go_single_markdown_required: 本次产生多份文件;请使用 --package。已提取产物保留在:{stage}")
396
+ title = first.stem
397
+ if first.is_dir():
398
+ meta = json.loads((first / "metadata.json").read_text(encoding="utf-8"))
399
+ title = str(meta.get("title") or title)
400
+ if not args.package and first.suffix == ".md":
401
+ _attach_video(args, url, first)
402
+ label = "已取得部分正文" if any(row.get("status") == "partial" for row in rows) else "已取得正文"
403
+ _say(args, f"{label}:{title}" + (f";来源:{source_label}" if source_label else ""))
404
+ if getattr(args, "verbose", False):
405
+ tools = ", ".join(dict.fromkeys(display_backend(row["backend"]) for row in rows if row.get("backend")))
406
+ _say(args, f"工具:{tools}")
407
+ default_destination = str(load_preferences().get("last_save_directory") or Path.cwd() / "out")
408
+ destination_text = requested_destination
409
+ while True:
410
+ if destination_text is None:
411
+ destination_text = _prompt(args, f"保存在哪里?[回车用 {default_destination};n 取消] ") or default_destination
412
+ if destination_text.lower() in {"n", "no", "取消"}:
413
+ _preserve_records(stage)
414
+ _say(args, "已取消保存。")
415
+ return 0
416
+ try:
417
+ destination = Path(destination_text.strip().strip('"')).expanduser().resolve()
418
+ target = _save(stage, destination, title=title, package=args.package, source=None if args.package else first)
419
+ break
420
+ except (OSError, ValueError) as exc:
421
+ if not tty or requested_destination:
422
+ workspace.keep = True
423
+ print(f"保存未完成,已提取产物保留在:{stage}", file=sys.stderr)
424
+ raise
425
+ print(f"保存失败:{sanitize_error_summary(exc)}", file=sys.stderr)
426
+ destination_text = None
427
+ published = {Path(p): target / Path(p).relative_to(stage) if args.package else target for p in result.outputs}
428
+ outputs = [str(published[Path(p)]) for p in result.outputs]
429
+ records = None
430
+ try:
431
+ records = _preserve_records(stage, published=published)
432
+ except (OSError, ValueError) as exc:
433
+ workspace.keep = True
434
+ print(f"正文已保存;运行记录暂留:{stage}({sanitize_error_summary(exc)})", file=sys.stderr)
435
+ if tty:
436
+ try:
437
+ save_preferences(last_save_directory=destination)
438
+ except OSError as exc:
439
+ print(f"正文已保存;未能记住保存目录:{sanitize_error_summary(exc)}", file=sys.stderr)
440
+ _emit_saved(args, target=target, outputs=outputs, records=records)
441
+ return 0
442
+ finally:
443
+ lock = getattr(run_args, "_compact_output_lock", None)
444
+ if lock is not None:
445
+ lock.release()
446
+ except (EOFError, KeyboardInterrupt):
447
+ print("已取消。", file=sys.stderr)
448
+ return 130
449
+ except (OSError, ValueError) as exc:
450
+ print(f"openmd go: {sanitize_error_summary(exc)}", file=sys.stderr)
451
+ if getattr(args, "json", False):
452
+ print(json.dumps({"ok": False, "command": "go", "error": sanitize_error_summary(exc)}, ensure_ascii=True))
453
+ return 1
openmd/ocr/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Optional local OCR routing, with no import-time inference or downloads."""
openmd/ocr/breakers.py ADDED
@@ -0,0 +1,84 @@
1
+ """Image-only capacity and repetition checks, ported from the approved T2 run.
2
+
3
+ No reference answer is read here. These checks detect loops and excess length,
4
+ not isolated hallucinations, omissions or numeric errors.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import html
11
+
12
+
13
+ def normalize_for_breaker(text: str) -> str:
14
+ # HTML table syntax/image placeholders are not visible characters. Counting
15
+ # tags against a pixel text budget falsely trips layout-crop fallback pages.
16
+ text = html.unescape(re.sub(r"<[^>]+>", " ", text))
17
+ text = re.sub(r"```.*?```", " ", text, flags=re.S)
18
+ text = re.sub(r"\s+", " ", text)
19
+ return re.sub(r"[#*_`|>$-]", "", text).strip()
20
+
21
+
22
+ def formula_like(text: str) -> bool:
23
+ raw = text.strip()
24
+ if not raw:
25
+ return True
26
+ # Formula-only fragments are exempt; prose containing a formula is not.
27
+ prose = re.sub(r"\$\$.*?\$\$|\$[^$]*\$|\\\[.*?\\\]|\\\(.*?\\\)", "", raw, flags=re.S)
28
+ if len(re.findall(r"[\u3400-\u9fffA-Za-z]", prose)) >= 15 and prose != raw:
29
+ return False
30
+ if re.search(r"\\(?:frac|sum|int|sqrt|left|right|begin|end|mathbf|mathrm|cdot|times)|\$|\\\[|\\\(", raw):
31
+ return True
32
+ han = len(re.findall(r"[\u3400-\u9fff]", raw))
33
+ letters = len(re.findall(r"[A-Za-z]", raw))
34
+ operators = len(re.findall(r"[=+*/^_{}<>∑∫√×÷±]|\.\.\.|…", raw))
35
+ digits = len(re.findall(r"\d", raw))
36
+ if han == 0 and (operators >= 1 or digits >= 2) and len(raw) <= 160 and letters <= max(8, 2 * (operators + digits)):
37
+ return True
38
+ return han <= 2 and operators >= 2 and (operators + digits) >= max(3, letters // 2)
39
+
40
+
41
+ def repetition_trip(text: str, config: dict) -> dict | None:
42
+ # Remove fenced/math-only blocks before splitting sentences.
43
+ clean = re.sub(r"```.*?```|\$\$.*?\$\$|\\\[.*?\\\]", " ", text, flags=re.S)
44
+ counts: dict[str, int] = {}
45
+ for piece in re.split(r"[。!?!?\n]|(?<=[a-z0-9])\.\s+", clean):
46
+ norm = normalize_for_breaker(piece)
47
+ if len(norm) < config["repeat_min_chars"] or formula_like(piece):
48
+ continue
49
+ counts[norm] = counts.get(norm, 0) + 1
50
+ if counts[norm] >= config["repeat_count"]:
51
+ # No recognized text enters a receipt (especially for private L3).
52
+ return {"rule": "repeat_sentence", "count": counts[norm], "chars": len(norm)}
53
+ return None
54
+
55
+
56
+ def estimate_expected_chars(image, config: dict) -> dict:
57
+ import numpy as np
58
+ from PIL import Image, ImageOps
59
+
60
+ probe = image.copy()
61
+ probe.thumbnail((1400, 1400), Image.Resampling.LANCZOS)
62
+ dark = np.asarray(ImageOps.grayscale(probe), dtype=np.uint8) < 220
63
+ active = dark.mean(axis=1) > 0.012
64
+ bands, in_band = 0, False
65
+ for value in active:
66
+ if value and not in_band:
67
+ bands += 1
68
+ in_band = bool(value)
69
+ bands = bands or max(1, round(probe.height / 80))
70
+ width_chars = max(24, min(180, round(probe.width / 13)))
71
+ density = float(dark.mean())
72
+ density_factor = max(0.65, min(1.8, density / 0.055))
73
+ estimated = int(max(256, min(120000, bands * width_chars * density_factor * 1.75)))
74
+ return {"estimated_chars": estimated, "ink_rows": bands, "probe_size": list(probe.size), "ink_fraction": round(density, 5), "max_length_chars": int(estimated * config["length_multiplier"])}
75
+
76
+
77
+ def breaker_reason(text: str, budget: dict, config: dict) -> dict | None:
78
+ repeated = repetition_trip(text, config)
79
+ if repeated:
80
+ return repeated
81
+ length = len(normalize_for_breaker(text))
82
+ if length > budget["max_length_chars"]:
83
+ return {"rule": "length_gt_image_estimate", "observed_chars": length, "estimated_chars": budget["estimated_chars"], "multiplier": config["length_multiplier"]}
84
+ return None
openmd/ocr/config.py ADDED
@@ -0,0 +1,54 @@
1
+ """One validated configuration for providers, workers and diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ from openmd.core.local_config import load_local_config
12
+
13
+ from .endpoint import validate_ollama_url
14
+
15
+
16
+ def settings() -> dict:
17
+ value = json.loads(files("openmd.ocr").joinpath("settings.json").read_text(encoding="utf-8"))
18
+ local = load_local_config()
19
+ overrides = local.get("ocr", {})
20
+ if not isinstance(overrides, dict) or set(overrides) - set(value):
21
+ raise ValueError("local_config_ocr_invalid")
22
+ value.update(overrides)
23
+ value["ovis_model"] = os.getenv("OPENMD_OCR_OVIS_MODEL") or value["ovis_model"]
24
+ value["minicpm_model"] = os.getenv("OPENMD_OCR_MINICPM_MODEL") or value["minicpm_model"]
25
+ for key, item in value.items():
26
+ if key.endswith("_model"):
27
+ if not isinstance(item, str) or not item.strip() or any(ord(c) < 32 for c in item):
28
+ raise ValueError(f"ocr_setting_invalid: {key}")
29
+ elif isinstance(item, bool) or not isinstance(item, (int, float)) or not math.isfinite(item) or item <= 0:
30
+ raise ValueError(f"ocr_setting_invalid: {key}")
31
+ for key in ("repeat_min_chars", "repeat_count", "num_predict", "num_ctx", "max_pixels", "tile_height", "pdf_dpi", "textlayer_min_chars", "office_sparse_chars"):
32
+ if not isinstance(value[key], int):
33
+ raise ValueError(f"ocr_setting_integer_required: {key}")
34
+ if not 0 < value["tile_overlap"] < 0.5 or not 0 < value["tile_search"] < 0.5:
35
+ raise ValueError("ocr_tile_settings_invalid")
36
+ if any(not 0 < value[key] <= 1 for key in ("layout_threshold", "textlayer_min_printable", "textlayer_image_coverage", "textlayer_hidden_ratio", "textlayer_background_delta")):
37
+ raise ValueError("ocr_ratio_setting_invalid")
38
+ url = (os.getenv("OPENMD_OLLAMA_URL") or local.get("ollama_url") or "http://127.0.0.1:11434").rstrip("/")
39
+ value["ollama_url"] = validate_ollama_url(url)
40
+ return value
41
+
42
+
43
+ def layout_runtime() -> tuple[Path | None, Path | None]:
44
+ local = load_local_config()
45
+ python_value = os.getenv("OPENMD_OCR_PYTHON") or local.get("ocr_python")
46
+ python = Path(python_value).expanduser().resolve() if python_value else None
47
+ explicit = os.getenv("OPENMD_OCR_LAYOUT_MODEL")
48
+ if explicit:
49
+ candidates = [Path(explicit).expanduser()]
50
+ else:
51
+ roots = [*(Path(p).expanduser() for p in os.getenv("OPENMD_MODEL_ROOTS", "").split(os.pathsep) if p), *(Path(p).expanduser() for p in local.get("model_roots", []))]
52
+ candidates = [p for root in roots for p in (root / "layout", root / "PP-DocLayoutV3", root / "PaddlePaddle/PP-DocLayoutV3", root)]
53
+ model = next((p.resolve() for p in candidates if (p / "config.json").is_file() and any(p.glob("*.safetensors"))), None)
54
+ return python if python and python.is_file() else None, model