browser-workspace 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/PRIVACY.md +21 -0
  2. package/README.md +138 -0
  3. package/SKILL.md +301 -0
  4. package/agent-workspace/agent_helpers.py +718 -0
  5. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  6. package/bin/browser-workspace +75 -0
  7. package/extension/manifest.json +26 -0
  8. package/extension/service-worker.mjs +126 -0
  9. package/extension/workspace-manager.mjs +680 -0
  10. package/extension/workspace-page.mjs +7 -0
  11. package/extension/workspace.html +8 -0
  12. package/interaction-skills/connection.md +52 -0
  13. package/interaction-skills/cookies.md +3 -0
  14. package/interaction-skills/cross-origin-iframes.md +3 -0
  15. package/interaction-skills/dialogs.md +64 -0
  16. package/interaction-skills/downloads.md +3 -0
  17. package/interaction-skills/drag-and-drop.md +3 -0
  18. package/interaction-skills/dropdowns.md +3 -0
  19. package/interaction-skills/iframes.md +3 -0
  20. package/interaction-skills/network-requests.md +3 -0
  21. package/interaction-skills/print-as-pdf.md +3 -0
  22. package/interaction-skills/profile-sync.md +90 -0
  23. package/interaction-skills/screenshots.md +17 -0
  24. package/interaction-skills/scrolling.md +22 -0
  25. package/interaction-skills/shadow-dom.md +3 -0
  26. package/interaction-skills/tabs.md +73 -0
  27. package/interaction-skills/uploads.md +1 -0
  28. package/interaction-skills/viewport.md +16 -0
  29. package/licenses/browser-harness-MIT.txt +21 -0
  30. package/managed_chrome.py +244 -0
  31. package/node/admin.mjs +44 -0
  32. package/package.json +36 -0
  33. package/platform_runner.py +187 -0
  34. package/platforms/chatgpt/README.md +60 -0
  35. package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  36. package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  37. package/platforms/chatgpt/actions/_mcp_app.py +80 -0
  38. package/platforms/chatgpt/actions/_new_thread.py +85 -0
  39. package/platforms/chatgpt/actions/_new_turn.py +33 -0
  40. package/platforms/chatgpt/actions/_project_setup.py +353 -0
  41. package/platforms/chatgpt/actions/_readiness.py +52 -0
  42. package/platforms/chatgpt/actions/_submit_thread.py +129 -0
  43. package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
  44. package/platforms/chatgpt/actions/_thread_turn.py +362 -0
  45. package/platforms/chatgpt/manifest.yaml +34 -0
  46. package/platforms/chatgpt/tests/test_contract.py +176 -0
  47. package/platforms/chrome-web-store/README.md +49 -0
  48. package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  49. package/platforms/chrome-web-store/flows/check-status.md +7 -0
  50. package/platforms/chrome-web-store/flows/open-item.md +10 -0
  51. package/platforms/chrome-web-store/flows/submit-review.md +10 -0
  52. package/platforms/chrome-web-store/flows/update-listing.md +18 -0
  53. package/platforms/chrome-web-store/flows/upload-package.md +11 -0
  54. package/platforms/chrome-web-store/flows/verify-published.md +9 -0
  55. package/platforms/chrome-web-store/manifest.yaml +33 -0
  56. package/platforms/chrome-web-store/tests/test_contract.py +83 -0
  57. package/platforms/microsoft-teams/README.md +32 -0
  58. package/platforms/microsoft-teams/actions/_meeting.py +210 -0
  59. package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  60. package/platforms/microsoft-teams/manifest.yaml +25 -0
  61. package/platforms/microsoft-teams/tests/test_contract.py +24 -0
  62. package/platforms/tiktok/README.md +10 -0
  63. package/platforms/tiktok/actions/_post.py +180 -0
  64. package/platforms/tiktok/manifest.yaml +99 -0
  65. package/platforms/wechat-channels/README.md +10 -0
  66. package/platforms/wechat-channels/actions/_common.py +163 -0
  67. package/platforms/wechat-channels/actions/_manage.py +130 -0
  68. package/platforms/wechat-channels/actions/_post.py +232 -0
  69. package/platforms/wechat-channels/manifest.yaml +118 -0
  70. package/platforms/wechat-channels/tests/test_contract.py +36 -0
  71. package/platforms/xhs/README.md +10 -0
  72. package/platforms/xhs/actions/_manage.py +355 -0
  73. package/platforms/xhs/actions/_post.py +192 -0
  74. package/platforms/xhs/manifest.yaml +98 -0
  75. package/platforms/youtube/README.md +10 -0
  76. package/platforms/youtube/actions/_post.py +100 -0
  77. package/platforms/youtube/manifest.yaml +94 -0
  78. package/pyproject.toml +29 -0
  79. package/session_client.py +97 -0
  80. package/session_daemon.py +325 -0
  81. package/src/browser_harness/__init__.py +2 -0
  82. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  83. package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
  84. package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
  85. package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
  86. package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
  87. package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
  88. package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
  89. package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
  90. package/src/browser_harness/_ipc.py +201 -0
  91. package/src/browser_harness/admin.py +1588 -0
  92. package/src/browser_harness/auth.py +546 -0
  93. package/src/browser_harness/daemon.py +894 -0
  94. package/src/browser_harness/helpers.py +668 -0
  95. package/src/browser_harness/macos.py +131 -0
  96. package/src/browser_harness/mcp_cli.py +16 -0
  97. package/src/browser_harness/paths.py +49 -0
  98. package/src/browser_harness/recorder.py +343 -0
  99. package/src/browser_harness/run.py +413 -0
  100. package/src/browser_harness/telemetry.py +308 -0
  101. package/src/browser_harness/video-template.html +970 -0
  102. package/src/browser_harness/video.py +749 -0
  103. package/src/browser_harness/video_render.py +524 -0
  104. package/src/browser_workspace.egg-info/PKG-INFO +10 -0
  105. package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
  106. package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
  107. package/src/browser_workspace.egg-info/requires.txt +5 -0
  108. package/src/browser_workspace.egg-info/top_level.txt +1 -0
@@ -0,0 +1,355 @@
1
+ import json, time, re
2
+
3
+ CFG = json.load(open("__CFG_PATH__"))
4
+ OP = CFG["operation"]
5
+ TARGET_NOTE_ID = CFG.get("note_id")
6
+ TARGET_TITLE = CFG.get("title")
7
+
8
+
9
+
10
+ def open_or_reuse_xhs(url):
11
+ current=current_tab()
12
+ current_url=current.get("url","")
13
+ if "xiaohongshu.com" in current_url:
14
+ target=current
15
+ else:
16
+ tabs=[t for t in list_tabs() if "xiaohongshu.com" in (t.get("url") or "")]
17
+ target=tabs[0] if tabs else None
18
+ if target:
19
+ switch_tab(target)
20
+ if page_info()["url"] != url:
21
+ goto_url(url)
22
+ else:
23
+ new_tab(url)
24
+ return current_tab()
25
+
26
+ def out(payload):
27
+ print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
28
+
29
+
30
+ def resolve_manager_card():
31
+ open_or_reuse_xhs("https://creator.xiaohongshu.com/new/note-manager")
32
+ try:
33
+ wait_for_load()
34
+ except Exception:
35
+ pass
36
+ raw=""
37
+ for _ in range(20):
38
+ time.sleep(.5)
39
+ try:
40
+ 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():"", impression:c.dataset.impression||"", text:(c.innerText||"").trim()})}).join("\\n")') or ""
41
+ except Exception:
42
+ raw=""
43
+ if raw:
44
+ break
45
+ rows=[]
46
+ for line in (raw or "").splitlines():
47
+ try:
48
+ row=json.loads(line)
49
+ imp=json.loads(row.get("impression") or "{}")
50
+ row["note_id"]=(((imp.get("noteTarget") or {}).get("value") or {}).get("noteId"))
51
+ rows.append(row)
52
+ except Exception:
53
+ pass
54
+ for row in rows:
55
+ if TARGET_NOTE_ID and row.get("note_id")==TARGET_NOTE_ID:
56
+ return row
57
+ if TARGET_TITLE and row.get("title")==TARGET_TITLE:
58
+ return row
59
+ return None
60
+
61
+
62
+ def card_exists_in_current_tab(note_id, title):
63
+ return js('Array.from(document.querySelectorAll(".note-card")).some(function(c){var t=c.querySelector(".note-card__title"); var ttl=t?(t.innerText||t.textContent||"").trim():""; var imp=c.dataset.impression||""; return '+json.dumps(bool(note_id))+' ? imp.indexOf('+json.dumps(note_id or "")+')>=0 : ttl==='+json.dumps(title or "")+'}) ? "yes" : "no"') == "yes"
64
+
65
+
66
+ def resolve_status(row):
67
+ text=row.get("text") or ""
68
+ if "审核中" in text:
69
+ return "reviewing","审核中"
70
+ if "未通过" in text:
71
+ return "rejected","未通过"
72
+ for label,status in [("审核中","reviewing"),("未通过","rejected"),("已发布","published")]:
73
+ clicked=js('var es=Array.from(document.querySelectorAll("*")).filter(function(e){return e.children.length===0 && e.offsetParent && (e.innerText||e.textContent||"").trim()==='+json.dumps(label)+'}); if(es.length){es[0].click();"yes"}else{"no"}')
74
+ if clicked=="yes":
75
+ time.sleep(1.2)
76
+ if card_exists_in_current_tab(row.get("note_id"), row.get("title")):
77
+ return status,label
78
+ return "unknown","unknown"
79
+
80
+
81
+ def open_update_editor(row):
82
+ note_id=row.get("note_id") or ""
83
+ title=row.get("title") or ""
84
+ clicked=js('var cards=Array.from(document.querySelectorAll(".note-card")); var c=cards.find(function(c){var t=c.querySelector(".note-card__title"); var ttl=t?(t.innerText||t.textContent||"").trim():""; var imp=c.dataset.impression||""; return '+json.dumps(bool(note_id))+' ? imp.indexOf('+json.dumps(note_id)+')>=0 : ttl==='+json.dumps(title)+'}); if(!c){"missing-card"}else{var a=c.querySelectorAll(".note-card__action-btn")[3]; if(a){a.click();"clicked"}else{"missing-edit"}}')
85
+ if clicked!="clicked":
86
+ return None, clicked
87
+ for _ in range(30):
88
+ time.sleep(.5)
89
+ url=page_info()["url"]
90
+ if "/publish/update" in url:
91
+ tab=current_tab()
92
+ return {"update_url":url,"target_id":tab.get("targetId") or tab.get("target_id")}, None
93
+ return None,"update_editor_timeout"
94
+
95
+
96
+ def editor_media_kind():
97
+ accepts=""
98
+ for _ in range(30):
99
+ try:
100
+ accepts=js('Array.from(document.querySelectorAll("input[type=file]")).map(function(e){return e.accept||""}).join("|")') or ""
101
+ except Exception:
102
+ accepts=""
103
+ if accepts:
104
+ break
105
+ time.sleep(.5)
106
+ if any(ext in accepts for ext in (".mp4",".mov",".mkv",".mpeg")):
107
+ return "video"
108
+ if any(ext in accepts for ext in (".jpg",".jpeg",".png",".webp")):
109
+ return "image"
110
+ return "unknown"
111
+
112
+
113
+ def set_update_fields(editor):
114
+ target_id=editor.get("target_id")
115
+ if target_id:
116
+ switch_tab(target_id)
117
+ if CFG.get("video"):
118
+ if editor_media_kind()!="video":
119
+ return False,"media_type_mismatch_video"
120
+ upload_file('input[type=file][accept*=".mp4"],input.upload-input', CFG["video"])
121
+ if target_id:
122
+ switch_tab(target_id)
123
+ for _ in range(120):
124
+ time.sleep(1)
125
+ text=js('(document.body.innerText||"").slice(0,1800)') or ""
126
+ # Once the editor is usable again and no obvious upload-progress copy is visible,
127
+ # proceed; final publish button enabled state is checked below.
128
+ if "上传中" not in text and "处理中" not in text:
129
+ break
130
+ if CFG.get("images"):
131
+ if editor_media_kind()!="image":
132
+ return False,"media_type_mismatch_image"
133
+ selector='input[type=file][accept*=".jpg"],input[type=file][accept*=".jpeg"],input[type=file][accept*=".png"]'
134
+ # upload_file accepts one file per call; replacing one image is
135
+ # enough for current Vlog use. Multiple-image update remains explicit and bounded.
136
+ first=True
137
+ for image in CFG["images"]:
138
+ upload_file(selector, image)
139
+ if target_id:
140
+ switch_tab(target_id)
141
+ time.sleep(1.5)
142
+ first=False
143
+ if target_id:
144
+ switch_tab(target_id)
145
+ if CFG.get("new_title") is not None:
146
+ fill_input('input[placeholder="填写标题会有更多赞哦"]', CFG["new_title"], clear_first=True, timeout=20)
147
+ if CFG.get("body") is not None:
148
+ fill_input('.tiptap.ProseMirror,[contenteditable=true]', CFG["body"], clear_first=True, timeout=20)
149
+ return True,None
150
+
151
+
152
+ def submit_update():
153
+ # Upload processing can keep the publish control disabled for a while. Wait for
154
+ # the real control to become enabled rather than failing immediately.
155
+ clicked=False
156
+ for _ in range(180):
157
+ try:
158
+ state=js('var b=Array.from(document.querySelectorAll("button,[role=button]")).find(function(e){return e.offsetParent && (e.innerText||e.textContent||"").trim()==="发布"}); if(!b){"missing"}else if(b.disabled || (b.getAttribute("aria-disabled")||"false")==="true"){"disabled"}else{b.click();"clicked"}')
159
+ if state=="clicked":
160
+ clicked=True
161
+ break
162
+ if state=="missing":
163
+ nodes=cdp("Accessibility.getFullAXTree")["nodes"]
164
+ 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)
165
+ if target:
166
+ obj=cdp("DOM.resolveNode", backendNodeId=target["backendDOMNodeId"])["object"]["objectId"]
167
+ value=cdp("Runtime.callFunctionOn", objectId=obj, functionDeclaration='function(){if(this.disabled||this.getAttribute("aria-disabled")==="true")return "disabled";this.click();return "clicked"}', returnByValue=True)["result"].get("value")
168
+ if value=="clicked":
169
+ clicked=True
170
+ break
171
+ except Exception:
172
+ pass
173
+ time.sleep(1)
174
+ if not clicked:
175
+ return False,"publish_button_missing_or_disabled"
176
+ for _ in range(30):
177
+ time.sleep(1)
178
+ try:
179
+ url=js('location.href') or ""
180
+ if "/publish/success" in url or "published=true" in url:
181
+ return True,None
182
+ except Exception:
183
+ pass
184
+ return False,"update_submit_unverified"
185
+
186
+
187
+ def own_profile_url():
188
+ open_or_reuse_xhs("https://www.xiaohongshu.com")
189
+ time.sleep(3)
190
+ href=js('var a=Array.from(document.querySelectorAll("a[href*=\\"/user/profile/\\"]")).find(function(a){return (a.innerText||a.textContent||"").trim()==="我"}); a?a.href:""')
191
+ return href or None
192
+
193
+
194
+ def open_post_detail(note_id):
195
+ profile=own_profile_url()
196
+ if not profile:
197
+ return None, "own_profile_not_found"
198
+ goto_url(profile); time.sleep(3)
199
+ selector='section.note-item[data-note-id="'+note_id+'"]'
200
+ href=""
201
+ for _ in range(30):
202
+ href=js('var e=document.querySelector('+json.dumps(selector)+' ); var a=e&&e.querySelector("a.cover,a.title"); a?a.href:""')
203
+ if href:
204
+ break
205
+ js('window.scrollBy(0, Math.max(window.innerHeight*0.85, 600)); "scrolled"')
206
+ time.sleep(.6)
207
+ if not href:
208
+ return None, "post_not_visible_in_my_posts"
209
+ # The My Posts card carries the live xsec token. Following that route opens the
210
+ # post detail overlay/page; raw /explore/<id> URLs can 404 without this token.
211
+ goto_url(href); time.sleep(3)
212
+ ready=js('document.querySelector(".note-container") ? "yes" : "no"')
213
+ if ready!="yes":
214
+ return None, "post_detail_not_opened"
215
+ return {"profile_url":profile,"detail_url":page_info()["url"]}, None
216
+
217
+
218
+ def expand_replies():
219
+ for _ in range(8):
220
+ n=js('var es=Array.from(document.querySelectorAll(".comments-el *")).filter(function(e){var t=(e.innerText||e.textContent||"").trim(); return e.offsetParent && e.children.length===0 && (/^展开\\s*\\d+\\s*条回复$/.test(t) || t==="展开更多回复" || t==="展开更多")}); es.slice(0,20).forEach(function(e){e.click()}); es.length')
221
+ try:
222
+ count=int(n or 0)
223
+ except Exception:
224
+ count=0
225
+ if count<=0:
226
+ break
227
+ time.sleep(.8)
228
+
229
+
230
+ def detail_comments(limit):
231
+ expand_replies()
232
+ raw=js('Array.from(document.querySelectorAll(".comment-item")).map(function(c,i){var author=c.querySelector(".author .name"); var content=c.querySelector(".content .note-text"); var date=c.querySelector(".date > span:first-child"); var loc=c.querySelector(".date .location"); var reply=c.querySelector(".reply .count"); return JSON.stringify({dom_index:i,comment_id:(c.id||"").replace(/^comment-/,""),author:author?(author.innerText||author.textContent||"").trim():"",text:content?(content.innerText||content.textContent||"").trim():"",date:date?(date.innerText||date.textContent||"").trim():"",location:loc?(loc.innerText||loc.textContent||"").trim():"",is_reply:c.classList.contains("comment-item-sub"),reply_label:reply?(reply.innerText||reply.textContent||"").trim():"",replyable:!!c.querySelector(".reply")})}).join("\\n")')
233
+ rows=[]
234
+ for line in (raw or "").splitlines():
235
+ try:
236
+ row=json.loads(line)
237
+ row["comment_index"]=len(rows)
238
+ rows.append(row)
239
+ if len(rows)>=limit:
240
+ break
241
+ except Exception:
242
+ pass
243
+ return rows
244
+
245
+
246
+ def activate_composer_for_comment():
247
+ # Top-level composer is present but inert until the visible "评论" affordance is clicked.
248
+ active=js('var e=document.querySelector("p.content-input[contenteditable=true]"); e&&e.offsetParent ? "yes" : "no"')
249
+ if active=="yes":
250
+ return True
251
+ clicked=js('var e=document.querySelector(".content-edit .inner-when-not-active, .content-edit .inner"); if(e){e.click();"yes"}else{"no"}')
252
+ if clicked!="yes":
253
+ return False
254
+ time.sleep(.4)
255
+ return js('var e=document.querySelector("p.content-input[contenteditable=true]"); e&&e.offsetParent ? "yes" : "no"')=="yes"
256
+
257
+
258
+ def type_and_send(text):
259
+ focused=js('var e=document.querySelector("p.content-input[contenteditable=true]"); if(e&&e.offsetParent){e.focus();e.click();"yes"}else{"no"}')
260
+ if focused!="yes":
261
+ return False,"comment_editor_missing"
262
+ type_text(text)
263
+ time.sleep(.2)
264
+ sent=js('var b=document.querySelector("button.btn.submit,button.submit"); if(b && b.offsetParent && !b.disabled){b.click();"yes"}else{"no"}')
265
+ if sent!="yes":
266
+ return False,"comment_submit_missing_or_disabled"
267
+ time.sleep(1.5)
268
+ return True,None
269
+
270
+
271
+ row=resolve_manager_card()
272
+ if not row:
273
+ out({"ok":False,"operation":OP,"error":"post_not_found","note_id":TARGET_NOTE_ID,"title":TARGET_TITLE})
274
+ raise SystemExit(2)
275
+
276
+ if OP=="status":
277
+ status,label=resolve_status(row)
278
+ out({"ok":True,"operation":"status","note_id":row.get("note_id"),"title":row.get("title"),"status":status,"status_label":label,"card_text":row.get("text")})
279
+ raise SystemExit(0)
280
+
281
+ if OP=="update":
282
+ editor,err=open_update_editor(row)
283
+ if err:
284
+ out({"ok":False,"operation":"update","error":err,"note_id":row.get("note_id"),"title":row.get("title")}); raise SystemExit(5)
285
+ ok,err=set_update_fields(editor)
286
+ if not ok:
287
+ out({"ok":False,"operation":"update","error":err,"note_id":row.get("note_id"),"title":row.get("title"),"media_kind":editor_media_kind()}); raise SystemExit(4)
288
+ submit_ok,submit_err=submit_update()
289
+ # XHS edits sometimes apply without a stable success redirect. Platform-side
290
+ # note-manager verification is authoritative and prevents unsafe blind retries.
291
+ open_or_reuse_xhs("https://creator.xiaohongshu.com/new/note-manager")
292
+ try:
293
+ wait_for_load()
294
+ except Exception:
295
+ pass
296
+ time.sleep(2)
297
+ new_title=CFG.get("new_title") or row.get("title")
298
+ found=card_exists_in_current_tab(row.get("note_id"), new_title)
299
+ if found:
300
+ out({"ok":True,"operation":"update","note_id":row.get("note_id"),"old_title":row.get("title"),"title":new_title,"media_replaced":bool(CFG.get("video") or CFG.get("images")),"verified":True,"submit_redirect_verified":submit_ok})
301
+ raise SystemExit(0)
302
+ out({"ok":False,"operation":"update","error":submit_err or "platform_verification_failed","note_id":row.get("note_id"),"old_title":row.get("title"),"title":new_title,"verified":False})
303
+ raise SystemExit(8)
304
+
305
+ route,err=open_post_detail(row.get("note_id"))
306
+ if err:
307
+ out({"ok":False,"operation":OP,"error":err,"note_id":row.get("note_id"),"title":row.get("title")})
308
+ raise SystemExit(6)
309
+
310
+ if OP=="comments":
311
+ rows=detail_comments(int(CFG.get("limit",50)))
312
+ out({"ok":True,"operation":"comments","note_id":row.get("note_id"),"title":row.get("title"),"source":"my_posts_detail","detail_url":route["detail_url"],"count":len(rows),"comments":rows})
313
+ raise SystemExit(0)
314
+
315
+ if OP=="comment":
316
+ if not activate_composer_for_comment():
317
+ out({"ok":False,"operation":"comment","error":"comment_editor_missing"}); raise SystemExit(5)
318
+ ok,send_err=type_and_send(CFG["text"])
319
+ if not ok:
320
+ out({"ok":False,"operation":"comment","error":send_err}); raise SystemExit(5)
321
+ verified=js('Array.from(document.querySelectorAll(".comment-item .content .note-text")).some(function(e){return (e.innerText||e.textContent||"").trim()==='+json.dumps(CFG["text"])+ '}) ? "yes" : "no"')=="yes"
322
+ out({"ok":verified,"operation":"comment","note_id":row.get("note_id"),"title":row.get("title"),"text":CFG["text"],"verified":verified})
323
+ raise SystemExit(0 if verified else 7)
324
+
325
+ if OP=="reply":
326
+ rows=detail_comments(200)
327
+ target=None
328
+ if CFG.get("comment_id"):
329
+ target=next((r for r in rows if r.get("comment_id")==CFG["comment_id"]),None)
330
+ elif CFG.get("comment_index") is not None:
331
+ idx=int(CFG["comment_index"]); target=next((r for r in rows if r["comment_index"]==idx),None)
332
+ else:
333
+ needle=CFG.get("contains") or ""
334
+ matches=[r for r in rows if needle in (r.get("text") or "")]
335
+ if len(matches)==1: target=matches[0]
336
+ elif len(matches)>1:
337
+ out({"ok":False,"operation":"reply","error":"ambiguous_comment_match","matches":len(matches)}); raise SystemExit(3)
338
+ if not target:
339
+ out({"ok":False,"operation":"reply","error":"comment_not_found"}); raise SystemExit(2)
340
+ if not target.get("replyable"):
341
+ out({"ok":False,"operation":"reply","error":"comment_not_replyable","comment":target}); raise SystemExit(4)
342
+ cid=target.get("comment_id")
343
+ opened=js('var c=document.getElementById('+json.dumps("comment-"+(cid or ""))+'); var r=c&&c.querySelector(".reply"); if(r){r.click();"yes"}else{"no"}')
344
+ if opened!="yes":
345
+ out({"ok":False,"operation":"reply","error":"reply_ui_missing"}); raise SystemExit(5)
346
+ time.sleep(.4)
347
+ ok,send_err=type_and_send(CFG["text"])
348
+ if not ok:
349
+ out({"ok":False,"operation":"reply","error":send_err}); raise SystemExit(5)
350
+ verified=js('Array.from(document.querySelectorAll(".comment-item .content .note-text")).some(function(e){return (e.innerText||e.textContent||"").trim()==='+json.dumps(CFG["text"])+ '}) ? "yes" : "no"')=="yes"
351
+ out({"ok":verified,"operation":"reply","note_id":row.get("note_id"),"title":row.get("title"),"replied_to":target,"text":CFG["text"],"verified":verified})
352
+ raise SystemExit(0 if verified else 7)
353
+
354
+ out({"ok":False,"error":"unsupported_operation","operation":OP})
355
+ raise SystemExit(2)
@@ -0,0 +1,192 @@
1
+ import time, json, os
2
+
3
+ CFG = json.load(open("__CFG_PATH__"))
4
+
5
+ # NOTE: XHS overrides JSON.stringify (anti-bot). This script avoids it entirely.
6
+ # Use js() for scalar returns only; use .click() instead of click_at_xy with rects.
7
+
8
+ mode = CFG.get("mode", "image")
9
+
10
+ def open_or_reuse_xhs(url):
11
+ current=current_tab()
12
+ current_url=current.get("url","")
13
+ if "xiaohongshu.com" in current_url:
14
+ target=current
15
+ else:
16
+ tabs=[t for t in list_tabs() if "xiaohongshu.com" in (t.get("url") or "")]
17
+ target=tabs[0] if tabs else None
18
+ if target:
19
+ switch_tab(target)
20
+ if page_info()["url"] != url:
21
+ goto_url(url)
22
+ else:
23
+ new_tab(url)
24
+ return current_tab()
25
+
26
+ images = CFG.get("images", [CFG.get("image", "")])
27
+ video = CFG.get("video", "")
28
+ tags = CFG.get("tags", [])
29
+
30
+ # Calculate steps dynamically
31
+ base_steps = 6 # navigate, tab, upload, title, body, action
32
+ extra = 0
33
+ if mode == "image" and len(images) > 1:
34
+ extra += 1
35
+ if mode == "video":
36
+ extra += 1 # wait for upload processing
37
+ if tags:
38
+ extra += 1
39
+ total_steps = base_steps + extra
40
+ step = [0]
41
+ def next_step(label):
42
+ step[0] += 1
43
+ print(f"[{step[0]}/{total_steps}] {label}")
44
+
45
+ next_step("Navigating to XHS creator...")
46
+ open_or_reuse_xhs("https://creator.xiaohongshu.com/publish/publish")
47
+ wait_for_load()
48
+ time.sleep(3)
49
+
50
+ url = page_info()["url"]
51
+ if "login" in url:
52
+ print("ERROR: Not logged in. Log in at creator.xiaohongshu.com first.")
53
+ capture_screenshot()
54
+ raise SystemExit(1)
55
+
56
+ if mode == "video":
57
+ # Video tab is the default (first) tab — no need to click
58
+ next_step("Using video upload tab (default)...")
59
+ time.sleep(1)
60
+
61
+ next_step("Uploading video: " + video)
62
+ # Wait for the upload input; XHS can render the tab shell before mounting the file input.
63
+ for _ in range(20):
64
+ ready = js('document.querySelector(".upload-input,input[type=file]") ? "ready" : "wait"')
65
+ if ready == "ready":
66
+ break
67
+ time.sleep(0.5)
68
+ # Try upload_file first, fall back to DOM.performSearch for hidden inputs
69
+ try:
70
+ upload_file(".upload-input,input[type=file][accept*=video],input[type=file]", video)
71
+ except Exception:
72
+ doc = cdp("DOM.getDocument", depth=-1, pierce=True)
73
+ sr = cdp("DOM.performSearch", query='input[type="file"]')
74
+ if sr.get("resultCount", 0) > 0:
75
+ nodes = cdp("DOM.getSearchResults", searchId=sr["searchId"], fromIndex=0, toIndex=1)
76
+ nid = nodes["nodeIds"][0]
77
+ cdp("DOM.setFileInputFiles", files=[video], nodeId=nid)
78
+ cdp("DOM.discardSearchResults", searchId=sr["searchId"])
79
+ else:
80
+ print("ERROR: Cannot find file input for video upload.")
81
+ capture_screenshot()
82
+ raise SystemExit(1)
83
+
84
+ 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!")
91
+ break
92
+ if attempt % 6 == 5:
93
+ pct = js('var el=document.querySelector("[class*=progress],.percent,[class*=uploadProgress]"); el ? el.textContent : "..."')
94
+ print(f" -> Uploading... {pct} ({(attempt+1)*5}s)")
95
+ else:
96
+ print("WARNING: Upload may still be processing.")
97
+ capture_screenshot()
98
+ time.sleep(2)
99
+
100
+ else:
101
+ # Image mode — click the 2nd tab (上传图文)
102
+ next_step("Switching to image+text tab...")
103
+ js('var tabs=document.querySelectorAll(".creator-tab-item,.tab-item,[class*=publishTypeTabs] span,[class*=publish-type] span");if(tabs.length>=2){tabs[1].click()}else{var all=document.querySelectorAll("span");for(var i=0;i<all.length;i++){if(all[i].textContent.indexOf("\\u56fe\\u6587")>=0){all[i].click();break}}};"done"')
104
+ time.sleep(2)
105
+
106
+ check = js('document.querySelector(".upload-input") ? "found" : "notfound"')
107
+ if check != "found":
108
+ js('var spans=document.querySelectorAll("span,div,a");for(var i=0;i<spans.length;i++){var t=spans[i].textContent;if(t&&t.indexOf("\\u56fe\\u6587")>=0&&spans[i].offsetWidth>0){spans[i].click();break}};"retry"')
109
+ time.sleep(2)
110
+
111
+ next_step("Uploading image: " + images[0])
112
+ upload_file(".upload-input", images[0])
113
+ time.sleep(4)
114
+
115
+ # Upload additional images if any
116
+ if len(images) > 1:
117
+ next_step(f"Uploading {len(images)-1} more image(s)...")
118
+ for i, img in enumerate(images[1:], 2):
119
+ add_btn = js('document.querySelector(".add-upload,.upload-more,.add-btn,[class*=addImage]") ? "found" : "notfound"')
120
+ if add_btn == "found":
121
+ upload_file(".add-upload,.upload-more,.add-btn,[class*=addImage] input[type=file],.upload-input", img)
122
+ else:
123
+ upload_file(".upload-input", img)
124
+ print(f" -> Image {i}/{len(images)}: {os.path.basename(img)}")
125
+ time.sleep(3)
126
+
127
+ next_step("Setting title: " + CFG["title"])
128
+ title_escaped = json.dumps(CFG["title"])
129
+ title_set = js("var inp=document.querySelector('input[placeholder=\"填写标题会有更多赞哦\"],.c-input_inner input,input[placeholder]');if(inp){var ns=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;ns.call(inp," + title_escaped + " );inp.dispatchEvent(new Event('input',{bubbles:true}));inp.dispatchEvent(new Event('change',{bubbles:true}));'done'}else{'missing'}")
130
+ if title_set != "done":
131
+ print("ERROR: title input not found")
132
+ capture_screenshot()
133
+ raise SystemExit(1)
134
+ time.sleep(0.5)
135
+
136
+ next_step("Setting body text...")
137
+ # Click into the editor area then type
138
+ editor_set = js('var ed=document.querySelector(".tiptap.ProseMirror,#post-textarea,.ql-editor,[contenteditable=true]");if(ed){ed.focus();ed.click();"done"}else{"missing"}')
139
+ if editor_set != "done":
140
+ print("ERROR: body editor not found")
141
+ capture_screenshot()
142
+ raise SystemExit(1)
143
+ time.sleep(0.3)
144
+
145
+ # Build body with tags appended as hashtags
146
+ body = CFG["body"]
147
+ if tags:
148
+ tag_str = " " + " ".join(f"#{t}" for t in tags)
149
+ body = body + tag_str
150
+ type_text(body)
151
+ time.sleep(0.5)
152
+
153
+ if tags:
154
+ next_step(f"Tags appended: {', '.join(tags)}")
155
+
156
+ if CFG["action"] == "publish":
157
+ 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"}')
159
+ if publish_result != "clicked":
160
+ # XHS may expose the publish control only through the accessibility tree.
161
+ nodes = cdp("Accessibility.getFullAXTree")["nodes"]
162
+ 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
+ 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:
170
+ print("ERROR: publish button not found")
171
+ capture_screenshot()
172
+ raise SystemExit(1)
173
+ verified = False
174
+ for _ in range(20):
175
+ time.sleep(1)
176
+ state = js('location.href.indexOf("publish/success")>=0 || /发布成功|成功发布/.test(document.body.innerText||"") ? "success" : "wait"')
177
+ if state == "success":
178
+ verified = True
179
+ break
180
+ if not verified:
181
+ print("WARNING: publish click completed but success state is not yet verified")
182
+ else:
183
+ next_step("Saving as draft...")
184
+ # Find 暂存 button by unicode
185
+ js('var btns=document.querySelectorAll("button");for(var i=0;i<btns.length;i++){if(btns[i].textContent.indexOf("\\u6682\\u5b58")>=0){btns[i].click();break}};"done"')
186
+
187
+ time.sleep(3)
188
+ capture_screenshot()
189
+ if CFG["action"] == "publish":
190
+ print("Done! Post published." if verified else "Done! Post submitted; verification pending.")
191
+ else:
192
+ print("Done! Post saved as draft.")
@@ -0,0 +1,98 @@
1
+ platform: xhs
2
+ status: migrated_unverified
3
+ last_verified: null
4
+ actions:
5
+ post: actions/_post.py
6
+ manage: actions/_manage.py
7
+ verification:
8
+ status: migrated_unverified
9
+ last_verified: null
10
+ evidence: Existing post and creator-manager mechanics migrated from the working
11
+ post adapter. Current Browser Workspace live verification pending.
12
+ url_patterns:
13
+ - https://creator.xiaohongshu.com/*
14
+ authentication:
15
+ required: true
16
+ notes: Use the caller's authenticated browser context; never store credentials.
17
+ provenance:
18
+ source: skills/post/scripts/platforms/xhs
19
+ migrated_code: true
20
+ flows:
21
+ post:
22
+ status: migrated_unverified
23
+ evidence: Migrated action; current Browser Workspace live verification pending.
24
+ manage:
25
+ status: migrated_unverified
26
+ evidence: Migrated manager action; current Browser Workspace live verification
27
+ pending.
28
+ display_name: Xiaohongshu
29
+ aliases:
30
+ - xiaohongshu
31
+ media:
32
+ modes:
33
+ - video
34
+ - images
35
+ video:
36
+ required_when_mode: video
37
+ formats:
38
+ - mp4
39
+ images:
40
+ required_when_mode: images
41
+ formats:
42
+ - jpg
43
+ - png
44
+ - webp
45
+ max_count: 18
46
+ mutually_exclusive:
47
+ - video
48
+ - images
49
+ fields:
50
+ title:
51
+ cli: --title
52
+ required: true
53
+ type: string
54
+ max_chars: 20
55
+ implemented: true
56
+ body:
57
+ cli: --body
58
+ required: true
59
+ type: string
60
+ max_chars: 1000
61
+ implemented: true
62
+ tags:
63
+ cli: --tags
64
+ required: false
65
+ type: string_list
66
+ max_count: 5
67
+ implemented: true
68
+ render: 'append as #tag'
69
+ publish:
70
+ cli: --publish
71
+ required: false
72
+ type: boolean
73
+ default: false
74
+ implemented: true
75
+ capabilities:
76
+ draft: true
77
+ publish: true
78
+ visibility: false
79
+ custom_thumbnail: false
80
+ series_or_collection: false
81
+ post_status: true
82
+ read_comments: true
83
+ write_comment: true
84
+ reply_comments: true
85
+ content_mapping:
86
+ headline: title
87
+ main_text: body
88
+ discovery_terms: tags
89
+ notes:
90
+ - Without --publish, the adapter saves a draft.
91
+ - After --publish, the XHS adapter automatically verifies the created note in creator
92
+ note manager and reports published/reviewing/rejected state.
93
+ - comments/comment/reply resolve the post from the signed-in My Posts profile list
94
+ and use that card's live xsec detail route.
95
+ - The opened post detail view is the canonical place to read comments, write a top-level
96
+ 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.
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: youtube-platform
3
+ description: "YouTube Studio Browser Workspace upload mechanics."
4
+ ---
5
+
6
+ # YouTube
7
+
8
+ Use the parent Browser Workspace skill contract first. The
9
+ `actions/` directory contains the canonical migrated Studio upload
10
+ mechanics; `skills/post` retains payload and publishing orchestration.