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,158 @@
1
+ """Bridge declarative command cards into the existing backend executor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import importlib.metadata
7
+ import json
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from dataclasses import replace
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from openmd.backends.base import BackendCapability, BackendDescriptor, BackendHealth
16
+ from openmd.core.local_config import load_local_config
17
+ from openmd.core.models import TranscriptResult
18
+ from openmd.core.secrets import sanitize_error_summary
19
+
20
+ from .loader import load_catalog, route_trace
21
+ from .readers import read_media, read_text
22
+ from .runner import ToolError, isolated_environment, resolve_launcher, run_tool
23
+
24
+ from openmd.runtime.tempdirs import owned_temporary_directory
25
+
26
+ GATE_OPTIONS = {"network": "enable_network_backends", "browser": "enable_browser_backends", "media_download": "enable_media_download_backends", "asr": "enable_asr_backends", "cloud": "enable_cloud_backends"}
27
+
28
+
29
+ def gates_open(capability: dict[str, Any], options) -> bool:
30
+ return all(gate not in {"account", "local_export"} and (gate == "local_compute" or bool(getattr(options, GATE_OPTIONS.get(gate, ""), False))) for gate in capability.get("gates", []))
31
+
32
+
33
+ def installed_identity(card: dict[str, Any]) -> dict[str, Any]:
34
+ launcher = resolve_launcher(card)
35
+ digest = hashlib.sha256(launcher.entry_path.read_bytes()).hexdigest()
36
+ actual = None
37
+ if card["kind"] == "command":
38
+ for parent in [launcher.entry_path.parent, *list(launcher.entry_path.parents)[:3]]:
39
+ metadata = parent / "package.json"
40
+ if metadata.is_file():
41
+ try:
42
+ actual = json.loads(metadata.read_text(encoding="utf-8")).get("version")
43
+ except (OSError, ValueError):
44
+ pass
45
+ if actual:
46
+ break
47
+ elif card["kind"] == "python_entry":
48
+ paths = load_local_config().get("tools", {}).get(card["id"], {}).get("paths")
49
+ wanted = card["id"].lower().replace("_", "-")
50
+ for dist in importlib.metadata.distributions(path=paths):
51
+ if str(dist.metadata.get("Name", "")).lower().replace("_", "-") == wanted:
52
+ actual = dist.version
53
+ break
54
+ if card["kind"] == "python_script":
55
+ actual = "sha256:" + digest
56
+ verified = card.get("installation", {}).get("sha256") == digest
57
+ else:
58
+ verified = actual == card.get("version")
59
+ locked_digest = card.get("installation", {}).get("sha256")
60
+ if locked_digest and locked_digest != digest:
61
+ verified = False
62
+ return {"tool_version": actual, "tool_version_verified": verified, "version_origin": "installed_metadata" if card["kind"] != "python_script" else "source_sha256", "entry_sha256": digest, "origin": launcher.origin}
63
+
64
+
65
+ class CommandToolProvider:
66
+ def __init__(self, card: dict[str, Any], capability: dict[str, Any], options) -> None:
67
+ self.card, self.capability, self.options = card, capability, options
68
+ self.name = capability["id"]
69
+
70
+ def descriptor(self) -> BackendDescriptor:
71
+ gates = self.capability.get("gates", [])
72
+ return BackendDescriptor(name=self.name, layer="command_card", capabilities=(BackendCapability(platforms=tuple(self.capability["sources"]), source_types=("article", "audio", "video"), input_types=("single", "batch", "collection"), source_levels=(self.capability["source_level"],), requires_network="network" in gates, requires_credentials=bool(self.capability.get("requires_login"))),), description="Declared tool with validated artifact output.", provider=self.card["id"])
73
+
74
+ def health(self) -> BackendHealth:
75
+ if not gates_open(self.capability, self.options):
76
+ return BackendHealth(ok=False, error_code="tool_permission_required", error_summary="Explicit runtime gates are required for this tool.")
77
+ try:
78
+ identity = installed_identity(self.card)
79
+ if not identity["tool_version_verified"]:
80
+ return BackendHealth(ok=False, error_code="tool_version_mismatch", error_summary="Installed tool identity differs from its locked card; inspect doctor before updating the card.")
81
+ return BackendHealth(ok=True, dependencies={self.card["id"]: identity["tool_version"]})
82
+ except (ToolError, OSError, ValueError) as exc:
83
+ return BackendHealth(ok=False, error_code=getattr(exc, "code", "tool_not_installed"), error_summary=sanitize_error_summary(exc))
84
+
85
+ def extract(self, request) -> TranscriptResult:
86
+ if not gates_open(self.capability, self.options):
87
+ return self._failed("tool_permission_required", "Required runtime gates have not been enabled.")
88
+ if request.item.platform not in self.capability["sources"]:
89
+ return self._failed("tool_input_unsupported", "Tool card does not cover this source.")
90
+ metadata: dict[str, Any] = {}
91
+ try:
92
+ identity = installed_identity(self.card)
93
+ if not identity["tool_version_verified"]:
94
+ return self._failed("tool_version_mismatch", "Installed tool differs from its locked card; run openmd doctor --profile tools.")
95
+ with owned_temporary_directory(prefix="openmd-tool-", ignore_cleanup_errors=True) as tmp:
96
+ workdir = Path(tmp)
97
+ run = run_tool(self.card, self.capability, request.item, workdir)
98
+ metadata = {"tool_run": {"seconds": run.seconds, "exit_code": run.exit_code, "files": run.files, "card_id": self.card["id"], **identity}, **route_trace(request.item.platform, self.name)}
99
+ if self.card["id"] == "yutto":
100
+ metadata["tool_run"]["anonymous_notice"] = "未提供登录认证信息" in run.stdout + run.stderr
101
+ if run.exit_code != 0:
102
+ return replace(self._failed("tool_backend_exit", f"Tool exited with code {run.exit_code}; raw logs were not retained."), metadata=metadata)
103
+ if self.capability.get("then"):
104
+ from openmd.runtime.asr import get_asr_backend
105
+
106
+ media = read_media(workdir)
107
+ result = get_asr_backend(self.capability["then"]).transcribe(media)
108
+ metadata.update({"media_tool": self.card["id"], "media_bytes": media.stat().st_size, **result.metadata})
109
+ result = replace(result, backend=self.name, source_level="media_asr" if result.has_usable_text else "partial_extraction", title=request.item.title or media.stem, source_notes=[*result.source_notes, f"Media fetched by {self.card['id']} and transcribed locally; command logs are not transcript evidence."])
110
+ if getattr(self.options, "keep_media_artifacts", False) and getattr(self.options, "media_artifact_dir", None):
111
+ destination = Path(self.options.media_artifact_dir)
112
+ try:
113
+ destination.mkdir(parents=True, exist_ok=True)
114
+ with tempfile.NamedTemporaryFile(prefix=hashlib.sha256(request.item.url.encode()).hexdigest()[:12] + "-", suffix=media.suffix, dir=destination, delete=False) as dst, media.open("rb") as src:
115
+ shutil.copyfileobj(src, dst)
116
+ metadata["media_retained"] = True
117
+ except OSError:
118
+ metadata["media_retained"] = False
119
+ result = replace(result, source_notes=[*result.source_notes, "Transcript succeeded, but the optional media copy could not be retained."])
120
+ else:
121
+ result = read_text(self.capability, run, workdir)
122
+ return replace(result, metadata={**result.metadata, **metadata})
123
+ except (ToolError, OSError, ValueError) as exc:
124
+ return replace(self._failed(getattr(exc, "code", "tool_artifact_invalid"), sanitize_error_summary(exc) or "Tool failed."), metadata=metadata)
125
+
126
+ def _failed(self, code: str, detail: str) -> TranscriptResult:
127
+ return TranscriptResult(text="", status="failed", source_level="partial_extraction", backend=self.name, source_notes=["The declared tool did not produce validated source content."], error_code=code, error_summary=detail)
128
+
129
+
130
+ def register_card_providers(registry, platform: str, options) -> None:
131
+ catalog = load_catalog()
132
+ requested = options.backend_override
133
+ for capability in catalog.capabilities.values():
134
+ card = catalog.cards[capability["card_id"]]
135
+ if card["kind"] != "builtin" and platform in capability["sources"] and (requested is None or requested == capability["id"]) and gates_open(capability, options):
136
+ registry.register(capability["id"], CommandToolProvider(card, capability, options))
137
+
138
+
139
+ def doctor_tools(*, probe_commands: bool = False) -> dict[str, Any]:
140
+ reports: list[dict[str, Any]] = []
141
+ for card in load_catalog().cards.values():
142
+ if card["kind"] == "builtin":
143
+ continue
144
+ record = {"tool": card["id"], "kind": card["kind"], "declared_version": card["version"], "requires_login": any(c.get("requires_login", False) for c in card["capabilities"]), "gates": sorted({g for c in card["capabilities"] for g in c.get("gates", [])}), "resolved": False, "origin": "none", "version": None, "version_probe": "not_requested"}
145
+ try:
146
+ identity = installed_identity(card)
147
+ record.update(identity)
148
+ record.update({"resolved": True, "version": identity["tool_version"]})
149
+ if not identity["tool_version_verified"]:
150
+ record["error_code"] = "tool_version_mismatch"
151
+ # Local metadata/hash inspection is sufficient for these cards; never
152
+ # run a tool's ordinary operation just to discover a version.
153
+ if probe_commands:
154
+ record["version_probe"] = "installed_metadata_and_source_hash"
155
+ except (ToolError, OSError, ValueError) as exc:
156
+ record["error_code"] = getattr(exc, "code", "tool_not_installed")
157
+ reports.append(record)
158
+ return {"ok": all(r["resolved"] and r.get("tool_version_verified") for r in reports), "tools": reports, "network_access": "not_probed", "private_session_state": "not_read"}
@@ -0,0 +1,108 @@
1
+ """Read declared artifacts; command logs and metadata never count as source text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from openmd.core.content_validation import validate_extraction_content
11
+ from openmd.core.models import TranscriptResult
12
+
13
+ from .runner import ToolError, ToolRun
14
+
15
+ MEDIA_EXTENSIONS = frozenset({".m4a", ".mp3", ".aac", ".opus", ".wav", ".mp4", ".webm", ".ogg", ".flac"})
16
+
17
+
18
+ def field_value(data: Any, path: str | list[str]) -> str | None:
19
+ if isinstance(path, list):
20
+ return next((value for key in path if (value := field_value(data, key))), None)
21
+ current = data
22
+ for key in path.split("."):
23
+ if not isinstance(current, dict) or key not in current:
24
+ return None
25
+ current = current[key]
26
+ return current if isinstance(current, str) and current.strip() else None
27
+
28
+
29
+ def contained_file(workdir: Path, relative: str) -> Path:
30
+ path = (workdir / relative).resolve()
31
+ if not path.is_relative_to(workdir.resolve()) or not path.is_file():
32
+ raise ToolError("tool_artifact_missing")
33
+ return path
34
+
35
+
36
+ def subtitle_text(text: str) -> str:
37
+ lines: list[str] = []
38
+ timestamp: str | None = None
39
+ for line in text.splitlines():
40
+ match = re.match(r"\s*((?:\d{2}:)?\d{2}:\d{2})[,.]\d+\s*-->", line)
41
+ if match:
42
+ timestamp = match.group(1)
43
+ if timestamp.count(":") == 1:
44
+ timestamp = "00:" + timestamp
45
+ elif not line.strip() or line.strip().isdigit() or line.strip() == "WEBVTT":
46
+ continue
47
+ elif timestamp:
48
+ value = re.sub(r"<[^>]+>", "", line).strip()
49
+ if value:
50
+ lines.append(f"[{timestamp}] {value}")
51
+ return "\n".join(lines)
52
+
53
+
54
+ def read_media(workdir: Path) -> Path:
55
+ media = [p for p in workdir.rglob("*") if p.is_file() and p.suffix.lower() in MEDIA_EXTENSIONS and p.resolve().is_relative_to(workdir.resolve()) and p.stat().st_size > 0]
56
+ if not media:
57
+ raise ToolError("tool_media_missing", "The tool returned no media; metadata and command logs are insufficient.")
58
+ return max(media, key=lambda p: p.stat().st_size)
59
+
60
+
61
+ def read_text(capability: dict[str, Any], run: ToolRun, workdir: Path) -> TranscriptResult:
62
+ reader = capability["reader"]
63
+ title = author = published_at = None
64
+ kind = reader["type"]
65
+ if kind == "markdown_file":
66
+ path = contained_file(workdir, reader["path"])
67
+ if path.stat().st_size > 16 * 1024 * 1024:
68
+ raise ToolError("tool_output_too_large")
69
+ text = path.read_text(encoding="utf-8-sig")
70
+ heading = re.search(r"^#\s+(.+)$", text, re.M)
71
+ title = heading.group(1).strip() if heading else None
72
+ elif kind in {"json_stdout", "json_file"}:
73
+ if kind == "json_file":
74
+ path = contained_file(workdir, reader["path"])
75
+ if path.stat().st_size > 16 * 1024 * 1024:
76
+ raise ToolError("tool_output_too_large")
77
+ raw_json = path.read_text(encoding="utf-8-sig")
78
+ else:
79
+ raw_json = run.stdout
80
+ try:
81
+ data = json.loads(raw_json)
82
+ except ValueError as exc:
83
+ raise ToolError("tool_json_invalid") from exc
84
+ if not isinstance(data, dict) or data.get("error") or data.get("success") is False:
85
+ raise ToolError("tool_upstream_failed", "Tool JSON contains an upstream error.")
86
+ fields = reader["fields"]
87
+ text = field_value(data, fields["text"]) or ""
88
+ title = field_value(data, fields.get("title", "title"))
89
+ author = field_value(data, fields.get("author", "author"))
90
+ published_at = field_value(data, fields.get("published_at", "published_at"))
91
+ if not title and text and reader.get("title_from_first_line", False):
92
+ title = text.splitlines()[0][:120]
93
+ elif kind == "subtitle_dir":
94
+ paths = sorted(p for p in workdir.rglob("*") if p.suffix.lower() in {".srt", ".vtt"} and p.is_file() and p.resolve().is_relative_to(workdir.resolve()))
95
+ if not paths:
96
+ raise ToolError("tool_subtitle_missing")
97
+ text = subtitle_text(paths[0].read_text(encoding="utf-8-sig"))
98
+ else:
99
+ raise ToolError("tool_reader_invalid")
100
+ text = text.strip()
101
+ if len(text) < capability.get("validation", {}).get("min_chars", 1):
102
+ raise ToolError("tool_content_empty", "Tool returned insufficient source text.")
103
+ if len(text) < 3000 and re.search(r"(?im)^\s*(?:#\s*)?(?:just a moment[.!…]*|verify you are human|请完成验证|请先登录|访问过于频繁|安全验证)\s*$", text):
104
+ raise ToolError("tool_challenge_page", "A challenge/login page is not source content.")
105
+ if re.match(r"\s*(?:<!doctype html|<html)", text, re.I):
106
+ raise ToolError("tool_html_instead_of_markdown")
107
+ result = validate_extraction_content(TranscriptResult(text=text + "\n", status="verified", source_level=capability["source_level"], backend=capability["id"], title=title, author=author, published_at=published_at, source_notes=["Original source text read from the tool's declared artifact.", *([capability["coverage_note"]] if capability.get("coverage_note") else [])]))
108
+ return result