@acedatacloud/skills 2026.713.6 → 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.713.6",
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",
@@ -12,14 +12,16 @@ allowed_tools: [Bash, web_search, web_fetch]
12
12
  license: Apache-2.0
13
13
  metadata:
14
14
  author: acedatacloud
15
- version: "2.1"
15
+ version: "2.2"
16
16
  ---
17
17
 
18
18
  # TGStat Research
19
19
 
20
20
  Use [scripts/tgstat.py](./scripts/tgstat.py) for public TGStat research. It uses
21
- only the Python standard library. Resolve the script at the start of every Bash
22
- call because each call runs in a fresh shell:
21
+ only the Python standard library. Under the hood it fetches TGStat pages through
22
+ the platform render service (headless Chromium), which bypasses the public
23
+ site's bot protection, so each lookup can take several seconds. Resolve the
24
+ script at the start of every Bash call because each call runs in a fresh shell:
23
25
 
24
26
  ```bash
25
27
  TGSTAT="$SKILL_DIR/scripts/tgstat.py"; [ -f "$TGSTAT" ] || TGSTAT=$(find /tmp -maxdepth 8 -path '*/skills/*/scripts/tgstat.py' 2>/dev/null | head -1)
@@ -93,10 +95,15 @@ python3 "$TGSTAT" info @durov
93
95
  python3 "$TGSTAT" stat https://t.me/example_public_chat
94
96
  ```
95
97
 
96
- In public mode, `info`/`stat` resolves whether the target is a channel or chat,
97
- returns public metadata, and enriches it with ranking metrics when the entity
98
- appears in the current public ranking. Empty metrics mean TGStat did not expose
99
- them publicly; do not call that full statistics.
98
+ In public mode, `info`/`stat` resolves whether the target is a channel or chat
99
+ and returns its public identity (title, description) plus the visible
100
+ subscriber / participant count. Deeper engagement metrics (average post reach,
101
+ citation index, ER) are charted client-side on TGStat, so they are only
102
+ available for entities that appear in the public top ratings — for those the
103
+ command enriches the result from the ranking snapshot. Empty or missing metrics
104
+ mean TGStat did not expose them on the public page; do not call that full
105
+ statistics. Use `rankings` when you need reach / citation index for large
106
+ public sources.
100
107
 
101
108
  For ad selection, rank by relevant reach/activity rather than subscriber count
102
109
  alone. High subscribers with weak reach or chat MAU can indicate an inactive or
@@ -8,6 +8,7 @@ import json
8
8
  import os
9
9
  import re
10
10
  import sys
11
+ import time
11
12
  import urllib.error
12
13
  import urllib.parse
13
14
  import urllib.request
@@ -15,14 +16,17 @@ from html.parser import HTMLParser
15
16
  from typing import Dict, List, Optional, Tuple, Union
16
17
 
17
18
  DEFAULT_PUBLIC_BASE = "https://tgstat.com"
18
- USER_AGENT = (
19
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
20
- "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36"
21
- )
19
+ # Public tgstat.com sits behind Cloudflare, so pages are fetched through the
20
+ # platform WebExtrator render service (headless Chromium) instead of directly.
21
+ RENDER_ENDPOINT = os.environ.get("TGSTAT_RENDER_ENDPOINT", "https://api.acedata.cloud/webextrator/render")
22
+ RENDER_TOKEN_ENV = "TGSTAT_RENDER_TOKEN"
23
+ RENDER_DELAY_SECONDS = 2
24
+ RENDER_BLOCK_RESOURCES = ["image", "font", "media", "stylesheet"]
22
25
  PUBLIC_USERNAME_RE = re.compile(r"[A-Za-z0-9_]{3,}")
23
26
  TGSTAT_HOST_RE = re.compile(r"^(?:[a-z]{2,3}\.)?tgstat\.com$", re.IGNORECASE)
24
27
  TGSTAT_INPUT_HOST_RE = re.compile(r"^(?:(?:[a-z]{2,3}\.)?tgstat\.com|tgstat\.ru)$", re.IGNORECASE)
25
- ENTITY_PATH_RE = re.compile(r"/(channel|chat)/(@[A-Za-z0-9_]{3,}|id\d+)/stat/?", re.IGNORECASE)
28
+ # The /stat suffix is optional: the summary metrics live on the main entity page.
29
+ ENTITY_PATH_RE = re.compile(r"/(channel|chat)/(@[A-Za-z0-9_]{3,}|id\d+)(?:/stat)?/?", re.IGNORECASE)
26
30
 
27
31
 
28
32
  class TGStatError(RuntimeError):
@@ -81,24 +85,6 @@ def _validate_request_url(url: str, initial_host: Optional[str] = None) -> None:
81
85
  raise TGStatError(f"unsupported TGStat request host: {allowed_from}")
82
86
 
83
87
 
84
- class SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
85
- def __init__(self, initial_host: str) -> None:
86
- super().__init__()
87
- self.initial_host = initial_host
88
-
89
- def redirect_request(self, req, fp, code, msg, headers, newurl):
90
- _validate_request_url(newurl, self.initial_host)
91
- return super().redirect_request(req, fp, code, msg, headers, newurl)
92
-
93
-
94
- def _open_url(request: urllib.request.Request, timeout: int):
95
- initial_url = request.full_url
96
- _validate_request_url(initial_url)
97
- initial_host = urllib.parse.urlsplit(initial_url).hostname or ""
98
- opener = urllib.request.build_opener(SafeRedirectHandler(initial_host))
99
- return opener.open(request, timeout=timeout)
100
-
101
-
102
88
  class RankingParser(HTMLParser):
103
89
  def __init__(self) -> None:
104
90
  super().__init__(convert_charrefs=True)
@@ -215,9 +201,12 @@ class DetailParser(HTMLParser):
215
201
  self.metrics: Dict[str, Union[int, float, str]] = {}
216
202
  self.metrics_display: Dict[str, str] = {}
217
203
  self.text_parts: List[str] = []
204
+ self._tag_index = 0
205
+ self._pending_at = -100
218
206
 
219
207
  def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
220
208
  attr = {key.lower(): value or "" for key, value in attrs}
209
+ self._tag_index += 1
221
210
  if tag == "meta":
222
211
  key = (attr.get("property") or attr.get("name") or "").lower()
223
212
  if key in {"og:title", "og:description", "description"} and attr.get("content"):
@@ -229,10 +218,12 @@ class DetailParser(HTMLParser):
229
218
  kind = ""
230
219
  if tag == "title":
231
220
  kind = "title"
232
- elif tag == "h4":
221
+ elif tag in {"h2", "h3", "h4"} and "text-dark" in classes:
233
222
  kind = "metric_value"
234
- elif tag == "div" and {"text-muted", "text-truncate"}.issubset(classes):
235
- if self.pending_metric is not None:
223
+ elif tag == "div" and {"text-uppercase", "font-12"}.issubset(classes):
224
+ # Only pair a label that sits right after its value inside the same
225
+ # stat card, so a stray numeric heading can't grab a distant label.
226
+ if self.pending_metric is not None and self._tag_index - self._pending_at <= 6:
236
227
  kind = "metric_label"
237
228
  if kind:
238
229
  self.capture = {"kind": kind, "depth": 1, "parts": []}
@@ -248,13 +239,15 @@ class DetailParser(HTMLParser):
248
239
  self.capture = None
249
240
  if kind == "title" and value:
250
241
  self.meta.setdefault("title", value)
251
- elif kind == "metric_value" and value:
242
+ elif kind == "metric_value" and _metric_number(value) is not None:
252
243
  self.pending_metric = value
244
+ self._pending_at = self._tag_index
253
245
  elif kind == "metric_label" and value and self.pending_metric is not None:
254
- key = _metric_key(value)
255
- self.metrics_display[key] = self.pending_metric
256
246
  number = _metric_number(self.pending_metric)
257
- self.metrics[key] = number if number is not None else self.pending_metric
247
+ if number is not None:
248
+ key = _metric_key(value)
249
+ self.metrics_display[key] = self.pending_metric
250
+ self.metrics[key] = number
258
251
  self.pending_metric = None
259
252
 
260
253
  def handle_data(self, data: str) -> None:
@@ -277,13 +270,24 @@ def parse_detail_html(html: str, url: str) -> dict:
277
270
  text = " ".join(parser.text_parts)
278
271
  entity = _entity_from_url(url)
279
272
  title = parser.meta.get("og:title") or parser.meta.get("title")
280
- recognized = bool(entity and parser.meta.get("og:title") and title and "tgstat" in title.casefold())
273
+ # A real entity page echoes the requested @username / id in its title or
274
+ # emits an og:title; tgstat error / search / interstitial pages do neither
275
+ # (the bare "TGStat" brand in <title> is not proof the entity exists).
276
+ identifier_token = str(entity[1]).casefold() if entity else ""
277
+ recognized = bool(
278
+ entity and title and (identifier_token in title.casefold() or bool(parser.meta.get("og:title")))
279
+ )
280
+ lowered_title = (title or "").casefold()
281
+ if "not found" in lowered_title or "404" in lowered_title:
282
+ status = "not_found"
283
+ elif "authentication required" in text.lower():
284
+ status = "restricted"
285
+ elif recognized:
286
+ status = "ok"
287
+ else:
288
+ status = "unrecognized"
281
289
  result = {
282
- "status": (
283
- "restricted"
284
- if "authentication required" in text.lower()
285
- else "ok" if recognized else "unrecognized"
286
- ),
290
+ "status": status,
287
291
  "type": entity[0] if entity else None,
288
292
  "identifier": entity[1] if entity else None,
289
293
  "title": title,
@@ -301,28 +305,88 @@ def _json_out(payload: object) -> None:
301
305
  print(json.dumps(payload, ensure_ascii=False, indent=2))
302
306
 
303
307
 
304
- def _request_with_url(
305
- url: str, timeout: int, data: Optional[bytes] = None, headers: Optional[dict] = None
306
- ) -> Tuple[str, str]:
307
- request_headers = {
308
- "User-Agent": USER_AGENT,
309
- "Accept": "application/json, text/html;q=0.9, */*;q=0.8",
310
- "Accept-Language": "en-US,en;q=0.9",
311
- **(headers or {}),
312
- }
313
- request = urllib.request.Request(url, data=data, headers=request_headers)
314
- try:
315
- with _open_url(request, timeout) as response:
316
- return response.read().decode("utf-8", "replace"), response.geturl()
317
- except urllib.error.HTTPError as exc:
318
- body = exc.read().decode("utf-8", "replace")
319
- raise TGStatError(f"HTTP {exc.code} from {_safe_url(url)}: {_safe_error_body(body)}") from exc
320
- except urllib.error.URLError as exc:
321
- raise TGStatError(f"network error for {_safe_url(url)}: {_safe_error_body(str(exc.reason))}") from exc
308
+ def _render_token() -> str:
309
+ token = os.environ.get(RENDER_TOKEN_ENV, "").strip()
310
+ if not token:
311
+ raise TGStatError("TGStat rendering is not configured: the platform render token is missing")
312
+ return token
313
+
314
+
315
+ # Render statuses worth retrying: Cloudflare/anti-bot interstitials surface as
316
+ # 422 antibot_blocked, and the render service can transiently 429/5xx.
317
+ _RETRYABLE_RENDER_STATUS = frozenset({422, 425, 429, 500, 502, 503, 504})
318
+ _RENDER_ATTEMPTS = 3
319
+
320
+
321
+ def _request_with_url(url: str, timeout: int) -> Tuple[str, str]:
322
+ # Public tgstat.com is behind Cloudflare; render it through the platform
323
+ # WebExtrator service (headless Chromium) rather than fetching directly.
324
+ # Anti-bot blocks are intermittent, so retry with a longer settle and a
325
+ # cache bypass before giving up.
326
+ _validate_request_url(url)
327
+ render_timeout = max(20, min(timeout, 45))
328
+ token = _render_token()
329
+ last_reason = ""
330
+ for attempt in range(_RENDER_ATTEMPTS):
331
+ payload = json.dumps(
332
+ {
333
+ "url": url,
334
+ "wait_until": "domcontentloaded",
335
+ "delay": RENDER_DELAY_SECONDS + 2 * attempt,
336
+ "timeout": render_timeout,
337
+ "block_resources": RENDER_BLOCK_RESOURCES,
338
+ "bypass_cache": attempt > 0,
339
+ }
340
+ ).encode()
341
+ request = urllib.request.Request(
342
+ RENDER_ENDPOINT,
343
+ data=payload,
344
+ headers={
345
+ "Authorization": f"Bearer {token}",
346
+ "Content-Type": "application/json",
347
+ "Accept": "application/json",
348
+ },
349
+ )
350
+ try:
351
+ with urllib.request.urlopen(request, timeout=render_timeout + 75) as response:
352
+ envelope = json.loads(response.read().decode("utf-8", "replace"))
353
+ except urllib.error.HTTPError as exc:
354
+ body = exc.read().decode("utf-8", "replace")
355
+ if exc.code in _RETRYABLE_RENDER_STATUS or "antibot" in body.lower():
356
+ last_reason = f"render blocked (HTTP {exc.code}): {_safe_error_body(body)}"
357
+ _render_backoff(attempt)
358
+ continue
359
+ raise TGStatError(f"render service HTTP {exc.code}: {_safe_error_body(body)}") from exc
360
+ except urllib.error.URLError as exc:
361
+ last_reason = f"render service unreachable: {_safe_error_body(str(exc.reason))}"
362
+ _render_backoff(attempt)
363
+ continue
364
+ except json.JSONDecodeError as exc:
365
+ raise TGStatError(f"render service returned invalid JSON: {_safe_error_body(str(exc))}") from exc
366
+ render = envelope.get("data") if isinstance(envelope, dict) else None
367
+ if isinstance(render, dict):
368
+ html = render.get("html") or ""
369
+ final_url = render.get("finalUrl") or render.get("url") or url
370
+ if html.strip() and "challenge-platform" not in html and "Just a moment" not in html:
371
+ final_host = urllib.parse.urlparse(final_url).hostname or ""
372
+ if not (TGSTAT_HOST_RE.fullmatch(final_host) or TGSTAT_INPUT_HOST_RE.fullmatch(final_host)):
373
+ raise TGStatError(f"render redirected to an unexpected host: {final_host}")
374
+ return html, final_url
375
+ last_reason = f"empty or interstitial render (upstream status {render.get('status')})"
376
+ else:
377
+ last_reason = "render service returned no page data"
378
+ _render_backoff(attempt)
379
+ raise TGStatError(f"could not render {_safe_url(url)}: {last_reason}")
380
+
322
381
 
382
+ def _render_backoff(attempt: int) -> None:
383
+ # Skip the wait after the final attempt; give Cloudflare a moment otherwise.
384
+ if attempt < _RENDER_ATTEMPTS - 1:
385
+ time.sleep(min(2 + 2 * attempt, 6))
323
386
 
324
- def _request(url: str, timeout: int, data: Optional[bytes] = None, headers: Optional[dict] = None) -> str:
325
- return _request_with_url(url, timeout, data, headers)[0]
387
+
388
+ def _request(url: str, timeout: int) -> str:
389
+ return _request_with_url(url, timeout)[0]
326
390
 
327
391
 
328
392
  def _public_base(value: str) -> str:
@@ -468,7 +532,10 @@ def command_rankings(args: argparse.Namespace) -> None:
468
532
  def _public_info(args: argparse.Namespace, target: str, peer_type: str) -> dict:
469
533
  identifier, inferred_type, exact_url = _normalize_target(target)
470
534
  types = [inferred_type] if inferred_type else ([peer_type] if peer_type != "auto" else ["channel", "chat"])
471
- urls = [exact_url] if exact_url else [f"{_public_base(args.host)}/{kind}/{identifier}/stat" for kind in types]
535
+ if exact_url:
536
+ urls = [re.sub(r"/stat/?$", "", exact_url)]
537
+ else:
538
+ urls = [f"{_public_base(args.host)}/{kind}/{identifier}" for kind in types]
472
539
  errors: List[str] = []
473
540
  for url in urls:
474
541
  if not url:
@@ -476,7 +543,7 @@ def _public_info(args: argparse.Namespace, target: str, peer_type: str) -> dict:
476
543
  try:
477
544
  html, final_url = _request_with_url(url, args.timeout)
478
545
  final_host = urllib.parse.urlparse(final_url).hostname or ""
479
- if not TGSTAT_HOST_RE.fullmatch(final_host):
546
+ if not (TGSTAT_HOST_RE.fullmatch(final_host) or TGSTAT_INPUT_HOST_RE.fullmatch(final_host)):
480
547
  raise TGStatError(f"TGStat redirected to an unexpected host: {final_host}")
481
548
  result = parse_detail_html(html, final_url)
482
549
  except TGStatError as exc:
@@ -504,7 +571,7 @@ def _public_info(args: argparse.Namespace, target: str, peer_type: str) -> dict:
504
571
  "source": ranking_url,
505
572
  }
506
573
  return result
507
- errors.append(f"restricted or empty public page: {url}")
574
+ errors.append(f"{result['status']} public page: {url}")
508
575
  raise TGStatError("; ".join(errors) or "could not resolve target on public TGStat pages")
509
576
 
510
577
 
@@ -521,7 +588,7 @@ def command_stat(args: argparse.Namespace) -> None:
521
588
  def build_parser() -> argparse.ArgumentParser:
522
589
  parser = argparse.ArgumentParser(description=__doc__)
523
590
  parser.add_argument("--host", default=os.environ.get("TGSTAT_PUBLIC_HOST", DEFAULT_PUBLIC_BASE))
524
- parser.add_argument("--timeout", type=int, default=30)
591
+ parser.add_argument("--timeout", type=int, default=45)
525
592
  sub = parser.add_subparsers(dest="command", required=True)
526
593
 
527
594
  sub.add_parser("profile").set_defaults(func=command_profile)
@@ -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))