openmd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)