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,167 @@
1
+ """Mock/gated adapter for WeChat Moments person-feed 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 WechatMomentsAdapter(PlatformAdapter):
16
+ """Resolve explicit Moments person selectors without reading local sessions."""
17
+
18
+ def __init__(
19
+ self,
20
+ platform_id: str = "wechat_moments",
21
+ default_backend: str = "opencli.wx.sns-feed",
22
+ ) -> 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
+ selector = parse_wechat_moments_selector(input_value)
37
+ person = selector["person"]
38
+ since = selector.get("since")
39
+ until = selector.get("until")
40
+ return [
41
+ ContentItem(
42
+ platform=self.id,
43
+ input_type="collection",
44
+ url=input_value.strip(),
45
+ id=_feed_id(person, since=since, until=until),
46
+ title=f"WeChat Moments: {person}",
47
+ source_type="conversation",
48
+ raw_meta={
49
+ "profile": "conversation",
50
+ "permission_scope": "user_accessible",
51
+ "target_kind": "wechat_moments_person",
52
+ "person": person,
53
+ "since": since,
54
+ "until": until,
55
+ "discovery_limit": limit,
56
+ "allow_all": all,
57
+ },
58
+ )
59
+ ]
60
+
61
+ def extract_text(
62
+ self,
63
+ item: ContentItem,
64
+ *,
65
+ backend: str | None = None,
66
+ backend_chain: tuple[str, ...] | None = None,
67
+ allow_fallback: bool = True,
68
+ ) -> TranscriptResult:
69
+ selected = (backend_chain or (backend or self.default_backend,))[0]
70
+ if selected in {"mock", "wechat_moments.mock"}:
71
+ posts = _posts_from_meta(item.raw_meta)
72
+ return TranscriptResult(
73
+ text=render_wechat_moments_markdown(posts),
74
+ status="verified",
75
+ source_level="native_text",
76
+ backend=selected,
77
+ title=item.title or "WeChat Moments feed",
78
+ source_notes=[
79
+ "local mock adapter smoke only; no WeChat database, session, OpenCLI, account, or decryption action was attempted",
80
+ ],
81
+ metadata={"profile": "conversation", "messages": posts},
82
+ )
83
+ return high_privacy_blocker_result(
84
+ platform=self.id,
85
+ backend=selected,
86
+ target_kind=str(item.raw_meta.get("target_kind") or "wechat_moments_person"),
87
+ state="refresh_needed",
88
+ error_code="refresh_needed",
89
+ error_summary=(
90
+ "Refresh or provide an approved scoped WeChat Moments export/cache before running real acquisition."
91
+ ),
92
+ next_action=(
93
+ "Use an explicitly approved export/cache or rerun collection with an enabled, scoped browser/backend "
94
+ "after refreshing local WeChat data."
95
+ ),
96
+ permission_scope=str(item.raw_meta.get("permission_scope") or "unknown"),
97
+ title=item.title or "WeChat Moments feed",
98
+ source_notes=[
99
+ "wechat moments adapter resolved the explicit person selector only; real acquisition is behind gated discovery/export providers",
100
+ f"allow_fallback={allow_fallback}",
101
+ ],
102
+ )
103
+
104
+
105
+ def parse_wechat_moments_selector(value: str) -> dict[str, str | None]:
106
+ parsed = urlparse(value.strip())
107
+ if parsed.scheme in {"wx", "wechat"} and parsed.netloc.lower() in {"moments", "sns"}:
108
+ person = unquote(parsed.path.strip("/"))
109
+ elif parsed.scheme in {"wx-moments", "wechat-moments"}:
110
+ person = unquote((parsed.netloc or parsed.path.strip("/")).strip("/"))
111
+ else:
112
+ raise ValueError("WeChat Moments selector must use wx://moments/<person>.")
113
+
114
+ if not person or person in {"*", "all", "friends", "timeline"}:
115
+ raise ValueError("WeChat Moments selector must name one explicit person.")
116
+
117
+ query = parse_qs(parsed.query)
118
+ return {
119
+ "person": person,
120
+ "since": _first_query_value(query, "since"),
121
+ "until": _first_query_value(query, "until"),
122
+ }
123
+
124
+
125
+ def render_wechat_moments_markdown(posts: list[dict[str, Any]]) -> str:
126
+ lines = ["# WeChat Moments feed", ""]
127
+ for post in posts:
128
+ timestamp = str(post.get("timestamp") or "unknown-time")
129
+ speaker = str(post.get("speaker") or post.get("author") or "unknown")
130
+ text = str(post.get("text") or "").strip()
131
+ if not text:
132
+ text = "_No text content._"
133
+ lines.append(f"- [{timestamp}] {speaker}: {text}")
134
+ media = post.get("attachments")
135
+ if isinstance(media, list) and media:
136
+ lines.append(f" - Attachments: {len(media)} referenced item(s)")
137
+ return "\n".join(lines).strip() + "\n"
138
+
139
+
140
+ def _posts_from_meta(raw_meta: dict[str, Any]) -> list[dict[str, Any]]:
141
+ value = raw_meta.get("messages") if isinstance(raw_meta, dict) else None
142
+ if isinstance(value, list) and value:
143
+ return [post for post in value if isinstance(post, dict)]
144
+ person = str(raw_meta.get("person") or "mock-person")
145
+ return [
146
+ {
147
+ "id": "moment_001",
148
+ "timestamp": "2026-06-23T12:00:00+08:00",
149
+ "speaker": person,
150
+ "text": "Mock Moments post scoped to one person.",
151
+ "attachments": [],
152
+ "links": [],
153
+ "source_map": [],
154
+ }
155
+ ]
156
+
157
+
158
+ def _first_query_value(query: dict[str, list[str]], key: str) -> str | None:
159
+ values = query.get(key)
160
+ if not values:
161
+ return None
162
+ value = values[0].strip()
163
+ return value or None
164
+
165
+
166
+ def _feed_id(person: str, *, since: str | None, until: str | None) -> str:
167
+ return hashlib.sha256(f"{person}:{since or ''}:{until or ''}".encode("utf-8")).hexdigest()[:12]
@@ -0,0 +1,265 @@
1
+ """Gated OpenCLI wrapper for WeChat Moments person-feed discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+ from collections.abc import Callable, Iterable
10
+ from typing import Any
11
+
12
+ from openmd.core.opencli_output import load_opencli_json
13
+ from openmd.adapters.high_privacy import HIGH_PRIVACY_STATUS_KEY, high_privacy_status_metadata
14
+ from openmd.adapters.wechat_moments.adapter import (
15
+ parse_wechat_moments_selector,
16
+ render_wechat_moments_markdown,
17
+ )
18
+ from openmd.backends.base import DiscoveryRequest, DiscoveryResult
19
+ from openmd.core.models import ContentItem
20
+ from openmd.core.secrets import sanitize_error_summary
21
+ from openmd.runtime.interpreters import run_interruptibly
22
+
23
+
24
+ OPENCLI_WX_SNS_FEED_DISCOVERY_BACKEND = "opencli.wx.sns-feed"
25
+ DEFAULT_WX_SNS_TIMEOUT_SECONDS = 180
26
+ CommandRunner = Callable[[list[str], int], tuple[int, str, str]]
27
+
28
+
29
+ class OpenCliWechatMomentsProvider:
30
+ """Parse sanitized Moments JSON from an explicitly enabled local CLI."""
31
+
32
+ name = OPENCLI_WX_SNS_FEED_DISCOVERY_BACKEND
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ runner: CommandRunner | None = None,
38
+ timeout: int = DEFAULT_WX_SNS_TIMEOUT_SECONDS,
39
+ ) -> None:
40
+ self._runner = runner or _run_command
41
+ self.timeout = timeout
42
+
43
+ def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
44
+ try:
45
+ selector = parse_wechat_moments_selector(request.input_url)
46
+ except ValueError as exc:
47
+ return DiscoveryResult.failure(
48
+ provider=self.name,
49
+ error_code="target_scope_required",
50
+ error_summary=sanitize_error_summary(exc),
51
+ metadata={"platform": request.platform, "limit": request.limit},
52
+ )
53
+
54
+ command = build_opencli_wx_sns_feed_args(
55
+ str(selector["person"]),
56
+ since=selector.get("since"),
57
+ until=selector.get("until"),
58
+ limit=request.limit,
59
+ )
60
+ exit_code, stdout, stderr = self._runner(command, self.timeout)
61
+ if exit_code != 0:
62
+ return DiscoveryResult.failure(
63
+ provider=self.name,
64
+ error_code=_exit_error_code(exit_code),
65
+ error_summary=sanitize_error_summary(stderr or stdout) or "opencli wx sns-feed failed.",
66
+ source_notes=["OpenCLI stdout/stderr was not persisted by Openmd."],
67
+ metadata={"platform": request.platform, "limit": request.limit},
68
+ )
69
+
70
+ try:
71
+ posts = parse_opencli_wx_sns_feed_output(stdout)
72
+ except ValueError as exc:
73
+ return DiscoveryResult.failure(
74
+ provider=self.name,
75
+ error_code="invalid_discovery_output",
76
+ error_summary=sanitize_error_summary(exc),
77
+ source_notes=["OpenCLI stdout was invalid JSON and was not persisted."],
78
+ metadata={"platform": request.platform, "limit": request.limit},
79
+ )
80
+ if request.limit is not None and not request.allow_all:
81
+ posts = posts[: max(0, request.limit)]
82
+ if not posts:
83
+ return DiscoveryResult.failure(
84
+ provider=self.name,
85
+ error_code="no_delta",
86
+ error_summary="opencli wx sns-feed returned no scoped Moments posts.",
87
+ source_notes=["OpenCLI JSON was parsed in memory and was not persisted."],
88
+ metadata={
89
+ "platform": request.platform,
90
+ "limit": request.limit,
91
+ HIGH_PRIVACY_STATUS_KEY: high_privacy_status_metadata(
92
+ platform=request.platform,
93
+ target_kind="wechat_moments_person",
94
+ state="no_delta",
95
+ next_action=(
96
+ "If posts are expected, refresh the scoped local WeChat cache/export and rerun the same selector."
97
+ ),
98
+ permission_scope="user_accessible",
99
+ ),
100
+ },
101
+ )
102
+
103
+ item = _feed_item(
104
+ person=str(selector["person"]),
105
+ input_url=request.input_url,
106
+ posts=posts,
107
+ since=selector.get("since"),
108
+ until=selector.get("until"),
109
+ limit=request.limit,
110
+ )
111
+ return DiscoveryResult.success(
112
+ provider=self.name,
113
+ items=[item],
114
+ source_notes=[
115
+ "OpenCLI wx sns-feed JSON was parsed in memory.",
116
+ "No broad contacts scan, database read, key extraction, decryption, login, or account mutation was attempted by Openmd.",
117
+ ],
118
+ metadata={
119
+ "platform": request.platform,
120
+ "limit": request.limit,
121
+ "normalized_url": request.input_url,
122
+ },
123
+ )
124
+
125
+
126
+ def build_opencli_wx_sns_feed_args(
127
+ person: str,
128
+ *,
129
+ since: str | None = None,
130
+ until: str | None = None,
131
+ limit: int | None = None,
132
+ ) -> list[str]:
133
+ command = ["opencli", "wx", "sns-feed", "--user", person, "--json"]
134
+ if since:
135
+ command.extend(["--since", since])
136
+ if until:
137
+ command.extend(["--until", until])
138
+ if limit is not None:
139
+ command.extend(["--limit", str(max(0, limit))])
140
+ return command
141
+
142
+
143
+ def parse_opencli_wx_sns_feed_output(stdout: str) -> list[dict[str, Any]]:
144
+ try:
145
+ payload = load_opencli_json(stdout)
146
+ except json.JSONDecodeError as exc:
147
+ raise ValueError("opencli wx sns-feed did not return valid JSON") from exc
148
+
149
+ posts: list[dict[str, Any]] = []
150
+ for index, row in enumerate(_candidate_rows(payload), start=1):
151
+ if not isinstance(row, dict):
152
+ continue
153
+ post = _parse_post_row(row, index=index)
154
+ if post is not None:
155
+ posts.append(post)
156
+ return posts
157
+
158
+
159
+ def _candidate_rows(payload: object) -> Iterable[object]:
160
+ if isinstance(payload, list):
161
+ return payload
162
+ if not isinstance(payload, dict):
163
+ return ()
164
+ for key in ("moments", "posts", "items", "list", "data", "records", "results"):
165
+ value = payload.get(key)
166
+ if isinstance(value, list):
167
+ return value
168
+ if isinstance(value, dict):
169
+ nested = list(_candidate_rows(value))
170
+ if nested:
171
+ return nested
172
+ return ()
173
+
174
+
175
+ def _parse_post_row(row: dict[str, Any], *, index: int) -> dict[str, Any] | None:
176
+ text = _first_string(row, "text", "content", "description", "message")
177
+ speaker = _first_string(row, "speaker", "author", "user", "nickname", "name")
178
+ timestamp = _first_string(row, "timestamp", "time", "datetime", "date", "created_at")
179
+ attachments = _list_or_empty(row.get("attachments") or row.get("media") or row.get("images"))
180
+ if not text and not attachments:
181
+ return None
182
+ return {
183
+ "id": _first_string(row, "id", "moment_id", "sns_id") or f"moment_{index:04d}",
184
+ "timestamp": timestamp or "unknown-time",
185
+ "speaker": speaker or "unknown",
186
+ "text": text or "",
187
+ "attachments": attachments,
188
+ "links": _list_or_empty(row.get("links")),
189
+ "source_map": [],
190
+ }
191
+
192
+
193
+ def _feed_item(
194
+ *,
195
+ person: str,
196
+ input_url: str,
197
+ posts: list[dict[str, Any]],
198
+ since: str | None,
199
+ until: str | None,
200
+ limit: int | None,
201
+ ) -> ContentItem:
202
+ return ContentItem(
203
+ platform="wechat_moments",
204
+ input_type="collection",
205
+ url=input_url,
206
+ id=f"wxm-{_stable_hash(person, since, until)}",
207
+ title=f"WeChat Moments: {person}",
208
+ source_type="conversation",
209
+ raw_meta={
210
+ "profile": "conversation",
211
+ "permission_scope": "user_accessible",
212
+ "person": person,
213
+ "since": since,
214
+ "until": until,
215
+ "messages": posts,
216
+ "markdown_preview": render_wechat_moments_markdown(posts),
217
+ "discovery_limit": limit,
218
+ "discovery_provider": OPENCLI_WX_SNS_FEED_DISCOVERY_BACKEND,
219
+ },
220
+ )
221
+
222
+
223
+ def _run_command(command: list[str], timeout: int) -> tuple[int, str, str]:
224
+ resolved = shutil.which(command[0]) or command[0]
225
+ try:
226
+ completed = run_interruptibly(
227
+ [resolved, *command[1:]],
228
+ capture_output=True,
229
+ text=True,
230
+ timeout=timeout,
231
+ check=False,
232
+ encoding="utf-8",
233
+ errors="replace",
234
+ )
235
+ except FileNotFoundError:
236
+ return 127, "", "opencli executable is not available on PATH."
237
+ except subprocess.TimeoutExpired:
238
+ return 124, "", f"opencli wx sns-feed timed out after {timeout}s."
239
+ return completed.returncode, completed.stdout or "", completed.stderr or ""
240
+
241
+
242
+ def _first_string(row: dict[str, Any], *keys: str) -> str | None:
243
+ for key in keys:
244
+ value = row.get(key)
245
+ if isinstance(value, str) and value.strip():
246
+ return value.strip()
247
+ if isinstance(value, int):
248
+ return str(value)
249
+ return None
250
+
251
+
252
+ def _list_or_empty(value: object) -> list[Any]:
253
+ return value if isinstance(value, list) else []
254
+
255
+
256
+ def _stable_hash(*parts: str | None) -> str:
257
+ return hashlib.sha256(":".join(part or "" for part in parts).encode("utf-8")).hexdigest()[:12]
258
+
259
+
260
+ def _exit_error_code(exit_code: int) -> str:
261
+ if exit_code == 127:
262
+ return "discovery_tool_missing"
263
+ if exit_code == 124:
264
+ return "discovery_timeout"
265
+ return f"discovery_exit_{exit_code}"
@@ -0,0 +1,11 @@
1
+ """Weixin/WeChat Official Account V1 platform adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from openmd.adapters.base import PlatformAdapter
6
+
7
+ from .adapter import WeixinOfficialAccountAdapter
8
+
9
+
10
+ def create_adapter(platform_id: str, default_backend: str) -> PlatformAdapter:
11
+ return WeixinOfficialAccountAdapter(platform_id, default_backend)
@@ -0,0 +1,148 @@
1
+ """V1 adapter for WeChat Official Account article inputs.
2
+
3
+ The adapter is intentionally conservative: it resolves single article URLs and
4
+ author/account collection selectors into ``ContentItem`` records, but real
5
+ network/browser acquisition remains behind the backend/discovery gates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from urllib.parse import parse_qs, urlparse
12
+
13
+ from openmd.adapters.base import PlatformAdapter
14
+ from openmd.backends.weixin import normalize_weixin_url
15
+ from openmd.core.detector import detect_platform, extract_first_url
16
+ from openmd.core.models import ContentItem, TranscriptResult
17
+
18
+
19
+ WEIXIN_SELECTOR_SCHEMES: frozenset[str] = frozenset(
20
+ {"weixin", "wechat", "wechat-official", "wx-official"}
21
+ )
22
+
23
+
24
+ class WeixinOfficialAccountAdapter(PlatformAdapter):
25
+ """Resolve Weixin article and account selectors without side effects."""
26
+
27
+ def __init__(self, platform_id: str = "weixin", default_backend: str = "weixin.article.local") -> None:
28
+ self.id = platform_id
29
+ self.default_backend = default_backend
30
+
31
+ def can_handle(self, input_value: str) -> bool:
32
+ return _is_weixin_article_url(input_value) or _is_weixin_collection_selector(input_value)
33
+
34
+ def resolve_items(
35
+ self,
36
+ input_value: str,
37
+ *,
38
+ limit: int | None = None,
39
+ all: bool = False,
40
+ ) -> list[ContentItem]:
41
+ normalized = normalize_weixin_url(extract_first_url(input_value))
42
+ if _is_weixin_article_url(normalized):
43
+ return [_article_item(normalized, platform=self.id)]
44
+
45
+ return [
46
+ ContentItem(
47
+ platform=self.id,
48
+ input_type="collection",
49
+ url=input_value.strip(),
50
+ id=_stable_id(input_value),
51
+ source_type="article",
52
+ raw_meta={
53
+ "profile": "article",
54
+ "permission_scope": "user_accessible",
55
+ "collection_selector": input_value.strip(),
56
+ "discovery_limit": limit,
57
+ "allow_all": all,
58
+ },
59
+ )
60
+ ]
61
+
62
+ def extract_text(
63
+ self,
64
+ item: ContentItem,
65
+ *,
66
+ backend: str | None = None,
67
+ backend_chain: tuple[str, ...] | None = None,
68
+ allow_fallback: bool = True,
69
+ ) -> TranscriptResult:
70
+ selected = (backend_chain or (backend or self.default_backend,))[0]
71
+ if selected in {"mock", "weixin.mock"}:
72
+ return TranscriptResult(
73
+ text="# Mock Weixin article\n\nMock official account article content.\n",
74
+ status="verified",
75
+ source_level="native_text",
76
+ backend=selected,
77
+ title=item.title or "Mock Weixin article",
78
+ source_notes=["local mock adapter smoke only; no network/browser/account action was attempted"],
79
+ )
80
+ return TranscriptResult(
81
+ text="",
82
+ status="failed",
83
+ source_level="partial_extraction",
84
+ backend=selected,
85
+ source_notes=[
86
+ "weixin adapter resolved the item only; real extraction is handled by gated backend providers",
87
+ f"allow_fallback={allow_fallback}",
88
+ ],
89
+ error_code="backend_requires_runtime_gate",
90
+ error_summary="Enable the appropriate Openmd backend gate before running real Weixin extraction.",
91
+ )
92
+
93
+
94
+ def _article_item(url: str, *, platform: str) -> ContentItem:
95
+ parsed = urlparse(url)
96
+ query = parse_qs(parsed.query)
97
+ title = query.get("title", [None])[0]
98
+ return ContentItem(
99
+ platform=platform,
100
+ input_type="single",
101
+ url=url,
102
+ id=_weixin_article_id(url),
103
+ title=title,
104
+ source_type="article",
105
+ raw_meta={
106
+ "profile": "article",
107
+ "permission_scope": "user_accessible",
108
+ "content_id": _weixin_article_id(url),
109
+ },
110
+ )
111
+
112
+
113
+ def _is_weixin_article_url(input_value: str) -> bool:
114
+ normalized = normalize_weixin_url(extract_first_url(input_value))
115
+ parsed = urlparse(normalized)
116
+ return (
117
+ parsed.scheme in {"http", "https"}
118
+ and parsed.netloc.lower() == "mp.weixin.qq.com"
119
+ and (parsed.path == "/s" or parsed.path.startswith("/s/"))
120
+ )
121
+
122
+
123
+ def _is_weixin_collection_selector(input_value: str) -> bool:
124
+ value = input_value.strip()
125
+ parsed = urlparse(value)
126
+ if parsed.scheme in WEIXIN_SELECTOR_SCHEMES:
127
+ return bool(parsed.netloc or parsed.path)
128
+ return detect_platform(value) == "weixin"
129
+
130
+
131
+ def _weixin_article_id(url: str) -> str:
132
+ normalized = normalize_weixin_url(url)
133
+ parsed = urlparse(normalized)
134
+ if parsed.path.startswith("/s/"):
135
+ slug = parsed.path.removeprefix("/s/").strip("/")
136
+ if slug:
137
+ return slug
138
+ query = parse_qs(parsed.query)
139
+ biz = query.get("__biz", [""])[0]
140
+ mid = query.get("mid", [""])[0]
141
+ idx = query.get("idx", [""])[0]
142
+ if biz and mid and idx:
143
+ return f"{biz}-{mid}-{idx}"
144
+ return _stable_id(normalized)
145
+
146
+
147
+ def _stable_id(value: str) -> str:
148
+ return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()[:12]