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/planner.py ADDED
@@ -0,0 +1,345 @@
1
+ """Pure V1 planning projection. This module must never construct a provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import stat
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.parse import unquote, urlparse
11
+
12
+ from openmd.core.detector import DOCUMENT_EXTENSIONS, IMAGE_TEXT_EXTENSIONS, detect_platform
13
+ from openmd.backends.bilibili import (
14
+ BILIBILI_OPENCLI_DISCOVERY_BACKEND,
15
+ BILIBILI_OPENCLI_SUBTITLE_BACKEND,
16
+ extract_bilibili_uid,
17
+ )
18
+ from openmd.backends.douyin import DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND, douyin_user_target
19
+ from openmd.backends.web_article import OPENCLI_WEB_READ_BACKEND
20
+ from openmd.backends.youtube import OPENCLI_YOUTUBE_TRANSCRIPT_BACKEND
21
+ from openmd.backends.youtube_transcript import extract_youtube_video_id
22
+ from openmd.backends.youtube_discovery import (
23
+ YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND,
24
+ YOUTUBE_DISCOVERY_BACKEND,
25
+ YOUTUBE_PLAYLIST_DISCOVERY_BACKEND,
26
+ normalize_youtube_collection_url,
27
+ youtube_data_api_channel_lookup,
28
+ youtube_data_api_playlist_id,
29
+ )
30
+
31
+ from .capabilities import CapabilityRecord, get_capability
32
+ from .operation_contract import COLLECTION_BINDINGS, collect_all_allowed
33
+
34
+
35
+ READ_ONLY_SIDE_EFFECTS = (
36
+ "no_provider_execution",
37
+ "no_network",
38
+ "no_browser_or_session_access",
39
+ "no_input_content_read",
40
+ "no_media_or_model_download",
41
+ )
42
+ _WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
43
+ _AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".flac"}
44
+ _VIDEO_SUFFIXES = {".mp4", ".m4v", ".mov", ".webm", ".mkv"}
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class PlanResult:
49
+ ok: bool
50
+ detected_source: str
51
+ input_kind: str
52
+ capability_tier: str | None
53
+ implementation_state: str | None
54
+ source_exhausted_requested: bool
55
+ source_exhausted_approved: bool
56
+ planned_provider: str | None
57
+ runtime_provider: str | None
58
+ binding_state: str | None
59
+ execution_ready: bool
60
+ required_gates: tuple[str, ...]
61
+ cost_class: str | None
62
+ side_effects: tuple[str, ...]
63
+ blocked_reason: str | None
64
+ error_code: str | None
65
+ discovery_provider: str | None = None
66
+ required_backend: str | None = None
67
+ required_discovery_backend: str | None = None
68
+
69
+ def as_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "ok": self.ok,
72
+ "detected_source": self.detected_source,
73
+ "input_kind": self.input_kind,
74
+ "capability_tier": self.capability_tier,
75
+ "implementation_state": self.implementation_state,
76
+ "source_exhausted_requested": self.source_exhausted_requested,
77
+ "source_exhausted_approved": self.source_exhausted_approved,
78
+ "planned_provider": self.planned_provider,
79
+ "runtime_provider": self.runtime_provider,
80
+ "binding_state": self.binding_state,
81
+ "execution_ready": self.execution_ready,
82
+ "required_gates": list(self.required_gates),
83
+ "cost_class": self.cost_class,
84
+ "side_effects": list(self.side_effects),
85
+ "blocked_reason": self.blocked_reason,
86
+ "error_code": self.error_code,
87
+ "discovery_provider": self.discovery_provider,
88
+ "required_backend": self.required_backend,
89
+ "required_discovery_backend": self.required_discovery_backend,
90
+ "readiness_scope": "static_contract_only",
91
+ "runtime_checks": "dependencies, models, credentials and source access are not probed",
92
+ }
93
+
94
+
95
+ def _local_candidate(value: str) -> Path | None:
96
+ """Resolve spelling only. Never expand a folder or touch an input body."""
97
+ if _WINDOWS_PATH.match(value):
98
+ return Path(value)
99
+ parsed = urlparse(value)
100
+ if parsed.scheme == "file":
101
+ if parsed.netloc not in {"", "localhost"}:
102
+ raise ValueError("network_path_not_supported")
103
+ raw = unquote(parsed.path)
104
+ if raw.startswith(("\\\\", "//")):
105
+ raise ValueError("network_path_not_supported")
106
+ if re.match(r"^/[A-Za-z]:/", raw):
107
+ raw = raw[1:]
108
+ return Path(raw)
109
+ if parsed.scheme:
110
+ return None
111
+ if value.startswith(("\\\\", "//")):
112
+ raise ValueError("network_path_not_supported")
113
+ return Path(value) if value else None
114
+
115
+
116
+ def _local_source(path: Path) -> str:
117
+ suffix = path.suffix.lower()
118
+ if suffix in _AUDIO_SUFFIXES:
119
+ return "local_audio"
120
+ if suffix in _VIDEO_SUFFIXES:
121
+ return "local_video"
122
+ if suffix in DOCUMENT_EXTENSIONS:
123
+ return "pdf_office"
124
+ if suffix in IMAGE_TEXT_EXTENSIONS:
125
+ return "screenshot"
126
+ return "unknown"
127
+
128
+
129
+ def _inspect_input(value: str) -> tuple[str, str, Path | None, str | None]:
130
+ """Return source/shape/path/error using URL syntax or one local stat only."""
131
+ if re.fullmatch(r"BV[0-9A-Za-z]+", value):
132
+ return "bilibili", "single", None, None
133
+ try:
134
+ path = _local_candidate(value)
135
+ if path is not None:
136
+ source = _local_source(path)
137
+ try:
138
+ mode = path.stat().st_mode
139
+ except FileNotFoundError:
140
+ return source, "file", path, "input_missing" if source != "unknown" else "source_unsupported"
141
+ except OSError:
142
+ return source, "file", path, "input_stat_failed"
143
+ if stat.S_ISDIR(mode):
144
+ return "unknown", "folder", path, "folder_source_not_inspected"
145
+ if not stat.S_ISREG(mode):
146
+ return source, "file", path, "input_not_regular_file"
147
+ return source, "file", path, None
148
+ parsed = urlparse(value)
149
+ if parsed.scheme in {"http", "https"}:
150
+ if not parsed.hostname or any(c.isspace() for c in value):
151
+ return "unknown", "single", None, "source_unsupported"
152
+ # Only HTTP URLs reach the legacy detector, so its local recursive
153
+ # directory classification is unreachable from this projection.
154
+ source = detect_platform(value)
155
+ if source == "pdf":
156
+ source = "pdf_office"
157
+ if source == "xiaohongshu" and "/user/profile/" in parsed.path:
158
+ source = "xiaohongshu_creator"
159
+ return source, _infer_remote_kind(value, source), None, None
160
+ if parsed.scheme in {"weixin", "wx-official", "wechat-official", "wechat", "wx", "wx-group", "wechat-group", "wx-moments", "wechat-moments", "qq", "qq-group", "qq_export", "qq-export"}:
161
+ source = detect_platform(value)
162
+ if source != "web_article":
163
+ return source, "collection", None, None
164
+ except (ValueError, OSError):
165
+ return "unknown", "single", None, "source_unsupported"
166
+ return "unknown", "single", None, "source_unsupported"
167
+
168
+
169
+ def _infer_remote_kind(value: str, source: str) -> str:
170
+ parsed = urlparse(value)
171
+ path = parsed.path.lower()
172
+ if source == "youtube" and normalize_youtube_collection_url(value) is not None:
173
+ return "collection"
174
+ if source == "bilibili" and ((parsed.hostname or "").startswith("space.") or "/collection" in path or "/lists/" in path):
175
+ return "collection"
176
+ if source == "xiaoyuzhou" and "/podcast/" in path:
177
+ return "collection"
178
+ if source == "douyin" and ("/user/" in path or "sec_uid=" in parsed.query):
179
+ return "collection"
180
+ if source in {"weixin", "xiaoe"} and any(marker in path for marker in ("/author", "/collection", "/course", "/catalog", "/column")):
181
+ return "collection"
182
+ if source in {"xiaohongshu_creator", "wechat_group", "wechat_moments", "qq_group"}:
183
+ return "collection"
184
+ return "single"
185
+
186
+
187
+ def _blocked(
188
+ source_id: str,
189
+ input_kind: str,
190
+ request_all: bool,
191
+ *,
192
+ code: str,
193
+ reason: str,
194
+ record: CapabilityRecord | None = None,
195
+ ) -> PlanResult:
196
+ return PlanResult(
197
+ ok=False,
198
+ detected_source=source_id,
199
+ input_kind=input_kind,
200
+ capability_tier=record.tier if record else None,
201
+ implementation_state=record.state if record else None,
202
+ source_exhausted_requested=request_all,
203
+ source_exhausted_approved=False,
204
+ planned_provider=record.planned_provider if record else None,
205
+ runtime_provider=record.runtime_provider if record else None,
206
+ binding_state=record.binding_state if record else None,
207
+ execution_ready=False,
208
+ required_gates=record.required_gates if record else (),
209
+ cost_class=record.cost_class if record else None,
210
+ side_effects=READ_ONLY_SIDE_EFFECTS,
211
+ blocked_reason=reason,
212
+ error_code=code,
213
+ )
214
+
215
+
216
+ def build_plan(
217
+ input_value: str,
218
+ *,
219
+ input_kind: str = "auto",
220
+ request_all: bool = False,
221
+ available_gates: set[str] | None = None,
222
+ ) -> PlanResult:
223
+ value = input_value.strip()
224
+ source_id, inferred_kind, local_path, input_error = _inspect_input(value)
225
+ selected_kind = inferred_kind if input_kind == "auto" else input_kind
226
+ if input_error:
227
+ reasons = {
228
+ "input_missing": "local input file does not exist",
229
+ "input_stat_failed": "local input metadata could not be read",
230
+ "input_not_regular_file": "local input is not a regular file",
231
+ "folder_source_not_inspected": "folder contents are not inspected by plan; plan individual files",
232
+ }
233
+ return _blocked(source_id, selected_kind, request_all, code=input_error, reason=reasons.get(input_error, "input source is not approved"))
234
+ try:
235
+ record = get_capability(source_id)
236
+ except ValueError:
237
+ return _blocked(source_id, selected_kind, request_all, code="source_unsupported", reason="input source is not approved")
238
+
239
+ if local_path is not None and selected_kind != inferred_kind:
240
+ return _blocked(source_id, selected_kind, request_all, code="input_shape_unsupported", reason="input kind does not match local input", record=record)
241
+ if inferred_kind == "collection" and selected_kind == "single":
242
+ return _blocked(source_id, selected_kind, request_all, code="input_shape_unsupported", reason="collection input cannot be planned as a single item", record=record)
243
+ if selected_kind not in record.input_kinds:
244
+ return _blocked(source_id, selected_kind, request_all, code="input_shape_unsupported", reason="input kind is not approved for this source", record=record)
245
+ if record.state == "paused":
246
+ return _blocked(source_id, selected_kind, request_all, code="capability_paused", reason="source is paused before provider execution", record=record)
247
+ if request_all and selected_kind not in record.source_exhausted_kinds:
248
+ return _blocked(source_id, selected_kind, True, code="all_not_approved", reason="source exhaustion is not approved for this input kind", record=record)
249
+
250
+ gates = available_gates or set()
251
+ required = list(record.required_gates)
252
+ runtime_provider = record.runtime_provider
253
+ if not set(required) <= gates and source_id in {"youtube", "bilibili", "weixin", "web_article"}:
254
+ # Project an allowed fallback from the same declarations as execution.
255
+ # Planning stays static: do not construct or probe a provider.
256
+ from openmd.routing.loader import effective_routes, load_catalog
257
+
258
+ catalog = load_catalog()
259
+ for route in effective_routes(source_id):
260
+ candidate = next((backend for backend in route["chain"]
261
+ if set(catalog.capabilities[backend].get("gates", [])) <= gates), None)
262
+ if candidate:
263
+ runtime_provider = candidate
264
+ required = list(catalog.capabilities[candidate].get("gates", []))
265
+ break
266
+ discovery_provider = None
267
+ required_discovery_backend = None
268
+ operation_block = None
269
+ if selected_kind == "collection":
270
+ binding = COLLECTION_BINDINGS.get(source_id)
271
+ if binding is None:
272
+ operation_block = "collection discovery is not implemented"
273
+ else:
274
+ discovery_provider, discovery_gates = binding
275
+ if source_id == "bilibili" and "network" not in gates and "browser" in gates:
276
+ discovery_provider = BILIBILI_OPENCLI_DISCOVERY_BACKEND
277
+ discovery_gates = ("browser",)
278
+ if source_id == "bilibili" and extract_bilibili_uid(value) is None:
279
+ operation_block = "only Bilibili creator discovery is currently implemented"
280
+ if source_id == "douyin" and not request_all and douyin_user_target(value) is None:
281
+ operation_block = "bounded Douyin discovery requires a creator identifier"
282
+ if source_id == "youtube":
283
+ playlist = youtube_data_api_playlist_id(value) is not None
284
+ if normalize_youtube_collection_url(value) is None:
285
+ operation_block = "input is not a supported YouTube collection"
286
+ if "browser" in gates and ("network" not in gates or "account" not in gates):
287
+ discovery_provider = YOUTUBE_PLAYLIST_DISCOVERY_BACKEND if playlist else YOUTUBE_DISCOVERY_BACKEND
288
+ discovery_gates = ("browser",)
289
+ elif playlist:
290
+ discovery_provider = YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND
291
+ elif youtube_data_api_channel_lookup(value) is None:
292
+ operation_block = "this YouTube collection requires the browser discovery gate"
293
+ required_discovery_backend = discovery_provider
294
+ required.extend(discovery_gates)
295
+ if request_all and source_id == "douyin":
296
+ discovery_provider = DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND
297
+ required_discovery_backend = discovery_provider
298
+ elif selected_kind == "list":
299
+ operation_block = "list contents are not inspected by plan; plan individual inputs"
300
+ elif source_id == "douyin":
301
+ operation_block = "standalone Douyin input resolution is not implemented; use creator collection"
302
+ elif source_id == "youtube" and extract_youtube_video_id(value) is None:
303
+ operation_block = "input does not identify a YouTube video"
304
+ if request_all and not collect_all_allowed(source_id, browser_enabled=True, network_enabled=True, discovery_provider=discovery_provider):
305
+ operation_block = "source exhaustion is not implemented for this operation"
306
+ if local_path is None and source_id in {"pdf_office", "screenshot"}:
307
+ operation_block = "this provider requires a local file"
308
+ missing_gates = tuple(dict.fromkeys(gate for gate in required if gate not in gates))
309
+ execution_ready = record.binding_state == "implemented" and not missing_gates and record.state not in {"blocked", "paused"} and operation_block is None
310
+ if operation_block:
311
+ blocked_reason = operation_block
312
+ elif record.binding_state == "unimplemented":
313
+ blocked_reason = "runtime provider is not implemented"
314
+ elif missing_gates:
315
+ blocked_reason = f"missing required gate(s): {', '.join(missing_gates)}"
316
+ elif record.state == "blocked":
317
+ blocked_reason = "capability is blocked"
318
+ else:
319
+ blocked_reason = None
320
+ # An executable route can differ from the default CLI primary. Surface the
321
+ # exact override so callers never silently execute a placeholder instead.
322
+ required_backend = None
323
+ if source_id in {"local_audio", "local_video", "tutorial_video", "douyin", "youtube", "bilibili", "weixin", "web_article"} or runtime_provider != record.planned_provider:
324
+ required_backend = runtime_provider
325
+ return PlanResult(
326
+ ok=True,
327
+ detected_source=source_id,
328
+ input_kind=selected_kind,
329
+ capability_tier=record.tier,
330
+ implementation_state=record.state,
331
+ source_exhausted_requested=request_all,
332
+ source_exhausted_approved=request_all and selected_kind in record.source_exhausted_kinds,
333
+ planned_provider=record.planned_provider,
334
+ runtime_provider=runtime_provider,
335
+ binding_state=record.binding_state,
336
+ execution_ready=execution_ready,
337
+ required_gates=missing_gates,
338
+ cost_class=record.cost_class,
339
+ side_effects=READ_ONLY_SIDE_EFFECTS,
340
+ blocked_reason=blocked_reason,
341
+ error_code=None,
342
+ discovery_provider=discovery_provider,
343
+ required_backend=required_backend,
344
+ required_discovery_backend=required_discovery_backend,
345
+ )
@@ -0,0 +1,61 @@
1
+ """Nonsecret preferences for the interactive single-link workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from uuid import uuid4
9
+
10
+
11
+ SCHEMA = "openmd.preferences/v1"
12
+
13
+
14
+ def data_directory() -> Path:
15
+ configured = os.environ.get("OPENMD_DATA_DIR")
16
+ return Path(configured).expanduser().resolve() if configured else Path.home() / ".openmd"
17
+
18
+
19
+ def preferences_path() -> Path:
20
+ return data_directory() / "preferences.json"
21
+
22
+
23
+ def load_preferences() -> dict[str, object]:
24
+ """Malformed preferences never grant permissions or block extraction."""
25
+ path = preferences_path()
26
+ try:
27
+ if not path.is_file() or path.stat().st_size > 16 * 1024:
28
+ return {}
29
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
30
+ except (OSError, ValueError):
31
+ return {}
32
+ if not isinstance(value, dict) or value.get("schema") != SCHEMA:
33
+ return {}
34
+ result: dict[str, object] = {}
35
+ directory = value.get("last_save_directory")
36
+ if isinstance(directory, str) and directory.strip():
37
+ result["last_save_directory"] = directory
38
+ if type(value.get("browser_always")) is bool:
39
+ result["browser_always"] = value["browser_always"]
40
+ return result
41
+
42
+
43
+ def save_preferences(*, last_save_directory: Path | None = None, browser_always: bool | None = None) -> None:
44
+ """Only these explicit nonsecret values can enter the preference file."""
45
+ value = {"schema": SCHEMA, **load_preferences()}
46
+ if last_save_directory is not None:
47
+ value["last_save_directory"] = str(last_save_directory.expanduser().resolve())
48
+ if browser_always is not None:
49
+ value["browser_always"] = bool(browser_always)
50
+ path = preferences_path()
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
53
+ try:
54
+ with temporary.open("x", encoding="utf-8", newline="\n") as handle:
55
+ json.dump(value, handle, ensure_ascii=False, indent=2)
56
+ handle.write("\n")
57
+ handle.flush()
58
+ os.fsync(handle.fileno())
59
+ temporary.replace(path)
60
+ finally:
61
+ temporary.unlink(missing_ok=True)
@@ -0,0 +1,129 @@
1
+ """Conservative display formatting for caption text, without rewriting words."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+
9
+ _TIME = r"\d{1,}:[0-5]\d(?::[0-5]\d)?"
10
+ # Local ASR emits "[start-end] text"; a caption keeps only its start time for display.
11
+ _CAPTION = re.compile(rf"^\[(?P<time>{_TIME})(?:-{_TIME})?\][ \t]+(?P<text>.+)$")
12
+ _TABLE_ROW = re.compile(
13
+ rf"^\s*\|\s*(?P<time>{_TIME})\s*\|(?P<speaker>[^|]*)\|(?P<text>.*)\|\s*$"
14
+ )
15
+ _TABLE_HEADER = re.compile(r"^\s*\|\s*timestamp\s*\|\s*speaker\s*\|\s*text\s*\|\s*$", re.I)
16
+ _TABLE_SEPARATOR = re.compile(r"^\s*\|\s*:?-+:?\s*\|\s*:?-+:?\s*\|\s*:?-+:?\s*\|\s*$")
17
+ _MARKDOWN_SPECIAL = re.compile(r"([\\`*_{}\[\]<>#!|])")
18
+ _SENTENCE_END = re.compile(r"[.!?。!?][\"'’”))]*$")
19
+
20
+
21
+ def markdown_label(value: object) -> str:
22
+ """Escape external text used in a generated heading or label."""
23
+ text = str(value or "").replace("\r", " ").replace("\n", " ")
24
+ return _MARKDOWN_SPECIAL.sub(r"\\\1", text)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class _Caption:
29
+ timestamp: str
30
+ text: str
31
+ speaker: str = ""
32
+ chapter: bool = False
33
+
34
+ @property
35
+ def seconds(self) -> int:
36
+ total = 0
37
+ for part in self.timestamp.split(":"):
38
+ total = total * 60 + int(part)
39
+ return total
40
+
41
+
42
+ def _table_captions(text: str) -> list[_Caption] | None:
43
+ lines = [line for line in text.splitlines() if line.strip()]
44
+ if len(lines) < 3 or not _TABLE_HEADER.fullmatch(lines[0]) or not _TABLE_SEPARATOR.fullmatch(lines[1]):
45
+ return None
46
+ captions = []
47
+ for line in lines[2:]:
48
+ match = _TABLE_ROW.fullmatch(line)
49
+ if match is None:
50
+ return None
51
+ content = match.group("text").strip()
52
+ if not content:
53
+ return None
54
+ captions.append(_Caption(match.group("time"), content, match.group("speaker").strip(), content.startswith("[Chapter] ")))
55
+ return captions
56
+
57
+
58
+ def _line_captions(text: str) -> list[_Caption] | None:
59
+ captions = []
60
+ for line in text.splitlines():
61
+ if not line.strip():
62
+ continue
63
+ match = _CAPTION.fullmatch(line)
64
+ if match is None:
65
+ return None
66
+ captions.append(_Caption(match.group("time"), match.group("text")))
67
+ return captions or None
68
+
69
+
70
+ def format_transcript(text: str, *, source_type: str | None, platform: str | None = None) -> str:
71
+ """Group recognized captions only; articles and mixed/unknown formats pass through.
72
+
73
+ No text is summarized, corrected, sorted, deduplicated or repunctuated.
74
+ Only exact transcript table/line formats are recognized. A partly matching
75
+ document is returned intact rather than dropping unfamiliar source lines.
76
+ """
77
+ if source_type not in {"video", "audio"}:
78
+ return text
79
+ captions = _table_captions(text)
80
+ table = captions is not None
81
+ if captions is None:
82
+ captions = _line_captions(text)
83
+ if captions is None:
84
+ return text
85
+
86
+ blocks: list[str] = []
87
+ group: list[_Caption] = []
88
+ group_chars = 0
89
+
90
+ def flush() -> None:
91
+ if not group:
92
+ return
93
+ first = group[0]
94
+ label = f"{markdown_label(first.speaker)} ({first.timestamp})" if first.speaker else first.timestamp
95
+ blocks.append(f"**{label}**\n\n" + " ".join(caption.text for caption in group))
96
+
97
+ for caption in captions:
98
+ if caption.chapter:
99
+ flush()
100
+ group = []
101
+ group_chars = 0
102
+ heading = caption.text[len("[Chapter] "):]
103
+ blocks.append(f"## {markdown_label(heading)} ({caption.timestamp})")
104
+ continue
105
+ if group:
106
+ duration = caption.seconds - group[0].seconds
107
+ gap = caption.seconds - group[-1].seconds
108
+ if table:
109
+ # Use source timestamps to bound long turns even when captions
110
+ # have no sentence-ending punctuation. Never split row text.
111
+ boundary = (
112
+ caption.speaker != group[0].speaker
113
+ or duration >= 60
114
+ or group_chars >= 2400
115
+ or (group_chars >= 1200 and bool(_SENTENCE_END.search(group[-1].text.rstrip())))
116
+ )
117
+ else:
118
+ boundary = duration >= 30 or gap >= 6 or group_chars >= 450
119
+ if gap < 0:
120
+ # Retain source order, even if upstream times move backwards.
121
+ boundary = True
122
+ if boundary:
123
+ flush()
124
+ group = []
125
+ group_chars = 0
126
+ group.append(caption)
127
+ group_chars += len(caption.text)
128
+ flush()
129
+ return "\n\n".join(blocks)