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,84 @@
|
|
|
1
|
+
"""Screenshot OCR backend abstractions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from openmd.core.models import TranscriptResult
|
|
9
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OCRBackend(ABC):
|
|
13
|
+
name: str
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def recognize(self, source_ref: str, *, title: str) -> TranscriptResult:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MockOCRBackend(OCRBackend):
|
|
21
|
+
name = "screenshot.mock"
|
|
22
|
+
|
|
23
|
+
def recognize(self, source_ref: str, *, title: str) -> TranscriptResult:
|
|
24
|
+
text = f"# {title}\n\nMock OCR text from a long screenshot for Openmd package smoke.\n"
|
|
25
|
+
return TranscriptResult(
|
|
26
|
+
text=text,
|
|
27
|
+
status="verified",
|
|
28
|
+
source_level="ocr",
|
|
29
|
+
backend=self.name,
|
|
30
|
+
source_notes=[
|
|
31
|
+
"screenshot OCR mock backend only",
|
|
32
|
+
"raw screenshot file was referenced, not copied into the package",
|
|
33
|
+
],
|
|
34
|
+
title=title or "screenshot",
|
|
35
|
+
metadata={
|
|
36
|
+
"profile": classify_ocr_profile(text),
|
|
37
|
+
"ocr_backend": self.name,
|
|
38
|
+
"source_ref": source_ref,
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MissingOCRBackend(OCRBackend):
|
|
44
|
+
def __init__(self, name: str) -> None:
|
|
45
|
+
self.name = name
|
|
46
|
+
|
|
47
|
+
def recognize(self, source_ref: str, *, title: str) -> TranscriptResult:
|
|
48
|
+
return TranscriptResult(
|
|
49
|
+
text="",
|
|
50
|
+
status="failed",
|
|
51
|
+
source_level="partial_extraction",
|
|
52
|
+
backend=self.name,
|
|
53
|
+
source_notes=[
|
|
54
|
+
"OCR backend is not installed or not enabled",
|
|
55
|
+
"no external OCR command was attempted",
|
|
56
|
+
],
|
|
57
|
+
error_code="ocr_backend_missing",
|
|
58
|
+
error_summary=sanitize_error_summary(
|
|
59
|
+
f"OCR backend {self.name!r} is not installed or enabled."
|
|
60
|
+
),
|
|
61
|
+
title=title or "screenshot",
|
|
62
|
+
metadata={"profile": "document", "ocr_backend": self.name, "source_ref": source_ref},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def classify_ocr_profile(text: str) -> str:
|
|
67
|
+
"""Return the likely V1 package profile for OCR text.
|
|
68
|
+
|
|
69
|
+
The first mock/gated lane keeps package output document-first. This helper
|
|
70
|
+
records the intended routing seam for future layout-aware OCR without
|
|
71
|
+
changing package writer contracts in this lane.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
if _looks_like_chat_log(text):
|
|
75
|
+
return "conversation"
|
|
76
|
+
return "document"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _looks_like_chat_log(text: str) -> bool:
|
|
80
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
81
|
+
if len(lines) < 2:
|
|
82
|
+
return False
|
|
83
|
+
chat_hint = re.compile(r"(^|\s)(\d{1,2}:\d{2}|上午|下午|AM|PM)(\s|$).{0,40}[::]")
|
|
84
|
+
return sum(1 for line in lines if chat_hint.search(line)) >= 2
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Wechat group conversation adapter factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from openmd.adapters.base import PlatformAdapter
|
|
6
|
+
|
|
7
|
+
from .adapter import WechatGroupAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_adapter(platform_id: str, default_backend: str) -> PlatformAdapter:
|
|
11
|
+
return WechatGroupAdapter(platform_id, default_backend)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Mock/gated adapter for WeChat group conversation package inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
8
|
+
|
|
9
|
+
from openmd.adapters.base import PlatformAdapter
|
|
10
|
+
from openmd.adapters.high_privacy import high_privacy_blocker_result
|
|
11
|
+
from openmd.core.detector import detect_platform
|
|
12
|
+
from openmd.core.models import ContentItem, TranscriptResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WechatGroupAdapter(PlatformAdapter):
|
|
16
|
+
"""Resolve explicit WeChat group selectors without reading local sessions."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, platform_id: str = "wechat_group", default_backend: str = "opencli.wx.history") -> None:
|
|
19
|
+
self.id = platform_id
|
|
20
|
+
self.default_backend = default_backend
|
|
21
|
+
|
|
22
|
+
def can_handle(self, input_value: str) -> bool:
|
|
23
|
+
return detect_platform(input_value) == self.id
|
|
24
|
+
|
|
25
|
+
def resolve_items(
|
|
26
|
+
self,
|
|
27
|
+
input_value: str,
|
|
28
|
+
*,
|
|
29
|
+
limit: int | None = None,
|
|
30
|
+
all: bool = False,
|
|
31
|
+
) -> list[ContentItem]:
|
|
32
|
+
selector = parse_wechat_group_selector(input_value)
|
|
33
|
+
group_name = selector["group_name"]
|
|
34
|
+
since = selector.get("since")
|
|
35
|
+
until = selector.get("until")
|
|
36
|
+
return [
|
|
37
|
+
ContentItem(
|
|
38
|
+
platform=self.id,
|
|
39
|
+
input_type="collection",
|
|
40
|
+
url=input_value.strip(),
|
|
41
|
+
id=_conversation_id(group_name, since=since, until=until),
|
|
42
|
+
title=f"WeChat group: {group_name}",
|
|
43
|
+
source_type="conversation",
|
|
44
|
+
raw_meta={
|
|
45
|
+
"profile": "conversation",
|
|
46
|
+
"permission_scope": "user_accessible",
|
|
47
|
+
"target_kind": "wechat_group",
|
|
48
|
+
"group_name": group_name,
|
|
49
|
+
"since": since,
|
|
50
|
+
"until": until,
|
|
51
|
+
"discovery_limit": limit,
|
|
52
|
+
"allow_all": all,
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
def extract_text(
|
|
58
|
+
self,
|
|
59
|
+
item: ContentItem,
|
|
60
|
+
*,
|
|
61
|
+
backend: str | None = None,
|
|
62
|
+
backend_chain: tuple[str, ...] | None = None,
|
|
63
|
+
allow_fallback: bool = True,
|
|
64
|
+
) -> TranscriptResult:
|
|
65
|
+
selected = (backend_chain or (backend or self.default_backend,))[0]
|
|
66
|
+
if selected in {"mock", "wechat_group.mock"}:
|
|
67
|
+
messages = _messages_from_meta(item.raw_meta)
|
|
68
|
+
return TranscriptResult(
|
|
69
|
+
text=render_wechat_group_markdown(messages),
|
|
70
|
+
status="verified",
|
|
71
|
+
source_level="native_text",
|
|
72
|
+
backend=selected,
|
|
73
|
+
title=item.title or "WeChat group conversation",
|
|
74
|
+
source_notes=[
|
|
75
|
+
"local mock adapter smoke only; no WeChat database, session, OpenCLI, account, or decryption action was attempted",
|
|
76
|
+
],
|
|
77
|
+
metadata={"profile": "conversation", "messages": messages},
|
|
78
|
+
)
|
|
79
|
+
return high_privacy_blocker_result(
|
|
80
|
+
platform=self.id,
|
|
81
|
+
backend=selected,
|
|
82
|
+
target_kind=str(item.raw_meta.get("target_kind") or "wechat_group"),
|
|
83
|
+
state="refresh_needed",
|
|
84
|
+
error_code="refresh_needed",
|
|
85
|
+
error_summary=(
|
|
86
|
+
"Refresh or provide an approved scoped WeChat group export/cache before running real acquisition."
|
|
87
|
+
),
|
|
88
|
+
next_action=(
|
|
89
|
+
"Use an explicitly approved export/cache or rerun collection with an enabled, scoped browser/backend "
|
|
90
|
+
"after refreshing local WeChat data."
|
|
91
|
+
),
|
|
92
|
+
permission_scope=str(item.raw_meta.get("permission_scope") or "unknown"),
|
|
93
|
+
title=item.title or "WeChat group conversation",
|
|
94
|
+
source_notes=[
|
|
95
|
+
"wechat group adapter resolved the explicit selector only; real acquisition is behind gated discovery/export providers",
|
|
96
|
+
f"allow_fallback={allow_fallback}",
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_wechat_group_selector(value: str) -> dict[str, str | None]:
|
|
102
|
+
parsed = urlparse(value.strip())
|
|
103
|
+
if parsed.scheme in {"wx", "wechat"} and parsed.netloc.lower() == "group":
|
|
104
|
+
group_name = unquote(parsed.path.strip("/"))
|
|
105
|
+
elif parsed.scheme in {"wx-group", "wechat-group"}:
|
|
106
|
+
group_name = unquote((parsed.netloc or parsed.path.strip("/")).strip("/"))
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError("WeChat group selector must use wx://group/<group-name>.")
|
|
109
|
+
|
|
110
|
+
if not group_name or group_name in {"*", "all"}:
|
|
111
|
+
raise ValueError("WeChat group selector must name one explicit group.")
|
|
112
|
+
|
|
113
|
+
query = parse_qs(parsed.query)
|
|
114
|
+
return {
|
|
115
|
+
"group_name": group_name,
|
|
116
|
+
"since": _first_query_value(query, "since"),
|
|
117
|
+
"until": _first_query_value(query, "until"),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render_wechat_group_markdown(messages: list[dict[str, Any]]) -> str:
|
|
122
|
+
lines = ["# WeChat group conversation", ""]
|
|
123
|
+
for message in messages:
|
|
124
|
+
timestamp = str(message.get("timestamp") or "unknown-time")
|
|
125
|
+
speaker = str(message.get("speaker") or "unknown")
|
|
126
|
+
text = str(message.get("text") or "").strip()
|
|
127
|
+
if not text:
|
|
128
|
+
text = "_No text content._"
|
|
129
|
+
lines.append(f"- [{timestamp}] {speaker}: {text}")
|
|
130
|
+
return "\n".join(lines).strip() + "\n"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _messages_from_meta(raw_meta: dict[str, Any]) -> list[dict[str, Any]]:
|
|
134
|
+
value = raw_meta.get("messages") if isinstance(raw_meta, dict) else None
|
|
135
|
+
if isinstance(value, list) and value:
|
|
136
|
+
return [message for message in value if isinstance(message, dict)]
|
|
137
|
+
group_name = str(raw_meta.get("group_name") or "mock-group")
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
"id": "msg_001",
|
|
141
|
+
"timestamp": "2026-06-23T10:00:00+08:00",
|
|
142
|
+
"speaker": "Mock sender",
|
|
143
|
+
"text": f"Mock message scoped to WeChat group {group_name}.",
|
|
144
|
+
"attachments": [],
|
|
145
|
+
"links": [],
|
|
146
|
+
"source_map": [],
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _first_query_value(query: dict[str, list[str]], key: str) -> str | None:
|
|
152
|
+
values = query.get(key)
|
|
153
|
+
if not values:
|
|
154
|
+
return None
|
|
155
|
+
value = values[0].strip()
|
|
156
|
+
return value or None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _conversation_id(group_name: str, *, since: str | None, until: str | None) -> str:
|
|
160
|
+
return hashlib.sha256(f"{group_name}:{since or ''}:{until or ''}".encode("utf-8")).hexdigest()[:12]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Gated OpenCLI wrapper for WeChat group history discovery.
|
|
2
|
+
|
|
3
|
+
The provider only runs when registered through ``--enable-browser-backends``.
|
|
4
|
+
Tests use fake runners. Openmd does not read WeChat databases, extract DB keys,
|
|
5
|
+
decrypt local stores, open accounts, or persist raw command output here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Callable, Iterable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from openmd.core.opencli_output import load_opencli_json
|
|
17
|
+
from openmd.adapters.high_privacy import HIGH_PRIVACY_STATUS_KEY, high_privacy_status_metadata
|
|
18
|
+
from openmd.adapters.wechat_group.adapter import parse_wechat_group_selector, render_wechat_group_markdown
|
|
19
|
+
from openmd.backends.base import DiscoveryRequest, DiscoveryResult
|
|
20
|
+
from openmd.core.models import ContentItem
|
|
21
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
22
|
+
from openmd.runtime.interpreters import run_interruptibly
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
OPENCLI_WX_HISTORY_DISCOVERY_BACKEND = "opencli.wx.history"
|
|
26
|
+
DEFAULT_WX_HISTORY_TIMEOUT_SECONDS = 180
|
|
27
|
+
CommandRunner = Callable[[list[str], int], tuple[int, str, str]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OpenCliWechatGroupHistoryProvider:
|
|
31
|
+
"""Parse sanitized WeChat group history JSON from an explicitly enabled CLI."""
|
|
32
|
+
|
|
33
|
+
name = OPENCLI_WX_HISTORY_DISCOVERY_BACKEND
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
runner: CommandRunner | None = None,
|
|
39
|
+
timeout: int = DEFAULT_WX_HISTORY_TIMEOUT_SECONDS,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._runner = runner or _run_command
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
|
|
44
|
+
def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
|
|
45
|
+
try:
|
|
46
|
+
selector = parse_wechat_group_selector(request.input_url)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
return DiscoveryResult.failure(
|
|
49
|
+
provider=self.name,
|
|
50
|
+
error_code="target_scope_required",
|
|
51
|
+
error_summary=sanitize_error_summary(exc),
|
|
52
|
+
metadata={"platform": request.platform, "limit": request.limit},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
command = build_opencli_wx_history_args(
|
|
56
|
+
str(selector["group_name"]),
|
|
57
|
+
since=selector.get("since"),
|
|
58
|
+
until=selector.get("until"),
|
|
59
|
+
limit=request.limit,
|
|
60
|
+
)
|
|
61
|
+
exit_code, stdout, stderr = self._runner(command, self.timeout)
|
|
62
|
+
if exit_code != 0:
|
|
63
|
+
return DiscoveryResult.failure(
|
|
64
|
+
provider=self.name,
|
|
65
|
+
error_code=_exit_error_code(exit_code),
|
|
66
|
+
error_summary=sanitize_error_summary(stderr or stdout) or "opencli wx history failed.",
|
|
67
|
+
source_notes=["OpenCLI stdout/stderr was not persisted by Openmd."],
|
|
68
|
+
metadata={"platform": request.platform, "limit": request.limit},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
messages = parse_opencli_wx_history_output(stdout)
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
return DiscoveryResult.failure(
|
|
75
|
+
provider=self.name,
|
|
76
|
+
error_code="invalid_discovery_output",
|
|
77
|
+
error_summary=sanitize_error_summary(exc),
|
|
78
|
+
source_notes=["OpenCLI stdout was invalid JSON and was not persisted."],
|
|
79
|
+
metadata={"platform": request.platform, "limit": request.limit},
|
|
80
|
+
)
|
|
81
|
+
if request.limit is not None and not request.allow_all:
|
|
82
|
+
messages = messages[: max(0, request.limit)]
|
|
83
|
+
if not messages:
|
|
84
|
+
return DiscoveryResult.failure(
|
|
85
|
+
provider=self.name,
|
|
86
|
+
error_code="no_delta",
|
|
87
|
+
error_summary="opencli wx history returned no scoped group messages.",
|
|
88
|
+
source_notes=["OpenCLI JSON was parsed in memory and was not persisted."],
|
|
89
|
+
metadata={
|
|
90
|
+
"platform": request.platform,
|
|
91
|
+
"limit": request.limit,
|
|
92
|
+
HIGH_PRIVACY_STATUS_KEY: high_privacy_status_metadata(
|
|
93
|
+
platform=request.platform,
|
|
94
|
+
target_kind="wechat_group",
|
|
95
|
+
state="no_delta",
|
|
96
|
+
next_action=(
|
|
97
|
+
"If messages are expected, refresh the scoped local WeChat cache/export and rerun the same selector."
|
|
98
|
+
),
|
|
99
|
+
permission_scope="user_accessible",
|
|
100
|
+
),
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
item = _conversation_item(
|
|
105
|
+
group_name=str(selector["group_name"]),
|
|
106
|
+
input_url=request.input_url,
|
|
107
|
+
messages=messages,
|
|
108
|
+
since=selector.get("since"),
|
|
109
|
+
until=selector.get("until"),
|
|
110
|
+
limit=request.limit,
|
|
111
|
+
)
|
|
112
|
+
return DiscoveryResult.success(
|
|
113
|
+
provider=self.name,
|
|
114
|
+
items=[item],
|
|
115
|
+
source_notes=[
|
|
116
|
+
"OpenCLI wx history JSON was parsed in memory.",
|
|
117
|
+
"No WeChat database, key extraction, decryption, login, or broad session scan was attempted by Openmd.",
|
|
118
|
+
],
|
|
119
|
+
metadata={
|
|
120
|
+
"platform": request.platform,
|
|
121
|
+
"limit": request.limit,
|
|
122
|
+
"normalized_url": request.input_url,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_opencli_wx_history_args(
|
|
128
|
+
group_name: str,
|
|
129
|
+
*,
|
|
130
|
+
since: str | None = None,
|
|
131
|
+
until: str | None = None,
|
|
132
|
+
limit: int | None = None,
|
|
133
|
+
) -> list[str]:
|
|
134
|
+
command = ["opencli", "wx", "history", group_name, "--json"]
|
|
135
|
+
if since:
|
|
136
|
+
command.extend(["--since", since])
|
|
137
|
+
if until:
|
|
138
|
+
command.extend(["--until", until])
|
|
139
|
+
if limit is not None:
|
|
140
|
+
command.extend(["--limit", str(max(0, limit))])
|
|
141
|
+
return command
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_opencli_wx_history_output(stdout: str) -> list[dict[str, Any]]:
|
|
145
|
+
try:
|
|
146
|
+
payload = load_opencli_json(stdout)
|
|
147
|
+
except json.JSONDecodeError as exc:
|
|
148
|
+
raise ValueError("opencli wx history did not return valid JSON") from exc
|
|
149
|
+
|
|
150
|
+
messages: list[dict[str, Any]] = []
|
|
151
|
+
for index, row in enumerate(_candidate_rows(payload), start=1):
|
|
152
|
+
if not isinstance(row, dict):
|
|
153
|
+
continue
|
|
154
|
+
message = _parse_message_row(row, index=index)
|
|
155
|
+
if message is not None:
|
|
156
|
+
messages.append(message)
|
|
157
|
+
return messages
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _candidate_rows(payload: object) -> Iterable[object]:
|
|
161
|
+
if isinstance(payload, list):
|
|
162
|
+
return payload
|
|
163
|
+
if not isinstance(payload, dict):
|
|
164
|
+
return ()
|
|
165
|
+
for key in ("messages", "items", "list", "data", "records", "results"):
|
|
166
|
+
value = payload.get(key)
|
|
167
|
+
if isinstance(value, list):
|
|
168
|
+
return value
|
|
169
|
+
if isinstance(value, dict):
|
|
170
|
+
nested = list(_candidate_rows(value))
|
|
171
|
+
if nested:
|
|
172
|
+
return nested
|
|
173
|
+
return ()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_message_row(row: dict[str, Any], *, index: int) -> dict[str, Any] | None:
|
|
177
|
+
text = _first_string(row, "text", "content", "message", "msg")
|
|
178
|
+
speaker = _first_string(row, "speaker", "sender", "from", "author", "nickname", "name")
|
|
179
|
+
timestamp = _first_string(row, "timestamp", "time", "datetime", "date", "created_at")
|
|
180
|
+
if not text and not row.get("attachments"):
|
|
181
|
+
return None
|
|
182
|
+
return {
|
|
183
|
+
"id": _first_string(row, "id", "msg_id", "message_id") or f"msg_{index:04d}",
|
|
184
|
+
"timestamp": timestamp or "unknown-time",
|
|
185
|
+
"speaker": speaker or "unknown",
|
|
186
|
+
"text": text or "",
|
|
187
|
+
"attachments": _list_or_empty(row.get("attachments")),
|
|
188
|
+
"links": _list_or_empty(row.get("links")),
|
|
189
|
+
"source_map": [],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _conversation_item(
|
|
194
|
+
*,
|
|
195
|
+
group_name: str,
|
|
196
|
+
input_url: str,
|
|
197
|
+
messages: list[dict[str, Any]],
|
|
198
|
+
since: str | None,
|
|
199
|
+
until: str | None,
|
|
200
|
+
limit: int | None,
|
|
201
|
+
) -> ContentItem:
|
|
202
|
+
title = f"WeChat group: {group_name}"
|
|
203
|
+
return ContentItem(
|
|
204
|
+
platform="wechat_group",
|
|
205
|
+
input_type="collection",
|
|
206
|
+
url=input_url,
|
|
207
|
+
id=f"wxg-{_stable_hash(group_name, since, until)}",
|
|
208
|
+
title=title,
|
|
209
|
+
source_type="conversation",
|
|
210
|
+
raw_meta={
|
|
211
|
+
"profile": "conversation",
|
|
212
|
+
"permission_scope": "user_accessible",
|
|
213
|
+
"group_name": group_name,
|
|
214
|
+
"since": since,
|
|
215
|
+
"until": until,
|
|
216
|
+
"messages": messages,
|
|
217
|
+
"markdown_preview": render_wechat_group_markdown(messages),
|
|
218
|
+
"discovery_limit": limit,
|
|
219
|
+
"discovery_provider": OPENCLI_WX_HISTORY_DISCOVERY_BACKEND,
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _run_command(command: list[str], timeout: int) -> tuple[int, str, str]:
|
|
225
|
+
resolved = shutil.which(command[0]) or command[0]
|
|
226
|
+
try:
|
|
227
|
+
completed = run_interruptibly(
|
|
228
|
+
[resolved, *command[1:]],
|
|
229
|
+
capture_output=True,
|
|
230
|
+
text=True,
|
|
231
|
+
timeout=timeout,
|
|
232
|
+
check=False,
|
|
233
|
+
encoding="utf-8",
|
|
234
|
+
errors="replace",
|
|
235
|
+
)
|
|
236
|
+
except FileNotFoundError:
|
|
237
|
+
return 127, "", "opencli executable is not available on PATH."
|
|
238
|
+
except subprocess.TimeoutExpired:
|
|
239
|
+
return 124, "", f"opencli wx history timed out after {timeout}s."
|
|
240
|
+
return completed.returncode, completed.stdout or "", completed.stderr or ""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _first_string(row: dict[str, Any], *keys: str) -> str | None:
|
|
244
|
+
for key in keys:
|
|
245
|
+
value = row.get(key)
|
|
246
|
+
if isinstance(value, str) and value.strip():
|
|
247
|
+
return value.strip()
|
|
248
|
+
if isinstance(value, int):
|
|
249
|
+
return str(value)
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _list_or_empty(value: object) -> list[Any]:
|
|
254
|
+
return value if isinstance(value, list) else []
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _stable_hash(*parts: str | None) -> str:
|
|
258
|
+
import hashlib
|
|
259
|
+
|
|
260
|
+
return hashlib.sha256(":".join(part or "" for part in parts).encode("utf-8")).hexdigest()[:12]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _exit_error_code(exit_code: int) -> str:
|
|
264
|
+
if exit_code == 127:
|
|
265
|
+
return "discovery_tool_missing"
|
|
266
|
+
if exit_code == 124:
|
|
267
|
+
return "discovery_timeout"
|
|
268
|
+
return f"discovery_exit_{exit_code}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Wechat Moments conversation adapter factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from openmd.adapters.base import PlatformAdapter
|
|
6
|
+
|
|
7
|
+
from .adapter import WechatMomentsAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_adapter(platform_id: str, default_backend: str) -> PlatformAdapter:
|
|
11
|
+
return WechatMomentsAdapter(platform_id, default_backend)
|