browser-workspace 0.4.5 → 0.4.9

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 (68) hide show
  1. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  2. package/package.json +1 -1
  3. package/platform_runner.py +11 -2
  4. package/platforms/chatgpt/actions/_lifecycle_common.py +73 -20
  5. package/platforms/chatgpt/actions/_new_turn.py +1 -0
  6. package/platforms/chatgpt/actions/_readiness.py +29 -3
  7. package/platforms/chatgpt/actions/_submit_thread.py +20 -3
  8. package/platforms/chatgpt/actions/_thread_turn.py +31 -2
  9. package/platforms/chatgpt/tests/test_contract.py +107 -1
  10. package/platforms/iyf/AIRPLAY.md +25 -0
  11. package/platforms/iyf/actions/_find.py +18 -0
  12. package/platforms/iyf/actions/_open.py +10 -0
  13. package/platforms/iyf/manifest.yaml +11 -0
  14. package/platforms/iyf/safari-airplay +57 -0
  15. package/pyproject.toml +2 -2
  16. package/session_client.py +10 -0
  17. package/src/browser_workspace.egg-info/PKG-INFO +2 -2
  18. package/src/browser_workspace.egg-info/SOURCES.txt +53 -46
  19. package/src/browser_workspace.egg-info/requires.txt +1 -1
  20. package/src/browser_workspace_app/package.json +1 -1
  21. package/src/browser_workspace_app/pyproject.toml +2 -2
  22. package/src/browser_workspace_app/session_client.py +10 -0
  23. package/src/browser_workspace_app/platform_runner.py +0 -204
  24. package/src/browser_workspace_app/platforms/chatgpt/README.md +0 -60
  25. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +0 -58
  26. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +0 -119
  27. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +0 -80
  28. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +0 -85
  29. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +0 -33
  30. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +0 -353
  31. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +0 -52
  32. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +0 -129
  33. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +0 -591
  34. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +0 -362
  35. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +0 -34
  36. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +0 -183
  37. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +0 -49
  38. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +0 -425
  39. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +0 -7
  40. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +0 -10
  41. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +0 -10
  42. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +0 -18
  43. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +0 -11
  44. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +0 -9
  45. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +0 -33
  46. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +0 -83
  47. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +0 -32
  48. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +0 -210
  49. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +0 -21
  50. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +0 -25
  51. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +0 -24
  52. package/src/browser_workspace_app/platforms/tiktok/README.md +0 -10
  53. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +0 -180
  54. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +0 -99
  55. package/src/browser_workspace_app/platforms/wechat-channels/README.md +0 -10
  56. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +0 -163
  57. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +0 -130
  58. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +0 -231
  59. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +0 -124
  60. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +0 -36
  61. package/src/browser_workspace_app/platforms/xhs/README.md +0 -10
  62. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +0 -355
  63. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +0 -281
  64. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +0 -107
  65. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +0 -34
  66. package/src/browser_workspace_app/platforms/youtube/README.md +0 -10
  67. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +0 -103
  68. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +0 -94
@@ -1,163 +0,0 @@
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/list"
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
@@ -1,130 +0,0 @@
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)
@@ -1,231 +0,0 @@
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
- print("[1/6] Opening or reusing the WeChat Channels create tab...")
34
- open_or_reuse_wechat("https://channels.weixin.qq.com/platform/post/create")
35
- wait_for_load()
36
- time.sleep(4)
37
-
38
- url = page_info()["url"]
39
- if "login" in url.lower():
40
- print("ERROR: Not logged in. Scan QR at https://channels.weixin.qq.com/")
41
- capture_screenshot()
42
- raise SystemExit(1)
43
-
44
- for i in range(10):
45
- sc = js('document.querySelector("wujie-app")?.shadowRoot ? "ok" : "no"')
46
- if sc == "ok":
47
- break
48
- time.sleep(1)
49
- else:
50
- print("ERROR: Shadow DOM not found after 10s.")
51
- capture_screenshot()
52
- raise SystemExit(1)
53
-
54
- for i in range(10):
55
- has_upload = shadow_js('.querySelector(".upload") ? "ok" : "no"')
56
- if has_upload == "ok":
57
- break
58
- time.sleep(1)
59
- else:
60
- print("ERROR: Upload area not found.")
61
- capture_screenshot()
62
- raise SystemExit(1)
63
-
64
- print(" -> Logged in, page loaded.")
65
-
66
- print("[2/6] Uploading video: " + CFG["video"])
67
- doc = cdp("DOM.getDocument", depth=-1, pierce=True)
68
- sr = cdp("DOM.performSearch", query='input[type="file"][accept*="video"]')
69
- if sr.get("resultCount", 0) > 0:
70
- nodes = cdp("DOM.getSearchResults", searchId=sr["searchId"], fromIndex=0, toIndex=1)
71
- nid = nodes["nodeIds"][0]
72
- cdp("DOM.setFileInputFiles", files=[CFG["video"]], nodeId=nid)
73
- cdp("DOM.discardSearchResults", searchId=sr["searchId"])
74
- print(" -> Video file set via DOM.")
75
- else:
76
- print("ERROR: Cannot find video file input.")
77
- capture_screenshot()
78
- raise SystemExit(1)
79
-
80
- print(" -> Waiting for upload...")
81
- for attempt in range(120):
82
- time.sleep(5)
83
- upload_state = js(SB + '.innerText ? (function(text){ var match=text.match(/(?:^|\\n)(\\d{1,3})%(?:\\n|$)/); var percent=match?parseInt(match[1],10):null; var uploading=text.indexOf(\"取消上传\")>=0; return JSON.stringify({percent:percent,uploading:uploading}); })(' + SB + '.innerText) : JSON.stringify({percent:null,uploading:false})')
84
- try:
85
- state = json.loads(upload_state or "{}")
86
- except Exception:
87
- state = {}
88
- # Do not infer completion from editor/preview presence or Publish enabled state:
89
- # those can appear while the video is still uploading.
90
- if state.get("percent") is None and not state.get("uploading"):
91
- print(" -> Upload done!")
92
- break
93
- if attempt % 3 == 2:
94
- pct = state.get("percent")
95
- print(" -> Uploading... " + ((str(pct) + "%") if pct is not None else "processing") + " (" + str((attempt+1)*5) + "s)")
96
- else:
97
- print("ERROR: Upload did not reach a completed state before timeout.")
98
- capture_screenshot()
99
- raise SystemExit(1)
100
- time.sleep(2)
101
-
102
- print("[3/6] Setting description...")
103
- tags = CFG.get("tags", [])
104
- desc = CFG["desc"]
105
- if tags:
106
- desc = desc + " " + " ".join(f"#{t}" for t in tags)
107
- desc_s = json.dumps(desc)
108
- shadow_js('.querySelector(".input-editor[contenteditable=true]")?.focus()')
109
- time.sleep(0.3)
110
- js(SB + '.querySelector(".input-editor").textContent = ' + desc_s)
111
- js(SB + '.querySelector(".input-editor").dispatchEvent(new Event("input", {bubbles:true}))')
112
- time.sleep(0.5)
113
- actual = shadow_js('.querySelector(".input-editor")?.textContent?.substring(0,50) || ""')
114
- if actual:
115
- print(" -> Description: " + actual)
116
- else:
117
- print(" -> WARNING: Description may not have been set; retrying through the semantic editor element...")
118
- retry_desc = json.dumps(CFG["desc"])
119
- js(SB + '.querySelector(".input-editor")?.focus()')
120
- js(SB + '.querySelector(".input-editor").textContent = ' + retry_desc)
121
- js(SB + '.querySelector(".input-editor").dispatchEvent(new Event("input", {bubbles:true}))')
122
- time.sleep(0.5)
123
-
124
- if CFG["title"]:
125
- print("[4/6] Setting short title: " + CFG["title"])
126
- title_s = json.dumps(CFG["title"])
127
- shadow_js('.querySelector(".weui-desktop-form__input")?.focus()')
128
- time.sleep(0.2)
129
- js('Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(' + SB + '.querySelector(".weui-desktop-form__input"), ' + title_s + ')')
130
- js(SB + '.querySelector(".weui-desktop-form__input").dispatchEvent(new Event("input", {bubbles:true}))')
131
- time.sleep(0.3)
132
- else:
133
- print("[4/6] No short title, skipping...")
134
-
135
- # Scroll down to make buttons visible
136
- print("[5/6] Pre-action screenshot...")
137
- capture_screenshot()
138
- time.sleep(1)
139
-
140
- def click_btn_by_text(text):
141
- # Semantic DOM click only. Never derive or use viewport/screen coordinates.
142
- text_s = json.dumps(text)
143
- return js(
144
- '(function(){ var body=' + SB + '; var target=null; '
145
- 'body.querySelectorAll(".weui-desktop-btn").forEach(function(btn){ '
146
- 'var style=getComputedStyle(btn); '
147
- 'if(btn.textContent.trim()===' + text_s + ' && '
148
- '!btn.disabled && !btn.classList.contains("weui-desktop-btn_disabled") && '
149
- 'style.display!=="none" && style.visibility!=="hidden"){ target=btn; } }); '
150
- 'if(!target) return false; target.click(); return true; })()'
151
- )
152
-
153
-
154
- if CFG["publish"]:
155
- print("[6/6] Publishing...")
156
- clicked = click_btn_by_text("发表")
157
- if clicked:
158
- time.sleep(3)
159
- # Handle confirmation dialog semantically as well.
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
- clicked = click_btn_by_text("保存草稿")
171
- if clicked:
172
- time.sleep(3)
173
- print(" -> Draft action submitted; verifying manager state...")
174
- action = "draft"
175
- else:
176
- print("ERROR: Save draft button disabled/not found. Check manager before retrying.")
177
- capture_screenshot()
178
- raise SystemExit(1)
179
-
180
- def verify_manager():
181
- open_or_reuse_wechat(MANAGER_URL)
182
- expected_title = CFG.get("title") or ""
183
- expected_desc = CFG.get("desc") or ""
184
- deadline = time.time() + 40
185
- content_deadline = time.time() + 15
186
- while time.time() < deadline:
187
- ready = js('document.querySelector("wujie-app")?.shadowRoot?.querySelector("body") ? "yes" : "no"') == "yes"
188
- if ready:
189
- raw = js(manager_scan_expression("", expected_title, expected_desc)) or "[]"
190
- try:
191
- rows = json.loads(raw)
192
- except json.JSONDecodeError:
193
- rows = []
194
- row = choose_manager_row(rows, None, expected_title, expected_desc)
195
- if row:
196
- status, label = status_label(row.get("text", ""))
197
- return {
198
- "status": status or "unknown",
199
- "status_label": label,
200
- "post_id": extract_stable_id(row),
201
- "row": row,
202
- "reason": None if status else "status_label_not_found",
203
- }
204
- has_content = bool(js('document.querySelector("wujie-app")?.shadowRoot?.querySelector("body")?.innerText?.trim() || ""'))
205
- if has_content and time.time() >= content_deadline:
206
- return {"status": "not_found", "status_label": None, "post_id": None, "row": None, "reason": "no_matching_manager_row"}
207
- time.sleep(1)
208
- return {"status": "unknown", "status_label": None, "post_id": None, "row": None, "reason": "manager_did_not_load"}
209
-
210
- verification = verify_manager()
211
- result = {
212
- "ok": verification["status"] in {"published", "reviewing"} if action == "publish" else verification["status"] == "draft",
213
- "platform": "wechat-channels",
214
- "operation": action,
215
- "status": verification["status"],
216
- "status_label": verification["status_label"],
217
- "post_id": verification["post_id"],
218
- "title": CFG.get("title"),
219
- "desc": CFG.get("desc"),
220
- "verified": verification["status"] in {"published", "reviewing", "rejected", "draft"},
221
- "reason": verification["reason"],
222
- "row": verification["row"],
223
- "retry_safe": False,
224
- }
225
- print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
226
- time.sleep(1)
227
- capture_screenshot()
228
- if not result["ok"]:
229
- raise SystemExit(7 if verification["status"] in {"unknown", "not_found"} else 8)
230
-
231
- print("Done!")
@@ -1,124 +0,0 @@
1
- platform: wechat-channels
2
- status: partially_verified
3
- last_verified: '2026-10-04'
4
- actions:
5
- post: actions/_post.py
6
- manage: actions/_manage.py
7
- verification:
8
- status: partially_verified
9
- last_verified: '2026-10-04'
10
- evidence: Live publish succeeded with creator confirmation 已发表 and manager list
11
- count increasing to 4. Exact title/description manager-row matching remains unverified
12
- because list rows do not expose those fields in visible text.
13
- url_patterns:
14
- - https://channels.weixin.qq.com/*
15
- authentication:
16
- required: true
17
- notes: Use the caller's authenticated browser context; never store credentials.
18
- provenance:
19
- source: skills/post/scripts/platforms/wechat-channels
20
- migrated_code: true
21
- flows:
22
- post:
23
- status: verified
24
- evidence: Live publish succeeded on 2026-10-04; creator UI showed 已发表 and manager
25
- list gained a new 10:33 row.
26
- manage:
27
- status: partially_verified
28
- evidence: Live manager list at /platform/post/list loaded and exposed video count/timestamps;
29
- exact title/description row matching is not yet verified.
30
- display_name: WeChat Channels
31
- aliases:
32
- - wechat
33
- - channels
34
- implementation_status: live_publish_verified_manager_matching_partial
35
- media:
36
- modes:
37
- - video
38
- video:
39
- required: true
40
- formats:
41
- - mp4
42
- codec_hint: H.264
43
- max_size_gb: 20
44
- max_duration_hours: 8
45
- fields:
46
- desc:
47
- cli: --desc
48
- required: true
49
- type: string
50
- max_chars: 1000
51
- implemented: true
52
- title:
53
- cli: --title
54
- required: true
55
- type: string
56
- min_chars: 6
57
- max_chars: 16
58
- implemented: true
59
- meaning: short title
60
- note: Live creator UI on 2026-10-04 kept Publish disabled until a valid short
61
- title was provided.
62
- tags:
63
- cli: --tags
64
- required: false
65
- type: string_list
66
- implemented: true
67
- render: 'append as #tag'
68
- publish:
69
- cli: --publish
70
- required: false
71
- type: boolean
72
- default: false
73
- implemented: true
74
- draft:
75
- cli: --draft
76
- required: false
77
- type: boolean
78
- default: true
79
- implemented: true
80
- note: default behavior already saves draft when --publish is absent
81
- status:
82
- cli: status
83
- required: false
84
- type: operation
85
- implemented: true
86
- verified: false
87
- target_fields:
88
- - post_id
89
- - title
90
- - desc
91
- values:
92
- - published
93
- - reviewing
94
- - rejected
95
- - draft
96
- - unknown
97
- - not_found
98
- capabilities:
99
- draft: true
100
- publish: true
101
- visibility: false
102
- custom_thumbnail: false
103
- post_status: true
104
- update_post: false
105
- read_comments: false
106
- write_comment: false
107
- reply_comments: false
108
- content_mapping:
109
- headline: title
110
- main_text: desc
111
- discovery_terms: tags
112
- notes:
113
- - The adapter reuses one existing channels.weixin.qq.com tab and creates one only
114
- when none exists.
115
- - After submit/draft, the adapter inspects the creator manager and returns structured
116
- status JSON.
117
- - The manager probe is semantic but this implementation run could not verify the live
118
- creator UI; unknown/not_found must be treated as authoritative uncertainty.
119
- - An uncertain submit is not retried automatically; run status and inspect manager
120
- state before any deliberate retry.
121
- - Update/edit, comment reading, comments, and replies are intentionally unimplemented
122
- until their exact creator UI is observed.
123
- - Live creator UI requires a valid short title before Publish becomes enabled; treat
124
- title as required for post actions.
@@ -1,36 +0,0 @@
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
@@ -1,10 +0,0 @@
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`.