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.
Files changed (117) hide show
  1. package/README.md +16 -2
  2. package/SKILL.md +60 -37
  3. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  4. package/bin/browser-workspace +174 -13
  5. package/package.json +1 -1
  6. package/platform_runner.py +23 -6
  7. package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
  8. package/platforms/chatgpt/tests/test_contract.py +7 -0
  9. package/platforms/wechat-channels/actions/_common.py +1 -1
  10. package/platforms/wechat-channels/actions/_post.py +37 -38
  11. package/platforms/wechat-channels/manifest.yaml +19 -13
  12. package/platforms/xhs/actions/_post.py +109 -20
  13. package/platforms/xhs/manifest.yaml +14 -5
  14. package/platforms/xhs/tests/test_contract.py +17 -0
  15. package/platforms/youtube/actions/_post.py +7 -4
  16. package/pyproject.toml +6 -2
  17. package/session_client.py +21 -11
  18. package/session_daemon.py +115 -9
  19. package/src/browser_workspace.egg-info/PKG-INFO +2 -1
  20. package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
  21. package/src/browser_workspace.egg-info/entry_points.txt +2 -0
  22. package/src/browser_workspace.egg-info/requires.txt +1 -0
  23. package/src/browser_workspace.egg-info/top_level.txt +1 -1
  24. package/src/browser_workspace_app/__init__.py +1 -0
  25. package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
  26. package/src/browser_workspace_app/cli.py +236 -0
  27. package/src/browser_workspace_app/extension/manifest.json +26 -0
  28. package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
  29. package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
  30. package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
  31. package/src/browser_workspace_app/extension/workspace.html +8 -0
  32. package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
  33. package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
  34. package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
  35. package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
  36. package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
  37. package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
  38. package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
  39. package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
  40. package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
  41. package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
  42. package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
  43. package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
  44. package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
  45. package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
  46. package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
  47. package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
  48. package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
  49. package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
  50. package/src/browser_workspace_app/managed_chrome.py +244 -0
  51. package/src/browser_workspace_app/node/admin.mjs +44 -0
  52. package/src/browser_workspace_app/package.json +36 -0
  53. package/src/browser_workspace_app/platform_runner.py +204 -0
  54. package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
  55. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  56. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  57. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
  58. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
  59. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
  60. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
  61. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
  62. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
  63. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
  64. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
  65. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
  66. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
  67. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
  68. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  69. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
  70. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
  71. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
  72. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
  73. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
  74. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
  75. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
  76. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
  77. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
  78. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
  79. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  80. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
  81. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
  82. package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
  83. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
  84. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
  85. package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
  86. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
  87. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
  88. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
  89. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
  90. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
  91. package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
  92. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
  93. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
  94. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
  95. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
  96. package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
  97. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
  98. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
  99. package/src/browser_workspace_app/pyproject.toml +33 -0
  100. package/src/browser_workspace_app/session_client.py +107 -0
  101. package/src/browser_workspace_app/session_daemon.py +431 -0
  102. package/src/browser_harness/__init__.py +0 -2
  103. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  104. package/src/browser_harness/_ipc.py +0 -201
  105. package/src/browser_harness/admin.py +0 -1588
  106. package/src/browser_harness/auth.py +0 -546
  107. package/src/browser_harness/daemon.py +0 -894
  108. package/src/browser_harness/helpers.py +0 -668
  109. package/src/browser_harness/macos.py +0 -131
  110. package/src/browser_harness/mcp_cli.py +0 -16
  111. package/src/browser_harness/paths.py +0 -49
  112. package/src/browser_harness/recorder.py +0 -343
  113. package/src/browser_harness/run.py +0 -413
  114. package/src/browser_harness/telemetry.py +0 -308
  115. package/src/browser_harness/video-template.html +0 -970
  116. package/src/browser_harness/video.py +0 -749
  117. 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
- 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":
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 % 6 == 5:
96
- pct = shadow_js('.querySelector("[class*=progress], .percent")?.textContent || "..."')
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("WARNING: Upload timeout.")
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, 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)
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 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
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
- c = find_btn_by_text("\u53d1\u8868") # 发表
156
- if c:
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
- c = find_btn_by_text("\u4fdd\u5b58\u8349\u7a3f") # 保存草稿
171
- if c:
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: migrated_unverified
3
- last_verified: null
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: 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.
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: migrated_unverified
23
- evidence: Migrated action; current Browser Workspace live verification pending.
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: migrated_unverified
26
- evidence: Migrated manager action; current Browser Workspace live verification
27
- pending.
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: code_only_live_ui_unverified
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: false
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(120):
86
- time.sleep(5)
87
- # Check if upload is complete by looking for video preview or progress
88
- done = js('document.querySelector("[class*=video-player],[class*=videoPreview],video,.upload-success,[class*=uploadDone]") ? "ready" : "wait"')
89
- if done == "ready":
90
- print(" -> Upload done!")
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 % 6 == 5:
95
+ if attempt % 15 == 14:
93
96
  pct = js('var el=document.querySelector("[class*=progress],.percent,[class*=uploadProgress]"); el ? el.textContent : "..."')
94
- print(f" -> Uploading... {pct} ({(attempt+1)*5}s)")
97
+ print(f" -> Upload state {upload_state}; {pct} ({attempt+1}s)")
95
98
  else:
96
- print("WARNING: Upload may still be processing.")
99
+ print("ERROR: video upload never reached the editor-ready state")
97
100
  capture_screenshot()
98
- time.sleep(2)
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
- 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){btn.scrollIntoView({block:"center"});btn.click();"clicked"}else{"missing"}')
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
- box = cdp("DOM.getBoxModel", backendNodeId=target["backendDOMNodeId"])["model"]["content"]
165
- x = sum(box[0::2]) / 4
166
- y = sum(box[1::2]) / 4
167
- click_at_xy(x, y)
168
- publish_result = "clicked"
169
- else:
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
- verified = False
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
- verified = True
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("WARNING: publish click completed but success state is not yet verified")
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: false
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
- - The current adapter does not expose the Neo Vlog collection/series field even though
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
- r = js('var btns=document.querySelectorAll("ytcp-button");var b=Array.from(btns).find(function(x){return x.textContent.trim()==="' + text + '"});b?JSON.stringify(b.getBoundingClientRect()):null')
17
- if r and r != "None":
18
- rect = json.loads(r)
19
- click_at_xy(int(rect["x"]+rect["width"]/2), int(rect["y"]+rect["height"]/2))
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.0"
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
- browser_harness = ["video-template.html"]
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
- lock_path=RUNTIME_DIR/'session-daemon.lock'
86
- with open(lock_path,'a+b') as lock:
87
- fcntl.flock(lock,fcntl.LOCK_EX)
88
- p=ping()
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"}')