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 @@
1
+ """Parsers for user-provided QQ group export files."""
@@ -0,0 +1,60 @@
1
+ """Small parser for user-provided QQ HTML group exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from html import unescape
7
+ from typing import Any
8
+
9
+
10
+ _MESSAGE_RE = re.compile(
11
+ r"<(?P<tag>div|li)[^>]+class=[\"'][^\"']*(?:message|msg)[^\"']*[\"'][^>]*>(?P<body>.*?)</(?P=tag)>",
12
+ re.IGNORECASE | re.DOTALL,
13
+ )
14
+
15
+
16
+ def parse_qq_html_export(text: str) -> list[dict[str, Any]]:
17
+ messages: list[dict[str, Any]] = []
18
+ blocks = [match.group("body") for match in _MESSAGE_RE.finditer(text)]
19
+ if not blocks:
20
+ blocks = [line for line in text.splitlines() if "sender" in line or "content" in line]
21
+
22
+ for index, block in enumerate(blocks, start=1):
23
+ message = _parse_message_block(block, index=index)
24
+ if message is not None:
25
+ messages.append(message)
26
+ return messages
27
+
28
+
29
+ def _parse_message_block(block: str, *, index: int) -> dict[str, Any] | None:
30
+ timestamp = _class_text(block, "time") or _class_text(block, "timestamp")
31
+ speaker = _class_text(block, "sender") or _class_text(block, "nickname") or _class_text(block, "name")
32
+ content = _class_text(block, "content") or _class_text(block, "text") or _strip_tags(block)
33
+ if not content:
34
+ return None
35
+ return {
36
+ "id": f"html_{index:04d}",
37
+ "timestamp": timestamp or "unknown-time",
38
+ "speaker": speaker or "unknown",
39
+ "text": content,
40
+ "attachments": [],
41
+ "links": [],
42
+ "source_map": [],
43
+ }
44
+
45
+
46
+ def _class_text(block: str, class_name: str) -> str | None:
47
+ pattern = re.compile(
48
+ rf"<[^>]+class=[\"'][^\"']*{re.escape(class_name)}[^\"']*[\"'][^>]*>(.*?)</[^>]+>",
49
+ re.IGNORECASE | re.DOTALL,
50
+ )
51
+ match = pattern.search(block)
52
+ if not match:
53
+ return None
54
+ return _strip_tags(match.group(1))
55
+
56
+
57
+ def _strip_tags(value: str) -> str:
58
+ text = re.sub(r"<[^>]+>", " ", value)
59
+ text = unescape(text)
60
+ return " ".join(text.split())
@@ -0,0 +1,71 @@
1
+ """Parser for qq-chat-exporter-style JSON group exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from typing import Any
8
+
9
+
10
+ def parse_qq_json_export(text: str) -> list[dict[str, Any]]:
11
+ try:
12
+ payload = json.loads(text)
13
+ except json.JSONDecodeError as exc:
14
+ raise ValueError("QQ JSON export is not valid JSON") from exc
15
+
16
+ messages: list[dict[str, Any]] = []
17
+ for index, row in enumerate(_candidate_rows(payload), start=1):
18
+ if not isinstance(row, dict):
19
+ continue
20
+ message = _parse_message_row(row, index=index)
21
+ if message is not None:
22
+ messages.append(message)
23
+ return messages
24
+
25
+
26
+ def _candidate_rows(payload: object) -> Iterable[object]:
27
+ if isinstance(payload, list):
28
+ return payload
29
+ if not isinstance(payload, dict):
30
+ return ()
31
+ for key in ("messages", "items", "list", "data", "records", "results"):
32
+ value = payload.get(key)
33
+ if isinstance(value, list):
34
+ return value
35
+ if isinstance(value, dict):
36
+ nested = list(_candidate_rows(value))
37
+ if nested:
38
+ return nested
39
+ return ()
40
+
41
+
42
+ def _parse_message_row(row: dict[str, Any], *, index: int) -> dict[str, Any] | None:
43
+ text = _first_string(row, "text", "content", "message", "msg")
44
+ speaker = _first_string(row, "speaker", "sender", "from", "author", "nickname", "name")
45
+ timestamp = _first_string(row, "timestamp", "time", "datetime", "date", "created_at")
46
+ attachments = _list_or_empty(row.get("attachments") or row.get("files") or row.get("images"))
47
+ if not text and not attachments:
48
+ return None
49
+ return {
50
+ "id": _first_string(row, "id", "msg_id", "message_id") or f"json_{index:04d}",
51
+ "timestamp": timestamp or "unknown-time",
52
+ "speaker": speaker or "unknown",
53
+ "text": text or "",
54
+ "attachments": attachments,
55
+ "links": _list_or_empty(row.get("links")),
56
+ "source_map": [],
57
+ }
58
+
59
+
60
+ def _first_string(row: dict[str, Any], *keys: str) -> str | None:
61
+ for key in keys:
62
+ value = row.get(key)
63
+ if isinstance(value, str) and value.strip():
64
+ return value.strip()
65
+ if isinstance(value, int):
66
+ return str(value)
67
+ return None
68
+
69
+
70
+ def _list_or_empty(value: object) -> list[Any]:
71
+ return value if isinstance(value, list) else []
@@ -0,0 +1,149 @@
1
+ """Adapter registry and default backend preferences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from dataclasses import dataclass
7
+
8
+ from collections.abc import Callable, Iterable
9
+
10
+ from openmd.core.detector import PLATFORM_IDS, detect_platform
11
+
12
+ from .base import PlatformAdapter, StubPlatformAdapter
13
+ from .opencli import OpenCliPlatformAdapter
14
+
15
+
16
+ AdapterFactory = Callable[[str, str], PlatformAdapter]
17
+ AdapterFactoryLoader = Callable[[str], AdapterFactory | None]
18
+
19
+
20
+ OPENCLI_ADAPTER_PLATFORMS: frozenset[str] = frozenset(
21
+ {
22
+ "bilibili",
23
+ "youtube",
24
+ "weixin",
25
+ "xiaoyuzhou",
26
+ "xiaohongshu",
27
+ "xiaoe",
28
+ "x",
29
+ }
30
+ )
31
+
32
+
33
+ from openmd.routing.loader import default_backend_chains
34
+
35
+
36
+ DEFAULT_BACKENDS: dict[str, tuple[str, ...]] = default_backend_chains(effective=False)
37
+
38
+ @dataclass(frozen=True)
39
+ class BackendSelection:
40
+ platform: str
41
+ primary: str
42
+ fallbacks: tuple[str, ...]
43
+
44
+ @property
45
+ def chain(self) -> tuple[str, ...]:
46
+ return (self.primary, *self.fallbacks)
47
+
48
+
49
+ class AdapterRegistry:
50
+ def __init__(self) -> None:
51
+ self._adapters: dict[str, PlatformAdapter] = {}
52
+ self._backends: dict[str, tuple[str, ...]] = default_backend_chains()
53
+
54
+ def register(
55
+ self,
56
+ adapter: PlatformAdapter,
57
+ *,
58
+ backends: Iterable[str] | None = None,
59
+ ) -> None:
60
+ self._adapters[adapter.id] = adapter
61
+ if backends is not None:
62
+ self._backends[adapter.id] = tuple(backends)
63
+
64
+ def get_adapter(self, platform: str) -> PlatformAdapter:
65
+ try:
66
+ return self._adapters[platform]
67
+ except KeyError as exc:
68
+ raise KeyError(f"no adapter registered for platform: {platform}") from exc
69
+
70
+ def adapter_for(self, input_value: str) -> PlatformAdapter:
71
+ return self.get_adapter(detect_platform(input_value))
72
+
73
+ def backend_chain(
74
+ self,
75
+ platform: str,
76
+ requested: str | None = None,
77
+ *,
78
+ allow_fallback: bool = True,
79
+ ) -> tuple[str, ...]:
80
+ if requested:
81
+ return (requested,)
82
+ chain = self._backends.get(platform, ("mock",))
83
+ if allow_fallback:
84
+ return chain
85
+ return chain[:1]
86
+
87
+ def select_backend(
88
+ self,
89
+ platform: str,
90
+ requested: str | None = None,
91
+ *,
92
+ allow_fallback: bool = True,
93
+ ) -> BackendSelection:
94
+ chain = self.backend_chain(platform, requested, allow_fallback=allow_fallback)
95
+ primary = chain[0]
96
+ fallbacks = chain[1:] if allow_fallback else ()
97
+ return BackendSelection(platform=platform, primary=primary, fallbacks=fallbacks)
98
+
99
+
100
+ def create_default_registry(
101
+ *,
102
+ adapter_factory_loader: AdapterFactoryLoader | None = None,
103
+ ) -> AdapterRegistry:
104
+ registry = AdapterRegistry()
105
+ load_factory = adapter_factory_loader or load_adapter_factory
106
+ for platform_id in PLATFORM_IDS:
107
+ backend = registry.backend_chain(platform_id)[0]
108
+ registry.register(_create_platform_adapter(platform_id, backend, load_factory))
109
+ return registry
110
+
111
+
112
+ def _create_platform_adapter(
113
+ platform_id: str,
114
+ default_backend: str,
115
+ load_factory: AdapterFactoryLoader,
116
+ ) -> PlatformAdapter:
117
+ factory = load_factory(platform_id)
118
+ if factory is not None:
119
+ adapter = factory(platform_id, default_backend)
120
+ if adapter.id != platform_id:
121
+ raise ValueError(
122
+ f"adapter factory for {platform_id} returned adapter id {adapter.id!r}"
123
+ )
124
+ return adapter
125
+ if platform_id in OPENCLI_ADAPTER_PLATFORMS:
126
+ return OpenCliPlatformAdapter(platform_id, default_backend)
127
+ return StubPlatformAdapter(platform_id, default_backend)
128
+
129
+
130
+ def load_adapter_factory(platform_id: str) -> AdapterFactory | None:
131
+ """Load an optional platform adapter factory by naming convention.
132
+
133
+ Platform lanes can add ``openmd.adapters.<platform_id>`` with a
134
+ ``create_adapter(platform_id, default_backend)`` function. The core registry
135
+ will pick it up without platform lanes editing this shared file.
136
+ """
137
+ module_name = f"openmd.adapters.{platform_id}"
138
+ try:
139
+ module = importlib.import_module(module_name)
140
+ except ModuleNotFoundError as exc:
141
+ if exc.name == module_name:
142
+ return None
143
+ raise
144
+ factory = getattr(module, "create_adapter", None)
145
+ if factory is None:
146
+ return None
147
+ if not callable(factory):
148
+ raise TypeError(f"{module_name}.create_adapter must be callable")
149
+ return factory
@@ -0,0 +1,11 @@
1
+ """V1 long screenshot OCR adapter factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from openmd.adapters.base import PlatformAdapter
6
+
7
+ from .adapter import ScreenshotOcrAdapter
8
+
9
+
10
+ def create_adapter(platform_id: str, default_backend: str) -> PlatformAdapter:
11
+ return ScreenshotOcrAdapter(platform_id, default_backend)
@@ -0,0 +1,154 @@
1
+ """V1 adapter for long screenshot OCR package inputs.
2
+
3
+ This lane is local and read-only by default. It resolves image files or folders
4
+ into ``ContentItem`` records and leaves OCR/layout extraction to explicit
5
+ backends. Raw screenshots are referenced as sources and are not copied into
6
+ package assets.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from urllib.parse import urlparse
13
+ import hashlib
14
+
15
+ from openmd.adapters.base import PlatformAdapter
16
+ from openmd.adapters.local_audio.adapter import local_path_from_input
17
+ from openmd.core.detector import IMAGE_TEXT_EXTENSIONS, extract_first_url
18
+ from openmd.core.models import ContentItem, TranscriptResult
19
+
20
+ from .backends.rapidocr import RapidOCRBackend
21
+ from .ocr import MissingOCRBackend, MockOCRBackend
22
+
23
+
24
+ class ScreenshotOcrAdapter(PlatformAdapter):
25
+ """Resolve local screenshots, folders, and image URL references without side effects."""
26
+
27
+ def __init__(self, platform_id: str = "screenshot", default_backend: str = "ocr.rapidocr") -> None:
28
+ self.id = platform_id
29
+ self.default_backend = default_backend
30
+
31
+ def can_handle(self, input_value: str) -> bool:
32
+ return _is_image_text_input(input_value)
33
+
34
+ def resolve_items(
35
+ self,
36
+ input_value: str,
37
+ *,
38
+ limit: int | None = None,
39
+ all: bool = False,
40
+ ) -> list[ContentItem]:
41
+ path = local_path_from_input(input_value)
42
+ if path is not None and path.is_dir():
43
+ files = sorted(candidate for candidate in path.rglob("*") if _is_image_text_path(candidate))
44
+ if limit is not None and not all:
45
+ files = files[: max(0, limit)]
46
+ return [_content_item_for_path(candidate, platform=self.id) for candidate in files]
47
+
48
+ if path is not None:
49
+ return [_content_item_for_path(path, platform=self.id)]
50
+
51
+ target = extract_first_url(input_value)
52
+ return [_content_item_for_reference(target, platform=self.id)]
53
+
54
+ def extract_text(
55
+ self,
56
+ item: ContentItem,
57
+ *,
58
+ backend: str | None = None,
59
+ backend_chain: tuple[str, ...] | None = None,
60
+ allow_fallback: bool = True,
61
+ ) -> TranscriptResult:
62
+ selected = (backend_chain or (backend or self.default_backend,))[0]
63
+ source_ref = _item_source_ref(item)
64
+ title = item.title or _title_from_source_ref(source_ref)
65
+ if selected == "screenshot.mock":
66
+ return MockOCRBackend().recognize(source_ref, title=title)
67
+ if selected == "ocr.rapidocr":
68
+ return RapidOCRBackend().recognize(source_ref, title=title)
69
+ return MissingOCRBackend(selected).recognize(source_ref, title=title)
70
+
71
+
72
+ def _content_item_for_path(path: Path, *, platform: str) -> ContentItem:
73
+ source_ref = path.resolve().as_uri() if path.exists() else str(path)
74
+ content_id = _image_content_id(path)
75
+ return ContentItem(
76
+ platform=platform,
77
+ input_type="single",
78
+ url=source_ref,
79
+ id=content_id,
80
+ title=path.stem,
81
+ source_type="image_text",
82
+ raw_meta={
83
+ "profile": "document",
84
+ "extraction_contract_version": "ocr-layout-v2",
85
+ "permission_scope": "local_file",
86
+ "local_path": str(path),
87
+ "content_id": content_id,
88
+ },
89
+ )
90
+
91
+
92
+ def _content_item_for_reference(source_ref: str, *, platform: str) -> ContentItem:
93
+ return ContentItem(
94
+ platform=platform,
95
+ input_type="single",
96
+ url=source_ref,
97
+ id=_stable_id(source_ref),
98
+ title=_title_from_source_ref(source_ref),
99
+ source_type="image_text",
100
+ raw_meta={
101
+ "profile": "document",
102
+ "extraction_contract_version": "ocr-layout-v2",
103
+ "permission_scope": "user_accessible",
104
+ "content_id": _stable_id(source_ref),
105
+ },
106
+ )
107
+
108
+
109
+ def _is_image_text_input(input_value: str) -> bool:
110
+ path = local_path_from_input(input_value)
111
+ if path is not None:
112
+ if path.is_dir():
113
+ return _directory_contains_image_text(path)
114
+ return _is_image_text_path(path)
115
+ parsed = urlparse(extract_first_url(input_value))
116
+ return parsed.path.lower().endswith(IMAGE_TEXT_EXTENSIONS)
117
+
118
+
119
+ def _is_image_text_path(path: Path) -> bool:
120
+ return path.suffix.lower() in IMAGE_TEXT_EXTENSIONS
121
+
122
+
123
+ def _directory_contains_image_text(path: Path) -> bool:
124
+ try:
125
+ return any(candidate.is_file() and _is_image_text_path(candidate) for candidate in path.rglob("*"))
126
+ except OSError:
127
+ return False
128
+
129
+
130
+ def _image_content_id(path: Path) -> str:
131
+ if path.is_file():
132
+ digest = hashlib.sha256()
133
+ with path.open("rb") as handle:
134
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
135
+ digest.update(chunk)
136
+ return digest.hexdigest()[:12]
137
+ return _stable_id(str(path))
138
+
139
+
140
+ def _item_source_ref(item: ContentItem) -> str:
141
+ local_path = item.raw_meta.get("local_path") if isinstance(item.raw_meta, dict) else None
142
+ if isinstance(local_path, str) and local_path.strip():
143
+ return local_path
144
+ return item.url
145
+
146
+
147
+ def _title_from_source_ref(source_ref: str) -> str:
148
+ parsed = urlparse(source_ref)
149
+ path = Path(parsed.path if parsed.scheme else source_ref)
150
+ return path.stem or "screenshot"
151
+
152
+
153
+ def _stable_id(value: str) -> str:
154
+ return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()[:12]
@@ -0,0 +1 @@
1
+ """Optional screenshot OCR backend placeholders."""
@@ -0,0 +1,15 @@
1
+ """PaddleOCR screenshot backend placeholder.
2
+
3
+ The real OCR engine is intentionally not imported or executed by the
4
+ mock/gated lane. Future work can replace this placeholder after dependency,
5
+ privacy, and quality smoke are explicitly approved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from openmd.adapters.screenshot.ocr import MissingOCRBackend
11
+
12
+
13
+ class PaddleOCRBackend(MissingOCRBackend):
14
+ def __init__(self) -> None:
15
+ super().__init__("ocr.paddle")
@@ -0,0 +1,176 @@
1
+ """Optional RapidOCR backend for local screenshots/images.
2
+
3
+ RapidOCR is imported lazily. The backend only reads local files or ``file://``
4
+ references and never captures screens, opens accounts, or fetches remote image
5
+ URLs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import math
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from openmd.adapters.local_audio.adapter import local_path_from_input
16
+ from openmd.adapters.screenshot.ocr import MissingOCRBackend, OCRBackend, classify_ocr_profile
17
+ from openmd.core.models import TranscriptResult
18
+ from openmd.core.secrets import sanitize_error_summary
19
+
20
+
21
+ class RapidOCRBackend(OCRBackend):
22
+ name = "ocr.rapidocr"
23
+
24
+ def is_available(self) -> bool:
25
+ return importlib.util.find_spec("rapidocr_onnxruntime") is not None
26
+
27
+ def recognize(self, source_ref: str, *, title: str) -> TranscriptResult:
28
+ source_path = _local_existing_path(source_ref)
29
+ if source_path is None:
30
+ return _failed_result(
31
+ self.name,
32
+ source_ref,
33
+ title,
34
+ "ocr_local_file_required",
35
+ "RapidOCR requires a local image file path or file:// reference.",
36
+ )
37
+
38
+ if not self.is_available():
39
+ return MissingOCRBackend(self.name).recognize(str(source_path), title=title)
40
+
41
+ try:
42
+ from rapidocr_onnxruntime import RapidOCR
43
+
44
+ rows, _elapsed = RapidOCR()(str(source_path))
45
+ except Exception as exc: # pragma: no cover - dependency-specific safety net
46
+ return _failed_result(self.name, str(source_path), title, "ocr_extraction_failed", exc)
47
+
48
+ lines, confidences = _extract_lines(rows)
49
+ text = "\n".join(lines).strip()
50
+ if not text:
51
+ return _failed_result(
52
+ self.name,
53
+ str(source_path),
54
+ title,
55
+ "ocr_empty_text",
56
+ "RapidOCR returned no recognized text.",
57
+ )
58
+
59
+ document_title = title or source_path.stem or "screenshot"
60
+ body = _with_title(document_title, text)
61
+ source_map, ambiguous = _layout_evidence(rows)
62
+ return TranscriptResult(
63
+ text=body,
64
+ status="partial" if ambiguous else "verified",
65
+ source_level="ocr",
66
+ backend=self.name,
67
+ source_notes=[
68
+ "RapidOCR recognized text from a local screenshot/image.",
69
+ "Raw screenshot file was referenced, not copied into the package.",
70
+ *(["Side-by-side OCR blocks have ambiguous reading order; verify against the source image."] if ambiguous else []),
71
+ ],
72
+ title=document_title,
73
+ metadata={
74
+ "profile": classify_ocr_profile(body),
75
+ "ocr_backend": self.name,
76
+ "source_ref": str(source_path),
77
+ "block_count": len(lines),
78
+ "average_confidence": _average(confidences),
79
+ "source_map": source_map,
80
+ "reading_order": "ambiguous" if ambiguous else "engine_order_not_independently_verified",
81
+ },
82
+ )
83
+
84
+
85
+ def _local_existing_path(source_ref: str) -> Path | None:
86
+ path = local_path_from_input(source_ref)
87
+ if path is None:
88
+ return None
89
+ return path if path.is_file() else None
90
+
91
+
92
+ def _extract_lines(rows: Any) -> tuple[list[str], list[float]]:
93
+ if not rows:
94
+ return [], []
95
+ lines: list[str] = []
96
+ confidences: list[float] = []
97
+ for row in rows:
98
+ if not isinstance(row, (list, tuple)) or len(row) < 2:
99
+ continue
100
+ text = row[1]
101
+ if isinstance(text, str) and text.strip():
102
+ lines.append(text.strip())
103
+ if len(row) >= 3 and isinstance(row[2], (int, float)):
104
+ confidences.append(float(row[2]))
105
+ return lines, confidences
106
+
107
+
108
+ def _average(values: list[float]) -> float | None:
109
+ if not values:
110
+ return None
111
+ return round(sum(values) / len(values), 4)
112
+
113
+
114
+ def _layout_evidence(rows: Any) -> tuple[list[dict[str, Any]], bool]:
115
+ """Retain pixel locators and flag side-by-side blocks without inventing order."""
116
+ boxes: list[dict[str, Any]] = []
117
+ if not isinstance(rows, (list, tuple)):
118
+ return boxes, False
119
+ line_number = 0
120
+ for row in rows:
121
+ if not isinstance(row, (list, tuple)) or len(row) < 2 or not isinstance(row[1], str) or not row[1].strip():
122
+ continue
123
+ line_number += 1
124
+ try:
125
+ points = [(float(point[0]), float(point[1])) for point in row[0]]
126
+ if len(points) < 4 or not all(math.isfinite(v) for point in points for v in point):
127
+ continue
128
+ xs, ys = zip(*points)
129
+ bbox = [min(xs), min(ys), max(xs), max(ys)]
130
+ boxes.append({"line": line_number, "bbox": bbox, "unit": "pixel"})
131
+ except (TypeError, ValueError, IndexError):
132
+ continue
133
+ # Only compare vertically overlapping blocks; this also flags table-like
134
+ # layouts, whose semantics cannot be certified by text confidence alone.
135
+ active: list[list[float]] = []
136
+ ambiguous = False
137
+ for entry in sorted(boxes, key=lambda entry: entry["bbox"][1]):
138
+ box = entry["bbox"]
139
+ active = [other for other in active if other[3] > box[1]]
140
+ for other in active:
141
+ overlap = min(other[3], box[3]) - max(other[1], box[1])
142
+ height = min(other[3] - other[1], box[3] - box[1])
143
+ gap = max(box[0] - other[2], other[0] - box[2])
144
+ if height > 0 and overlap >= height / 2 and gap > height:
145
+ ambiguous = True
146
+ active.append(box)
147
+ return boxes, ambiguous
148
+
149
+
150
+ def _with_title(title: str, text: str) -> str:
151
+ if text.lstrip().startswith("#"):
152
+ return text if text.endswith("\n") else f"{text}\n"
153
+ return f"# {title}\n\n{text}\n"
154
+
155
+
156
+ def _failed_result(
157
+ backend: str,
158
+ source_ref: str,
159
+ title: str,
160
+ code: str,
161
+ error: object,
162
+ ) -> TranscriptResult:
163
+ return TranscriptResult(
164
+ text="",
165
+ status="failed",
166
+ source_level="partial_extraction",
167
+ backend=backend,
168
+ source_notes=[
169
+ "OCR backend did not produce usable text.",
170
+ "Raw screenshot file was referenced, not copied into the package.",
171
+ ],
172
+ error_code=code,
173
+ error_summary=sanitize_error_summary(error),
174
+ title=title or "screenshot",
175
+ metadata={"profile": "document", "ocr_backend": backend, "source_ref": source_ref},
176
+ )
@@ -0,0 +1,15 @@
1
+ """Tesseract screenshot backend placeholder.
2
+
3
+ The real OCR engine is intentionally not imported or executed by the
4
+ mock/gated lane. Future work can replace this placeholder after dependency,
5
+ privacy, and quality smoke are explicitly approved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from openmd.adapters.screenshot.ocr import MissingOCRBackend
11
+
12
+
13
+ class TesseractOCRBackend(MissingOCRBackend):
14
+ def __init__(self) -> None:
15
+ super().__init__("ocr.tesseract")