browser-workspace 0.4.5 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  2. package/package.json +1 -1
  3. package/platform_runner.py +11 -2
  4. package/platforms/chatgpt/actions/_lifecycle_common.py +73 -20
  5. package/platforms/chatgpt/actions/_new_turn.py +1 -0
  6. package/platforms/chatgpt/actions/_readiness.py +29 -3
  7. package/platforms/chatgpt/actions/_submit_thread.py +20 -3
  8. package/platforms/chatgpt/actions/_thread_turn.py +31 -2
  9. package/platforms/chatgpt/tests/test_contract.py +107 -1
  10. package/platforms/iyf/AIRPLAY.md +25 -0
  11. package/platforms/iyf/actions/_find.py +18 -0
  12. package/platforms/iyf/actions/_open.py +10 -0
  13. package/platforms/iyf/manifest.yaml +11 -0
  14. package/platforms/iyf/safari-airplay +57 -0
  15. package/pyproject.toml +2 -2
  16. package/session_client.py +10 -0
  17. package/src/browser_workspace.egg-info/PKG-INFO +2 -2
  18. package/src/browser_workspace.egg-info/SOURCES.txt +53 -46
  19. package/src/browser_workspace.egg-info/requires.txt +1 -1
  20. package/src/browser_workspace_app/package.json +1 -1
  21. package/src/browser_workspace_app/pyproject.toml +2 -2
  22. package/src/browser_workspace_app/session_client.py +10 -0
  23. package/src/browser_workspace_app/platform_runner.py +0 -204
  24. package/src/browser_workspace_app/platforms/chatgpt/README.md +0 -60
  25. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +0 -58
  26. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +0 -119
  27. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +0 -80
  28. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +0 -85
  29. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +0 -33
  30. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +0 -353
  31. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +0 -52
  32. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +0 -129
  33. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +0 -591
  34. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +0 -362
  35. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +0 -34
  36. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +0 -183
  37. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +0 -49
  38. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +0 -425
  39. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +0 -7
  40. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +0 -10
  41. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +0 -10
  42. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +0 -18
  43. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +0 -11
  44. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +0 -9
  45. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +0 -33
  46. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +0 -83
  47. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +0 -32
  48. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +0 -210
  49. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +0 -21
  50. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +0 -25
  51. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +0 -24
  52. package/src/browser_workspace_app/platforms/tiktok/README.md +0 -10
  53. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +0 -180
  54. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +0 -99
  55. package/src/browser_workspace_app/platforms/wechat-channels/README.md +0 -10
  56. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +0 -163
  57. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +0 -130
  58. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +0 -231
  59. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +0 -124
  60. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +0 -36
  61. package/src/browser_workspace_app/platforms/xhs/README.md +0 -10
  62. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +0 -355
  63. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +0 -281
  64. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +0 -107
  65. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +0 -34
  66. package/src/browser_workspace_app/platforms/youtube/README.md +0 -10
  67. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +0 -103
  68. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +0 -94
@@ -1,355 +0,0 @@
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 (current_tab().get("url") or "") != 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=current_tab().get("url") or ""
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":current_tab().get("url") or ""}, 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)
@@ -1,281 +0,0 @@
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 (current_tab().get("url") or "") != 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
- collection = (CFG.get("collection") or "").strip()
30
- collection_description = (CFG.get("collection_description") or "").strip()
31
-
32
- # Calculate steps dynamically
33
- base_steps = 6 # navigate, tab, upload, title, body, action
34
- extra = 0
35
- if mode == "image" and len(images) > 1:
36
- extra += 1
37
- if mode == "video":
38
- extra += 1 # wait for upload processing
39
- if tags:
40
- extra += 1
41
- total_steps = base_steps + extra
42
- step = [0]
43
- def next_step(label):
44
- step[0] += 1
45
- print(f"[{step[0]}/{total_steps}] {label}")
46
-
47
- next_step("Navigating to XHS creator...")
48
- open_or_reuse_xhs("https://creator.xiaohongshu.com/publish/publish")
49
- wait_for_load()
50
- time.sleep(3)
51
-
52
- url = current_tab().get("url") or ""
53
- if "login" in url:
54
- print("ERROR: Not logged in. Log in at creator.xiaohongshu.com first.")
55
- capture_screenshot()
56
- raise SystemExit(1)
57
-
58
- if mode == "video":
59
- # Video tab is the default (first) tab — no need to click
60
- next_step("Using video upload tab (default)...")
61
- time.sleep(1)
62
-
63
- next_step("Uploading video: " + video)
64
- # Wait for the upload input; XHS can render the tab shell before mounting the file input.
65
- for _ in range(20):
66
- ready = js('document.querySelector(".upload-input,input[type=file]") ? "ready" : "wait"')
67
- if ready == "ready":
68
- break
69
- time.sleep(0.5)
70
- # Try upload_file first, fall back to DOM.performSearch for hidden inputs
71
- try:
72
- upload_file(".upload-input,input[type=file][accept*=video],input[type=file]", video)
73
- except Exception:
74
- doc = cdp("DOM.getDocument", depth=-1, pierce=True)
75
- sr = cdp("DOM.performSearch", query='input[type="file"]')
76
- if sr.get("resultCount", 0) > 0:
77
- nodes = cdp("DOM.getSearchResults", searchId=sr["searchId"], fromIndex=0, toIndex=1)
78
- nid = nodes["nodeIds"][0]
79
- cdp("DOM.setFileInputFiles", files=[video], nodeId=nid)
80
- cdp("DOM.discardSearchResults", searchId=sr["searchId"])
81
- else:
82
- print("ERROR: Cannot find file input for video upload.")
83
- capture_screenshot()
84
- raise SystemExit(1)
85
-
86
- next_step("Waiting for video upload...")
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!")
94
- break
95
- if attempt % 15 == 14:
96
- pct = js('var el=document.querySelector("[class*=progress],.percent,[class*=uploadProgress]"); el ? el.textContent : "..."')
97
- print(f" -> Upload state {upload_state}; {pct} ({attempt+1}s)")
98
- else:
99
- print("ERROR: video upload never reached the editor-ready state")
100
- capture_screenshot()
101
- raise SystemExit(1)
102
-
103
- else:
104
- # Image mode — click the 2nd tab (上传图文)
105
- next_step("Switching to image+text tab...")
106
- 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"')
107
- time.sleep(2)
108
-
109
- check = js('document.querySelector(".upload-input") ? "found" : "notfound"')
110
- if check != "found":
111
- 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"')
112
- time.sleep(2)
113
-
114
- next_step("Uploading image: " + images[0])
115
- upload_file(".upload-input", images[0])
116
- time.sleep(4)
117
-
118
- # Upload additional images if any
119
- if len(images) > 1:
120
- next_step(f"Uploading {len(images)-1} more image(s)...")
121
- for i, img in enumerate(images[1:], 2):
122
- add_btn = js('document.querySelector(".add-upload,.upload-more,.add-btn,[class*=addImage]") ? "found" : "notfound"')
123
- if add_btn == "found":
124
- upload_file(".add-upload,.upload-more,.add-btn,[class*=addImage] input[type=file],.upload-input", img)
125
- else:
126
- upload_file(".upload-input", img)
127
- print(f" -> Image {i}/{len(images)}: {os.path.basename(img)}")
128
- time.sleep(3)
129
-
130
- next_step("Setting title: " + CFG["title"])
131
- title_escaped = json.dumps(CFG["title"])
132
- 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'}")
133
- if title_set != "done":
134
- print("ERROR: title input not found")
135
- capture_screenshot()
136
- raise SystemExit(1)
137
- time.sleep(0.5)
138
-
139
- next_step("Setting body text...")
140
- # Click into the editor area then type
141
- editor_set = js('var ed=document.querySelector(".tiptap.ProseMirror,#post-textarea,.ql-editor,[contenteditable=true]");if(ed){ed.focus();ed.click();"done"}else{"missing"}')
142
- if editor_set != "done":
143
- print("ERROR: body editor not found")
144
- capture_screenshot()
145
- raise SystemExit(1)
146
- time.sleep(0.3)
147
-
148
- # Build body with tags appended as hashtags
149
- body = CFG["body"]
150
- if tags:
151
- tag_str = " " + " ".join(f"#{t}" for t in tags)
152
- body = body + tag_str
153
- type_text(body)
154
- time.sleep(0.5)
155
-
156
- if tags:
157
- next_step(f"Tags appended: {', '.join(tags)}")
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
-
220
- if CFG["action"] == "publish":
221
- next_step("Publishing...")
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)
233
- if publish_result != "clicked":
234
- # XHS may expose the publish control only through the accessibility tree.
235
- nodes = cdp("Accessibility.getFullAXTree")["nodes"]
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)
237
- if target:
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":
249
- print("ERROR: publish button not found")
250
- capture_screenshot()
251
- raise SystemExit(1)
252
- redirect_verified = False
253
- for _ in range(20):
254
- time.sleep(1)
255
- state = js('location.href.indexOf("publish/success")>=0 || /发布成功|成功发布/.test(document.body.innerText||"") ? "success" : "wait"')
256
- if state == "success":
257
- redirect_verified = True
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"}
268
- if not verified:
269
- print("ERROR: creator manager reports " + receipt["status"])
270
- raise SystemExit(2)
271
- else:
272
- next_step("Saving as draft...")
273
- # Find 暂存 button by unicode
274
- 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"')
275
-
276
- time.sleep(3)
277
- capture_screenshot()
278
- if CFG["action"] == "publish":
279
- print("Done! Post published." if verified else "Done! Post submitted; verification pending.")
280
- else:
281
- print("Done! Post saved as draft.")