@acedatacloud/skills 2026.714.0 → 2026.714.2
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.
|
|
3
|
+
"version": "2026.714.2",
|
|
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",
|
package/skills/tgstat/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ allowed_tools: [Bash, web_search, web_fetch]
|
|
|
12
12
|
license: Apache-2.0
|
|
13
13
|
metadata:
|
|
14
14
|
author: acedatacloud
|
|
15
|
-
version: "2.
|
|
15
|
+
version: "2.3"
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
# TGStat Research
|
|
@@ -105,6 +105,12 @@ mean TGStat did not expose them on the public page; do not call that full
|
|
|
105
105
|
statistics. Use `rankings` when you need reach / citation index for large
|
|
106
106
|
public sources.
|
|
107
107
|
|
|
108
|
+
Individual channel/chat pages are more aggressively bot-protected than the
|
|
109
|
+
ranking pages, so an `info`/`stat` lookup may occasionally return a
|
|
110
|
+
`render blocked` / `could not render` error. It fails fast (well under the
|
|
111
|
+
Bash timeout) — retry once, or fall back to `rankings` and `search` for
|
|
112
|
+
discovery, which are far more reliable.
|
|
113
|
+
|
|
108
114
|
For ad selection, rank by relevant reach/activity rather than subscriber count
|
|
109
115
|
alone. High subscribers with weak reach or chat MAU can indicate an inactive or
|
|
110
116
|
inflated audience. Present metrics as evidence, not a guarantee of lead quality.
|
|
@@ -316,24 +316,44 @@ def _render_token() -> str:
|
|
|
316
316
|
# 422 antibot_blocked, and the render service can transiently 429/5xx.
|
|
317
317
|
_RETRYABLE_RENDER_STATUS = frozenset({422, 425, 429, 500, 502, 503, 504})
|
|
318
318
|
_RENDER_ATTEMPTS = 3
|
|
319
|
+
# Total wall-time budget for one lookup. The agent sandbox kills a Bash command
|
|
320
|
+
# at ~60s, so keep every render (incl. anti-bot retries) safely under that: a
|
|
321
|
+
# stubborn page fails fast instead of being killed mid-retry.
|
|
322
|
+
_RENDER_BUDGET_SECONDS = 46
|
|
323
|
+
# Shared deadline across every render in a single command invocation, so a
|
|
324
|
+
# multi-render command (info tries channel + chat + a ranking fallback) can
|
|
325
|
+
# never exceed the sandbox cap by stacking per-render budgets.
|
|
326
|
+
_OPERATION_DEADLINE: Optional[float] = None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _op_deadline() -> float:
|
|
330
|
+
global _OPERATION_DEADLINE
|
|
331
|
+
if _OPERATION_DEADLINE is None:
|
|
332
|
+
_OPERATION_DEADLINE = time.monotonic() + _RENDER_BUDGET_SECONDS + 2
|
|
333
|
+
return _OPERATION_DEADLINE
|
|
319
334
|
|
|
320
335
|
|
|
321
336
|
def _request_with_url(url: str, timeout: int) -> Tuple[str, str]:
|
|
322
337
|
# Public tgstat.com is behind Cloudflare; render it through the platform
|
|
323
338
|
# WebExtrator service (headless Chromium) rather than fetching directly.
|
|
324
|
-
# Anti-bot blocks are intermittent, so retry with a
|
|
325
|
-
#
|
|
339
|
+
# Anti-bot blocks are intermittent, so retry with a cache bypass — but stay
|
|
340
|
+
# inside a wall-time budget so the caller never times out mid-retry.
|
|
326
341
|
_validate_request_url(url)
|
|
327
|
-
render_timeout = max(
|
|
342
|
+
render_timeout = max(15, min(timeout, 30))
|
|
328
343
|
token = _render_token()
|
|
329
|
-
|
|
344
|
+
deadline = min(time.monotonic() + _RENDER_BUDGET_SECONDS, _op_deadline())
|
|
345
|
+
last_reason = "render timed out"
|
|
330
346
|
for attempt in range(_RENDER_ATTEMPTS):
|
|
347
|
+
remaining = deadline - time.monotonic()
|
|
348
|
+
if remaining < 8:
|
|
349
|
+
break
|
|
350
|
+
nav_timeout = max(6, int(min(render_timeout, remaining - 5)))
|
|
331
351
|
payload = json.dumps(
|
|
332
352
|
{
|
|
333
353
|
"url": url,
|
|
334
354
|
"wait_until": "domcontentloaded",
|
|
335
|
-
"delay": RENDER_DELAY_SECONDS +
|
|
336
|
-
"timeout":
|
|
355
|
+
"delay": RENDER_DELAY_SECONDS + attempt,
|
|
356
|
+
"timeout": nav_timeout,
|
|
337
357
|
"block_resources": RENDER_BLOCK_RESOURCES,
|
|
338
358
|
"bypass_cache": attempt > 0,
|
|
339
359
|
}
|
|
@@ -348,18 +368,20 @@ def _request_with_url(url: str, timeout: int) -> Tuple[str, str]:
|
|
|
348
368
|
},
|
|
349
369
|
)
|
|
350
370
|
try:
|
|
351
|
-
|
|
371
|
+
# `remaining` bounds the socket op; the render service returns one
|
|
372
|
+
# JSON body, so this single read is what the wall budget relies on.
|
|
373
|
+
with urllib.request.urlopen(request, timeout=remaining) as response:
|
|
352
374
|
envelope = json.loads(response.read().decode("utf-8", "replace"))
|
|
353
375
|
except urllib.error.HTTPError as exc:
|
|
354
376
|
body = exc.read().decode("utf-8", "replace")
|
|
355
377
|
if exc.code in _RETRYABLE_RENDER_STATUS or "antibot" in body.lower():
|
|
356
378
|
last_reason = f"render blocked (HTTP {exc.code}): {_safe_error_body(body)}"
|
|
357
|
-
_render_backoff(attempt)
|
|
379
|
+
_render_backoff(attempt, deadline)
|
|
358
380
|
continue
|
|
359
381
|
raise TGStatError(f"render service HTTP {exc.code}: {_safe_error_body(body)}") from exc
|
|
360
382
|
except urllib.error.URLError as exc:
|
|
361
383
|
last_reason = f"render service unreachable: {_safe_error_body(str(exc.reason))}"
|
|
362
|
-
_render_backoff(attempt)
|
|
384
|
+
_render_backoff(attempt, deadline)
|
|
363
385
|
continue
|
|
364
386
|
except json.JSONDecodeError as exc:
|
|
365
387
|
raise TGStatError(f"render service returned invalid JSON: {_safe_error_body(str(exc))}") from exc
|
|
@@ -375,14 +397,18 @@ def _request_with_url(url: str, timeout: int) -> Tuple[str, str]:
|
|
|
375
397
|
last_reason = f"empty or interstitial render (upstream status {render.get('status')})"
|
|
376
398
|
else:
|
|
377
399
|
last_reason = "render service returned no page data"
|
|
378
|
-
_render_backoff(attempt)
|
|
400
|
+
_render_backoff(attempt, deadline)
|
|
379
401
|
raise TGStatError(f"could not render {_safe_url(url)}: {last_reason}")
|
|
380
402
|
|
|
381
403
|
|
|
382
|
-
def _render_backoff(attempt: int) -> None:
|
|
383
|
-
#
|
|
384
|
-
if attempt
|
|
385
|
-
|
|
404
|
+
def _render_backoff(attempt: int, deadline: float) -> None:
|
|
405
|
+
# Short settle between attempts, but never sleep past the wall-time budget.
|
|
406
|
+
if attempt >= _RENDER_ATTEMPTS - 1:
|
|
407
|
+
return
|
|
408
|
+
remaining = deadline - time.monotonic()
|
|
409
|
+
if remaining <= 8:
|
|
410
|
+
return
|
|
411
|
+
time.sleep(min(1.5 + attempt, 3.0, remaining - 8))
|
|
386
412
|
|
|
387
413
|
|
|
388
414
|
def _request(url: str, timeout: int) -> str:
|
|
@@ -588,7 +614,7 @@ def command_stat(args: argparse.Namespace) -> None:
|
|
|
588
614
|
def build_parser() -> argparse.ArgumentParser:
|
|
589
615
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
590
616
|
parser.add_argument("--host", default=os.environ.get("TGSTAT_PUBLIC_HOST", DEFAULT_PUBLIC_BASE))
|
|
591
|
-
parser.add_argument("--timeout", type=int, default=
|
|
617
|
+
parser.add_argument("--timeout", type=int, default=30)
|
|
592
618
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
593
619
|
|
|
594
620
|
sub.add_parser("profile").set_defaults(func=command_profile)
|
|
@@ -616,6 +642,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
616
642
|
|
|
617
643
|
|
|
618
644
|
def main() -> None:
|
|
645
|
+
global _OPERATION_DEADLINE
|
|
646
|
+
_OPERATION_DEADLINE = None # fresh render budget per command invocation
|
|
619
647
|
args = build_parser().parse_args()
|
|
620
648
|
try:
|
|
621
649
|
args.func(args)
|
|
@@ -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)
|
|
259
|
-
|
|
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
|
-
|
|
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
|
|
272
|
+
if host_state == "not_found":
|
|
275
273
|
raise PublishError("未找到 <xhs-publish-btn> 发布按钮容器")
|
|
276
|
-
if
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
(() =>
|
|
976
|
-
|
|
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, "
|
|
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))
|