openmd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,213 @@
1
+ """youtube-transcript-api backend provider.
2
+
3
+ This provider is intentionally opt-in because it performs network requests when
4
+ enabled. The default CLI keeps using skeleton providers unless the operator
5
+ explicitly enables real network backends.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ from collections.abc import Callable, Iterable
12
+ from typing import Any
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ from openmd.core.models import ContentItem, TranscriptResult
16
+ from openmd.core.secrets import sanitize_error_summary
17
+
18
+ from .base import BackendCapability, BackendDescriptor, BackendExtractRequest, BackendHealth
19
+
20
+
21
+ YOUTUBE_TRANSCRIPT_BACKEND = "youtube-transcript-api"
22
+ DEFAULT_LANGUAGES: tuple[str, ...] = ("zh-Hans", "zh-CN", "zh", "en")
23
+
24
+
25
+ def extract_youtube_video_id(input_value: str) -> str | None:
26
+ """Extract a YouTube video id from common URL forms or a raw id."""
27
+ value = input_value.strip()
28
+ if not value:
29
+ return None
30
+ if "://" not in value and "/" not in value and "?" not in value:
31
+ return value
32
+
33
+ parsed = urlparse(value if "://" in value else f"https://{value}")
34
+ host = parsed.netloc.lower()
35
+ path_parts = [part for part in parsed.path.split("/") if part]
36
+
37
+ if host.endswith("youtu.be") and path_parts:
38
+ return path_parts[0]
39
+
40
+ query_video_id = parse_qs(parsed.query).get("v")
41
+ if query_video_id and query_video_id[0]:
42
+ return query_video_id[0]
43
+
44
+ if path_parts and path_parts[0] in {"embed", "shorts", "live"} and len(path_parts) >= 2:
45
+ return path_parts[1]
46
+
47
+ return None
48
+
49
+
50
+ class YouTubeTranscriptApiProvider:
51
+ """Fetch YouTube-native transcripts through youtube-transcript-api."""
52
+
53
+ name = YOUTUBE_TRANSCRIPT_BACKEND
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ languages: tuple[str, ...] = DEFAULT_LANGUAGES,
59
+ api_factory: Callable[[], Any] | None = None,
60
+ ) -> None:
61
+ self.languages = languages
62
+ self._api_factory = api_factory
63
+
64
+ def descriptor(self) -> BackendDescriptor:
65
+ capability = BackendCapability(
66
+ platforms=("youtube",),
67
+ source_types=("video",),
68
+ input_types=("single",),
69
+ source_levels=("native_caption", "auto_caption"),
70
+ requires_network=True,
71
+ requires_credentials=False,
72
+ optional_config=("languages",),
73
+ )
74
+ return BackendDescriptor(
75
+ name=self.name,
76
+ layer="L1 specialized extractor",
77
+ capabilities=(capability,),
78
+ description="Fetch existing YouTube manual or generated transcripts.",
79
+ provider="youtube-transcript-api",
80
+ )
81
+
82
+ def health(self) -> BackendHealth:
83
+ if self._api_factory is not None:
84
+ return BackendHealth(ok=True, dependencies={"youtube-transcript-api": "injected"})
85
+ if importlib.util.find_spec("youtube_transcript_api") is None:
86
+ return BackendHealth(
87
+ ok=False,
88
+ dependencies={"youtube-transcript-api": "missing"},
89
+ error_code="backend_dependency_missing",
90
+ error_summary="youtube-transcript-api is not installed.",
91
+ )
92
+ return BackendHealth(ok=True, dependencies={"youtube-transcript-api": "available"})
93
+
94
+ def extract(self, request: BackendExtractRequest) -> TranscriptResult:
95
+ item = request.item
96
+ if item.platform != "youtube":
97
+ return _failed(
98
+ item,
99
+ "unsupported_platform",
100
+ f"youtube-transcript-api only supports youtube, got {item.platform}.",
101
+ )
102
+
103
+ video_id = item.id or extract_youtube_video_id(item.url)
104
+ if not video_id:
105
+ return _failed(item, "invalid_input", "Could not extract a YouTube video id.")
106
+
107
+ try:
108
+ api = self._create_api()
109
+ fetched_transcript = api.fetch(video_id, languages=list(self.languages))
110
+ except ImportError as exc:
111
+ return _failed(item, "backend_dependency_missing", exc, video_id=video_id)
112
+ except Exception as exc:
113
+ return _failed(item, _error_code_for_exception(exc), exc, video_id=video_id)
114
+
115
+ text = _format_transcript(fetched_transcript)
116
+ if not text.strip():
117
+ return _failed(item, "empty_result", "youtube-transcript-api returned no transcript text.", video_id=video_id)
118
+
119
+ is_generated = bool(getattr(fetched_transcript, "is_generated", False))
120
+ language = getattr(fetched_transcript, "language", None)
121
+ language_code = getattr(fetched_transcript, "language_code", None)
122
+ source_level = "auto_caption" if is_generated else "native_caption"
123
+ notes = [
124
+ "youtube-transcript-api fetched an existing YouTube transcript.",
125
+ f"language_code: {language_code or 'unknown'}",
126
+ "caption_type: generated" if is_generated else "caption_type: manual_or_official",
127
+ ]
128
+ return TranscriptResult(
129
+ text=text,
130
+ status="verified",
131
+ source_level=source_level,
132
+ backend=self.name,
133
+ source_notes=notes,
134
+ metadata={
135
+ "video_id": video_id,
136
+ "language": language,
137
+ "language_code": language_code,
138
+ "is_generated": is_generated,
139
+ },
140
+ )
141
+
142
+ def _create_api(self) -> Any:
143
+ if self._api_factory is not None:
144
+ return self._api_factory()
145
+ try:
146
+ from youtube_transcript_api import YouTubeTranscriptApi
147
+ except ImportError as exc:
148
+ raise ImportError("youtube-transcript-api is not installed.") from exc
149
+ return YouTubeTranscriptApi()
150
+
151
+
152
+ def _format_transcript(fetched_transcript: Iterable[Any]) -> str:
153
+ lines: list[str] = []
154
+ for snippet in fetched_transcript:
155
+ text = str(_snippet_value(snippet, "text", "")).replace("\r", " ").replace("\n", " ").strip()
156
+ if not text:
157
+ continue
158
+ start = _snippet_value(snippet, "start", 0.0)
159
+ lines.append(f"[{_format_timestamp(start)}] {text}")
160
+ return "\n".join(lines) + ("\n" if lines else "")
161
+
162
+
163
+ def _snippet_value(snippet: Any, key: str, default: Any) -> Any:
164
+ if isinstance(snippet, dict):
165
+ return snippet.get(key, default)
166
+ return getattr(snippet, key, default)
167
+
168
+
169
+ def _format_timestamp(start_seconds: Any) -> str:
170
+ try:
171
+ total_seconds = max(0, int(float(start_seconds)))
172
+ except (TypeError, ValueError):
173
+ total_seconds = 0
174
+ hours, remainder = divmod(total_seconds, 3600)
175
+ minutes, seconds = divmod(remainder, 60)
176
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
177
+
178
+
179
+ def _error_code_for_exception(exc: Exception) -> str:
180
+ name = exc.__class__.__name__.lower()
181
+ if "requestblocked" in name:
182
+ return "youtube_request_blocked"
183
+ if "ipblocked" in name:
184
+ return "youtube_ip_blocked"
185
+ if "transcriptsdisabled" in name:
186
+ return "youtube_transcripts_disabled"
187
+ if "notranscriptfound" in name:
188
+ return "youtube_transcript_not_found"
189
+ if "videounavailable" in name:
190
+ return "youtube_video_unavailable"
191
+ return "youtube_transcript_api_error"
192
+
193
+
194
+ def _failed(
195
+ item: ContentItem,
196
+ code: str,
197
+ error: object,
198
+ *,
199
+ video_id: str | None = None,
200
+ ) -> TranscriptResult:
201
+ metadata = {"platform": item.platform}
202
+ if video_id:
203
+ metadata["video_id"] = video_id
204
+ return TranscriptResult(
205
+ text="",
206
+ status="failed",
207
+ source_level="partial_extraction",
208
+ backend=YOUTUBE_TRANSCRIPT_BACKEND,
209
+ source_notes=["youtube-transcript-api returned structured failure"],
210
+ error_code=code,
211
+ error_summary=sanitize_error_summary(error),
212
+ metadata=metadata,
213
+ )
@@ -0,0 +1,231 @@
1
+ // Built-in OpenCLI Browser Bridge (Apache-2.0): lets the bundled opencli read pages
2
+ // through this browser, so users install one extension. See vendor/opencli-bridge/VENDOR.md.
3
+ import "./vendor/opencli-bridge/background.js";
4
+
5
+ // Openmd extension service worker.
6
+ // Daily flow: click the toolbar icon -> save the current page (or every highlighted tab) to the
7
+ // default location. Right-click a selection -> save every link inside it. Jobs run one by one.
8
+ const HOST = "com.openmd.host";
9
+ const POLL_MS = 2000;
10
+ const SETUP_HINT = "还没连上本机 openmd:请在终端运行 openmd browser-setup,然后重新加载插件。";
11
+
12
+ function native(message) {
13
+ return new Promise((resolve) => {
14
+ chrome.runtime.sendNativeMessage(HOST, message, (reply) => {
15
+ const err = chrome.runtime.lastError;
16
+ if (err) resolve({ ok: false, error: /not found|forbidden/i.test(err.message) ? SETUP_HINT : err.message });
17
+ else resolve(reply || { ok: false, error: "本机 openmd 没有回应" });
18
+ });
19
+ });
20
+ }
21
+
22
+ const isWeb = (url) => /^https?:\/\//.test(url || "");
23
+
24
+ async function getSettings() {
25
+ const { settings } = await chrome.storage.local.get("settings");
26
+ return { videoMode: "note", ...(settings || { locations: [], defaultId: null, clickMode: "auto" }) };
27
+ }
28
+
29
+ async function applyClickMode() {
30
+ const { clickMode, locations } = await getSettings();
31
+ // Without a popup, a click fires action.onClicked and saves directly.
32
+ const direct = clickMode === "auto" && locations.length > 0;
33
+ await chrome.action.setPopup({ popup: direct ? "" : "popup.html" });
34
+ await chrome.action.setTitle({ title: direct ? "保存到 Openmd(默认位置)。按住 Ctrl 多选标签页可一次保存多个" : "保存到 Openmd" });
35
+ }
36
+
37
+ async function seedSettings() {
38
+ const current = await getSettings();
39
+ if (current.locations.length) return;
40
+ const ping = await native({ type: "ping" });
41
+ if (!ping.ok || !ping.save_to) return;
42
+ const id = crypto.randomUUID();
43
+ await chrome.storage.local.set({ settings: { locations: [{ id, name: "默认", path: ping.save_to }], defaultId: id, clickMode: "auto" } });
44
+ }
45
+
46
+ // ---------------------------------------------------------------- tracking
47
+
48
+ async function tracked() {
49
+ const { jobs = {}, batches = {} } = await chrome.storage.local.get(["jobs", "batches"]);
50
+ return { jobs, batches };
51
+ }
52
+
53
+ async function showRunning(jobs) {
54
+ const n = Object.keys(jobs).length;
55
+ await chrome.action.setBadgeBackgroundColor({ color: "#1d5c49" });
56
+ await chrome.action.setBadgeText({ text: n > 1 ? String(n) : n ? "…" : "" });
57
+ }
58
+
59
+ async function flash(text, color) {
60
+ await chrome.action.setBadgeBackgroundColor({ color });
61
+ await chrome.action.setBadgeText({ text });
62
+ setTimeout(async () => showRunning((await tracked()).jobs), 6000);
63
+ }
64
+
65
+ function notify(title, message) {
66
+ chrome.notifications.create(`openmd:${Date.now()}`, {
67
+ type: "basic", iconUrl: "icons/icon-128.png", title, message: String(message || "").slice(0, 300),
68
+ });
69
+ }
70
+
71
+ function fail(title, message) {
72
+ flash("!", "#b3261e");
73
+ notify(title, message);
74
+ }
75
+
76
+ async function locationFor(locationId) {
77
+ const settings = await getSettings();
78
+ return settings.locations.find((l) => l.id === (locationId || settings.defaultId)) || settings.locations[0];
79
+ }
80
+
81
+ async function track(jobs, batch, location) {
82
+ const state = await tracked();
83
+ for (const job of jobs) state.jobs[job.id] = { title: job.title || job.url, batch };
84
+ state.batches[batch] = { total: jobs.length, failed: [], location };
85
+ await chrome.storage.local.set(state);
86
+ await showRunning(state.jobs);
87
+ poll();
88
+ }
89
+
90
+ // ---------------------------------------------------------------- saving
91
+
92
+ // video: "note" (just mention the video), "text" (+ local transcript), "file" (+ the video file).
93
+ async function saveMany(items, locationId, video) {
94
+ const web = items.filter((item) => isWeb(item.url));
95
+ if (!web.length) return fail("没法保存", "只能保存普通网页(http / https)。");
96
+ const location = await locationFor(locationId);
97
+ if (!location) {
98
+ chrome.runtime.openOptionsPage();
99
+ return fail("先设置一个保存位置", "已打开设置页。");
100
+ }
101
+ video = video || (await getSettings()).videoMode || "note";
102
+ const titles = Object.fromEntries(web.map((item) => [item.url, item.title]));
103
+ const reply = web.length === 1
104
+ ? await native({ type: "save", url: web[0].url, save_to: location.path, video })
105
+ : await native({ type: "save_many", urls: web.map((item) => item.url), save_to: location.path, video });
106
+ if (!reply.ok) return fail("没能开始保存", reply.error);
107
+ const jobs = (reply.jobs || [reply.job]).map((job) => ({ ...job, title: titles[job.url] }));
108
+ await track(jobs, reply.batch || jobs[0].id, location.name);
109
+ return { ok: true, count: jobs.length, location: location.name };
110
+ }
111
+
112
+ async function highlightedTabs(tab) {
113
+ const tabs = await chrome.tabs.query({ highlighted: true, windowId: tab.windowId });
114
+ // Clicking the icon on a tab outside the highlighted set saves just that tab.
115
+ return tabs.length > 1 && tabs.some((t) => t.id === tab.id) ? tabs : [tab];
116
+ }
117
+
118
+ async function linksInSelection(tabId) {
119
+ const [{ result } = {}] = await chrome.scripting.executeScript({
120
+ target: { tabId },
121
+ func: () => {
122
+ const selection = window.getSelection();
123
+ const found = [];
124
+ for (let i = 0; i < selection.rangeCount; i += 1) {
125
+ const range = selection.getRangeAt(i);
126
+ const root = range.commonAncestorContainer.nodeType === 1 ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement;
127
+ const anchors = root.closest("a[href]") ? [root.closest("a[href]")] : [...root.querySelectorAll("a[href]")];
128
+ for (const a of anchors) if (range.intersectsNode(a)) found.push({ url: a.href, title: (a.innerText || a.title || a.href).trim().slice(0, 120) });
129
+ }
130
+ return found;
131
+ },
132
+ });
133
+ const seen = new Set();
134
+ return (result || []).filter((item) => /^https?:\/\//.test(item.url) && !seen.has(item.url) && seen.add(item.url));
135
+ }
136
+
137
+ // ---------------------------------------------------------------- polling
138
+
139
+ let polling = false;
140
+ async function poll() {
141
+ if (polling) return;
142
+ polling = true;
143
+ try {
144
+ for (;;) {
145
+ const state = await tracked();
146
+ const ids = Object.keys(state.jobs);
147
+ if (!ids.length) break;
148
+ const reply = await native({ type: "statuses", ids });
149
+ if (reply.ok) {
150
+ for (const id of ids) {
151
+ const job = reply.jobs[id] || { state: "unknown" };
152
+ if (!["done", "failed", "cancelled", "unknown"].includes(job.state)) continue;
153
+ const meta = state.jobs[id];
154
+ delete state.jobs[id];
155
+ const batch = state.batches[meta.batch];
156
+ if (batch && job.state !== "done" && job.state !== "cancelled") batch.failed.push(`${meta.title}:${job.error || "状态未知"}`);
157
+ const remaining = Object.values(state.jobs).some((m) => m.batch === meta.batch);
158
+ if (batch && !remaining) {
159
+ delete state.batches[meta.batch];
160
+ if (batch.failed.length) {
161
+ const head = batch.total > 1 ? `${batch.total} 个里有 ${batch.failed.length} 个没保存成功` : "没能保存";
162
+ fail(head, batch.failed.slice(0, 3).join("\n"));
163
+ } else {
164
+ await flash("✓", "#2c8a5f");
165
+ }
166
+ }
167
+ }
168
+ await chrome.storage.local.set(state);
169
+ await showRunning(state.jobs);
170
+ }
171
+ await new Promise((r) => setTimeout(r, POLL_MS));
172
+ }
173
+ } finally {
174
+ polling = false;
175
+ }
176
+ }
177
+
178
+ // ---------------------------------------------------------------- entry points
179
+
180
+ chrome.action.onClicked.addListener(async (tab) => {
181
+ const tabs = await highlightedTabs(tab);
182
+ saveMany(tabs.map((t) => ({ url: t.url, title: t.title })));
183
+ });
184
+
185
+ chrome.runtime.onInstalled.addListener(async () => {
186
+ chrome.contextMenus.removeAll(() => {
187
+ chrome.contextMenus.create({ id: "save-page", title: "用 Openmd 保存此页", contexts: ["page"] });
188
+ chrome.contextMenus.create({ id: "save-link", title: "用 Openmd 保存此链接", contexts: ["link"] });
189
+ chrome.contextMenus.create({ id: "save-selection", title: "用 Openmd 保存选中的链接", contexts: ["selection"] });
190
+ // Posts with an attached video (X for now): opt in per save, so a plain click stays fast.
191
+ const posts = ["*://x.com/*", "*://*.x.com/*", "*://twitter.com/*", "*://*.twitter.com/*"];
192
+ chrome.contextMenus.create({ id: "save-video-text", title: "用 Openmd 保存(含视频文字版)", contexts: ["page", "link", "video"], documentUrlPatterns: posts });
193
+ chrome.contextMenus.create({ id: "save-video-file", title: "用 Openmd 保存(含视频文字版和视频文件)", contexts: ["page", "link", "video"], documentUrlPatterns: posts });
194
+ });
195
+ await seedSettings();
196
+ await applyClickMode();
197
+ if (!(await getSettings()).locations.length) chrome.runtime.openOptionsPage();
198
+ });
199
+
200
+ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
201
+ if (info.menuItemId === "save-link" && info.linkUrl) return saveMany([{ url: info.linkUrl, title: info.linkUrl }]);
202
+ if (info.menuItemId === "save-page" && tab?.url) return saveMany([{ url: tab.url, title: tab.title }]);
203
+ if (info.menuItemId === "save-video-text" || info.menuItemId === "save-video-file") {
204
+ const url = info.linkUrl || info.pageUrl || tab?.url;
205
+ const video = info.menuItemId === "save-video-file" ? "file" : "text";
206
+ return saveMany([{ url, title: info.linkUrl ? url : tab?.title }], null, video);
207
+ }
208
+ if (info.menuItemId === "save-selection" && tab?.id) {
209
+ try {
210
+ const links = await linksInSelection(tab.id);
211
+ if (!links.length) return fail("选中的区域里没有链接", "拖选一段包含链接的内容(比如课程目录、合集列表)再试。");
212
+ return saveMany(links);
213
+ } catch (err) {
214
+ return fail("读不到这个页面的选中内容", String(err?.message || err));
215
+ }
216
+ }
217
+ });
218
+
219
+ chrome.storage.onChanged.addListener((changes) => {
220
+ if (changes.settings) applyClickMode();
221
+ });
222
+
223
+ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
224
+ if (msg?.cmd === "save") { saveMany(msg.items, msg.locationId).then(reply); return true; }
225
+ if (msg?.cmd === "native") { native(msg.message).then(reply); return true; }
226
+ return false;
227
+ });
228
+
229
+ chrome.runtime.onStartup.addListener(async () => { await applyClickMode(); poll(); });
230
+ applyClickMode();
231
+ poll();
@@ -0,0 +1,6 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head><meta charset="utf-8"><title>Openmd</title>
4
+ <style>body{font:15px/1.6 system-ui,sans-serif;color:#555;background:#fafafa;display:grid;place-items:center;height:100vh;margin:0}</style></head>
5
+ <body><p>这是 Openmd 的后台读取窗口,可以忽略;关掉也不影响使用。</p></body>
6
+ </html>
@@ -0,0 +1,43 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Openmd",
4
+ "version": "1.3.0",
5
+ "description": "一键把当前网页、视频或播客保存成干净的 Markdown 原文 / 逐字稿(由本机 openmd 处理);内置 OpenCLI 浏览器桥接,借用你已登录的浏览器读取 B 站、公众号等。",
6
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0voJuuFdrQiqZFKlcfFdjc9xcD8zMtD8JH4fmNAuO2Fl6swv1qINbzy6QiUX2SGMyEZTg/r6tFLiCHGuFHZ1b3LFW1ytCzQS/OuwTs3tRG4y07AQk+9ZWAtMVy02+Njo4lSuORh/ODwQb1o5JkgH1DJbdGwNyfWFgA3KljYEYBh4GvJScDfqFt25l079hyF2y/zTHVmq7EhFj+mO9SFRZp8K2tpaUCdtS9TGA40RLw77P209kJSsa0Ii0/CMgXawb/305FMmWif438vUoKiGbte9HQBy51ouwpQxeTi3zsszpV/KSFFejPDMpr0RfF/YrInSnEo9tMcxQRb/ltlJgQIDAQAB",
7
+ "icons": {
8
+ "16": "icons/icon-16.png",
9
+ "32": "icons/icon-32.png",
10
+ "48": "icons/icon-48.png",
11
+ "128": "icons/icon-128.png"
12
+ },
13
+ "action": {
14
+ "default_title": "保存到 Openmd",
15
+ "default_popup": "popup.html",
16
+ "default_icon": {
17
+ "16": "icons/icon-16.png",
18
+ "32": "icons/icon-32.png"
19
+ }
20
+ },
21
+ "background": {
22
+ "service_worker": "background.js",
23
+ "type": "module"
24
+ },
25
+ "permissions": [
26
+ "nativeMessaging",
27
+ "activeTab",
28
+ "contextMenus",
29
+ "notifications",
30
+ "storage",
31
+ "scripting",
32
+ "debugger",
33
+ "tabs",
34
+ "cookies",
35
+ "alarms",
36
+ "tabGroups",
37
+ "downloads"
38
+ ],
39
+ "options_page": "options.html",
40
+ "host_permissions": [
41
+ "<all_urls>"
42
+ ]
43
+ }
@@ -0,0 +1,42 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Openmd 设置</title>
6
+ <link rel="stylesheet" href="popup.css">
7
+ </head>
8
+ <body class="options">
9
+ <main>
10
+ <header class="opt-head">
11
+ <img src="icons/icon-48.png" width="36" height="36" alt="">
12
+ <div>
13
+ <h1>Openmd 设置</h1>
14
+ <p class="host" id="host">正在连接本机 openmd…</p>
15
+ </div>
16
+ </header>
17
+
18
+ <section>
19
+ <h2>点击图标时</h2>
20
+ <label class="choice"><input type="radio" name="mode" value="auto"> 直接保存到默认位置</label>
21
+ <label class="choice"><input type="radio" name="mode" value="choose"> 先选择保存位置</label>
22
+ </section>
23
+
24
+ <section>
25
+ <h2>帖子里带视频时(目前 X)</h2>
26
+ <label class="choice"><input type="radio" name="video" value="note"> 只注明有视频(最快)</label>
27
+ <label class="choice"><input type="radio" name="video" value="text"> 附上视频逐字稿(本机转写,较慢)</label>
28
+ <p class="hint">不论选哪个,在 X 页面右键都可以单独选“含视频文字版”或“含视频文字版和视频文件”。</p>
29
+ </section>
30
+
31
+ <section>
32
+ <h2>保存位置</h2>
33
+ <p class="hint">名字随便起,比如“笔记收件箱”“D 盘资料”。圆点选中的是默认位置。</p>
34
+ <ul id="rows" class="rows"></ul>
35
+ <button id="add" type="button" class="secondary">+ 添加位置</button>
36
+ </section>
37
+
38
+ <p class="saved" id="saved" role="status" aria-live="polite"></p>
39
+ </main>
40
+ <script src="options.js"></script>
41
+ </body>
42
+ </html>
@@ -0,0 +1,117 @@
1
+ const $ = (id) => document.getElementById(id);
2
+ let settings = { locations: [], defaultId: null, clickMode: "auto", videoMode: "note" };
3
+
4
+ function native(message) {
5
+ return chrome.runtime.sendMessage({ cmd: "native", message });
6
+ }
7
+
8
+ function isAbsoluteWindowsPath(p) {
9
+ return /^[A-Za-z]:[\\/]/.test(p) || /^\\\\/.test(p);
10
+ }
11
+
12
+ let savedTimer;
13
+ async function persist() {
14
+ settings.locations = settings.locations.filter((l) => l.name.trim() || l.path.trim());
15
+ if (!settings.locations.some((l) => l.id === settings.defaultId)) settings.defaultId = settings.locations[0]?.id || null;
16
+ await chrome.storage.local.set({ settings });
17
+ $("saved").textContent = "已保存设置";
18
+ clearTimeout(savedTimer);
19
+ savedTimer = setTimeout(() => { $("saved").textContent = ""; }, 1500);
20
+ }
21
+
22
+ function row(loc) {
23
+ const li = document.createElement("li");
24
+ li.className = "row";
25
+
26
+ const radio = document.createElement("input");
27
+ radio.type = "radio";
28
+ radio.name = "default";
29
+ radio.id = `def-${loc.id}`;
30
+ radio.title = "设为默认";
31
+ radio.checked = loc.id === settings.defaultId;
32
+ radio.onchange = () => { settings.defaultId = loc.id; persist(); };
33
+
34
+ const name = document.createElement("input");
35
+ name.type = "text";
36
+ name.id = `name-${loc.id}`;
37
+ name.className = "name";
38
+ name.placeholder = "名字";
39
+ name.value = loc.name;
40
+ name.onchange = () => { loc.name = name.value.trim() || "未命名"; name.value = loc.name; persist(); };
41
+
42
+ const path = document.createElement("input");
43
+ path.type = "text";
44
+ path.id = `path-${loc.id}`;
45
+ path.className = "path";
46
+ path.placeholder = "例如 D:\\资料\\收件箱";
47
+ path.spellcheck = false;
48
+ path.value = loc.path;
49
+ const check = () => path.classList.toggle("invalid", !!path.value && !isAbsoluteWindowsPath(path.value));
50
+ path.onchange = () => { loc.path = path.value.trim(); check(); persist(); };
51
+ check();
52
+
53
+ const pick = document.createElement("button");
54
+ pick.type = "button";
55
+ pick.className = "secondary";
56
+ pick.textContent = "选择…";
57
+ pick.onclick = async () => {
58
+ pick.disabled = true;
59
+ const reply = await native({ type: "pick_folder", initial: loc.path });
60
+ pick.disabled = false;
61
+ if (reply?.ok && reply.path) { loc.path = reply.path; path.value = reply.path; check(); persist(); }
62
+ };
63
+
64
+ const remove = document.createElement("button");
65
+ remove.type = "button";
66
+ remove.className = "remove";
67
+ remove.textContent = "删除";
68
+ remove.onclick = () => {
69
+ settings.locations = settings.locations.filter((l) => l.id !== loc.id);
70
+ persist();
71
+ render();
72
+ };
73
+
74
+ li.append(radio, name, path, pick, remove);
75
+ return li;
76
+ }
77
+
78
+ function render() {
79
+ const rows = $("rows");
80
+ rows.textContent = "";
81
+ for (const loc of settings.locations) rows.append(row(loc));
82
+ document.querySelectorAll('input[name="mode"]').forEach((r) => { r.checked = r.value === settings.clickMode; });
83
+ document.querySelectorAll('input[name="video"]').forEach((r) => { r.checked = r.value === (settings.videoMode || "note"); });
84
+ }
85
+
86
+ async function init() {
87
+ const stored = (await chrome.storage.local.get("settings")).settings;
88
+ if (stored) settings = { videoMode: "note", ...stored };
89
+ const ping = await native({ type: "ping" });
90
+ const host = $("host");
91
+ host.textContent = ping?.ok ? "本机 openmd 已连接" : (ping?.error || "未连接本机 openmd");
92
+ host.className = `host ${ping?.ok ? "ok" : "bad"}`;
93
+ if (!settings.locations.length && ping?.ok && ping.save_to) {
94
+ const id = crypto.randomUUID();
95
+ settings = { locations: [{ id, name: "默认", path: ping.save_to }], defaultId: id, clickMode: "auto", videoMode: "note" };
96
+ await persist();
97
+ }
98
+ render();
99
+ }
100
+
101
+ $("add").addEventListener("click", () => {
102
+ const loc = { id: crypto.randomUUID(), name: `位置 ${settings.locations.length + 1}`, path: "" };
103
+ settings.locations.push(loc);
104
+ if (!settings.defaultId) settings.defaultId = loc.id;
105
+ render();
106
+ document.getElementById(`path-${loc.id}`).focus();
107
+ });
108
+
109
+ document.querySelectorAll('input[name="mode"]').forEach((r) => {
110
+ r.addEventListener("change", () => { settings.clickMode = r.value; persist(); });
111
+ });
112
+
113
+ init();
114
+
115
+ document.querySelectorAll('input[name="video"]').forEach((r) => {
116
+ r.addEventListener("change", () => { settings.videoMode = r.value; persist(); });
117
+ });