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,11 @@
|
|
|
1
|
+
"""V1 PDF document adapter factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from openmd.adapters.base import PlatformAdapter
|
|
6
|
+
|
|
7
|
+
from .adapter import PdfDocumentAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_adapter(platform_id: str, default_backend: str) -> PlatformAdapter:
|
|
11
|
+
return PdfDocumentAdapter(platform_id, default_backend)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""V1 adapter for local PDF and Office document inputs.
|
|
2
|
+
|
|
3
|
+
This lane is local and read-only by default. It resolves PDF files or folders
|
|
4
|
+
into ``ContentItem`` records and leaves parsing to explicit parser backends.
|
|
5
|
+
Raw documents are referenced as sources and are not copied into package assets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
import hashlib
|
|
13
|
+
|
|
14
|
+
from openmd.adapters.base import PlatformAdapter
|
|
15
|
+
from openmd.adapters.local_audio.adapter import local_path_from_input
|
|
16
|
+
from openmd.core.detector import DOCUMENT_EXTENSIONS, extract_first_url
|
|
17
|
+
from openmd.core.models import ContentItem, TranscriptResult
|
|
18
|
+
|
|
19
|
+
from .backends.markitdown import MarkItDownPDFParser
|
|
20
|
+
from .parser import MissingPDFParser, MockPDFParser
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PdfDocumentAdapter(PlatformAdapter):
|
|
24
|
+
"""Resolve local PDF files, folders, and PDF URL references without side effects."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, platform_id: str = "pdf", default_backend: str = "pdf.markitdown") -> None:
|
|
27
|
+
self.id = platform_id
|
|
28
|
+
self.default_backend = default_backend
|
|
29
|
+
|
|
30
|
+
def can_handle(self, input_value: str) -> bool:
|
|
31
|
+
return _is_pdf_input(input_value)
|
|
32
|
+
|
|
33
|
+
def resolve_items(
|
|
34
|
+
self,
|
|
35
|
+
input_value: str,
|
|
36
|
+
*,
|
|
37
|
+
limit: int | None = None,
|
|
38
|
+
all: bool = False,
|
|
39
|
+
) -> list[ContentItem]:
|
|
40
|
+
path = local_path_from_input(input_value)
|
|
41
|
+
if path is not None and path.is_dir():
|
|
42
|
+
files = sorted(candidate for candidate in path.rglob("*") if candidate.is_file() and _is_pdf_path(candidate))
|
|
43
|
+
if limit is not None and not all:
|
|
44
|
+
files = files[: max(0, limit)]
|
|
45
|
+
return [_content_item_for_path(candidate, platform=self.id) for candidate in files]
|
|
46
|
+
|
|
47
|
+
if path is not None:
|
|
48
|
+
return [_content_item_for_path(path, platform=self.id)]
|
|
49
|
+
|
|
50
|
+
target = extract_first_url(input_value)
|
|
51
|
+
return [_content_item_for_reference(target, platform=self.id)]
|
|
52
|
+
|
|
53
|
+
def extract_text(
|
|
54
|
+
self,
|
|
55
|
+
item: ContentItem,
|
|
56
|
+
*,
|
|
57
|
+
backend: str | None = None,
|
|
58
|
+
backend_chain: tuple[str, ...] | None = None,
|
|
59
|
+
allow_fallback: bool = True,
|
|
60
|
+
) -> TranscriptResult:
|
|
61
|
+
selected = (backend_chain or (backend or self.default_backend,))[0]
|
|
62
|
+
source_ref = _item_source_ref(item)
|
|
63
|
+
title = item.title or _title_from_source_ref(source_ref)
|
|
64
|
+
if selected == "pdf.mock":
|
|
65
|
+
return MockPDFParser().parse(source_ref, title=title)
|
|
66
|
+
if selected == "pdf.markitdown":
|
|
67
|
+
return MarkItDownPDFParser().parse(source_ref, title=title)
|
|
68
|
+
return MissingPDFParser(selected).parse(source_ref, title=title)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _content_item_for_path(path: Path, *, platform: str) -> ContentItem:
|
|
72
|
+
source_ref = path.resolve().as_uri() if path.exists() else str(path)
|
|
73
|
+
content_id = _pdf_content_id(path)
|
|
74
|
+
return ContentItem(
|
|
75
|
+
platform=platform,
|
|
76
|
+
input_type="single",
|
|
77
|
+
url=source_ref,
|
|
78
|
+
id=content_id,
|
|
79
|
+
title=path.stem,
|
|
80
|
+
source_type="document",
|
|
81
|
+
raw_meta={
|
|
82
|
+
"profile": "document",
|
|
83
|
+
"extraction_contract_version": "document-native-v2",
|
|
84
|
+
"permission_scope": "local_file",
|
|
85
|
+
"local_path": str(path),
|
|
86
|
+
"content_id": content_id,
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _content_item_for_reference(source_ref: str, *, platform: str) -> ContentItem:
|
|
92
|
+
return ContentItem(
|
|
93
|
+
platform=platform,
|
|
94
|
+
input_type="single",
|
|
95
|
+
url=source_ref,
|
|
96
|
+
id=_stable_id(source_ref),
|
|
97
|
+
title=_title_from_source_ref(source_ref),
|
|
98
|
+
source_type="document",
|
|
99
|
+
raw_meta={
|
|
100
|
+
"profile": "document",
|
|
101
|
+
"extraction_contract_version": "document-native-v2",
|
|
102
|
+
"permission_scope": "user_accessible",
|
|
103
|
+
"content_id": _stable_id(source_ref),
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_pdf_input(input_value: str) -> bool:
|
|
109
|
+
path = local_path_from_input(input_value)
|
|
110
|
+
if path is not None:
|
|
111
|
+
if path.is_dir():
|
|
112
|
+
return _directory_contains_pdf(path)
|
|
113
|
+
return _is_pdf_path(path)
|
|
114
|
+
parsed = urlparse(extract_first_url(input_value))
|
|
115
|
+
return parsed.path.lower().endswith(DOCUMENT_EXTENSIONS)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_pdf_path(path: Path) -> bool:
|
|
119
|
+
return path.suffix.lower() in DOCUMENT_EXTENSIONS
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _directory_contains_pdf(path: Path) -> bool:
|
|
123
|
+
try:
|
|
124
|
+
return any(candidate.is_file() and _is_pdf_path(candidate) for candidate in path.rglob("*"))
|
|
125
|
+
except OSError:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _pdf_content_id(path: Path) -> str:
|
|
130
|
+
if path.is_file():
|
|
131
|
+
digest = hashlib.sha256()
|
|
132
|
+
with path.open("rb") as handle:
|
|
133
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
134
|
+
digest.update(chunk)
|
|
135
|
+
return digest.hexdigest()[:12]
|
|
136
|
+
return _stable_id(str(path))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _item_source_ref(item: ContentItem) -> str:
|
|
140
|
+
local_path = item.raw_meta.get("local_path") if isinstance(item.raw_meta, dict) else None
|
|
141
|
+
if isinstance(local_path, str) and local_path.strip():
|
|
142
|
+
return local_path
|
|
143
|
+
return item.url
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _title_from_source_ref(source_ref: str) -> str:
|
|
147
|
+
parsed = urlparse(source_ref)
|
|
148
|
+
path = Path(parsed.path if parsed.scheme else source_ref)
|
|
149
|
+
return path.stem or "PDF document"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _stable_id(value: str) -> str:
|
|
153
|
+
return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()[:12]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional document parser backends."""
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Optional local MarkItDown PDF/Office backend (legacy PDF backend name).
|
|
2
|
+
|
|
3
|
+
The dependency is imported lazily so normal Openmd imports, mock lanes, and
|
|
4
|
+
safe skeleton discovery never execute the external parser. The backend only
|
|
5
|
+
parses local files or ``file://`` references; remote PDF fetching is deliberately
|
|
6
|
+
out of scope for this local document lane.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from zipfile import BadZipFile, ZipFile
|
|
16
|
+
|
|
17
|
+
from openmd.adapters.local_audio.adapter import local_path_from_input
|
|
18
|
+
from openmd.adapters.pdf.parser import MissingPDFParser, PDFParser
|
|
19
|
+
from openmd.core.models import TranscriptResult
|
|
20
|
+
from openmd.core.detector import DOCUMENT_EXTENSIONS
|
|
21
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MarkItDownPDFParser(PDFParser):
|
|
25
|
+
name = "pdf.markitdown"
|
|
26
|
+
|
|
27
|
+
def is_available(self) -> bool:
|
|
28
|
+
return importlib.util.find_spec("markitdown") is not None
|
|
29
|
+
|
|
30
|
+
def parse(self, source_ref: str, *, title: str) -> TranscriptResult:
|
|
31
|
+
source_path = _local_existing_path(source_ref)
|
|
32
|
+
if source_path is None:
|
|
33
|
+
return _failed_result(
|
|
34
|
+
self.name,
|
|
35
|
+
source_ref,
|
|
36
|
+
title,
|
|
37
|
+
"document_local_file_required",
|
|
38
|
+
"MarkItDown document parsing requires a local file path or file:// reference.",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if source_path.suffix.lower() not in DOCUMENT_EXTENSIONS:
|
|
42
|
+
return _failed_result(
|
|
43
|
+
self.name, str(source_path), title, "document_format_unsupported",
|
|
44
|
+
"Supported local document formats are PDF, DOCX, PPTX and XLSX.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
source_format = source_path.suffix.lower().lstrip(".")
|
|
48
|
+
office_parts = {"docx": "word/document.xml", "pptx": "ppt/presentation.xml", "xlsx": "xl/workbook.xml"}
|
|
49
|
+
if source_format in office_parts:
|
|
50
|
+
try:
|
|
51
|
+
with ZipFile(source_path) as archive:
|
|
52
|
+
if not {"[Content_Types].xml", office_parts[source_format]}.issubset(archive.namelist()):
|
|
53
|
+
raise BadZipFile("required Office parts are missing")
|
|
54
|
+
except (BadZipFile, OSError):
|
|
55
|
+
return _failed_result(
|
|
56
|
+
self.name, str(source_path), title, "document_invalid_container",
|
|
57
|
+
"The Office input is not a valid OOXML document container.",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not self.is_available():
|
|
61
|
+
return MissingPDFParser(self.name).parse(str(source_path), title=title)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
from markitdown import MarkItDown
|
|
65
|
+
|
|
66
|
+
if source_format in office_parts:
|
|
67
|
+
from markitdown.converters import DocxConverter, PptxConverter, XlsxConverter
|
|
68
|
+
|
|
69
|
+
# Do not let a broken Office file fall back to plain text or a
|
|
70
|
+
# ZIP file listing and become a falsely verified document.
|
|
71
|
+
converter = {"docx": DocxConverter, "pptx": PptxConverter, "xlsx": XlsxConverter}[source_format]
|
|
72
|
+
parser = MarkItDown(enable_builtins=False)
|
|
73
|
+
parser.register_converter(converter())
|
|
74
|
+
else:
|
|
75
|
+
parser = MarkItDown()
|
|
76
|
+
converted: Any = parser.convert(str(source_path))
|
|
77
|
+
text = (getattr(converted, "text_content", None) or "").strip()
|
|
78
|
+
except Exception as exc: # pragma: no cover - dependency-specific safety net
|
|
79
|
+
return _failed_result(
|
|
80
|
+
self.name,
|
|
81
|
+
str(source_path),
|
|
82
|
+
title,
|
|
83
|
+
"document_extraction_failed",
|
|
84
|
+
exc,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if not _has_document_text(text, source_format):
|
|
88
|
+
return _failed_result(
|
|
89
|
+
self.name,
|
|
90
|
+
str(source_path),
|
|
91
|
+
title,
|
|
92
|
+
"document_empty_text",
|
|
93
|
+
"MarkItDown returned no text for the document.",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
document_title = title or source_path.stem or "PDF document"
|
|
97
|
+
return TranscriptResult(
|
|
98
|
+
text=_with_title(document_title, text),
|
|
99
|
+
status="verified",
|
|
100
|
+
source_level="native_text",
|
|
101
|
+
backend=self.name,
|
|
102
|
+
source_notes=[
|
|
103
|
+
f"MarkItDown parsed local {source_format.upper()} text.",
|
|
104
|
+
"Raw document file was referenced, not copied into the package.",
|
|
105
|
+
"Native text extraction does not certify reading order, tables, formulas or image OCR quality.",
|
|
106
|
+
],
|
|
107
|
+
title=document_title,
|
|
108
|
+
metadata={
|
|
109
|
+
"profile": "document",
|
|
110
|
+
"parser_backend": self.name,
|
|
111
|
+
"source_ref": str(source_path),
|
|
112
|
+
"page_mapping": "not_available",
|
|
113
|
+
"source_format": source_format,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _has_document_text(text: str, source_format: str) -> bool:
|
|
119
|
+
"""Ignore converter scaffolding without removing actual source headings."""
|
|
120
|
+
if source_format not in {"pptx", "xlsx"}:
|
|
121
|
+
return bool(text.strip())
|
|
122
|
+
visible = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
|
|
123
|
+
if source_format == "xlsx":
|
|
124
|
+
# Sheet headings and pandas' unnamed-column labels are generated even
|
|
125
|
+
# for empty workbooks, so they cannot establish source body coverage.
|
|
126
|
+
visible = re.sub(r"^## .*?$", "", visible, flags=re.MULTILINE)
|
|
127
|
+
visible = re.sub(r"Unnamed: \d+(?:_level_\d+)?", "", visible)
|
|
128
|
+
visible = re.sub(r"[|\s:\-]", "", visible)
|
|
129
|
+
return bool(visible.strip())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _local_existing_path(source_ref: str) -> Path | None:
|
|
133
|
+
path = local_path_from_input(source_ref)
|
|
134
|
+
if path is None:
|
|
135
|
+
return None
|
|
136
|
+
return path if path.is_file() else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _with_title(title: str, text: str) -> str:
|
|
140
|
+
if text.lstrip().startswith("#"):
|
|
141
|
+
return text if text.endswith("\n") else f"{text}\n"
|
|
142
|
+
return f"# {title}\n\n{text}\n"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _failed_result(
|
|
146
|
+
backend: str,
|
|
147
|
+
source_ref: str,
|
|
148
|
+
title: str,
|
|
149
|
+
code: str,
|
|
150
|
+
error: object,
|
|
151
|
+
) -> TranscriptResult:
|
|
152
|
+
return TranscriptResult(
|
|
153
|
+
text="",
|
|
154
|
+
status="failed",
|
|
155
|
+
source_level="partial_extraction",
|
|
156
|
+
backend=backend,
|
|
157
|
+
source_notes=[
|
|
158
|
+
"Document parser backend did not produce usable text.",
|
|
159
|
+
"Raw document file was referenced, not copied into the package.",
|
|
160
|
+
],
|
|
161
|
+
error_code=code,
|
|
162
|
+
error_summary=sanitize_error_summary(error),
|
|
163
|
+
title=title or "PDF document",
|
|
164
|
+
metadata={"profile": "document", "parser_backend": backend, "source_ref": source_ref},
|
|
165
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""PDF parser backend abstractions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from openmd.core.models import TranscriptResult
|
|
8
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PDFParser(ABC):
|
|
12
|
+
name: str
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def parse(self, source_ref: str, *, title: str) -> TranscriptResult:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MockPDFParser(PDFParser):
|
|
20
|
+
name = "pdf.mock"
|
|
21
|
+
|
|
22
|
+
def parse(self, source_ref: str, *, title: str) -> TranscriptResult:
|
|
23
|
+
return TranscriptResult(
|
|
24
|
+
text=f"# {title}\n\nPage 1: Mock PDF document text for Openmd package smoke.\n",
|
|
25
|
+
status="verified",
|
|
26
|
+
source_level="native_text",
|
|
27
|
+
backend=self.name,
|
|
28
|
+
source_notes=[
|
|
29
|
+
"PDF mock backend only",
|
|
30
|
+
"raw PDF file was referenced, not copied into the package",
|
|
31
|
+
],
|
|
32
|
+
title=title or "PDF document",
|
|
33
|
+
metadata={"profile": "document", "parser_backend": self.name, "source_ref": source_ref},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
class MissingPDFParser(PDFParser):
|
|
37
|
+
def __init__(self, name: str) -> None:
|
|
38
|
+
self.name = name
|
|
39
|
+
|
|
40
|
+
def parse(self, source_ref: str, *, title: str) -> TranscriptResult:
|
|
41
|
+
return TranscriptResult(
|
|
42
|
+
text="",
|
|
43
|
+
status="failed",
|
|
44
|
+
source_level="partial_extraction",
|
|
45
|
+
backend=self.name,
|
|
46
|
+
source_notes=[
|
|
47
|
+
"PDF parser backend is not installed or not enabled",
|
|
48
|
+
"no external PDF parser command was attempted",
|
|
49
|
+
],
|
|
50
|
+
error_code="document_backend_missing",
|
|
51
|
+
error_summary=sanitize_error_summary(
|
|
52
|
+
f"PDF parser backend {self.name!r} is not installed or enabled."
|
|
53
|
+
),
|
|
54
|
+
title=title or "PDF document",
|
|
55
|
+
metadata={"profile": "document", "parser_backend": self.name, "source_ref": source_ref},
|
|
56
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""QQ group conversation adapter factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from openmd.adapters.base import PlatformAdapter
|
|
6
|
+
|
|
7
|
+
from .adapter import QQGroupAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_adapter(platform_id: str, default_backend: str) -> PlatformAdapter:
|
|
11
|
+
return QQGroupAdapter(platform_id, default_backend)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Mock/gated adapter for QQ group export package inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
|
|
11
|
+
from openmd.adapters.base import PlatformAdapter
|
|
12
|
+
from openmd.adapters.high_privacy import high_privacy_blocker_result
|
|
13
|
+
from openmd.adapters.qq_group.parsers.html import parse_qq_html_export
|
|
14
|
+
from openmd.adapters.qq_group.parsers.json import parse_qq_json_export
|
|
15
|
+
from openmd.core.detector import detect_platform
|
|
16
|
+
from openmd.core.models import ContentItem, TranscriptResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class QQGroupAdapter(PlatformAdapter):
|
|
20
|
+
"""Resolve QQ group exports without database access or decryption."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, platform_id: str = "qq_group", default_backend: str = "qq_group.export") -> None:
|
|
23
|
+
self.id = platform_id
|
|
24
|
+
self.default_backend = default_backend
|
|
25
|
+
|
|
26
|
+
def can_handle(self, input_value: str) -> bool:
|
|
27
|
+
return detect_platform(input_value) == self.id
|
|
28
|
+
|
|
29
|
+
def resolve_items(
|
|
30
|
+
self,
|
|
31
|
+
input_value: str,
|
|
32
|
+
*,
|
|
33
|
+
limit: int | None = None,
|
|
34
|
+
all: bool = False,
|
|
35
|
+
) -> list[ContentItem]:
|
|
36
|
+
value = input_value.strip()
|
|
37
|
+
if _is_selector(value):
|
|
38
|
+
group_name = _group_from_selector(value)
|
|
39
|
+
raw_meta: dict[str, Any] = {
|
|
40
|
+
"profile": "conversation",
|
|
41
|
+
"permission_scope": "user_accessible",
|
|
42
|
+
"target_kind": "qq_group",
|
|
43
|
+
"group_name": group_name,
|
|
44
|
+
"discovery_limit": limit,
|
|
45
|
+
"allow_all": all,
|
|
46
|
+
}
|
|
47
|
+
title = f"QQ group: {group_name}"
|
|
48
|
+
identity = _stable_hash(group_name)
|
|
49
|
+
else:
|
|
50
|
+
path = _local_path_from_input(value)
|
|
51
|
+
group_name = _group_name_from_path(path)
|
|
52
|
+
raw_meta = {
|
|
53
|
+
"profile": "conversation",
|
|
54
|
+
"permission_scope": "user_export",
|
|
55
|
+
"target_kind": "qq_group_export",
|
|
56
|
+
"group_name": group_name,
|
|
57
|
+
"local_path": str(path),
|
|
58
|
+
"export_format": path.suffix.lower().lstrip("."),
|
|
59
|
+
"discovery_limit": limit,
|
|
60
|
+
"allow_all": all,
|
|
61
|
+
}
|
|
62
|
+
title = f"QQ group export: {group_name}"
|
|
63
|
+
identity = _stable_hash(str(path.resolve()) if path.exists() else str(path))
|
|
64
|
+
|
|
65
|
+
return [
|
|
66
|
+
ContentItem(
|
|
67
|
+
platform=self.id,
|
|
68
|
+
input_type="collection",
|
|
69
|
+
url=value,
|
|
70
|
+
id=identity,
|
|
71
|
+
title=title,
|
|
72
|
+
source_type="conversation",
|
|
73
|
+
raw_meta=raw_meta,
|
|
74
|
+
)
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def extract_text(
|
|
78
|
+
self,
|
|
79
|
+
item: ContentItem,
|
|
80
|
+
*,
|
|
81
|
+
backend: str | None = None,
|
|
82
|
+
backend_chain: tuple[str, ...] | None = None,
|
|
83
|
+
allow_fallback: bool = True,
|
|
84
|
+
) -> TranscriptResult:
|
|
85
|
+
selected = (backend_chain or (backend or self.default_backend,))[0]
|
|
86
|
+
if selected in {"mock", "qq_group.mock"}:
|
|
87
|
+
messages = _messages_from_item(item)
|
|
88
|
+
return TranscriptResult(
|
|
89
|
+
text=render_qq_group_markdown(messages),
|
|
90
|
+
status="verified",
|
|
91
|
+
source_level="manual_export",
|
|
92
|
+
backend=selected,
|
|
93
|
+
title=item.title or "QQ group conversation",
|
|
94
|
+
source_notes=[
|
|
95
|
+
"local mock adapter smoke only; no QQ database, session, DB key extraction, or decryption action was attempted",
|
|
96
|
+
],
|
|
97
|
+
metadata={"profile": "conversation", "messages": messages},
|
|
98
|
+
)
|
|
99
|
+
if selected == "qq_group.export":
|
|
100
|
+
messages, blocker = _messages_from_export_item(item)
|
|
101
|
+
if blocker is not None:
|
|
102
|
+
return high_privacy_blocker_result(
|
|
103
|
+
platform=self.id,
|
|
104
|
+
backend=selected,
|
|
105
|
+
target_kind=str(item.raw_meta.get("target_kind") or "qq_group_export"),
|
|
106
|
+
state=blocker["state"],
|
|
107
|
+
error_code=blocker["error_code"],
|
|
108
|
+
error_summary=blocker["error_summary"],
|
|
109
|
+
next_action=blocker["next_action"],
|
|
110
|
+
permission_scope=str(item.raw_meta.get("permission_scope") or "unknown"),
|
|
111
|
+
title=item.title or "QQ group conversation",
|
|
112
|
+
source_notes=[
|
|
113
|
+
"qq group adapter resolved the explicit selector/export only; no QQ database, DB key extraction, or decryption action was attempted",
|
|
114
|
+
f"allow_fallback={allow_fallback}",
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
return TranscriptResult(
|
|
118
|
+
text=render_qq_group_markdown(messages),
|
|
119
|
+
status="verified",
|
|
120
|
+
source_level="manual_export",
|
|
121
|
+
backend=selected,
|
|
122
|
+
title=item.title or "QQ group conversation",
|
|
123
|
+
source_notes=[
|
|
124
|
+
"parsed an explicitly provided QQ group export file",
|
|
125
|
+
"no QQ database, DB key extraction, session, or decryption action was attempted",
|
|
126
|
+
],
|
|
127
|
+
metadata={"profile": "conversation", "messages": messages},
|
|
128
|
+
)
|
|
129
|
+
return high_privacy_blocker_result(
|
|
130
|
+
platform=self.id,
|
|
131
|
+
backend=selected,
|
|
132
|
+
target_kind=str(item.raw_meta.get("target_kind") or "qq_group"),
|
|
133
|
+
state="export_required",
|
|
134
|
+
error_code="export_required",
|
|
135
|
+
error_summary="Provide an approved QQ group export file before running real parsing.",
|
|
136
|
+
next_action="Export the scoped QQ group conversation to JSON, HTML, or TXT and pass that local file to Openmd.",
|
|
137
|
+
permission_scope=str(item.raw_meta.get("permission_scope") or "unknown"),
|
|
138
|
+
title=item.title or "QQ group conversation",
|
|
139
|
+
source_notes=[
|
|
140
|
+
"qq group adapter resolved the export/selector only; no QQ database, DB key extraction, or decryption action was attempted",
|
|
141
|
+
f"allow_fallback={allow_fallback}",
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_qq_export_file(path: Path) -> list[dict[str, Any]]:
|
|
147
|
+
suffix = path.suffix.lower()
|
|
148
|
+
text = path.read_text(encoding="utf-8")
|
|
149
|
+
if suffix == ".json":
|
|
150
|
+
return parse_qq_json_export(text)
|
|
151
|
+
if suffix == ".html":
|
|
152
|
+
return parse_qq_html_export(text)
|
|
153
|
+
if suffix == ".txt":
|
|
154
|
+
return parse_qq_txt_export(text)
|
|
155
|
+
raise ValueError(f"unsupported QQ export suffix: {suffix}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_qq_txt_export(text: str) -> list[dict[str, Any]]:
|
|
159
|
+
messages: list[dict[str, Any]] = []
|
|
160
|
+
pattern = re.compile(r"^\[?(?P<time>[^\]\s][^\]]{5,40})\]?\s+(?P<speaker>[^::]+)[::]\s*(?P<text>.*)$")
|
|
161
|
+
for index, line in enumerate(text.splitlines(), start=1):
|
|
162
|
+
stripped = line.strip()
|
|
163
|
+
if not stripped:
|
|
164
|
+
continue
|
|
165
|
+
match = pattern.match(stripped)
|
|
166
|
+
if match:
|
|
167
|
+
messages.append(
|
|
168
|
+
{
|
|
169
|
+
"id": f"txt_{index:04d}",
|
|
170
|
+
"timestamp": match.group("time").strip(),
|
|
171
|
+
"speaker": match.group("speaker").strip(),
|
|
172
|
+
"text": match.group("text").strip(),
|
|
173
|
+
"attachments": [],
|
|
174
|
+
"links": [],
|
|
175
|
+
"source_map": [],
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
return messages
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def render_qq_group_markdown(messages: list[dict[str, Any]]) -> str:
|
|
182
|
+
lines = ["# QQ group conversation", ""]
|
|
183
|
+
for message in messages:
|
|
184
|
+
timestamp = str(message.get("timestamp") or "unknown-time")
|
|
185
|
+
speaker = str(message.get("speaker") or "unknown")
|
|
186
|
+
text = str(message.get("text") or "").strip() or "_No text content._"
|
|
187
|
+
lines.append(f"- [{timestamp}] {speaker}: {text}")
|
|
188
|
+
return "\n".join(lines).strip() + "\n"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _messages_from_item(item: ContentItem) -> list[dict[str, Any]]:
|
|
192
|
+
raw_meta = item.raw_meta if isinstance(item.raw_meta, dict) else {}
|
|
193
|
+
value = raw_meta.get("messages")
|
|
194
|
+
if isinstance(value, list) and value:
|
|
195
|
+
return [message for message in value if isinstance(message, dict)]
|
|
196
|
+
|
|
197
|
+
local_path = raw_meta.get("local_path")
|
|
198
|
+
if isinstance(local_path, str):
|
|
199
|
+
path = Path(local_path)
|
|
200
|
+
if path.exists():
|
|
201
|
+
messages = parse_qq_export_file(path)
|
|
202
|
+
if messages:
|
|
203
|
+
return messages
|
|
204
|
+
|
|
205
|
+
group_name = str(raw_meta.get("group_name") or "mock-group")
|
|
206
|
+
return [
|
|
207
|
+
{
|
|
208
|
+
"id": "qq_msg_001",
|
|
209
|
+
"timestamp": "2026-06-23T14:00:00+08:00",
|
|
210
|
+
"speaker": "Mock QQ sender",
|
|
211
|
+
"text": f"Mock message scoped to QQ group {group_name}.",
|
|
212
|
+
"attachments": [],
|
|
213
|
+
"links": [],
|
|
214
|
+
"source_map": [],
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _messages_from_export_item(item: ContentItem) -> tuple[list[dict[str, Any]], dict[str, str] | None]:
|
|
220
|
+
raw_meta = item.raw_meta if isinstance(item.raw_meta, dict) else {}
|
|
221
|
+
local_path = raw_meta.get("local_path")
|
|
222
|
+
if not isinstance(local_path, str) or not local_path.strip():
|
|
223
|
+
return [], {
|
|
224
|
+
"state": "export_required",
|
|
225
|
+
"error_code": "export_required",
|
|
226
|
+
"error_summary": "A scoped QQ group export file is required.",
|
|
227
|
+
"next_action": "Export the scoped QQ group conversation to JSON, HTML, or TXT and pass that local file to Openmd.",
|
|
228
|
+
}
|
|
229
|
+
path = Path(local_path)
|
|
230
|
+
if not path.exists():
|
|
231
|
+
return [], {
|
|
232
|
+
"state": "export_required",
|
|
233
|
+
"error_code": "export_required",
|
|
234
|
+
"error_summary": "The configured QQ group export file does not exist.",
|
|
235
|
+
"next_action": "Provide an existing scoped QQ group JSON, HTML, or TXT export file.",
|
|
236
|
+
}
|
|
237
|
+
try:
|
|
238
|
+
messages = parse_qq_export_file(path)
|
|
239
|
+
except ValueError as exc:
|
|
240
|
+
return [], {
|
|
241
|
+
"state": "export_required",
|
|
242
|
+
"error_code": "export_format_unsupported",
|
|
243
|
+
"error_summary": str(exc),
|
|
244
|
+
"next_action": "Use a supported scoped QQ export format: JSON, HTML, or TXT.",
|
|
245
|
+
}
|
|
246
|
+
if not messages:
|
|
247
|
+
return [], {
|
|
248
|
+
"state": "no_delta",
|
|
249
|
+
"error_code": "no_delta",
|
|
250
|
+
"error_summary": "The QQ group export file contained no parseable messages.",
|
|
251
|
+
"next_action": "Confirm the export scope/date range and provide a non-empty scoped export.",
|
|
252
|
+
}
|
|
253
|
+
return messages, None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _is_selector(value: str) -> bool:
|
|
257
|
+
parsed = urlparse(value)
|
|
258
|
+
return parsed.scheme in {"qq", "qq-group", "qq_export", "qq-export"}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _group_from_selector(value: str) -> str:
|
|
262
|
+
parsed = urlparse(value)
|
|
263
|
+
if parsed.scheme == "qq" and parsed.netloc.lower() == "group":
|
|
264
|
+
group_name = parsed.path.strip("/")
|
|
265
|
+
else:
|
|
266
|
+
group_name = parsed.netloc or parsed.path.strip("/")
|
|
267
|
+
group_name = unquote(group_name)
|
|
268
|
+
if not group_name or group_name in {"*", "all"}:
|
|
269
|
+
raise ValueError("QQ group selector must name one explicit group.")
|
|
270
|
+
return group_name
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _local_path_from_input(value: str) -> Path:
|
|
274
|
+
parsed = urlparse(value)
|
|
275
|
+
if parsed.scheme == "file":
|
|
276
|
+
return Path(unquote(parsed.path))
|
|
277
|
+
return Path(value)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _group_name_from_path(path: Path) -> str:
|
|
281
|
+
stem = path.name
|
|
282
|
+
for suffix in (".json", ".html", ".txt"):
|
|
283
|
+
if stem.lower().endswith(suffix):
|
|
284
|
+
stem = stem[: -len(suffix)]
|
|
285
|
+
return stem or "qq-group-export"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _stable_hash(value: str) -> str:
|
|
289
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
|