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,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]
|