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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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")
|