@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 +1 -1
- package/skills/tgstat/SKILL.md +14 -7
- package/skills/tgstat/scripts/tgstat.py +127 -60
- package/skills/xiaohongshu/scripts/vendor/xhs/cdp.py +110 -0
- package/skills/xiaohongshu/scripts/vendor/xhs/publish.py +137 -17
- package/skills/xiaohongshu/tests/test_xiaohongshu.py +355 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.
|
|
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",
|
package/skills/tgstat/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
22
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
221
|
+
elif tag in {"h2", "h3", "h4"} and "text-dark" in classes:
|
|
233
222
|
kind = "metric_value"
|
|
234
|
-
elif tag == "div" and {"text-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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"
|
|
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=
|
|
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)
|
|
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))
|