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.
- package/PRIVACY.md +21 -0
- package/README.md +138 -0
- package/SKILL.md +301 -0
- package/agent-workspace/agent_helpers.py +718 -0
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +75 -0
- package/extension/manifest.json +26 -0
- package/extension/service-worker.mjs +126 -0
- package/extension/workspace-manager.mjs +680 -0
- package/extension/workspace-page.mjs +7 -0
- package/extension/workspace.html +8 -0
- package/interaction-skills/connection.md +52 -0
- package/interaction-skills/cookies.md +3 -0
- package/interaction-skills/cross-origin-iframes.md +3 -0
- package/interaction-skills/dialogs.md +64 -0
- package/interaction-skills/downloads.md +3 -0
- package/interaction-skills/drag-and-drop.md +3 -0
- package/interaction-skills/dropdowns.md +3 -0
- package/interaction-skills/iframes.md +3 -0
- package/interaction-skills/network-requests.md +3 -0
- package/interaction-skills/print-as-pdf.md +3 -0
- package/interaction-skills/profile-sync.md +90 -0
- package/interaction-skills/screenshots.md +17 -0
- package/interaction-skills/scrolling.md +22 -0
- package/interaction-skills/shadow-dom.md +3 -0
- package/interaction-skills/tabs.md +73 -0
- package/interaction-skills/uploads.md +1 -0
- package/interaction-skills/viewport.md +16 -0
- package/licenses/browser-harness-MIT.txt +21 -0
- package/managed_chrome.py +244 -0
- package/node/admin.mjs +44 -0
- package/package.json +36 -0
- package/platform_runner.py +187 -0
- package/platforms/chatgpt/README.md +60 -0
- package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/platforms/chatgpt/actions/_readiness.py +52 -0
- package/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
- package/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/platforms/chatgpt/manifest.yaml +34 -0
- package/platforms/chatgpt/tests/test_contract.py +176 -0
- package/platforms/chrome-web-store/README.md +49 -0
- package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/platforms/chrome-web-store/manifest.yaml +33 -0
- package/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/platforms/microsoft-teams/README.md +32 -0
- package/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/platforms/microsoft-teams/manifest.yaml +25 -0
- package/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/platforms/tiktok/README.md +10 -0
- package/platforms/tiktok/actions/_post.py +180 -0
- package/platforms/tiktok/manifest.yaml +99 -0
- package/platforms/wechat-channels/README.md +10 -0
- package/platforms/wechat-channels/actions/_common.py +163 -0
- package/platforms/wechat-channels/actions/_manage.py +130 -0
- package/platforms/wechat-channels/actions/_post.py +232 -0
- package/platforms/wechat-channels/manifest.yaml +118 -0
- package/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/platforms/xhs/README.md +10 -0
- package/platforms/xhs/actions/_manage.py +355 -0
- package/platforms/xhs/actions/_post.py +192 -0
- package/platforms/xhs/manifest.yaml +98 -0
- package/platforms/youtube/README.md +10 -0
- package/platforms/youtube/actions/_post.py +100 -0
- package/platforms/youtube/manifest.yaml +94 -0
- package/pyproject.toml +29 -0
- package/session_client.py +97 -0
- package/session_daemon.py +325 -0
- package/src/browser_harness/__init__.py +2 -0
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +201 -0
- package/src/browser_harness/admin.py +1588 -0
- package/src/browser_harness/auth.py +546 -0
- package/src/browser_harness/daemon.py +894 -0
- package/src/browser_harness/helpers.py +668 -0
- package/src/browser_harness/macos.py +131 -0
- package/src/browser_harness/mcp_cli.py +16 -0
- package/src/browser_harness/paths.py +49 -0
- package/src/browser_harness/recorder.py +343 -0
- package/src/browser_harness/run.py +413 -0
- package/src/browser_harness/telemetry.py +308 -0
- package/src/browser_harness/video-template.html +970 -0
- package/src/browser_harness/video.py +749 -0
- package/src/browser_harness/video_render.py +524 -0
- package/src/browser_workspace.egg-info/PKG-INFO +10 -0
- package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
- package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
- package/src/browser_workspace.egg-info/requires.txt +5 -0
- 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.
|