browser-workspace 0.4.0
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.
- package/PRIVACY.md +21 -0
- package/README.md +138 -0
- package/SKILL.md +301 -0
- package/agent-workspace/agent_helpers.py +718 -0
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +75 -0
- package/extension/manifest.json +26 -0
- package/extension/service-worker.mjs +126 -0
- package/extension/workspace-manager.mjs +680 -0
- package/extension/workspace-page.mjs +7 -0
- package/extension/workspace.html +8 -0
- package/interaction-skills/connection.md +52 -0
- package/interaction-skills/cookies.md +3 -0
- package/interaction-skills/cross-origin-iframes.md +3 -0
- package/interaction-skills/dialogs.md +64 -0
- package/interaction-skills/downloads.md +3 -0
- package/interaction-skills/drag-and-drop.md +3 -0
- package/interaction-skills/dropdowns.md +3 -0
- package/interaction-skills/iframes.md +3 -0
- package/interaction-skills/network-requests.md +3 -0
- package/interaction-skills/print-as-pdf.md +3 -0
- package/interaction-skills/profile-sync.md +90 -0
- package/interaction-skills/screenshots.md +17 -0
- package/interaction-skills/scrolling.md +22 -0
- package/interaction-skills/shadow-dom.md +3 -0
- package/interaction-skills/tabs.md +73 -0
- package/interaction-skills/uploads.md +1 -0
- package/interaction-skills/viewport.md +16 -0
- package/licenses/browser-harness-MIT.txt +21 -0
- package/managed_chrome.py +244 -0
- package/node/admin.mjs +44 -0
- package/package.json +36 -0
- package/platform_runner.py +187 -0
- package/platforms/chatgpt/README.md +60 -0
- package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/platforms/chatgpt/actions/_readiness.py +52 -0
- package/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
- package/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/platforms/chatgpt/manifest.yaml +34 -0
- package/platforms/chatgpt/tests/test_contract.py +176 -0
- package/platforms/chrome-web-store/README.md +49 -0
- package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/platforms/chrome-web-store/manifest.yaml +33 -0
- package/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/platforms/microsoft-teams/README.md +32 -0
- package/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/platforms/microsoft-teams/manifest.yaml +25 -0
- package/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/platforms/tiktok/README.md +10 -0
- package/platforms/tiktok/actions/_post.py +180 -0
- package/platforms/tiktok/manifest.yaml +99 -0
- package/platforms/wechat-channels/README.md +10 -0
- package/platforms/wechat-channels/actions/_common.py +163 -0
- package/platforms/wechat-channels/actions/_manage.py +130 -0
- package/platforms/wechat-channels/actions/_post.py +232 -0
- package/platforms/wechat-channels/manifest.yaml +118 -0
- package/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/platforms/xhs/README.md +10 -0
- package/platforms/xhs/actions/_manage.py +355 -0
- package/platforms/xhs/actions/_post.py +192 -0
- package/platforms/xhs/manifest.yaml +98 -0
- package/platforms/youtube/README.md +10 -0
- package/platforms/youtube/actions/_post.py +100 -0
- package/platforms/youtube/manifest.yaml +94 -0
- package/pyproject.toml +29 -0
- package/session_client.py +97 -0
- package/session_daemon.py +325 -0
- package/src/browser_harness/__init__.py +2 -0
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +201 -0
- package/src/browser_harness/admin.py +1588 -0
- package/src/browser_harness/auth.py +546 -0
- package/src/browser_harness/daemon.py +894 -0
- package/src/browser_harness/helpers.py +668 -0
- package/src/browser_harness/macos.py +131 -0
- package/src/browser_harness/mcp_cli.py +16 -0
- package/src/browser_harness/paths.py +49 -0
- package/src/browser_harness/recorder.py +343 -0
- package/src/browser_harness/run.py +413 -0
- package/src/browser_harness/telemetry.py +308 -0
- package/src/browser_harness/video-template.html +970 -0
- package/src/browser_harness/video.py +749 -0
- package/src/browser_harness/video_render.py +524 -0
- package/src/browser_workspace.egg-info/PKG-INFO +10 -0
- package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
- package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
- package/src/browser_workspace.egg-info/requires.txt +5 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Pure helpers shared by WeChat Channels browser actions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
from urllib.parse import parse_qs, urlparse
|
|
8
|
+
|
|
9
|
+
MANAGER_URL = "https://channels.weixin.qq.com/platform/post"
|
|
10
|
+
|
|
11
|
+
STATUS_LABELS: tuple[tuple[str, str], ...] = (
|
|
12
|
+
("审核中", "reviewing"),
|
|
13
|
+
("未通过", "rejected"),
|
|
14
|
+
("草稿", "draft"),
|
|
15
|
+
("已发布", "published"),
|
|
16
|
+
("已发表", "published"),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_ID_PRIORITY: tuple[str, ...] = (
|
|
20
|
+
"finderobjectid",
|
|
21
|
+
"postid",
|
|
22
|
+
"contentid",
|
|
23
|
+
"videoid",
|
|
24
|
+
"objectid",
|
|
25
|
+
"exportid",
|
|
26
|
+
"feedid",
|
|
27
|
+
"mediaid",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def status_label(text: str) -> tuple[Optional[str], Optional[str]]:
|
|
32
|
+
for label, status in STATUS_LABELS:
|
|
33
|
+
if label in text:
|
|
34
|
+
return status, label
|
|
35
|
+
return None, None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalized_key(key: str) -> str:
|
|
39
|
+
return re.sub(r"[^a-z0-9]", "", key.lower())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ids_from_query(value: str) -> dict[str, str]:
|
|
43
|
+
parsed = urlparse(value)
|
|
44
|
+
if not parsed.query:
|
|
45
|
+
return {}
|
|
46
|
+
result: dict[str, str] = {}
|
|
47
|
+
for key, values in parse_qs(parsed.query).items():
|
|
48
|
+
normalized = _normalized_key(key)
|
|
49
|
+
if normalized in _ID_PRIORITY and values:
|
|
50
|
+
result[normalized] = values[0]
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract_stable_id(row: dict[str, Any]) -> Optional[str]:
|
|
55
|
+
ids = dict(row.get("stable_ids") or {})
|
|
56
|
+
for source in row.get("urls") or []:
|
|
57
|
+
ids.update(_ids_from_query(str(source)))
|
|
58
|
+
for key in _ID_PRIORITY:
|
|
59
|
+
if ids.get(key):
|
|
60
|
+
return ids[key]
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def choose_manager_row(
|
|
65
|
+
rows: list[dict[str, Any]],
|
|
66
|
+
post_id: Optional[str],
|
|
67
|
+
title: Optional[str],
|
|
68
|
+
desc: Optional[str],
|
|
69
|
+
) -> Optional[dict[str, Any]]:
|
|
70
|
+
if post_id:
|
|
71
|
+
matches = [row for row in rows if extract_stable_id(row) == post_id]
|
|
72
|
+
elif title:
|
|
73
|
+
matches = [row for row in rows if title in row.get("text", "")]
|
|
74
|
+
elif desc:
|
|
75
|
+
matches = [row for row in rows if desc in row.get("text", "")]
|
|
76
|
+
else:
|
|
77
|
+
return None
|
|
78
|
+
if not matches:
|
|
79
|
+
return None
|
|
80
|
+
return min(matches, key=lambda row: len(row.get("text", "")))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def manager_scan_expression(
|
|
84
|
+
post_id: Optional[str],
|
|
85
|
+
title: Optional[str],
|
|
86
|
+
desc: Optional[str],
|
|
87
|
+
) -> str:
|
|
88
|
+
values = (
|
|
89
|
+
json.dumps(post_id or ""),
|
|
90
|
+
json.dumps(title or ""),
|
|
91
|
+
json.dumps(desc or ""),
|
|
92
|
+
)
|
|
93
|
+
return """
|
|
94
|
+
(() => {
|
|
95
|
+
const root = document.querySelector('wujie-app')?.shadowRoot;
|
|
96
|
+
const body = root?.querySelector('body') || root;
|
|
97
|
+
if (!body) return '[]';
|
|
98
|
+
const priority = ['finderobjectid', 'postid', 'contentid', 'videoid', 'objectid', 'exportid', 'feedid', 'mediaid'];
|
|
99
|
+
const labels = ['审核中', '未通过', '草稿', '已发布', '已发表'];
|
|
100
|
+
const normalize = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
101
|
+
const targetPostId = %s;
|
|
102
|
+
const targetTitle = %s;
|
|
103
|
+
const targetDesc = %s;
|
|
104
|
+
const candidates = new Map();
|
|
105
|
+
const addCandidate = (element, kind, stableIds) => {
|
|
106
|
+
const attributes = {};
|
|
107
|
+
for (const attribute of element.attributes || []) {
|
|
108
|
+
const name = attribute.name.toLowerCase();
|
|
109
|
+
if (name.startsWith('data-') || ['aria-label', 'title'].includes(name)) attributes[name] = attribute.value;
|
|
110
|
+
}
|
|
111
|
+
const urls = [];
|
|
112
|
+
for (const anchor of element.querySelectorAll('a[href]')) {
|
|
113
|
+
if (anchor.href) urls.push(anchor.href);
|
|
114
|
+
}
|
|
115
|
+
const text = (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
116
|
+
if (!text || text.length > 3000) return;
|
|
117
|
+
const encodedIds = Array.from(stableIds.entries()).sort().map(([key, value]) => `${key}=${value}`).join(';');
|
|
118
|
+
const key = `${kind}:${encodedIds}:${text}`;
|
|
119
|
+
if (candidates.has(key)) return;
|
|
120
|
+
candidates.set(key, {kind, stable_ids: Object.fromEntries(stableIds), attributes, urls, text, tag: element.tagName.toLowerCase()});
|
|
121
|
+
};
|
|
122
|
+
const collectIds = (element) => {
|
|
123
|
+
const ids = new Map();
|
|
124
|
+
for (const attribute of element.attributes || []) {
|
|
125
|
+
const normalized = normalize(attribute.name);
|
|
126
|
+
const value = attribute.value.trim();
|
|
127
|
+
if (value && priority.includes(normalized)) ids.set(normalized, value);
|
|
128
|
+
}
|
|
129
|
+
return ids;
|
|
130
|
+
};
|
|
131
|
+
for (const element of body.querySelectorAll('*')) {
|
|
132
|
+
const ids = collectIds(element);
|
|
133
|
+
const text = element.innerText || element.textContent || '';
|
|
134
|
+
if ((targetTitle && text.includes(targetTitle)) || (targetDesc && text.includes(targetDesc))) {
|
|
135
|
+
let textCandidate = element;
|
|
136
|
+
let textIds = ids;
|
|
137
|
+
for (let depth = 0; textCandidate && textCandidate !== body && depth < 12; depth += 1) {
|
|
138
|
+
const candidateText = textCandidate.innerText || textCandidate.textContent || '';
|
|
139
|
+
const candidateIds = collectIds(textCandidate);
|
|
140
|
+
if (labels.some((label) => candidateText.includes(label)) || candidateIds.size) {
|
|
141
|
+
textIds = candidateIds;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
textCandidate = textCandidate.parentElement;
|
|
145
|
+
}
|
|
146
|
+
addCandidate(textCandidate && textCandidate !== body ? textCandidate : element, 'text', textIds);
|
|
147
|
+
}
|
|
148
|
+
if (!ids.size) continue;
|
|
149
|
+
let labeled = null;
|
|
150
|
+
let cursor = element;
|
|
151
|
+
for (let depth = 0; cursor && cursor !== body && depth < 12; depth += 1) {
|
|
152
|
+
const cursorText = cursor.innerText || cursor.textContent || '';
|
|
153
|
+
if (labels.some((label) => cursorText.includes(label))) {
|
|
154
|
+
labeled = cursor;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
cursor = cursor.parentElement;
|
|
158
|
+
}
|
|
159
|
+
addCandidate(labeled || element, 'id', ids);
|
|
160
|
+
}
|
|
161
|
+
return JSON.stringify(Array.from(candidates.values()));
|
|
162
|
+
})()
|
|
163
|
+
""".strip() % values
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from _common import (
|
|
7
|
+
MANAGER_URL,
|
|
8
|
+
choose_manager_row,
|
|
9
|
+
extract_stable_id,
|
|
10
|
+
manager_scan_expression,
|
|
11
|
+
status_label,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
CFG = json.load(open("__CFG_PATH__"))
|
|
15
|
+
OP = CFG["operation"]
|
|
16
|
+
POST_ID = CFG.get("post_id")
|
|
17
|
+
TITLE = CFG.get("title")
|
|
18
|
+
DESC = CFG.get("desc")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def out(payload: dict) -> None:
|
|
22
|
+
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def open_or_reuse_wechat(url: str) -> dict:
|
|
26
|
+
current = current_tab()
|
|
27
|
+
tabs = [
|
|
28
|
+
tab
|
|
29
|
+
for tab in list_tabs()
|
|
30
|
+
if urlparse(tab.get("url") or "").hostname == "channels.weixin.qq.com"
|
|
31
|
+
]
|
|
32
|
+
target = (
|
|
33
|
+
current
|
|
34
|
+
if urlparse(current.get("url") or "").hostname == "channels.weixin.qq.com"
|
|
35
|
+
else tabs[0] if tabs else None
|
|
36
|
+
)
|
|
37
|
+
if target is None:
|
|
38
|
+
new_tab(url)
|
|
39
|
+
return current_tab()
|
|
40
|
+
switch_tab(target)
|
|
41
|
+
if page_info().get("url") != url:
|
|
42
|
+
goto_url(url)
|
|
43
|
+
return current_tab()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def wait_for_shadow(timeout_seconds: int = 20) -> bool:
|
|
47
|
+
expression = (
|
|
48
|
+
'document.querySelector("wujie-app")?.shadowRoot?.querySelector("body") '
|
|
49
|
+
'? "yes" : "no"'
|
|
50
|
+
)
|
|
51
|
+
deadline = time.monotonic() + timeout_seconds
|
|
52
|
+
while time.monotonic() < deadline:
|
|
53
|
+
if js(expression) == "yes":
|
|
54
|
+
return True
|
|
55
|
+
time.sleep(0.5)
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def manager_has_content() -> bool:
|
|
60
|
+
expression = (
|
|
61
|
+
'(document.querySelector("wujie-app")?.shadowRoot?.querySelector("body")'
|
|
62
|
+
'?.innerText || "").trim()'
|
|
63
|
+
)
|
|
64
|
+
return bool(js(expression))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def manager_rows(
|
|
68
|
+
post_id: str | None,
|
|
69
|
+
title: str | None,
|
|
70
|
+
desc: str | None,
|
|
71
|
+
) -> list[dict]:
|
|
72
|
+
try:
|
|
73
|
+
raw = json.loads(js(manager_scan_expression(post_id, title, desc)) or "[]")
|
|
74
|
+
except (TypeError, json.JSONDecodeError):
|
|
75
|
+
return []
|
|
76
|
+
return raw if isinstance(raw, list) else []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_manager() -> tuple[str, dict]:
|
|
80
|
+
open_or_reuse_wechat(MANAGER_URL)
|
|
81
|
+
if not wait_for_shadow():
|
|
82
|
+
return "unknown", {"reason": "manager_shadow_root_missing"}
|
|
83
|
+
deadline = time.monotonic() + 30
|
|
84
|
+
content_deadline = time.monotonic() + 12
|
|
85
|
+
row = None
|
|
86
|
+
while time.monotonic() < deadline:
|
|
87
|
+
rows = manager_rows(POST_ID, TITLE, DESC)
|
|
88
|
+
row = choose_manager_row(rows, POST_ID, TITLE, DESC)
|
|
89
|
+
if row is not None:
|
|
90
|
+
break
|
|
91
|
+
if manager_has_content() and time.monotonic() >= content_deadline:
|
|
92
|
+
return "not_found", {"reason": "no_matching_manager_row"}
|
|
93
|
+
time.sleep(1)
|
|
94
|
+
if row is None:
|
|
95
|
+
return "unknown", {"reason": "manager_did_not_load"}
|
|
96
|
+
status, label = status_label(row.get("text", ""))
|
|
97
|
+
return status or "unknown", {
|
|
98
|
+
"reason": None if status else "status_label_not_found",
|
|
99
|
+
"row": row,
|
|
100
|
+
"post_id": extract_stable_id(row) or POST_ID,
|
|
101
|
+
"status_label": label,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if OP != "status":
|
|
106
|
+
out(
|
|
107
|
+
{
|
|
108
|
+
"ok": False,
|
|
109
|
+
"platform": "wechat-channels",
|
|
110
|
+
"operation": OP,
|
|
111
|
+
"error": "unsupported_operation",
|
|
112
|
+
"supported_operations": ["status"],
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
raise SystemExit(2)
|
|
116
|
+
|
|
117
|
+
status, details = resolve_manager()
|
|
118
|
+
result = {
|
|
119
|
+
"ok": status in {"published", "reviewing", "rejected", "draft"},
|
|
120
|
+
"platform": "wechat-channels",
|
|
121
|
+
"operation": "status",
|
|
122
|
+
"status": status,
|
|
123
|
+
"post_id": details.get("post_id"),
|
|
124
|
+
"status_label": details.get("status_label"),
|
|
125
|
+
"target": {"post_id": POST_ID, "title": TITLE, "desc": DESC},
|
|
126
|
+
"reason": details.get("reason"),
|
|
127
|
+
"row": details.get("row"),
|
|
128
|
+
}
|
|
129
|
+
out(result)
|
|
130
|
+
raise SystemExit(0 if result["ok"] else 2)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from _common import (
|
|
7
|
+
MANAGER_URL,
|
|
8
|
+
choose_manager_row,
|
|
9
|
+
extract_stable_id,
|
|
10
|
+
manager_scan_expression,
|
|
11
|
+
status_label,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
CFG = json.load(open("__CFG_PATH__"))
|
|
15
|
+
|
|
16
|
+
SB = 'document.querySelector("wujie-app").shadowRoot.querySelector("body")'
|
|
17
|
+
|
|
18
|
+
def open_or_reuse_wechat(url):
|
|
19
|
+
current = current_tab()
|
|
20
|
+
tabs = [tab for tab in list_tabs() if urlparse(tab.get("url") or "").hostname == "channels.weixin.qq.com"]
|
|
21
|
+
target = current if urlparse(current.get("url") or "").hostname == "channels.weixin.qq.com" else (tabs[0] if tabs else None)
|
|
22
|
+
if target is None:
|
|
23
|
+
new_tab(url)
|
|
24
|
+
return current_tab()
|
|
25
|
+
switch_tab(target)
|
|
26
|
+
if page_info().get("url") != url:
|
|
27
|
+
goto_url(url)
|
|
28
|
+
return current_tab()
|
|
29
|
+
|
|
30
|
+
def shadow_js(expr):
|
|
31
|
+
return js(SB + expr)
|
|
32
|
+
|
|
33
|
+
def find_rect(sel):
|
|
34
|
+
code = SB + '.querySelector("' + sel + '") ? (function(){ var r = ' + SB + '.querySelector("' + sel + '").getBoundingClientRect(); return JSON.stringify({x:Math.round(r.x+r.width/2), y:Math.round(r.y+r.height/2)}); })() : "null"'
|
|
35
|
+
r = js(code)
|
|
36
|
+
if r and r != "null":
|
|
37
|
+
return json.loads(r)
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
print("[1/6] Opening or reusing the WeChat Channels create tab...")
|
|
41
|
+
open_or_reuse_wechat("https://channels.weixin.qq.com/platform/post/create")
|
|
42
|
+
wait_for_load()
|
|
43
|
+
time.sleep(4)
|
|
44
|
+
|
|
45
|
+
url = page_info()["url"]
|
|
46
|
+
if "login" in url.lower():
|
|
47
|
+
print("ERROR: Not logged in. Scan QR at https://channels.weixin.qq.com/")
|
|
48
|
+
capture_screenshot()
|
|
49
|
+
raise SystemExit(1)
|
|
50
|
+
|
|
51
|
+
for i in range(10):
|
|
52
|
+
sc = js('document.querySelector("wujie-app")?.shadowRoot ? "ok" : "no"')
|
|
53
|
+
if sc == "ok":
|
|
54
|
+
break
|
|
55
|
+
time.sleep(1)
|
|
56
|
+
else:
|
|
57
|
+
print("ERROR: Shadow DOM not found after 10s.")
|
|
58
|
+
capture_screenshot()
|
|
59
|
+
raise SystemExit(1)
|
|
60
|
+
|
|
61
|
+
for i in range(10):
|
|
62
|
+
has_upload = shadow_js('.querySelector(".upload") ? "ok" : "no"')
|
|
63
|
+
if has_upload == "ok":
|
|
64
|
+
break
|
|
65
|
+
time.sleep(1)
|
|
66
|
+
else:
|
|
67
|
+
print("ERROR: Upload area not found.")
|
|
68
|
+
capture_screenshot()
|
|
69
|
+
raise SystemExit(1)
|
|
70
|
+
|
|
71
|
+
print(" -> Logged in, page loaded.")
|
|
72
|
+
|
|
73
|
+
print("[2/6] Uploading video: " + CFG["video"])
|
|
74
|
+
doc = cdp("DOM.getDocument", depth=-1, pierce=True)
|
|
75
|
+
sr = cdp("DOM.performSearch", query='input[type="file"][accept*="video"]')
|
|
76
|
+
if sr.get("resultCount", 0) > 0:
|
|
77
|
+
nodes = cdp("DOM.getSearchResults", searchId=sr["searchId"], fromIndex=0, toIndex=1)
|
|
78
|
+
nid = nodes["nodeIds"][0]
|
|
79
|
+
cdp("DOM.setFileInputFiles", files=[CFG["video"]], nodeId=nid)
|
|
80
|
+
cdp("DOM.discardSearchResults", searchId=sr["searchId"])
|
|
81
|
+
print(" -> Video file set via DOM.")
|
|
82
|
+
else:
|
|
83
|
+
print("ERROR: Cannot find video file input.")
|
|
84
|
+
capture_screenshot()
|
|
85
|
+
raise SystemExit(1)
|
|
86
|
+
|
|
87
|
+
print(" -> Waiting for upload...")
|
|
88
|
+
for attempt in range(120):
|
|
89
|
+
time.sleep(5)
|
|
90
|
+
btn_cls = shadow_js('.querySelector(".weui-desktop-btn_primary")?.classList?.contains("weui-desktop-btn_disabled") ? "disabled" : "enabled"')
|
|
91
|
+
vid_ok = shadow_js('.querySelector(".cover-wrap, video, [class*=uploaded], [class*=video-info], .upload-success, .post-edit-wrap") ? "ready" : "wait"')
|
|
92
|
+
if vid_ok == "ready" or btn_cls == "enabled":
|
|
93
|
+
print(" -> Upload done!")
|
|
94
|
+
break
|
|
95
|
+
if attempt % 6 == 5:
|
|
96
|
+
pct = shadow_js('.querySelector("[class*=progress], .percent")?.textContent || "..."')
|
|
97
|
+
print(" -> Uploading... " + str(pct) + " (" + str((attempt+1)*5) + "s)")
|
|
98
|
+
else:
|
|
99
|
+
print("WARNING: Upload timeout.")
|
|
100
|
+
capture_screenshot()
|
|
101
|
+
time.sleep(2)
|
|
102
|
+
|
|
103
|
+
print("[3/6] Setting description...")
|
|
104
|
+
tags = CFG.get("tags", [])
|
|
105
|
+
desc = CFG["desc"]
|
|
106
|
+
if tags:
|
|
107
|
+
desc = desc + " " + " ".join(f"#{t}" for t in tags)
|
|
108
|
+
desc_s = json.dumps(desc)
|
|
109
|
+
shadow_js('.querySelector(".input-editor[contenteditable=true]")?.focus()')
|
|
110
|
+
time.sleep(0.3)
|
|
111
|
+
js(SB + '.querySelector(".input-editor").textContent = ' + desc_s)
|
|
112
|
+
js(SB + '.querySelector(".input-editor").dispatchEvent(new Event("input", {bubbles:true}))')
|
|
113
|
+
time.sleep(0.5)
|
|
114
|
+
actual = shadow_js('.querySelector(".input-editor")?.textContent?.substring(0,50) || ""')
|
|
115
|
+
if actual:
|
|
116
|
+
print(" -> Description: " + actual)
|
|
117
|
+
else:
|
|
118
|
+
print(" -> WARNING: Description may not have been set, trying click+type...")
|
|
119
|
+
c = find_rect(".input-editor")
|
|
120
|
+
if c:
|
|
121
|
+
click_at_xy(c["x"], c["y"])
|
|
122
|
+
time.sleep(0.5)
|
|
123
|
+
type_text(CFG["desc"])
|
|
124
|
+
time.sleep(0.5)
|
|
125
|
+
|
|
126
|
+
if CFG["title"]:
|
|
127
|
+
print("[4/6] Setting short title: " + CFG["title"])
|
|
128
|
+
title_s = json.dumps(CFG["title"])
|
|
129
|
+
shadow_js('.querySelector(".weui-desktop-form__input")?.focus()')
|
|
130
|
+
time.sleep(0.2)
|
|
131
|
+
js('Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(' + SB + '.querySelector(".weui-desktop-form__input"), ' + title_s + ')')
|
|
132
|
+
js(SB + '.querySelector(".weui-desktop-form__input").dispatchEvent(new Event("input", {bubbles:true}))')
|
|
133
|
+
time.sleep(0.3)
|
|
134
|
+
else:
|
|
135
|
+
print("[4/6] No short title, skipping...")
|
|
136
|
+
|
|
137
|
+
# Scroll down to make buttons visible
|
|
138
|
+
shadow_js('.scrollTo(0, 99999)')
|
|
139
|
+
js('document.querySelector("wujie-app").shadowRoot.querySelector("html")?.scrollTo(0, 99999)')
|
|
140
|
+
time.sleep(1)
|
|
141
|
+
|
|
142
|
+
print("[5/6] Pre-action screenshot...")
|
|
143
|
+
capture_screenshot()
|
|
144
|
+
time.sleep(1)
|
|
145
|
+
|
|
146
|
+
def find_btn_by_text(text):
|
|
147
|
+
# Find a visible button by its text content (avoids matching hidden dialog buttons)
|
|
148
|
+
r = js(SB + '.querySelectorAll(".weui-desktop-btn").forEach(function(b){}); var found=null; ' + SB + '.querySelectorAll(".weui-desktop-btn").forEach(function(b){ var rect=b.getBoundingClientRect(); if(b.textContent.trim()==="' + text + '" && rect.width>0 && !b.classList.contains("weui-desktop-btn_disabled")){ found={x:Math.round(rect.x+rect.width/2), y:Math.round(rect.y+rect.height/2)}; }}); found ? JSON.stringify(found) : "null"')
|
|
149
|
+
if r and r != "null":
|
|
150
|
+
return json.loads(r)
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
if CFG["publish"]:
|
|
154
|
+
print("[6/6] Publishing...")
|
|
155
|
+
c = find_btn_by_text("\u53d1\u8868") # 发表
|
|
156
|
+
if c:
|
|
157
|
+
click_at_xy(c["x"], c["y"])
|
|
158
|
+
time.sleep(3)
|
|
159
|
+
# Handle confirmation dialog
|
|
160
|
+
shadow_js('.querySelectorAll(".weui-desktop-dialog__wrp").forEach(function(d){ if(d.style.display !== "none"){ var b = d.querySelector(".weui-desktop-btn_primary"); if(b) b.click(); }})')
|
|
161
|
+
time.sleep(3)
|
|
162
|
+
print(" -> Publish action submitted; verifying manager state...")
|
|
163
|
+
action = "publish"
|
|
164
|
+
else:
|
|
165
|
+
print("ERROR: Publish button disabled/not found. Check manager before retrying.")
|
|
166
|
+
capture_screenshot()
|
|
167
|
+
raise SystemExit(1)
|
|
168
|
+
else:
|
|
169
|
+
print("[6/6] Saving draft...")
|
|
170
|
+
c = find_btn_by_text("\u4fdd\u5b58\u8349\u7a3f") # 保存草稿
|
|
171
|
+
if c:
|
|
172
|
+
click_at_xy(c["x"], c["y"])
|
|
173
|
+
time.sleep(3)
|
|
174
|
+
print(" -> Draft action submitted; verifying manager state...")
|
|
175
|
+
action = "draft"
|
|
176
|
+
else:
|
|
177
|
+
print("ERROR: Save draft button disabled/not found. Check manager before retrying.")
|
|
178
|
+
capture_screenshot()
|
|
179
|
+
raise SystemExit(1)
|
|
180
|
+
|
|
181
|
+
def verify_manager():
|
|
182
|
+
open_or_reuse_wechat(MANAGER_URL)
|
|
183
|
+
expected_title = CFG.get("title") or ""
|
|
184
|
+
expected_desc = CFG.get("desc") or ""
|
|
185
|
+
deadline = time.time() + 40
|
|
186
|
+
content_deadline = time.time() + 15
|
|
187
|
+
while time.time() < deadline:
|
|
188
|
+
ready = js('document.querySelector("wujie-app")?.shadowRoot?.querySelector("body") ? "yes" : "no"') == "yes"
|
|
189
|
+
if ready:
|
|
190
|
+
raw = js(manager_scan_expression("", expected_title, expected_desc)) or "[]"
|
|
191
|
+
try:
|
|
192
|
+
rows = json.loads(raw)
|
|
193
|
+
except json.JSONDecodeError:
|
|
194
|
+
rows = []
|
|
195
|
+
row = choose_manager_row(rows, None, expected_title, expected_desc)
|
|
196
|
+
if row:
|
|
197
|
+
status, label = status_label(row.get("text", ""))
|
|
198
|
+
return {
|
|
199
|
+
"status": status or "unknown",
|
|
200
|
+
"status_label": label,
|
|
201
|
+
"post_id": extract_stable_id(row),
|
|
202
|
+
"row": row,
|
|
203
|
+
"reason": None if status else "status_label_not_found",
|
|
204
|
+
}
|
|
205
|
+
has_content = bool(js('document.querySelector("wujie-app")?.shadowRoot?.querySelector("body")?.innerText?.trim() || ""'))
|
|
206
|
+
if has_content and time.time() >= content_deadline:
|
|
207
|
+
return {"status": "not_found", "status_label": None, "post_id": None, "row": None, "reason": "no_matching_manager_row"}
|
|
208
|
+
time.sleep(1)
|
|
209
|
+
return {"status": "unknown", "status_label": None, "post_id": None, "row": None, "reason": "manager_did_not_load"}
|
|
210
|
+
|
|
211
|
+
verification = verify_manager()
|
|
212
|
+
result = {
|
|
213
|
+
"ok": verification["status"] in {"published", "reviewing"} if action == "publish" else verification["status"] == "draft",
|
|
214
|
+
"platform": "wechat-channels",
|
|
215
|
+
"operation": action,
|
|
216
|
+
"status": verification["status"],
|
|
217
|
+
"status_label": verification["status_label"],
|
|
218
|
+
"post_id": verification["post_id"],
|
|
219
|
+
"title": CFG.get("title"),
|
|
220
|
+
"desc": CFG.get("desc"),
|
|
221
|
+
"verified": verification["status"] in {"published", "reviewing", "rejected", "draft"},
|
|
222
|
+
"reason": verification["reason"],
|
|
223
|
+
"row": verification["row"],
|
|
224
|
+
"retry_safe": False,
|
|
225
|
+
}
|
|
226
|
+
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
|
|
227
|
+
time.sleep(1)
|
|
228
|
+
capture_screenshot()
|
|
229
|
+
if not result["ok"]:
|
|
230
|
+
raise SystemExit(7 if verification["status"] in {"unknown", "not_found"} else 8)
|
|
231
|
+
|
|
232
|
+
print("Done!")
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
platform: wechat-channels
|
|
2
|
+
status: migrated_unverified
|
|
3
|
+
last_verified: null
|
|
4
|
+
actions:
|
|
5
|
+
post: actions/_post.py
|
|
6
|
+
manage: actions/_manage.py
|
|
7
|
+
verification:
|
|
8
|
+
status: migrated_unverified
|
|
9
|
+
last_verified: null
|
|
10
|
+
evidence: Existing post/shadow-DOM and manager mechanics migrated from the working
|
|
11
|
+
post adapter. Current Browser Workspace live verification pending.
|
|
12
|
+
url_patterns:
|
|
13
|
+
- https://channels.weixin.qq.com/*
|
|
14
|
+
authentication:
|
|
15
|
+
required: true
|
|
16
|
+
notes: Use the caller's authenticated browser context; never store credentials.
|
|
17
|
+
provenance:
|
|
18
|
+
source: skills/post/scripts/platforms/wechat-channels
|
|
19
|
+
migrated_code: true
|
|
20
|
+
flows:
|
|
21
|
+
post:
|
|
22
|
+
status: migrated_unverified
|
|
23
|
+
evidence: Migrated action; current Browser Workspace live verification pending.
|
|
24
|
+
manage:
|
|
25
|
+
status: migrated_unverified
|
|
26
|
+
evidence: Migrated manager action; current Browser Workspace live verification
|
|
27
|
+
pending.
|
|
28
|
+
display_name: WeChat Channels
|
|
29
|
+
aliases:
|
|
30
|
+
- wechat
|
|
31
|
+
- channels
|
|
32
|
+
implementation_status: code_only_live_ui_unverified
|
|
33
|
+
media:
|
|
34
|
+
modes:
|
|
35
|
+
- video
|
|
36
|
+
video:
|
|
37
|
+
required: true
|
|
38
|
+
formats:
|
|
39
|
+
- mp4
|
|
40
|
+
codec_hint: H.264
|
|
41
|
+
max_size_gb: 20
|
|
42
|
+
max_duration_hours: 8
|
|
43
|
+
fields:
|
|
44
|
+
desc:
|
|
45
|
+
cli: --desc
|
|
46
|
+
required: true
|
|
47
|
+
type: string
|
|
48
|
+
max_chars: 1000
|
|
49
|
+
implemented: true
|
|
50
|
+
title:
|
|
51
|
+
cli: --title
|
|
52
|
+
required: false
|
|
53
|
+
type: string
|
|
54
|
+
min_chars: 6
|
|
55
|
+
max_chars: 16
|
|
56
|
+
implemented: true
|
|
57
|
+
meaning: short title
|
|
58
|
+
tags:
|
|
59
|
+
cli: --tags
|
|
60
|
+
required: false
|
|
61
|
+
type: string_list
|
|
62
|
+
implemented: true
|
|
63
|
+
render: 'append as #tag'
|
|
64
|
+
publish:
|
|
65
|
+
cli: --publish
|
|
66
|
+
required: false
|
|
67
|
+
type: boolean
|
|
68
|
+
default: false
|
|
69
|
+
implemented: true
|
|
70
|
+
draft:
|
|
71
|
+
cli: --draft
|
|
72
|
+
required: false
|
|
73
|
+
type: boolean
|
|
74
|
+
default: true
|
|
75
|
+
implemented: true
|
|
76
|
+
note: default behavior already saves draft when --publish is absent
|
|
77
|
+
status:
|
|
78
|
+
cli: status
|
|
79
|
+
required: false
|
|
80
|
+
type: operation
|
|
81
|
+
implemented: true
|
|
82
|
+
verified: false
|
|
83
|
+
target_fields:
|
|
84
|
+
- post_id
|
|
85
|
+
- title
|
|
86
|
+
- desc
|
|
87
|
+
values:
|
|
88
|
+
- published
|
|
89
|
+
- reviewing
|
|
90
|
+
- rejected
|
|
91
|
+
- draft
|
|
92
|
+
- unknown
|
|
93
|
+
- not_found
|
|
94
|
+
capabilities:
|
|
95
|
+
draft: true
|
|
96
|
+
publish: true
|
|
97
|
+
visibility: false
|
|
98
|
+
custom_thumbnail: false
|
|
99
|
+
post_status: true
|
|
100
|
+
update_post: false
|
|
101
|
+
read_comments: false
|
|
102
|
+
write_comment: false
|
|
103
|
+
reply_comments: false
|
|
104
|
+
content_mapping:
|
|
105
|
+
headline: title
|
|
106
|
+
main_text: desc
|
|
107
|
+
discovery_terms: tags
|
|
108
|
+
notes:
|
|
109
|
+
- The adapter reuses one existing channels.weixin.qq.com tab and creates one only
|
|
110
|
+
when none exists.
|
|
111
|
+
- After submit/draft, the adapter inspects the creator manager and returns structured
|
|
112
|
+
status JSON.
|
|
113
|
+
- The manager probe is semantic but this implementation run could not verify the live
|
|
114
|
+
creator UI; unknown/not_found must be treated as authoritative uncertainty.
|
|
115
|
+
- An uncertain submit is not retried automatically; run status and inspect manager
|
|
116
|
+
state before any deliberate retry.
|
|
117
|
+
- Update/edit, comment reading, comments, and replies are intentionally unimplemented
|
|
118
|
+
until their exact creator UI is observed.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from importlib.util import module_from_spec, spec_from_file_location
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
HERE=Path(__file__).resolve().parents[1]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_common():
|
|
8
|
+
path=HERE/'actions'/'_common.py'
|
|
9
|
+
spec=spec_from_file_location('wechat_channels_common',path)
|
|
10
|
+
assert spec and spec.loader
|
|
11
|
+
mod=module_from_spec(spec); spec.loader.exec_module(mod); return mod
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_status_label_mapping():
|
|
15
|
+
c=load_common()
|
|
16
|
+
assert c.status_label('审核中 1 分钟前') == ('reviewing','审核中')
|
|
17
|
+
assert c.status_label('已发表 2026-09-15') == ('published','已发表')
|
|
18
|
+
assert c.status_label('no status') == (None,None)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_stable_id_prefers_url_finder_id():
|
|
22
|
+
c=load_common()
|
|
23
|
+
row={'stable_ids':{'objectid':'object-1'},'urls':['https://channels.weixin.qq.com/platform/post?finderObjectId=finder-2']}
|
|
24
|
+
assert c.extract_stable_id(row) == 'finder-2'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_choose_manager_row_prefers_post_id():
|
|
28
|
+
c=load_common()
|
|
29
|
+
rows=[{'text':'审核中 title','stable_ids':{'postid':'post-1'},'urls':[]},{'text':'已发布 other','stable_ids':{'postid':'post-2'},'urls':[]}]
|
|
30
|
+
assert c.choose_manager_row(rows,'post-2','title',None)['text'] == '已发布 other'
|
|
31
|
+
assert c.choose_manager_row(rows,None,'title',None)['text'] == '审核中 title'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_rejected_publish_is_not_success():
|
|
35
|
+
source=(HERE/'actions'/'_post.py').read_text()
|
|
36
|
+
assert 'verification["status"] in {"published", "reviewing"}' in source
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xhs-platform
|
|
3
|
+
description: "Xiaohongshu Browser Workspace posting and creator-manager assets."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Xiaohongshu
|
|
7
|
+
|
|
8
|
+
Use the parent Browser Workspace skill contract first. The
|
|
9
|
+
`actions/` directory contains the canonical migrated mechanics used by
|
|
10
|
+
`skills/post`; posting-domain validation and publish semantics remain in `post`.
|