browser-workspace 0.4.1 → 0.4.5
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/README.md +16 -2
- package/SKILL.md +60 -37
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +174 -13
- package/package.json +1 -1
- package/platform_runner.py +23 -6
- package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
- package/platforms/chatgpt/tests/test_contract.py +7 -0
- package/platforms/wechat-channels/actions/_common.py +1 -1
- package/platforms/wechat-channels/actions/_post.py +37 -38
- package/platforms/wechat-channels/manifest.yaml +19 -13
- package/platforms/xhs/actions/_post.py +109 -20
- package/platforms/xhs/manifest.yaml +14 -5
- package/platforms/xhs/tests/test_contract.py +17 -0
- package/platforms/youtube/actions/_post.py +7 -4
- package/pyproject.toml +6 -2
- package/session_client.py +21 -11
- package/session_daemon.py +115 -9
- package/src/browser_workspace.egg-info/PKG-INFO +2 -1
- package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
- package/src/browser_workspace.egg-info/entry_points.txt +2 -0
- package/src/browser_workspace.egg-info/requires.txt +1 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -1
- package/src/browser_workspace_app/__init__.py +1 -0
- package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
- package/src/browser_workspace_app/cli.py +236 -0
- package/src/browser_workspace_app/extension/manifest.json +26 -0
- package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
- package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
- package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
- package/src/browser_workspace_app/extension/workspace.html +8 -0
- package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
- package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
- package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
- package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
- package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
- package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
- package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
- package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
- package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
- package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
- package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
- package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
- package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
- package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
- package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
- package/src/browser_workspace_app/managed_chrome.py +244 -0
- package/src/browser_workspace_app/node/admin.mjs +44 -0
- package/src/browser_workspace_app/package.json +36 -0
- package/src/browser_workspace_app/platform_runner.py +204 -0
- package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
- package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
- package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
- package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
- package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
- package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
- package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
- package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
- package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
- package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
- package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
- package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
- package/src/browser_workspace_app/pyproject.toml +33 -0
- package/src/browser_workspace_app/session_client.py +107 -0
- package/src/browser_workspace_app/session_daemon.py +431 -0
- package/src/browser_harness/__init__.py +0 -2
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +0 -201
- package/src/browser_harness/admin.py +0 -1588
- package/src/browser_harness/auth.py +0 -546
- package/src/browser_harness/daemon.py +0 -894
- package/src/browser_harness/helpers.py +0 -668
- package/src/browser_harness/macos.py +0 -131
- package/src/browser_harness/mcp_cli.py +0 -16
- package/src/browser_harness/paths.py +0 -49
- package/src/browser_harness/recorder.py +0 -343
- package/src/browser_harness/run.py +0 -413
- package/src/browser_harness/telemetry.py +0 -308
- package/src/browser_harness/video-template.html +0 -970
- package/src/browser_harness/video.py +0 -749
- package/src/browser_harness/video_render.py +0 -524
|
@@ -30,13 +30,6 @@ def open_or_reuse_wechat(url):
|
|
|
30
30
|
def shadow_js(expr):
|
|
31
31
|
return js(SB + expr)
|
|
32
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
33
|
print("[1/6] Opening or reusing the WeChat Channels create tab...")
|
|
41
34
|
open_or_reuse_wechat("https://channels.weixin.qq.com/platform/post/create")
|
|
42
35
|
wait_for_load()
|
|
@@ -87,17 +80,23 @@ else:
|
|
|
87
80
|
print(" -> Waiting for upload...")
|
|
88
81
|
for attempt in range(120):
|
|
89
82
|
time.sleep(5)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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"):
|
|
93
91
|
print(" -> Upload done!")
|
|
94
92
|
break
|
|
95
|
-
if attempt %
|
|
96
|
-
pct =
|
|
97
|
-
print(" -> Uploading... " + str(pct) + " (" + str((attempt+1)*5) + "s)")
|
|
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)")
|
|
98
96
|
else:
|
|
99
|
-
print("
|
|
97
|
+
print("ERROR: Upload did not reach a completed state before timeout.")
|
|
100
98
|
capture_screenshot()
|
|
99
|
+
raise SystemExit(1)
|
|
101
100
|
time.sleep(2)
|
|
102
101
|
|
|
103
102
|
print("[3/6] Setting description...")
|
|
@@ -115,13 +114,12 @@ actual = shadow_js('.querySelector(".input-editor")?.textContent?.substring(0,50
|
|
|
115
114
|
if actual:
|
|
116
115
|
print(" -> Description: " + actual)
|
|
117
116
|
else:
|
|
118
|
-
print(" -> WARNING: Description may not have been set
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
time.sleep(0.5)
|
|
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)
|
|
125
123
|
|
|
126
124
|
if CFG["title"]:
|
|
127
125
|
print("[4/6] Setting short title: " + CFG["title"])
|
|
@@ -135,28 +133,30 @@ else:
|
|
|
135
133
|
print("[4/6] No short title, skipping...")
|
|
136
134
|
|
|
137
135
|
# 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
136
|
print("[5/6] Pre-action screenshot...")
|
|
143
137
|
capture_screenshot()
|
|
144
138
|
time.sleep(1)
|
|
145
139
|
|
|
146
|
-
def
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
|
|
152
153
|
|
|
153
154
|
if CFG["publish"]:
|
|
154
155
|
print("[6/6] Publishing...")
|
|
155
|
-
|
|
156
|
-
if
|
|
157
|
-
click_at_xy(c["x"], c["y"])
|
|
156
|
+
clicked = click_btn_by_text("发表")
|
|
157
|
+
if clicked:
|
|
158
158
|
time.sleep(3)
|
|
159
|
-
# Handle confirmation dialog
|
|
159
|
+
# Handle confirmation dialog semantically as well.
|
|
160
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
161
|
time.sleep(3)
|
|
162
162
|
print(" -> Publish action submitted; verifying manager state...")
|
|
@@ -167,9 +167,8 @@ if CFG["publish"]:
|
|
|
167
167
|
raise SystemExit(1)
|
|
168
168
|
else:
|
|
169
169
|
print("[6/6] Saving draft...")
|
|
170
|
-
|
|
171
|
-
if
|
|
172
|
-
click_at_xy(c["x"], c["y"])
|
|
170
|
+
clicked = click_btn_by_text("保存草稿")
|
|
171
|
+
if clicked:
|
|
173
172
|
time.sleep(3)
|
|
174
173
|
print(" -> Draft action submitted; verifying manager state...")
|
|
175
174
|
action = "draft"
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
platform: wechat-channels
|
|
2
|
-
status:
|
|
3
|
-
last_verified:
|
|
2
|
+
status: partially_verified
|
|
3
|
+
last_verified: '2026-10-04'
|
|
4
4
|
actions:
|
|
5
5
|
post: actions/_post.py
|
|
6
6
|
manage: actions/_manage.py
|
|
7
7
|
verification:
|
|
8
|
-
status:
|
|
9
|
-
last_verified:
|
|
10
|
-
evidence:
|
|
11
|
-
|
|
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.
|
|
12
13
|
url_patterns:
|
|
13
14
|
- https://channels.weixin.qq.com/*
|
|
14
15
|
authentication:
|
|
@@ -19,17 +20,18 @@ provenance:
|
|
|
19
20
|
migrated_code: true
|
|
20
21
|
flows:
|
|
21
22
|
post:
|
|
22
|
-
status:
|
|
23
|
-
evidence:
|
|
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.
|
|
24
26
|
manage:
|
|
25
|
-
status:
|
|
26
|
-
evidence:
|
|
27
|
-
|
|
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.
|
|
28
30
|
display_name: WeChat Channels
|
|
29
31
|
aliases:
|
|
30
32
|
- wechat
|
|
31
33
|
- channels
|
|
32
|
-
implementation_status:
|
|
34
|
+
implementation_status: live_publish_verified_manager_matching_partial
|
|
33
35
|
media:
|
|
34
36
|
modes:
|
|
35
37
|
- video
|
|
@@ -49,12 +51,14 @@ fields:
|
|
|
49
51
|
implemented: true
|
|
50
52
|
title:
|
|
51
53
|
cli: --title
|
|
52
|
-
required:
|
|
54
|
+
required: true
|
|
53
55
|
type: string
|
|
54
56
|
min_chars: 6
|
|
55
57
|
max_chars: 16
|
|
56
58
|
implemented: true
|
|
57
59
|
meaning: short title
|
|
60
|
+
note: Live creator UI on 2026-10-04 kept Publish disabled until a valid short
|
|
61
|
+
title was provided.
|
|
58
62
|
tags:
|
|
59
63
|
cli: --tags
|
|
60
64
|
required: false
|
|
@@ -116,3 +120,5 @@ notes:
|
|
|
116
120
|
state before any deliberate retry.
|
|
117
121
|
- Update/edit, comment reading, comments, and replies are intentionally unimplemented
|
|
118
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.
|
|
@@ -26,6 +26,8 @@ def open_or_reuse_xhs(url):
|
|
|
26
26
|
images = CFG.get("images", [CFG.get("image", "")])
|
|
27
27
|
video = CFG.get("video", "")
|
|
28
28
|
tags = CFG.get("tags", [])
|
|
29
|
+
collection = (CFG.get("collection") or "").strip()
|
|
30
|
+
collection_description = (CFG.get("collection_description") or "").strip()
|
|
29
31
|
|
|
30
32
|
# Calculate steps dynamically
|
|
31
33
|
base_steps = 6 # navigate, tab, upload, title, body, action
|
|
@@ -82,20 +84,21 @@ if mode == "video":
|
|
|
82
84
|
raise SystemExit(1)
|
|
83
85
|
|
|
84
86
|
next_step("Waiting for video upload...")
|
|
85
|
-
for attempt in range(
|
|
86
|
-
time.sleep(
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
if
|
|
90
|
-
|
|
87
|
+
for attempt in range(180):
|
|
88
|
+
time.sleep(1)
|
|
89
|
+
# A preview/video element appears immediately, even while XHS still says
|
|
90
|
+
# "上传中 0%". Treat the editor's completed state as authoritative.
|
|
91
|
+
upload_state = js('(function(){var t=document.body.innerText||""; if(/上传中\\s*\\d*%?/.test(t)||t.indexOf("取消上传")>=0)return "uploading"; if(t.indexOf("重新上传")>=0||t.indexOf("检测为高清视频")>=0)return "ready"; return "waiting"})()')
|
|
92
|
+
if upload_state == "ready":
|
|
93
|
+
print(" -> Upload processed and editor-ready!")
|
|
91
94
|
break
|
|
92
|
-
if attempt %
|
|
95
|
+
if attempt % 15 == 14:
|
|
93
96
|
pct = js('var el=document.querySelector("[class*=progress],.percent,[class*=uploadProgress]"); el ? el.textContent : "..."')
|
|
94
|
-
print(f" ->
|
|
97
|
+
print(f" -> Upload state {upload_state}; {pct} ({attempt+1}s)")
|
|
95
98
|
else:
|
|
96
|
-
print("
|
|
99
|
+
print("ERROR: video upload never reached the editor-ready state")
|
|
97
100
|
capture_screenshot()
|
|
98
|
-
|
|
101
|
+
raise SystemExit(1)
|
|
99
102
|
|
|
100
103
|
else:
|
|
101
104
|
# Image mode — click the 2nd tab (上传图文)
|
|
@@ -153,32 +156,118 @@ time.sleep(0.5)
|
|
|
153
156
|
if tags:
|
|
154
157
|
next_step(f"Tags appended: {', '.join(tags)}")
|
|
155
158
|
|
|
159
|
+
def choose_collection(name, description):
|
|
160
|
+
if not name:
|
|
161
|
+
return None
|
|
162
|
+
opened = js('var es=Array.from(document.querySelectorAll("*")).filter(function(e){var t=(e.innerText||e.textContent||"").trim(); return e.offsetParent && e.children.length===0 && t==="选择合集"}); if(es.length){es[0].click();"yes"}else{"no"}')
|
|
163
|
+
if opened != "yes":
|
|
164
|
+
raise RuntimeError("collection chooser not found")
|
|
165
|
+
time.sleep(.5)
|
|
166
|
+
selected = js('var es=Array.from(document.querySelectorAll("*")).filter(function(e){return e.offsetParent && e.children.length===0 && (e.innerText||e.textContent||"").trim()===' + json.dumps(name) + '}); if(es.length){es[0].click();"yes"}else{"no"}')
|
|
167
|
+
if selected == "yes":
|
|
168
|
+
time.sleep(.5)
|
|
169
|
+
return "selected"
|
|
170
|
+
create = js('var es=Array.from(document.querySelectorAll("button,*")).filter(function(e){return e.offsetParent && e.children.length===0 && (e.innerText||e.textContent||"").trim()==="创建合集"}); if(es.length){es[0].click();"yes"}else{"no"}')
|
|
171
|
+
if create != "yes":
|
|
172
|
+
raise RuntimeError("collection not found and create control missing")
|
|
173
|
+
time.sleep(.5)
|
|
174
|
+
fill_input('input[placeholder="好的合集名称能吸引更多用户"]', name, clear_first=True, timeout=20)
|
|
175
|
+
if description:
|
|
176
|
+
fill_input('input[placeholder="简单介绍你的合集"],textarea[placeholder="简单介绍你的合集"]', description, clear_first=True, timeout=20)
|
|
177
|
+
created = js('var b=Array.from(document.querySelectorAll("button")).find(function(e){return e.offsetParent && (e.innerText||e.textContent||"").trim()==="创建并加入"}); if(b&&!b.disabled){b.click();"yes"}else{"no"}')
|
|
178
|
+
if created != "yes":
|
|
179
|
+
raise RuntimeError("create-and-join collection control unavailable")
|
|
180
|
+
for _ in range(20):
|
|
181
|
+
time.sleep(.5)
|
|
182
|
+
visible = js('(document.body.innerText||"").indexOf(' + json.dumps(name) + ')>=0 && (document.body.innerText||"").indexOf("创建并加入")<0 ? "yes" : "no"')
|
|
183
|
+
if visible == "yes":
|
|
184
|
+
return "created"
|
|
185
|
+
raise RuntimeError("collection creation was not verified")
|
|
186
|
+
|
|
187
|
+
if collection:
|
|
188
|
+
next_step("Joining collection: " + collection)
|
|
189
|
+
print(" -> Collection " + choose_collection(collection, collection_description))
|
|
190
|
+
|
|
191
|
+
def creator_manager_receipt(title):
|
|
192
|
+
goto_url("https://creator.xiaohongshu.com/new/note-manager")
|
|
193
|
+
try:
|
|
194
|
+
wait_for_load()
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
rows = []
|
|
198
|
+
for _ in range(30):
|
|
199
|
+
time.sleep(.5)
|
|
200
|
+
raw = js('Array.from(document.querySelectorAll(".note-card")).map(function(c){var t=c.querySelector(".note-card__title"); return JSON.stringify({title:t?(t.innerText||t.textContent||"").trim():"", text:(c.innerText||"").trim(), impression:c.dataset.impression||""})}).join("\\n")') or ""
|
|
201
|
+
rows = []
|
|
202
|
+
for line in raw.splitlines():
|
|
203
|
+
try:
|
|
204
|
+
row = json.loads(line)
|
|
205
|
+
imp = json.loads(row.get("impression") or "{}")
|
|
206
|
+
row["note_id"] = (((imp.get("noteTarget") or {}).get("value") or {}).get("noteId"))
|
|
207
|
+
if row.get("title") == title:
|
|
208
|
+
rows.append(row)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
if rows:
|
|
212
|
+
break
|
|
213
|
+
if not rows:
|
|
214
|
+
return None
|
|
215
|
+
newest = rows[0]
|
|
216
|
+
text = newest.get("text") or ""
|
|
217
|
+
status = "reviewing" if "审核中" in text else "rejected" if "未通过" in text else "published"
|
|
218
|
+
return {"note_id": newest.get("note_id"), "title": title, "status": status, "duplicate_count": len(rows), "card_text": text}
|
|
219
|
+
|
|
156
220
|
if CFG["action"] == "publish":
|
|
157
221
|
next_step("Publishing...")
|
|
158
|
-
|
|
222
|
+
# XHS can render the publish button before video processing is complete. A
|
|
223
|
+
# programmatic click on a disabled button is a no-op, so wait for the real
|
|
224
|
+
# control to become enabled before clicking it.
|
|
225
|
+
publish_result = "missing"
|
|
226
|
+
for attempt in range(180):
|
|
227
|
+
publish_result = js('var btn=[...document.querySelectorAll("button,[role=button]")].find(function(b){return b.offsetParent && (b.innerText||b.textContent||"").trim()==="发布"}) || document.querySelector(".publishBtn,.publish-btn,button.css-k01wfk,[class*=submit]");if(!btn){"missing"}else if(btn.disabled || btn.getAttribute("aria-disabled")==="true" || btn.classList.contains("disabled")){"disabled"}else{btn.scrollIntoView({block:"center"});btn.click();"clicked"}')
|
|
228
|
+
if publish_result == "clicked":
|
|
229
|
+
break
|
|
230
|
+
if attempt % 15 == 14:
|
|
231
|
+
print(f" -> Publish control {publish_result}; waiting for processing... ({attempt+1}s)")
|
|
232
|
+
time.sleep(1)
|
|
159
233
|
if publish_result != "clicked":
|
|
160
234
|
# XHS may expose the publish control only through the accessibility tree.
|
|
161
235
|
nodes = cdp("Accessibility.getFullAXTree")["nodes"]
|
|
162
236
|
target = next((n for n in nodes if (n.get("role") or {}).get("value") == "button" and (n.get("name") or {}).get("value") == "发布" and n.get("backendDOMNodeId")), None)
|
|
163
237
|
if target:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
238
|
+
resolved = cdp("DOM.resolveNode", backendNodeId=target["backendDOMNodeId"])["object"]
|
|
239
|
+
object_id = resolved.get("objectId")
|
|
240
|
+
if object_id:
|
|
241
|
+
cdp(
|
|
242
|
+
"Runtime.callFunctionOn",
|
|
243
|
+
objectId=object_id,
|
|
244
|
+
functionDeclaration="function(){ this.click(); return true; }",
|
|
245
|
+
returnByValue=True,
|
|
246
|
+
)
|
|
247
|
+
publish_result = "clicked"
|
|
248
|
+
if publish_result != "clicked":
|
|
170
249
|
print("ERROR: publish button not found")
|
|
171
250
|
capture_screenshot()
|
|
172
251
|
raise SystemExit(1)
|
|
173
|
-
|
|
252
|
+
redirect_verified = False
|
|
174
253
|
for _ in range(20):
|
|
175
254
|
time.sleep(1)
|
|
176
255
|
state = js('location.href.indexOf("publish/success")>=0 || /发布成功|成功发布/.test(document.body.innerText||"") ? "success" : "wait"')
|
|
177
256
|
if state == "success":
|
|
178
|
-
|
|
257
|
+
redirect_verified = True
|
|
179
258
|
break
|
|
259
|
+
receipt = creator_manager_receipt(CFG["title"])
|
|
260
|
+
if not receipt or not receipt.get("note_id"):
|
|
261
|
+
print("ERROR: publish was not verified in creator manager")
|
|
262
|
+
capture_screenshot()
|
|
263
|
+
raise SystemExit(2)
|
|
264
|
+
receipt["redirect_verified"] = redirect_verified
|
|
265
|
+
receipt["collection"] = collection or None
|
|
266
|
+
print("XHS_RECEIPT " + json.dumps(receipt, ensure_ascii=False, separators=(",", ":")))
|
|
267
|
+
verified = receipt["status"] in {"published", "reviewing"}
|
|
180
268
|
if not verified:
|
|
181
|
-
print("
|
|
269
|
+
print("ERROR: creator manager reports " + receipt["status"])
|
|
270
|
+
raise SystemExit(2)
|
|
182
271
|
else:
|
|
183
272
|
next_step("Saving as draft...")
|
|
184
273
|
# Find 暂存 button by unicode
|
|
@@ -2,8 +2,8 @@ platform: xhs
|
|
|
2
2
|
status: migrated_unverified
|
|
3
3
|
last_verified: null
|
|
4
4
|
actions:
|
|
5
|
-
post: actions/_post.py
|
|
6
|
-
manage: actions/_manage.py
|
|
5
|
+
post: "actions/_post.py"
|
|
6
|
+
manage: "actions/_manage.py"
|
|
7
7
|
verification:
|
|
8
8
|
status: migrated_unverified
|
|
9
9
|
last_verified: null
|
|
@@ -72,12 +72,22 @@ fields:
|
|
|
72
72
|
type: boolean
|
|
73
73
|
default: false
|
|
74
74
|
implemented: true
|
|
75
|
+
collection:
|
|
76
|
+
required: false
|
|
77
|
+
type: string
|
|
78
|
+
max_chars: 20
|
|
79
|
+
implemented: true
|
|
80
|
+
collection_description:
|
|
81
|
+
required: false
|
|
82
|
+
type: string
|
|
83
|
+
max_chars: 50
|
|
84
|
+
implemented: true
|
|
75
85
|
capabilities:
|
|
76
86
|
draft: true
|
|
77
87
|
publish: true
|
|
78
88
|
visibility: false
|
|
79
89
|
custom_thumbnail: false
|
|
80
|
-
series_or_collection:
|
|
90
|
+
series_or_collection: true
|
|
81
91
|
post_status: true
|
|
82
92
|
read_comments: true
|
|
83
93
|
write_comment: true
|
|
@@ -94,5 +104,4 @@ notes:
|
|
|
94
104
|
and use that card's live xsec detail route.
|
|
95
105
|
- The opened post detail view is the canonical place to read comments, write a top-level
|
|
96
106
|
comment, and reply to comments.
|
|
97
|
-
-
|
|
98
|
-
a prior manual browser-harness session created and attached a collection.
|
|
107
|
+
- Optional `collection` selects an existing exact-name collection or creates and joins it when absent.
|
|
@@ -15,3 +15,20 @@ def test_actions_use_current_tab_url_for_navigation_identity():
|
|
|
15
15
|
manage = (HERE / "actions" / "_manage.py").read_text()
|
|
16
16
|
assert '(current_tab().get("url") or "") != url' in post
|
|
17
17
|
assert '(current_tab().get("url") or "") != url' in manage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_video_readiness_uses_editor_state_not_preview():
|
|
21
|
+
source=(HERE/'actions'/'_post.py').read_text()
|
|
22
|
+
assert '上传中' in source
|
|
23
|
+
assert '重新上传' in source
|
|
24
|
+
assert 'Upload processed and editor-ready!' in source
|
|
25
|
+
|
|
26
|
+
def test_collection_and_receipt_contracts_are_present():
|
|
27
|
+
source=(HERE/'actions'/'_post.py').read_text()
|
|
28
|
+
manifest=(HERE/'manifest.yaml').read_text()
|
|
29
|
+
assert 'choose_collection' in source
|
|
30
|
+
assert 'creator_manager_receipt' in source
|
|
31
|
+
assert 'XHS_RECEIPT' in source
|
|
32
|
+
assert 'duplicate_count' in source
|
|
33
|
+
assert 'series_or_collection: true' in manifest
|
|
34
|
+
assert 'collection:' in manifest
|
|
@@ -12,11 +12,14 @@ def next_step(label):
|
|
|
12
12
|
print(f"[{step[0]}/{total_steps}] {label}")
|
|
13
13
|
|
|
14
14
|
def click_btn(text):
|
|
15
|
+
text_s = json.dumps(text)
|
|
15
16
|
for _ in range(5):
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
clicked = js(
|
|
18
|
+
'(function(){ var btns=document.querySelectorAll("ytcp-button"); '
|
|
19
|
+
'var b=Array.from(btns).find(function(el){return el.textContent.trim()===' + text_s + ' && !el.disabled;}); '
|
|
20
|
+
'if(!b)return false; b.click(); return true; })()'
|
|
21
|
+
)
|
|
22
|
+
if clicked:
|
|
20
23
|
return True
|
|
21
24
|
time.sleep(1)
|
|
22
25
|
return False
|
package/pyproject.toml
CHANGED
|
@@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "browser-workspace"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.5"
|
|
8
8
|
description = "Agent browser workspace with persistent Python sessions, tab leasing, snapshots, and platform workflows."
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
dependencies = [
|
|
11
|
+
"browser-harness @ git+https://github.com/lalalic/browser-harness.git@80b74e116b9126f5159c84e5e05a750573e5f192",
|
|
11
12
|
"cdp-use==1.4.5",
|
|
12
13
|
"fetch-use==0.4.0",
|
|
13
14
|
"pillow==12.3.0",
|
|
@@ -15,6 +16,9 @@ dependencies = [
|
|
|
15
16
|
"websockets==15.0.1",
|
|
16
17
|
]
|
|
17
18
|
|
|
19
|
+
[project.scripts]
|
|
20
|
+
browser-workspace = "browser_workspace_app.cli:main"
|
|
21
|
+
|
|
18
22
|
[tool.setuptools]
|
|
19
23
|
package-dir = {"" = "src"}
|
|
20
24
|
|
|
@@ -22,7 +26,7 @@ package-dir = {"" = "src"}
|
|
|
22
26
|
where = ["src"]
|
|
23
27
|
|
|
24
28
|
[tool.setuptools.package-data]
|
|
25
|
-
|
|
29
|
+
browser_workspace_app = ["**/*"]
|
|
26
30
|
|
|
27
31
|
[tool.pytest.ini_options]
|
|
28
32
|
pythonpath = ["src"]
|
package/session_client.py
CHANGED
|
@@ -78,20 +78,30 @@ def ping():
|
|
|
78
78
|
try: return request({'op':'ping'},ensure=False)
|
|
79
79
|
except Exception: return None
|
|
80
80
|
|
|
81
|
+
def owner_is_running():
|
|
82
|
+
"""An exclusive owner lock means a live daemon already exists."""
|
|
83
|
+
owner_path=RUNTIME_DIR/'session.lock'
|
|
84
|
+
with open(owner_path,'a+b') as owner:
|
|
85
|
+
try:
|
|
86
|
+
fcntl.flock(owner,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
87
|
+
except BlockingIOError:
|
|
88
|
+
return True
|
|
89
|
+
finally:
|
|
90
|
+
try: fcntl.flock(owner,fcntl.LOCK_UN)
|
|
91
|
+
except OSError: pass
|
|
92
|
+
return False
|
|
93
|
+
|
|
81
94
|
def ensure_daemon():
|
|
82
95
|
p=ping()
|
|
83
96
|
if p and p.get('ok'): return p
|
|
84
97
|
RUNTIME_DIR.mkdir(parents=True,exist_ok=True)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
if owner_is_running():
|
|
99
|
+
raise RuntimeError('browser-workspace daemon is alive but its socket is unreachable; refusing duplicate startup')
|
|
100
|
+
runtime_python=ensure_runtime_python()
|
|
101
|
+
log=open(RUNTIME_DIR/'session-daemon.log','ab',buffering=0)
|
|
102
|
+
subprocess.Popen([str(runtime_python),str(SOURCE/'session_daemon.py')],stdin=subprocess.DEVNULL,stdout=log,stderr=log,start_new_session=True,close_fds=True)
|
|
103
|
+
deadline=time.time()+15
|
|
104
|
+
while time.time()<deadline:
|
|
105
|
+
time.sleep(.1); p=ping()
|
|
89
106
|
if p and p.get('ok'): return p
|
|
90
|
-
runtime_python=ensure_runtime_python()
|
|
91
|
-
log=open(RUNTIME_DIR/'session-daemon.log','ab',buffering=0)
|
|
92
|
-
subprocess.Popen([str(runtime_python),str(SOURCE/'session_daemon.py')],stdin=subprocess.DEVNULL,stdout=log,stderr=log,start_new_session=True,close_fds=True)
|
|
93
|
-
deadline=time.time()+15
|
|
94
|
-
while time.time()<deadline:
|
|
95
|
-
time.sleep(.1); p=ping()
|
|
96
|
-
if p and p.get('ok'): return p
|
|
97
107
|
raise RuntimeError(f'browser-workspace session daemon did not start; see {RUNTIME_DIR / "session-daemon.log"}')
|