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,77 @@
|
|
|
1
|
+
"""Runtime providers for local generic media inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from openmd.adapters.local_audio.adapter import LocalAudioAdapter
|
|
6
|
+
from openmd.runtime.asr import get_asr_backend
|
|
7
|
+
from openmd.adapters.local_audio.asr import MissingASRBackend, MockASRBackend
|
|
8
|
+
from openmd.backends.base import (
|
|
9
|
+
BackendCapability,
|
|
10
|
+
BackendDescriptor,
|
|
11
|
+
BackendExtractRequest,
|
|
12
|
+
BackendHealth,
|
|
13
|
+
)
|
|
14
|
+
from openmd.backends.executor import BackendProviderRegistry
|
|
15
|
+
from openmd.backends.registry import BackendRuntimeOptions
|
|
16
|
+
from openmd.core.models import TranscriptResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LocalAudioASRProvider:
|
|
20
|
+
def __init__(self, backend) -> None:
|
|
21
|
+
self.backend = backend
|
|
22
|
+
self.name = backend.name
|
|
23
|
+
|
|
24
|
+
def descriptor(self) -> BackendDescriptor:
|
|
25
|
+
source_level = "partial_extraction" if isinstance(self.backend, MissingASRBackend) else "asr"
|
|
26
|
+
return BackendDescriptor(
|
|
27
|
+
name=self.name,
|
|
28
|
+
layer="local",
|
|
29
|
+
capabilities=(
|
|
30
|
+
BackendCapability(
|
|
31
|
+
platforms=("generic_media",),
|
|
32
|
+
source_types=("audio",),
|
|
33
|
+
input_types=("single", "batch", "collection", "generic"),
|
|
34
|
+
source_levels=(source_level,),
|
|
35
|
+
requires_network=False,
|
|
36
|
+
requires_credentials=False,
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
description="Local audio ASR provider.",
|
|
40
|
+
provider="openmd.local_audio",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def health(self) -> BackendHealth:
|
|
44
|
+
is_available = getattr(self.backend, "is_available", None)
|
|
45
|
+
if callable(is_available) and not is_available():
|
|
46
|
+
return BackendHealth(
|
|
47
|
+
ok=False,
|
|
48
|
+
error_code="asr_backend_missing",
|
|
49
|
+
error_summary=f"{self.name} is not installed or enabled.",
|
|
50
|
+
)
|
|
51
|
+
if isinstance(self.backend, MissingASRBackend):
|
|
52
|
+
return BackendHealth(
|
|
53
|
+
ok=False,
|
|
54
|
+
error_code="asr_backend_missing",
|
|
55
|
+
error_summary=f"{self.name} is not installed or enabled.",
|
|
56
|
+
)
|
|
57
|
+
return BackendHealth(ok=True, dependencies={self.name: "ok"})
|
|
58
|
+
|
|
59
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
60
|
+
adapter = LocalAudioAdapter("generic_media", request.primary_backend)
|
|
61
|
+
return adapter.extract_text(
|
|
62
|
+
request.item,
|
|
63
|
+
backend=request.primary_backend,
|
|
64
|
+
backend_chain=(request.primary_backend,),
|
|
65
|
+
allow_fallback=False,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register_backend_providers(
|
|
70
|
+
registry: BackendProviderRegistry,
|
|
71
|
+
options: BackendRuntimeOptions,
|
|
72
|
+
) -> None:
|
|
73
|
+
registry.register("local_audio.mock", LocalAudioASRProvider(MockASRBackend()))
|
|
74
|
+
registry.register("asr.faster_whisper", LocalAudioASRProvider(get_asr_backend("asr.faster_whisper")))
|
|
75
|
+
registry.register("asr.auto", LocalAudioASRProvider(get_asr_backend("asr.auto")))
|
|
76
|
+
registry.register("asr.qwen3", LocalAudioASRProvider(get_asr_backend("asr.qwen3")))
|
|
77
|
+
registry.register("asr.fallback", LocalAudioASRProvider(MissingASRBackend("asr.fallback")))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Defer daily browser consent until execution reaches a browser candidate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
from openmd.core.models import TranscriptResult
|
|
8
|
+
from openmd.routing.loader import load_catalog
|
|
9
|
+
from openmd.routing.providers import gates_open
|
|
10
|
+
|
|
11
|
+
from .executor import BackendProviderRegistry
|
|
12
|
+
from .registry import create_runtime_backend_provider_registry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeferredBrowserRegistry(BackendProviderRegistry):
|
|
16
|
+
def __init__(self, original, platform, args, authorize):
|
|
17
|
+
super().__init__()
|
|
18
|
+
self.original = original
|
|
19
|
+
self.platform = platform
|
|
20
|
+
self.args = args
|
|
21
|
+
self.authorize = authorize
|
|
22
|
+
|
|
23
|
+
def provider_for(self, backend):
|
|
24
|
+
provider = self.original.provider_for(backend)
|
|
25
|
+
# The normal registry uses safe skeleton placeholders for closed gates.
|
|
26
|
+
from .executor import SkeletonBackendProvider
|
|
27
|
+
if provider is not None and not isinstance(provider, SkeletonBackendProvider):
|
|
28
|
+
return provider
|
|
29
|
+
capability = load_catalog().capabilities.get(backend, {})
|
|
30
|
+
if "browser" not in capability.get("gates", []):
|
|
31
|
+
return provider
|
|
32
|
+
from openmd.cli import _backend_runtime_options
|
|
33
|
+
|
|
34
|
+
possible = replace(_backend_runtime_options(self.args), enable_browser_backends=True)
|
|
35
|
+
if not gates_open(capability, possible):
|
|
36
|
+
return provider
|
|
37
|
+
return _DeferredBrowserProvider(self, backend)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _DeferredBrowserProvider:
|
|
41
|
+
def __init__(self, registry, backend):
|
|
42
|
+
self.registry = registry
|
|
43
|
+
self.name = backend
|
|
44
|
+
|
|
45
|
+
def extract(self, request):
|
|
46
|
+
if self.registry.authorize(self.name):
|
|
47
|
+
from openmd.cli import _backend_runtime_options
|
|
48
|
+
|
|
49
|
+
registry = create_runtime_backend_provider_registry(
|
|
50
|
+
self.registry.platform, _backend_runtime_options(self.registry.args)
|
|
51
|
+
)
|
|
52
|
+
provider = registry.provider_for(self.name)
|
|
53
|
+
if provider is not None:
|
|
54
|
+
self.registry.original = registry
|
|
55
|
+
return provider.extract(request)
|
|
56
|
+
return TranscriptResult(
|
|
57
|
+
text="", status="failed", source_level="partial_extraction", backend=self.name,
|
|
58
|
+
error_code="browser_permission_required",
|
|
59
|
+
error_summary="此路线需要浏览器读取许可;可在提示中允许一次,或显式使用 --enable-browser-backends。",
|
|
60
|
+
)
|
openmd/backends/ocr.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""OCR providers shared by screenshot and document route chains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from openmd.backends.base import BackendCapability, BackendDescriptor, BackendHealth
|
|
6
|
+
from openmd.ocr.pipeline import LocalOCRBackend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OCRRouterProvider:
|
|
10
|
+
def __init__(self, name: str, options) -> None:
|
|
11
|
+
self.name, self.options = name, options
|
|
12
|
+
|
|
13
|
+
def descriptor(self):
|
|
14
|
+
return BackendDescriptor(name=self.name, layer="local", provider="openmd.ocr", description="Serial local Ollama OCR with monitored fallback and page receipts.", capabilities=(BackendCapability(platforms=("screenshot", "pdf"), source_types=("image_text", "document"), input_types=("single", "batch", "collection", "generic"), source_levels=("ocr",), requires_network=True),))
|
|
15
|
+
|
|
16
|
+
def health(self):
|
|
17
|
+
if not self.options.enable_network_backends:
|
|
18
|
+
return BackendHealth(ok=False, error_code="ocr_permission_required", error_summary="Enable network backends explicitly to call the configured Ollama API.")
|
|
19
|
+
from openmd.ocr.config import settings
|
|
20
|
+
from openmd.ocr.ollama import probe_ollama
|
|
21
|
+
|
|
22
|
+
report = probe_ollama(settings())
|
|
23
|
+
key = "minicpm_model" if self.name == "ocr.minicpm" else "ovis_model"
|
|
24
|
+
ready = bool(report["models"].get(key, {}).get("available"))
|
|
25
|
+
return BackendHealth(ok=ready, dependencies={"ollama": str(report.get("version"))}, error_code=None if ready else report.get("error_code"))
|
|
26
|
+
|
|
27
|
+
def extract(self, request):
|
|
28
|
+
if request.item.platform == "pdf":
|
|
29
|
+
from openmd.ocr.documents import extract_pdf
|
|
30
|
+
|
|
31
|
+
return extract_pdf(request.item, backend=self.name, allow_ocr=self.options.enable_network_backends)
|
|
32
|
+
backend = LocalOCRBackend(self.name)
|
|
33
|
+
if not self.options.enable_network_backends:
|
|
34
|
+
return backend.failed("ocr_permission_required", request.item.title or "screenshot", "Use --enable-network-backends to call the configured Ollama API.")
|
|
35
|
+
source = request.item.raw_meta.get("local_path") or request.item.url
|
|
36
|
+
return backend.recognize(source, title=request.item.title or "screenshot")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def register_ocr_providers(registry, options):
|
|
40
|
+
for name in ("ocr.ovis3", "ocr.minicpm", "ocr.chain"):
|
|
41
|
+
registry.register(name, OCRRouterProvider(name, options))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Safe OpenCLI command wrapper scaffold."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from openmd.core.models import TranscriptResult
|
|
9
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
10
|
+
|
|
11
|
+
from .base import BackendCommandResult
|
|
12
|
+
|
|
13
|
+
from openmd.runtime.interpreters import run_interruptibly
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenCliBackend:
|
|
17
|
+
"""Run read-only OpenCLI commands without persisting raw stdout or stderr."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, executable: str = "opencli", *, timeout: int = 60) -> None:
|
|
20
|
+
self.executable = executable
|
|
21
|
+
self.timeout = timeout
|
|
22
|
+
|
|
23
|
+
def run(self, args: Sequence[str], *, name: str = "opencli") -> BackendCommandResult:
|
|
24
|
+
command = (self.executable, *args)
|
|
25
|
+
try:
|
|
26
|
+
completed = run_interruptibly(
|
|
27
|
+
command,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
check=False,
|
|
31
|
+
timeout=self.timeout,
|
|
32
|
+
)
|
|
33
|
+
except FileNotFoundError as exc:
|
|
34
|
+
return BackendCommandResult(
|
|
35
|
+
name=name,
|
|
36
|
+
command=command,
|
|
37
|
+
exit_code=127,
|
|
38
|
+
error_summary=sanitize_error_summary(exc),
|
|
39
|
+
)
|
|
40
|
+
except subprocess.TimeoutExpired as exc:
|
|
41
|
+
return BackendCommandResult(
|
|
42
|
+
name=name,
|
|
43
|
+
command=command,
|
|
44
|
+
exit_code=124,
|
|
45
|
+
stdout_summary=sanitize_error_summary(exc.stdout or "") or "",
|
|
46
|
+
stderr_summary=sanitize_error_summary(exc.stderr or "") or "",
|
|
47
|
+
error_summary=f"command timed out after {self.timeout}s",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
stderr_summary = sanitize_error_summary(completed.stderr or "") or ""
|
|
51
|
+
stdout_summary = sanitize_error_summary(completed.stdout or "") or ""
|
|
52
|
+
return BackendCommandResult(
|
|
53
|
+
name=name,
|
|
54
|
+
command=command,
|
|
55
|
+
exit_code=completed.returncode,
|
|
56
|
+
stdout_summary=stdout_summary,
|
|
57
|
+
stderr_summary=stderr_summary,
|
|
58
|
+
error_summary=stderr_summary if completed.returncode else None,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def command_result_to_transcript(
|
|
63
|
+
result: BackendCommandResult,
|
|
64
|
+
*,
|
|
65
|
+
source_level: str = "third_party_extractor",
|
|
66
|
+
) -> TranscriptResult:
|
|
67
|
+
if result.success and result.stdout_summary.strip():
|
|
68
|
+
return TranscriptResult(
|
|
69
|
+
text=result.stdout_summary,
|
|
70
|
+
status="verified",
|
|
71
|
+
source_level=source_level, # type: ignore[arg-type]
|
|
72
|
+
backend=result.name,
|
|
73
|
+
source_notes=["OpenCLI wrapper returned summarized stdout"],
|
|
74
|
+
)
|
|
75
|
+
return TranscriptResult(
|
|
76
|
+
text="",
|
|
77
|
+
status="failed",
|
|
78
|
+
source_level="partial_extraction",
|
|
79
|
+
backend=result.name,
|
|
80
|
+
error_code=f"backend_exit_{result.exit_code}",
|
|
81
|
+
error_summary=result.error_summary or result.stderr_summary or "OpenCLI command returned no usable text.",
|
|
82
|
+
)
|
openmd/backends/pdf.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Runtime providers for PDF/Office inputs under the legacy PDF identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
from openmd.adapters.pdf.adapter import PdfDocumentAdapter
|
|
8
|
+
from openmd.adapters.pdf.backends.markitdown import MarkItDownPDFParser
|
|
9
|
+
from openmd.adapters.pdf.parser import MissingPDFParser, MockPDFParser
|
|
10
|
+
from openmd.backends.base import (
|
|
11
|
+
BackendCapability,
|
|
12
|
+
BackendDescriptor,
|
|
13
|
+
BackendExtractRequest,
|
|
14
|
+
BackendHealth,
|
|
15
|
+
)
|
|
16
|
+
from openmd.backends.executor import BackendProviderRegistry
|
|
17
|
+
from openmd.backends.registry import BackendRuntimeOptions
|
|
18
|
+
from openmd.core.models import TranscriptResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PDFParserProvider:
|
|
22
|
+
def __init__(self, parser, options: BackendRuntimeOptions | None = None) -> None:
|
|
23
|
+
self.parser = parser
|
|
24
|
+
self.name = parser.name
|
|
25
|
+
self.options = options or BackendRuntimeOptions()
|
|
26
|
+
|
|
27
|
+
def descriptor(self) -> BackendDescriptor:
|
|
28
|
+
source_level = "native_text" if isinstance(self.parser, (MockPDFParser, MarkItDownPDFParser)) else "partial_extraction"
|
|
29
|
+
return BackendDescriptor(
|
|
30
|
+
name=self.name,
|
|
31
|
+
layer="local",
|
|
32
|
+
capabilities=(
|
|
33
|
+
BackendCapability(
|
|
34
|
+
platforms=("pdf",),
|
|
35
|
+
source_types=("document",),
|
|
36
|
+
input_types=("single", "batch", "collection", "generic"),
|
|
37
|
+
source_levels=(source_level,),
|
|
38
|
+
requires_network=False,
|
|
39
|
+
requires_credentials=False,
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
description="Local PDF/DOCX/PPTX/XLSX document parser provider.",
|
|
43
|
+
provider="openmd.pdf",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def health(self) -> BackendHealth:
|
|
47
|
+
is_available = getattr(self.parser, "is_available", None)
|
|
48
|
+
if callable(is_available) and not is_available():
|
|
49
|
+
return BackendHealth(
|
|
50
|
+
ok=False,
|
|
51
|
+
error_code="document_backend_missing",
|
|
52
|
+
error_summary=f"{self.name} is not installed or enabled.",
|
|
53
|
+
)
|
|
54
|
+
if isinstance(self.parser, MissingPDFParser):
|
|
55
|
+
return BackendHealth(
|
|
56
|
+
ok=False,
|
|
57
|
+
error_code="document_backend_missing",
|
|
58
|
+
error_summary=f"{self.name} is not installed or enabled.",
|
|
59
|
+
)
|
|
60
|
+
return BackendHealth(ok=True, dependencies={self.name: "ok"})
|
|
61
|
+
|
|
62
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
63
|
+
from openmd.adapters.local_audio.adapter import local_path_from_input
|
|
64
|
+
from openmd.ocr.documents import PDFDependencyMissing, document_failure, inspect_pdf
|
|
65
|
+
|
|
66
|
+
source = request.item.raw_meta.get("local_path") or request.item.url
|
|
67
|
+
path = local_path_from_input(source)
|
|
68
|
+
probes = None
|
|
69
|
+
if self.name == "pdf.markitdown" and path and path.is_file() and path.suffix.lower() == ".pdf":
|
|
70
|
+
try:
|
|
71
|
+
probes = inspect_pdf(path)
|
|
72
|
+
if any(not page["reliable"] for page in probes):
|
|
73
|
+
return document_failure(self.name, request.item.title or path.stem, "pdf_ocr_required", "One or more pages require OCR; continue to the page router.")
|
|
74
|
+
except PDFDependencyMissing as exc:
|
|
75
|
+
return document_failure(self.name, request.item.title or path.stem, "pymupdf_missing", str(exc))
|
|
76
|
+
except (OSError, ValueError, RuntimeError):
|
|
77
|
+
return document_failure(self.name, request.item.title or path.stem, "pdf_probe_failed", "PyMuPDF could not inspect the local PDF.")
|
|
78
|
+
adapter = PdfDocumentAdapter("pdf", request.primary_backend)
|
|
79
|
+
result = adapter.extract_text(
|
|
80
|
+
request.item,
|
|
81
|
+
backend=request.primary_backend,
|
|
82
|
+
backend_chain=(request.primary_backend,),
|
|
83
|
+
allow_fallback=False,
|
|
84
|
+
)
|
|
85
|
+
if probes is not None:
|
|
86
|
+
result = replace(result, metadata={**result.metadata, "pages": [{**p, "status": "verified", "engine": self.name, "model": None, "digest": None, "fallback_used": False, "reason": None, "ovis_seconds": 0.0, "fallback_seconds": 0.0, "peak_vram_mb": None, "han_ratio": None, "slice_parameters": {}, "breakers": []} for p in probes]})
|
|
87
|
+
if self.name == "pdf.markitdown" and path and path.is_file() and path.suffix.lower() in {".docx", ".pptx", ".xlsx"} and result.error_code not in {"document_invalid_container", "document_extraction_failed"}:
|
|
88
|
+
from openmd.ocr.office import recover_office
|
|
89
|
+
|
|
90
|
+
item = replace(request.item, raw_meta={**request.item.raw_meta, "local_path": str(path)})
|
|
91
|
+
result = recover_office(item, result, allow_ocr=self.options.enable_network_backends, allow_external_resources=self.options.enable_network_backends)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PDFTextLayerProvider:
|
|
96
|
+
name = "pdf.textlayer"
|
|
97
|
+
|
|
98
|
+
def descriptor(self):
|
|
99
|
+
return BackendDescriptor(name=self.name, layer="local", provider="openmd.pdf", description="PyMuPDF native page text; incomplete PDFs continue to OCR.", capabilities=(BackendCapability(platforms=("pdf",), source_types=("document",), input_types=("single", "batch", "collection", "generic"), source_levels=("native_text",), requires_network=False),))
|
|
100
|
+
|
|
101
|
+
def health(self):
|
|
102
|
+
import importlib.util
|
|
103
|
+
|
|
104
|
+
available = importlib.util.find_spec("pymupdf") is not None
|
|
105
|
+
return BackendHealth(ok=available, error_code=None if available else "pymupdf_missing")
|
|
106
|
+
|
|
107
|
+
def extract(self, request):
|
|
108
|
+
from openmd.ocr.documents import extract_pdf
|
|
109
|
+
|
|
110
|
+
return extract_pdf(request.item, backend=self.name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def register_backend_providers(
|
|
114
|
+
registry: BackendProviderRegistry,
|
|
115
|
+
options: BackendRuntimeOptions,
|
|
116
|
+
) -> None:
|
|
117
|
+
registry.register("pdf.mock", PDFParserProvider(MockPDFParser()))
|
|
118
|
+
registry.register("pdf.markitdown", PDFParserProvider(MarkItDownPDFParser(), options))
|
|
119
|
+
registry.register("pdf.textlayer", PDFTextLayerProvider())
|
|
120
|
+
from openmd.backends.ocr import register_ocr_providers
|
|
121
|
+
|
|
122
|
+
register_ocr_providers(registry, options)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Runtime backend provider registration helpers.
|
|
2
|
+
|
|
3
|
+
This module centralizes core provider wiring so platform lanes can add
|
|
4
|
+
platform-specific modules without repeatedly editing the CLI or central adapter
|
|
5
|
+
registry. Real network/browser providers still need explicit CLI opt-in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .executor import (
|
|
15
|
+
BackendProviderRegistry,
|
|
16
|
+
DiscoveryProviderRegistry,
|
|
17
|
+
MockDiscoveryProvider,
|
|
18
|
+
create_safe_backend_provider_registry,
|
|
19
|
+
create_safe_discovery_provider_registry,
|
|
20
|
+
)
|
|
21
|
+
from .youtube_discovery import (
|
|
22
|
+
OpenCliYouTubeChannelProvider,
|
|
23
|
+
OpenCliYouTubePlaylistProvider,
|
|
24
|
+
YouTubeDataApiChannelProvider,
|
|
25
|
+
YouTubeDataApiPlaylistProvider,
|
|
26
|
+
YOUTUBE_DATA_API_DISCOVERY_BACKEND,
|
|
27
|
+
YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND,
|
|
28
|
+
YOUTUBE_DISCOVERY_BACKEND,
|
|
29
|
+
YOUTUBE_PLAYLIST_DISCOVERY_BACKEND,
|
|
30
|
+
)
|
|
31
|
+
from .youtube_transcript import YOUTUBE_TRANSCRIPT_BACKEND, YouTubeTranscriptApiProvider
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class BackendRuntimeOptions:
|
|
36
|
+
"""Safe runtime gates shared by extraction and discovery provider setup."""
|
|
37
|
+
|
|
38
|
+
backend_override: str | None = None
|
|
39
|
+
requested_discovery_provider: str | None = None
|
|
40
|
+
enable_network_backends: bool = False
|
|
41
|
+
enable_browser_backends: bool = False
|
|
42
|
+
enable_media_download_backends: bool = False
|
|
43
|
+
enable_asr_backends: bool = False
|
|
44
|
+
enable_cloud_backends: bool = False
|
|
45
|
+
keep_media_artifacts: bool = False
|
|
46
|
+
media_artifact_dir: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
ExtensionModuleLoader = Callable[[str], Iterable[object]]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
DISCOVERY_BACKEND_ALIASES: dict[str, str] = {
|
|
53
|
+
"api": YOUTUBE_DATA_API_DISCOVERY_BACKEND,
|
|
54
|
+
"data-api": YOUTUBE_DATA_API_DISCOVERY_BACKEND,
|
|
55
|
+
"data-api-playlist": YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND,
|
|
56
|
+
"mock": "mock.discovery",
|
|
57
|
+
"skeleton": "skeleton.discovery",
|
|
58
|
+
"opencli": YOUTUBE_DISCOVERY_BACKEND,
|
|
59
|
+
"opencli-playlist": YOUTUBE_PLAYLIST_DISCOVERY_BACKEND,
|
|
60
|
+
"playlist": YOUTUBE_PLAYLIST_DISCOVERY_BACKEND,
|
|
61
|
+
"youtube-data-api": YOUTUBE_DATA_API_DISCOVERY_BACKEND,
|
|
62
|
+
"youtube-data-api-playlist": YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND,
|
|
63
|
+
"youtube-playlist": YOUTUBE_PLAYLIST_DISCOVERY_BACKEND,
|
|
64
|
+
"wechat-article-exporter-api": "wechat-article-exporter.http",
|
|
65
|
+
"wechat-article-exporter-http": "wechat-article-exporter.http",
|
|
66
|
+
"weixin-exporter-api": "wechat-article-exporter.http",
|
|
67
|
+
"weixin-exporter-http": "wechat-article-exporter.http",
|
|
68
|
+
"weixin-http": "wechat-article-exporter.http",
|
|
69
|
+
"openmd-douyin": "opencli.openmd-douyin.creator-videos",
|
|
70
|
+
"openmd-douyin-creator-videos": "opencli.openmd-douyin.creator-videos",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_discovery_backend(value: str | None) -> str | None:
|
|
75
|
+
if value is None:
|
|
76
|
+
return None
|
|
77
|
+
return DISCOVERY_BACKEND_ALIASES.get(value, value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_runtime_backend_provider_registry(
|
|
81
|
+
platform: str,
|
|
82
|
+
options: BackendRuntimeOptions,
|
|
83
|
+
*,
|
|
84
|
+
extension_module_loader: ExtensionModuleLoader | None = None,
|
|
85
|
+
) -> BackendProviderRegistry:
|
|
86
|
+
registry = create_safe_backend_provider_registry()
|
|
87
|
+
_register_builtin_backend_providers(registry, platform, options)
|
|
88
|
+
_register_extension_backend_providers(
|
|
89
|
+
registry,
|
|
90
|
+
platform,
|
|
91
|
+
options,
|
|
92
|
+
extension_module_loader=extension_module_loader,
|
|
93
|
+
)
|
|
94
|
+
from openmd.routing.providers import register_card_providers
|
|
95
|
+
|
|
96
|
+
register_card_providers(registry, platform, options)
|
|
97
|
+
return registry
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_runtime_discovery_provider_registry(
|
|
101
|
+
platform: str,
|
|
102
|
+
options: BackendRuntimeOptions,
|
|
103
|
+
*,
|
|
104
|
+
extension_module_loader: ExtensionModuleLoader | None = None,
|
|
105
|
+
) -> DiscoveryProviderRegistry:
|
|
106
|
+
registry = create_safe_discovery_provider_registry()
|
|
107
|
+
requested_provider = normalize_discovery_backend(options.requested_discovery_provider)
|
|
108
|
+
if requested_provider is None and options.backend_override == "mock":
|
|
109
|
+
registry.register(
|
|
110
|
+
"mock.discovery",
|
|
111
|
+
MockDiscoveryProvider(),
|
|
112
|
+
platforms=(platform,),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
should_register_network_provider = (
|
|
116
|
+
options.enable_network_backends
|
|
117
|
+
and platform == "youtube"
|
|
118
|
+
and (requested_provider is None or requested_provider == YOUTUBE_DATA_API_DISCOVERY_BACKEND)
|
|
119
|
+
)
|
|
120
|
+
if should_register_network_provider:
|
|
121
|
+
registry.register(
|
|
122
|
+
YOUTUBE_DATA_API_DISCOVERY_BACKEND,
|
|
123
|
+
YouTubeDataApiChannelProvider(),
|
|
124
|
+
platforms=("youtube",),
|
|
125
|
+
default_for_platforms=requested_provider is None,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
should_register_network_playlist_provider = (
|
|
129
|
+
options.enable_network_backends
|
|
130
|
+
and platform == "youtube"
|
|
131
|
+
and (requested_provider is None or requested_provider == YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND)
|
|
132
|
+
)
|
|
133
|
+
if should_register_network_playlist_provider:
|
|
134
|
+
registry.register(
|
|
135
|
+
YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND,
|
|
136
|
+
YouTubeDataApiPlaylistProvider(),
|
|
137
|
+
platforms=("youtube",),
|
|
138
|
+
default_for_platforms=requested_provider == YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
should_register_browser_channel_provider = (
|
|
142
|
+
options.enable_browser_backends
|
|
143
|
+
and platform == "youtube"
|
|
144
|
+
and (requested_provider is None or requested_provider == YOUTUBE_DISCOVERY_BACKEND)
|
|
145
|
+
)
|
|
146
|
+
if should_register_browser_channel_provider:
|
|
147
|
+
registry.register(
|
|
148
|
+
YOUTUBE_DISCOVERY_BACKEND,
|
|
149
|
+
OpenCliYouTubeChannelProvider(),
|
|
150
|
+
platforms=("youtube",),
|
|
151
|
+
default_for_platforms=requested_provider is None and not should_register_network_provider,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
should_register_browser_playlist_provider = (
|
|
155
|
+
options.enable_browser_backends
|
|
156
|
+
and platform == "youtube"
|
|
157
|
+
and (requested_provider is None or requested_provider == YOUTUBE_PLAYLIST_DISCOVERY_BACKEND)
|
|
158
|
+
)
|
|
159
|
+
if should_register_browser_playlist_provider:
|
|
160
|
+
registry.register(
|
|
161
|
+
YOUTUBE_PLAYLIST_DISCOVERY_BACKEND,
|
|
162
|
+
OpenCliYouTubePlaylistProvider(),
|
|
163
|
+
platforms=("youtube",),
|
|
164
|
+
default_for_platforms=requested_provider == YOUTUBE_PLAYLIST_DISCOVERY_BACKEND,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
_register_extension_discovery_providers(
|
|
168
|
+
registry,
|
|
169
|
+
platform,
|
|
170
|
+
options,
|
|
171
|
+
extension_module_loader=extension_module_loader,
|
|
172
|
+
)
|
|
173
|
+
return registry
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _register_builtin_backend_providers(
|
|
177
|
+
registry: BackendProviderRegistry,
|
|
178
|
+
platform: str,
|
|
179
|
+
options: BackendRuntimeOptions,
|
|
180
|
+
) -> None:
|
|
181
|
+
if options.enable_network_backends and platform == "youtube":
|
|
182
|
+
registry.register(YOUTUBE_TRANSCRIPT_BACKEND, YouTubeTranscriptApiProvider())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _register_extension_backend_providers(
|
|
186
|
+
registry: BackendProviderRegistry,
|
|
187
|
+
platform: str,
|
|
188
|
+
options: BackendRuntimeOptions,
|
|
189
|
+
*,
|
|
190
|
+
extension_module_loader: ExtensionModuleLoader | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
for module in _load_backend_extension_modules(platform, extension_module_loader):
|
|
193
|
+
registrar = getattr(module, "register_backend_providers", None)
|
|
194
|
+
if registrar is not None:
|
|
195
|
+
if not callable(registrar):
|
|
196
|
+
raise TypeError("register_backend_providers must be callable")
|
|
197
|
+
registrar(registry, options)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _register_extension_discovery_providers(
|
|
201
|
+
registry: DiscoveryProviderRegistry,
|
|
202
|
+
platform: str,
|
|
203
|
+
options: BackendRuntimeOptions,
|
|
204
|
+
*,
|
|
205
|
+
extension_module_loader: ExtensionModuleLoader | None = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
for module in _load_backend_extension_modules(platform, extension_module_loader):
|
|
208
|
+
registrar = getattr(module, "register_discovery_providers", None)
|
|
209
|
+
if registrar is not None:
|
|
210
|
+
if not callable(registrar):
|
|
211
|
+
raise TypeError("register_discovery_providers must be callable")
|
|
212
|
+
registrar(registry, options)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _load_backend_extension_modules(
|
|
216
|
+
platform: str,
|
|
217
|
+
extension_module_loader: ExtensionModuleLoader | None = None,
|
|
218
|
+
) -> tuple[object, ...]:
|
|
219
|
+
if extension_module_loader is not None:
|
|
220
|
+
return tuple(extension_module_loader(platform))
|
|
221
|
+
|
|
222
|
+
modules: list[object] = []
|
|
223
|
+
for module_name in _backend_extension_module_names(platform):
|
|
224
|
+
try:
|
|
225
|
+
modules.append(importlib.import_module(module_name))
|
|
226
|
+
except ModuleNotFoundError as exc:
|
|
227
|
+
if exc.name == module_name:
|
|
228
|
+
continue
|
|
229
|
+
raise
|
|
230
|
+
return tuple(modules)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _backend_extension_module_names(platform: str) -> tuple[str, str]:
|
|
234
|
+
return (f"openmd.backends.{platform}", f"openmd.backends.{platform}_providers")
|