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.
Files changed (108) hide show
  1. package/PRIVACY.md +21 -0
  2. package/README.md +138 -0
  3. package/SKILL.md +301 -0
  4. package/agent-workspace/agent_helpers.py +718 -0
  5. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  6. package/bin/browser-workspace +75 -0
  7. package/extension/manifest.json +26 -0
  8. package/extension/service-worker.mjs +126 -0
  9. package/extension/workspace-manager.mjs +680 -0
  10. package/extension/workspace-page.mjs +7 -0
  11. package/extension/workspace.html +8 -0
  12. package/interaction-skills/connection.md +52 -0
  13. package/interaction-skills/cookies.md +3 -0
  14. package/interaction-skills/cross-origin-iframes.md +3 -0
  15. package/interaction-skills/dialogs.md +64 -0
  16. package/interaction-skills/downloads.md +3 -0
  17. package/interaction-skills/drag-and-drop.md +3 -0
  18. package/interaction-skills/dropdowns.md +3 -0
  19. package/interaction-skills/iframes.md +3 -0
  20. package/interaction-skills/network-requests.md +3 -0
  21. package/interaction-skills/print-as-pdf.md +3 -0
  22. package/interaction-skills/profile-sync.md +90 -0
  23. package/interaction-skills/screenshots.md +17 -0
  24. package/interaction-skills/scrolling.md +22 -0
  25. package/interaction-skills/shadow-dom.md +3 -0
  26. package/interaction-skills/tabs.md +73 -0
  27. package/interaction-skills/uploads.md +1 -0
  28. package/interaction-skills/viewport.md +16 -0
  29. package/licenses/browser-harness-MIT.txt +21 -0
  30. package/managed_chrome.py +244 -0
  31. package/node/admin.mjs +44 -0
  32. package/package.json +36 -0
  33. package/platform_runner.py +187 -0
  34. package/platforms/chatgpt/README.md +60 -0
  35. package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  36. package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  37. package/platforms/chatgpt/actions/_mcp_app.py +80 -0
  38. package/platforms/chatgpt/actions/_new_thread.py +85 -0
  39. package/platforms/chatgpt/actions/_new_turn.py +33 -0
  40. package/platforms/chatgpt/actions/_project_setup.py +353 -0
  41. package/platforms/chatgpt/actions/_readiness.py +52 -0
  42. package/platforms/chatgpt/actions/_submit_thread.py +129 -0
  43. package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
  44. package/platforms/chatgpt/actions/_thread_turn.py +362 -0
  45. package/platforms/chatgpt/manifest.yaml +34 -0
  46. package/platforms/chatgpt/tests/test_contract.py +176 -0
  47. package/platforms/chrome-web-store/README.md +49 -0
  48. package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  49. package/platforms/chrome-web-store/flows/check-status.md +7 -0
  50. package/platforms/chrome-web-store/flows/open-item.md +10 -0
  51. package/platforms/chrome-web-store/flows/submit-review.md +10 -0
  52. package/platforms/chrome-web-store/flows/update-listing.md +18 -0
  53. package/platforms/chrome-web-store/flows/upload-package.md +11 -0
  54. package/platforms/chrome-web-store/flows/verify-published.md +9 -0
  55. package/platforms/chrome-web-store/manifest.yaml +33 -0
  56. package/platforms/chrome-web-store/tests/test_contract.py +83 -0
  57. package/platforms/microsoft-teams/README.md +32 -0
  58. package/platforms/microsoft-teams/actions/_meeting.py +210 -0
  59. package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  60. package/platforms/microsoft-teams/manifest.yaml +25 -0
  61. package/platforms/microsoft-teams/tests/test_contract.py +24 -0
  62. package/platforms/tiktok/README.md +10 -0
  63. package/platforms/tiktok/actions/_post.py +180 -0
  64. package/platforms/tiktok/manifest.yaml +99 -0
  65. package/platforms/wechat-channels/README.md +10 -0
  66. package/platforms/wechat-channels/actions/_common.py +163 -0
  67. package/platforms/wechat-channels/actions/_manage.py +130 -0
  68. package/platforms/wechat-channels/actions/_post.py +232 -0
  69. package/platforms/wechat-channels/manifest.yaml +118 -0
  70. package/platforms/wechat-channels/tests/test_contract.py +36 -0
  71. package/platforms/xhs/README.md +10 -0
  72. package/platforms/xhs/actions/_manage.py +355 -0
  73. package/platforms/xhs/actions/_post.py +192 -0
  74. package/platforms/xhs/manifest.yaml +98 -0
  75. package/platforms/youtube/README.md +10 -0
  76. package/platforms/youtube/actions/_post.py +100 -0
  77. package/platforms/youtube/manifest.yaml +94 -0
  78. package/pyproject.toml +29 -0
  79. package/session_client.py +97 -0
  80. package/session_daemon.py +325 -0
  81. package/src/browser_harness/__init__.py +2 -0
  82. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  83. package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
  84. package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
  85. package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
  86. package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
  87. package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
  88. package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
  89. package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
  90. package/src/browser_harness/_ipc.py +201 -0
  91. package/src/browser_harness/admin.py +1588 -0
  92. package/src/browser_harness/auth.py +546 -0
  93. package/src/browser_harness/daemon.py +894 -0
  94. package/src/browser_harness/helpers.py +668 -0
  95. package/src/browser_harness/macos.py +131 -0
  96. package/src/browser_harness/mcp_cli.py +16 -0
  97. package/src/browser_harness/paths.py +49 -0
  98. package/src/browser_harness/recorder.py +343 -0
  99. package/src/browser_harness/run.py +413 -0
  100. package/src/browser_harness/telemetry.py +308 -0
  101. package/src/browser_harness/video-template.html +970 -0
  102. package/src/browser_harness/video.py +749 -0
  103. package/src/browser_harness/video_render.py +524 -0
  104. package/src/browser_workspace.egg-info/PKG-INFO +10 -0
  105. package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
  106. package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
  107. package/src/browser_workspace.egg-info/requires.txt +5 -0
  108. 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`.