@acedatacloud/skills 2026.714.0 → 2026.714.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.714.0",
3
+ "version": "2026.714.1",
4
4
  "description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -11,6 +11,7 @@ import os
11
11
  import random
12
12
  import select
13
13
  import time
14
+ from collections import deque
14
15
  from typing import Any
15
16
 
16
17
  from .errors import CDPError, ElementNotFoundError
@@ -105,6 +106,7 @@ class Page:
105
106
  self.session_id = session_id
106
107
  self._transport = cdp._transport
107
108
  self._id_counter = 1000
109
+ self._events: deque[dict[str, Any]] = deque(maxlen=256)
108
110
 
109
111
  def _send_session(self, method: str, params: dict | None = None) -> dict:
110
112
  """向 session 发送命令。"""
@@ -132,8 +134,25 @@ class Page:
132
134
  if "error" in data:
133
135
  raise CDPError(f"CDP 错误: {data['error']}")
134
136
  return data.get("result", {})
137
+ if "method" in data and data.get("sessionId") == self.session_id:
138
+ self._events.append(data)
135
139
  raise CDPError(f"等待 session 响应超时 (id={msg_id})")
136
140
 
141
+ def clear_events(self) -> None:
142
+ self._events.clear()
143
+
144
+ def wait_for_event(self, timeout: float) -> dict[str, Any] | None:
145
+ if self._events:
146
+ return self._events.popleft()
147
+ try:
148
+ raw = self._transport.recv(timeout)
149
+ except TimeoutError:
150
+ return None
151
+ data = json.loads(raw)
152
+ if "method" in data and data.get("sessionId") == self.session_id:
153
+ return data
154
+ return None
155
+
137
156
  def navigate(self, url: str) -> None:
138
157
  """导航到指定 URL。"""
139
158
  logger.info("导航到: %s", url)
@@ -335,6 +354,97 @@ class Page:
335
354
  self.mouse_click(x, y)
336
355
  return True
337
356
 
357
+ def click_pierced_button(self, host_name: str, button_text: str) -> bool:
358
+ document = self._send_session("DOM.getDocument", {"depth": -1, "pierce": True})
359
+ targets: list[dict[str, Any]] = []
360
+
361
+ def attrs(node: dict[str, Any]) -> dict[str, str]:
362
+ values = node.get("attributes") or []
363
+ return dict(zip(values[0::2], values[1::2]))
364
+
365
+ def content(node: dict[str, Any]) -> str:
366
+ chunks = [node.get("nodeValue") or ""]
367
+ for child in node.get("children") or []:
368
+ chunks.append(content(child))
369
+ for shadow_root in node.get("shadowRoots") or []:
370
+ chunks.append(content(shadow_root))
371
+ return "".join(chunks).strip()
372
+
373
+ def find_button(node: dict[str, Any]) -> None:
374
+ properties = attrs(node)
375
+ if (
376
+ node.get("nodeName") == "BUTTON"
377
+ and content(node) == button_text
378
+ and "disabled" not in properties
379
+ and properties.get("aria-disabled") != "true"
380
+ ):
381
+ targets.append(node)
382
+ return
383
+ for child in node.get("children") or []:
384
+ find_button(child)
385
+ for shadow_root in node.get("shadowRoots") or []:
386
+ find_button(shadow_root)
387
+
388
+ def find_host(node: dict[str, Any]) -> None:
389
+ properties = attrs(node)
390
+ if node.get("nodeName") == host_name.upper() and properties.get("is-publish") == "true":
391
+ for shadow_root in node.get("shadowRoots") or []:
392
+ find_button(shadow_root)
393
+ return
394
+ for child in node.get("children") or []:
395
+ find_host(child)
396
+
397
+ find_host(document["root"])
398
+ for target in targets:
399
+ node_ref = {"backendNodeId": target["backendNodeId"]}
400
+ try:
401
+ self._send_session("DOM.scrollIntoViewIfNeeded", node_ref)
402
+ model = self._send_session("DOM.getBoxModel", node_ref).get("model")
403
+ except CDPError:
404
+ continue
405
+ if not model or not model.get("width") or not model.get("height"):
406
+ continue
407
+ quad = model.get("border") or model.get("content")
408
+ if not quad or len(quad) < 8:
409
+ continue
410
+ x = sum(quad[0::2]) / 4
411
+ y = sum(quad[1::2]) / 4
412
+ hit = self._send_session(
413
+ "DOM.getNodeForLocation",
414
+ {"x": int(x), "y": int(y), "includeUserAgentShadowDOM": True},
415
+ )
416
+ hit_id = hit.get("backendNodeId")
417
+ if hit_id != target.get("backendNodeId"):
418
+ if not hit_id:
419
+ continue
420
+ try:
421
+ hit_object_id = self._send_session(
422
+ "DOM.resolveNode", {"backendNodeId": hit_id}
423
+ ).get("object", {}).get("objectId")
424
+ button_object_id = self._send_session(
425
+ "DOM.resolveNode", node_ref
426
+ ).get("object", {}).get("objectId")
427
+ if not hit_object_id or not button_object_id:
428
+ continue
429
+ contains = self._send_session(
430
+ "Runtime.callFunctionOn",
431
+ {
432
+ "objectId": hit_object_id,
433
+ "functionDeclaration": "function(button) { return button.contains(this); }",
434
+ "arguments": [{"objectId": button_object_id}],
435
+ "returnByValue": True,
436
+ },
437
+ ).get("result", {}).get("value")
438
+ except CDPError:
439
+ contains = False
440
+ if contains is not True:
441
+ continue
442
+ self.mouse_move(x, y)
443
+ time.sleep(random.uniform(0.03, 0.08))
444
+ self.mouse_click(x, y)
445
+ return True
446
+ return False
447
+
338
448
  def input_text(self, selector: str, text: str) -> None:
339
449
  """向指定选择器的元素输入文本。"""
340
450
  self.evaluate(
@@ -7,6 +7,8 @@ import logging
7
7
  import random
8
8
  import re
9
9
  import time
10
+ import base64
11
+ from urllib.parse import urlsplit
10
12
 
11
13
  from .cdp import Page
12
14
  from .errors import (
@@ -255,46 +257,161 @@ def click_publish_button(page: Page) -> None:
255
257
  """
256
258
  )
257
259
 
258
- # 2) dispatchEvent 触发发布
259
- fire_result = page.evaluate(
260
+ # 2) 点击 closed shadow DOM 内的真实按钮(CDP Input,isTrusted=true)
261
+ host_state = page.evaluate(
260
262
  """
261
263
  (() => {
262
264
  const host = document.querySelector('xhs-publish-btn[is-publish="true"]');
263
265
  if (!host) return 'not_found';
264
266
  if (host.getAttribute('submit-disabled') === 'true') return 'disabled';
265
267
  window.__xhsPublishStartedAt = Date.now();
266
- if (typeof host._onPublish === 'function') host._onPublish();
267
- else host.dispatchEvent(new CustomEvent('publish', {
268
- bubbles: true, composed: true, cancelable: true
269
- }));
270
- return 'fired';
268
+ return 'ready';
271
269
  })()
272
270
  """
273
271
  )
274
- if fire_result == "not_found":
272
+ if host_state == "not_found":
275
273
  raise PublishError("未找到 <xhs-publish-btn> 发布按钮容器")
276
- if fire_result == "disabled":
274
+ if host_state == "disabled":
277
275
  raise PublishError("发布按钮 submit-disabled=true,不可发布")
276
+ page._send_session("Network.enable")
277
+ page.clear_events()
278
+ if not page.click_pierced_button("xhs-publish-btn", "发布"):
279
+ raise PublishError("未找到可安全点击的原生发布按钮")
278
280
 
279
- # 3) 轮询等待 publish API 响应(15s 超时)
281
+ # 3) 优先从 CDP Network 事件读取响应,页面 hook 仅作兼容兜底
280
282
  deadline = time.monotonic() + 15
281
283
  result = None
284
+ requests: dict[str, bool] = {}
285
+
286
+ def publish_path_kind(url: str) -> str:
287
+ segments = {part for part in urlsplit(url).path.lower().split("/") if part}
288
+ note_segments = {"note", "notes", "publish_note", "create_note"}
289
+ if not segments & note_segments:
290
+ return ""
291
+ if segments & {"publish", "publish_note"}:
292
+ return "publish"
293
+ if segments & {"create", "create_note"}:
294
+ return "create"
295
+ return ""
296
+
297
+ def publish_result(body: str) -> dict[str, Any] | None:
298
+ try:
299
+ parsed = json.loads(body)
300
+ except (json.JSONDecodeError, TypeError):
301
+ return None
302
+ if not isinstance(parsed, dict):
303
+ return None
304
+ serialized = json.dumps(parsed, ensure_ascii=False)
305
+ has_publish_evidence = bool(
306
+ re.search(r'"note_id"\s*:\s*"[^"\s]+"', serialized)
307
+ or re.search(r'"code"\s*:\s*-913\d', serialized)
308
+ or "禁止发笔记" in serialized
309
+ or "HTTPBizError" in serialized
310
+ )
311
+ return parsed if has_publish_evidence else None
312
+
313
+ def find_field(value: Any, name: str) -> Any:
314
+ if isinstance(value, dict):
315
+ if name in value:
316
+ return value[name]
317
+ for item in value.values():
318
+ found = find_field(item, name)
319
+ if found is not None:
320
+ return found
321
+ elif isinstance(value, list):
322
+ for item in value:
323
+ found = find_field(item, name)
324
+ if found is not None:
325
+ return found
326
+ return None
327
+
328
+ def find_risk(value: Any) -> tuple[int, str] | None:
329
+ if isinstance(value, dict):
330
+ code_value = value.get("code")
331
+ if isinstance(code_value, int) and -9140 <= code_value <= -9130:
332
+ return code_value, str(value.get("msg") or "账号被风控")
333
+ for item in value.values():
334
+ found = find_risk(item)
335
+ if found:
336
+ return found
337
+ elif isinstance(value, list):
338
+ for item in value:
339
+ found = find_risk(item)
340
+ if found:
341
+ return found
342
+ return None
343
+
282
344
  while time.monotonic() < deadline:
283
- result = page.evaluate("window.__xhsPublishResult")
345
+ event = page.wait_for_event(min(0.3, max(0.01, deadline - time.monotonic())))
346
+ if event and event.get("method") == "Network.requestWillBeSent":
347
+ params = event.get("params") or {}
348
+ request = params.get("request") or {}
349
+ method = str(request.get("method") or "")
350
+ url = str(request.get("url") or "")
351
+ path_kind = publish_path_kind(url)
352
+ if method in {"POST", "PUT", "PATCH"} and path_kind:
353
+ requests[str(params.get("requestId") or "")] = path_kind == "publish"
354
+ elif event and event.get("method") == "Network.loadingFinished":
355
+ params = event.get("params") or {}
356
+ request_id = str(params.get("requestId") or "")
357
+ if request_id in requests:
358
+ authoritative_success = requests[request_id]
359
+ try:
360
+ response_body = page._send_session(
361
+ "Network.getResponseBody", {"requestId": request_id}
362
+ )
363
+ body = response_body.get("body", "")
364
+ if response_body.get("base64Encoded") is True:
365
+ body = base64.b64decode(body, validate=True).decode("utf-8")
366
+ parsed = publish_result(body)
367
+ if parsed:
368
+ risk = find_risk(parsed)
369
+ note_id = find_field(parsed, "note_id")
370
+ if risk or authoritative_success or not note_id:
371
+ result = {"source": "cdp", **parsed}
372
+ except (CDPError, ValueError, UnicodeDecodeError):
373
+ result = None
374
+ finally:
375
+ requests.pop(request_id, None)
376
+ elif event and event.get("method") == "Network.loadingFailed":
377
+ request_id = str((event.get("params") or {}).get("requestId") or "")
378
+ requests.pop(request_id, None)
379
+ if not result:
380
+ fallback = page.evaluate("window.__xhsPublishResult")
381
+ if isinstance(fallback, dict):
382
+ fallback_code = fallback.get("code")
383
+ fallback_msg = str(fallback.get("msg") or "")
384
+ if (
385
+ isinstance(fallback_code, int)
386
+ and fallback_code != 0
387
+ or "HTTPBizError" in fallback_msg
388
+ or "禁止发笔记" in fallback_msg
389
+ ):
390
+ result = fallback
284
391
  if result:
285
392
  break
286
- time.sleep(0.3)
287
393
 
288
394
  if not result:
289
395
  raise PublishError("15s 内未捕获到发布成功反馈")
290
396
 
291
397
  # 4) 解析业务码
292
398
  source = result.get("source", "unknown")
293
- code = result.get("code")
294
- msg = result.get("msg", "")
399
+ nested_risk = find_risk(result)
400
+ if nested_risk:
401
+ raise AccountRiskControlError(*nested_risk)
402
+ code = find_field(result, "code")
403
+ msg = find_field(result, "msg") or ""
295
404
  success = result.get("success")
405
+ note_id = find_field(result, "note_id")
406
+ if not isinstance(note_id, str):
407
+ note_id = ""
296
408
  logger.info("捕获发布响应(来源=%s code=%s msg=%r)", source, code, msg)
297
409
 
410
+ if note_id:
411
+ logger.info("发布成功(CDP 已返回 note_id)")
412
+ time.sleep(2)
413
+ return
414
+
298
415
  if code == 0 or success is True:
299
416
  confirmed = page.evaluate(
300
417
  """
@@ -972,13 +1089,16 @@ def _set_visibility(page: Page, visibility: str) -> None:
972
1089
 
973
1090
  selected = page.evaluate(
974
1091
  f"""
975
- (() => [...document.querySelectorAll({json.dumps(VISIBILITY_OPTIONS)})]
976
- .some(opt => opt.textContent.includes({json.dumps(visibility)}) && (
1092
+ (() => {{
1093
+ const legacySelected = [...document.querySelectorAll({json.dumps(VISIBILITY_OPTIONS)})]
1094
+ .some(opt => opt.textContent.includes({json.dumps(visibility)}) && (
977
1095
  opt.classList.contains('active')
978
1096
  || opt.classList.contains('selected')
979
1097
  || opt.getAttribute('aria-selected') === 'true'
980
1098
  || opt.querySelector('input:checked')
981
- )))()
1099
+ ));
1100
+ return legacySelected;
1101
+ }})()
982
1102
  """
983
1103
  )
984
1104
  if not selected:
@@ -538,14 +538,367 @@ class CommandSafetyTests(unittest.TestCase):
538
538
  import xhs.publish as publish
539
539
 
540
540
  page = MagicMock()
541
- page.evaluate.side_effect = [None, "fired", *([None] * 60)]
541
+ page.evaluate.side_effect = [None, "ready", *([None] * 60)]
542
+ page.wait_for_event.return_value = None
543
+ page.click_pierced_button.return_value = True
542
544
  with (
543
545
  patch.object(publish.time, "monotonic", side_effect=[0, 0, *range(1, 17)]),
544
- patch.object(publish.time, "sleep"),
545
546
  self.assertRaisesRegex(publish.PublishError, "未捕获到发布成功反馈"),
546
547
  ):
547
548
  publish.click_publish_button(page)
548
549
 
550
+ def test_publish_ignores_creator_telemetry_and_waits_for_finished_body(self) -> None:
551
+ if str(MODULE.VENDOR_DIR) not in sys.path:
552
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
553
+ import xhs.publish as publish
554
+
555
+ page = MagicMock()
556
+ page.evaluate.side_effect = [None, "ready", None, None, None, None, True]
557
+ page.click_pierced_button.return_value = True
558
+ page.wait_for_event.side_effect = [
559
+ {
560
+ "method": "Network.requestWillBeSent",
561
+ "params": {
562
+ "requestId": "telemetry",
563
+ "request": {
564
+ "method": "POST",
565
+ "url": "https://creator.xiaohongshu.com/api/telemetry",
566
+ },
567
+ },
568
+ },
569
+ {"method": "Network.loadingFinished", "params": {"requestId": "telemetry"}},
570
+ {
571
+ "method": "Network.requestWillBeSent",
572
+ "params": {
573
+ "requestId": "publish",
574
+ "request": {
575
+ "method": "POST",
576
+ "url": "https://edith.xiaohongshu.com/api/sns/web/v1/note/publish",
577
+ },
578
+ },
579
+ },
580
+ {
581
+ "method": "Network.responseReceived",
582
+ "params": {"requestId": "publish"},
583
+ },
584
+ {"method": "Network.loadingFinished", "params": {"requestId": "publish"}},
585
+ ]
586
+
587
+ def send(method: str, _params: dict | None = None) -> dict:
588
+ if method == "Network.getResponseBody":
589
+ return {"body": '{"code":0,"data":{"note_id":"note"}}'}
590
+ return {}
591
+
592
+ page._send_session.side_effect = send
593
+ with (
594
+ patch.object(publish.time, "monotonic", return_value=0),
595
+ patch.object(publish.time, "sleep"),
596
+ ):
597
+ publish.click_publish_button(page)
598
+
599
+ body_calls = [
600
+ call
601
+ for call in page._send_session.call_args_list
602
+ if call.args and call.args[0] == "Network.getResponseBody"
603
+ ]
604
+ self.assertEqual(len(body_calls), 1)
605
+ self.assertEqual(body_calls[0].args[1], {"requestId": "publish"})
606
+
607
+ def test_publish_decodes_base64_body_and_ignores_success_fallback(self) -> None:
608
+ if str(MODULE.VENDOR_DIR) not in sys.path:
609
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
610
+ import base64
611
+ import xhs.publish as publish
612
+
613
+ page = MagicMock()
614
+ page.evaluate.side_effect = [
615
+ None,
616
+ "ready",
617
+ {"code": 0, "success": True, "note_id": "autosave"},
618
+ {"code": 0, "success": True, "note_id": "autosave"},
619
+ True,
620
+ ]
621
+ page.click_pierced_button.return_value = True
622
+ page.wait_for_event.side_effect = [
623
+ {
624
+ "method": "Network.requestWillBeSent",
625
+ "params": {
626
+ "requestId": "publish",
627
+ "request": {
628
+ "method": "POST",
629
+ "url": "https://edith.xiaohongshu.com/api/note/publish",
630
+ },
631
+ },
632
+ },
633
+ {"method": "Network.responseReceived", "params": {"requestId": "publish"}},
634
+ {"method": "Network.loadingFinished", "params": {"requestId": "publish"}},
635
+ ]
636
+ encoded = base64.b64encode(b'{"data":{"note_id":"published"}}').decode()
637
+
638
+ def send(method: str, _params: dict | None = None) -> dict:
639
+ if method == "Network.getResponseBody":
640
+ return {"body": encoded, "base64Encoded": True}
641
+ return {}
642
+
643
+ page._send_session.side_effect = send
644
+ with (
645
+ patch.object(publish.time, "monotonic", return_value=0),
646
+ patch.object(publish.time, "sleep"),
647
+ ):
648
+ publish.click_publish_button(page)
649
+
650
+ self.assertEqual(
651
+ [call.args[0] for call in page._send_session.call_args_list].count(
652
+ "Network.getResponseBody"
653
+ ),
654
+ 1,
655
+ )
656
+
657
+ def test_publish_classifies_nested_risk_control_response(self) -> None:
658
+ if str(MODULE.VENDOR_DIR) not in sys.path:
659
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
660
+ import xhs.publish as publish
661
+
662
+ page = MagicMock()
663
+ page.evaluate.side_effect = [None, "ready", None]
664
+ page.click_pierced_button.return_value = True
665
+ page.wait_for_event.side_effect = [
666
+ {
667
+ "method": "Network.requestWillBeSent",
668
+ "params": {
669
+ "requestId": "publish",
670
+ "request": {
671
+ "method": "POST",
672
+ "url": "https://edith.xiaohongshu.com/api/note/publish",
673
+ },
674
+ },
675
+ },
676
+ {"method": "Network.loadingFinished", "params": {"requestId": "publish"}},
677
+ ]
678
+ page._send_session.side_effect = lambda method, _params=None: (
679
+ {"body": '{"code":0,"data":{"code":-9136,"msg":"禁止发笔记"}}'}
680
+ if method == "Network.getResponseBody"
681
+ else {}
682
+ )
683
+ with (
684
+ patch.object(publish.time, "monotonic", return_value=0),
685
+ self.assertRaises(publish.AccountRiskControlError),
686
+ ):
687
+ publish.click_publish_button(page)
688
+
689
+ def test_create_note_id_cannot_outrun_publish_response(self) -> None:
690
+ if str(MODULE.VENDOR_DIR) not in sys.path:
691
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
692
+ import xhs.publish as publish
693
+
694
+ page = MagicMock()
695
+ page.evaluate.side_effect = [None, "ready", None, None, None, True]
696
+ page.click_pierced_button.return_value = True
697
+ page.wait_for_event.side_effect = [
698
+ {
699
+ "method": "Network.requestWillBeSent",
700
+ "params": {
701
+ "requestId": "create",
702
+ "request": {
703
+ "method": "POST",
704
+ "url": "https://edith.xiaohongshu.com/api/note/create",
705
+ },
706
+ },
707
+ },
708
+ {"method": "Network.loadingFinished", "params": {"requestId": "create"}},
709
+ {
710
+ "method": "Network.requestWillBeSent",
711
+ "params": {
712
+ "requestId": "publish",
713
+ "request": {
714
+ "method": "POST",
715
+ "url": "https://edith.xiaohongshu.com/api/note/publish",
716
+ },
717
+ },
718
+ },
719
+ {"method": "Network.loadingFinished", "params": {"requestId": "publish"}},
720
+ ]
721
+
722
+ def send(method: str, params: dict | None = None) -> dict:
723
+ if method != "Network.getResponseBody":
724
+ return {}
725
+ note_id = "draft" if params == {"requestId": "create"} else "published"
726
+ return {"body": json.dumps({"data": {"note_id": note_id}})}
727
+
728
+ page._send_session.side_effect = send
729
+ with (
730
+ patch.object(publish.time, "monotonic", return_value=0),
731
+ patch.object(publish.time, "sleep"),
732
+ ):
733
+ publish.click_publish_button(page)
734
+
735
+ body_ids = [
736
+ call.args[1]["requestId"]
737
+ for call in page._send_session.call_args_list
738
+ if call.args and call.args[0] == "Network.getResponseBody"
739
+ ]
740
+ self.assertEqual(body_ids, ["create", "publish"])
741
+
742
+ def test_page_retains_unsolicited_events_while_waiting_for_response(self) -> None:
743
+ if str(MODULE.VENDOR_DIR) not in sys.path:
744
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
745
+ from xhs.cdp import Page
746
+
747
+ transport = MagicMock()
748
+ expected_event = {
749
+ "method": "Network.responseReceived",
750
+ "sessionId": "session",
751
+ "params": {"requestId": "request"},
752
+ }
753
+ transport.recv.side_effect = [
754
+ json.dumps(expected_event),
755
+ json.dumps({"id": 1001, "result": {"ok": True}}),
756
+ ]
757
+ page = Page(MagicMock(_transport=transport), "target", "session")
758
+
759
+ self.assertEqual(page._wait_session(1001), {"ok": True})
760
+ self.assertEqual(page.wait_for_event(0), expected_event)
761
+
762
+ def test_page_event_queue_is_bounded(self) -> None:
763
+ if str(MODULE.VENDOR_DIR) not in sys.path:
764
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
765
+ from xhs.cdp import Page
766
+
767
+ page = Page(MagicMock(_transport=MagicMock()), "target", "session")
768
+ for index in range(300):
769
+ page._events.append({"method": "Network.event", "index": index})
770
+ self.assertEqual(len(page._events), 256)
771
+ self.assertEqual(page._events[0]["index"], 44)
772
+
773
+ def test_pierced_publish_button_requires_coordinate_hit(self) -> None:
774
+ if str(MODULE.VENDOR_DIR) not in sys.path:
775
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
776
+ from xhs.cdp import Page
777
+
778
+ page = Page(MagicMock(_transport=MagicMock()), "target", "session")
779
+ page._send_session = MagicMock(
780
+ side_effect=[
781
+ {
782
+ "root": {
783
+ "nodeName": "#document",
784
+ "children": [
785
+ {
786
+ "nodeName": "XHS-PUBLISH-BTN",
787
+ "attributes": ["is-publish", "true"],
788
+ "shadowRoots": [
789
+ {
790
+ "nodeName": "#document-fragment",
791
+ "children": [
792
+ {
793
+ "nodeName": "BUTTON",
794
+ "backendNodeId": 42,
795
+ "children": [
796
+ {"nodeName": "#text", "nodeValue": "发布"}
797
+ ],
798
+ }
799
+ ],
800
+ }
801
+ ],
802
+ }
803
+ ],
804
+ }
805
+ },
806
+ {},
807
+ {
808
+ "model": {
809
+ "width": 10,
810
+ "height": 10,
811
+ "border": [0, 0, 10, 0, 10, 10, 0, 10],
812
+ }
813
+ },
814
+ {"backendNodeId": 43},
815
+ {"object": {"objectId": "hit"}},
816
+ {"object": {"objectId": "button"}},
817
+ {"result": {"value": False}},
818
+ ]
819
+ )
820
+ page.mouse_click = MagicMock()
821
+
822
+ self.assertFalse(page.click_pierced_button("xhs-publish-btn", "发布"))
823
+ page.mouse_click.assert_not_called()
824
+
825
+ def test_pierced_publish_button_skips_hidden_first_candidate(self) -> None:
826
+ if str(MODULE.VENDOR_DIR) not in sys.path:
827
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
828
+ from xhs.cdp import Page
829
+
830
+ def button(backend_node_id: int) -> dict:
831
+ return {
832
+ "nodeName": "BUTTON",
833
+ "backendNodeId": backend_node_id,
834
+ "children": [{"nodeName": "#text", "nodeValue": "发布"}],
835
+ }
836
+
837
+ page = Page(MagicMock(_transport=MagicMock()), "target", "session")
838
+ page._send_session = MagicMock(
839
+ side_effect=[
840
+ {
841
+ "root": {
842
+ "nodeName": "#document",
843
+ "children": [
844
+ {
845
+ "nodeName": "XHS-PUBLISH-BTN",
846
+ "attributes": ["is-publish", "true"],
847
+ "shadowRoots": [
848
+ {
849
+ "nodeName": "#document-fragment",
850
+ "children": [button(41), button(42)],
851
+ }
852
+ ],
853
+ }
854
+ ],
855
+ }
856
+ },
857
+ {},
858
+ {"model": {"width": 0, "height": 0, "border": [0] * 8}},
859
+ {},
860
+ {
861
+ "model": {
862
+ "width": 10,
863
+ "height": 10,
864
+ "border": [0, 0, 10, 0, 10, 10, 0, 10],
865
+ }
866
+ },
867
+ {"backendNodeId": 42},
868
+ ]
869
+ )
870
+ page.mouse_move = MagicMock()
871
+ page.mouse_click = MagicMock()
872
+
873
+ with patch("xhs.cdp.time.sleep"):
874
+ self.assertTrue(page.click_pierced_button("xhs-publish-btn", "发布"))
875
+ page.mouse_click.assert_called_once()
876
+
877
+ def test_visibility_requires_explicit_selected_marker(self) -> None:
878
+ if str(MODULE.VENDOR_DIR) not in sys.path:
879
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
880
+ import xhs.publish as publish
881
+
882
+ page = MagicMock()
883
+ page.evaluate.side_effect = [True, False]
884
+ with (
885
+ patch.object(publish.time, "sleep"),
886
+ self.assertRaisesRegex(publish.PublishError, "可见范围未确认生效"),
887
+ ):
888
+ publish._set_visibility(page, "仅自己可见")
889
+ script = page.evaluate.call_args_list[1].args[0]
890
+ self.assertNotIn("dropdown?.textContent", script)
891
+
892
+ def test_visibility_accepts_explicit_selected_marker(self) -> None:
893
+ if str(MODULE.VENDOR_DIR) not in sys.path:
894
+ sys.path.insert(0, str(MODULE.VENDOR_DIR))
895
+ import xhs.publish as publish
896
+
897
+ page = MagicMock()
898
+ page.evaluate.side_effect = [True, True]
899
+ with patch.object(publish.time, "sleep"):
900
+ publish._set_visibility(page, "仅自己可见")
901
+
549
902
  def test_verification_challenge_stops_without_navigation_retry(self) -> None:
550
903
  if str(MODULE.VENDOR_DIR) not in sys.path:
551
904
  sys.path.insert(0, str(MODULE.VENDOR_DIR))