xiaohongshu-matrices-cli 0.9.0__py3-none-any.whl
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.
- xhs_cli/__init__.py +8 -0
- xhs_cli/__main__.py +6 -0
- xhs_cli/account_bridge.py +143 -0
- xhs_cli/cli.py +153 -0
- xhs_cli/client.py +264 -0
- xhs_cli/client_mixins.py +786 -0
- xhs_cli/command_normalizers.py +112 -0
- xhs_cli/commands/__init__.py +0 -0
- xhs_cli/commands/_common.py +116 -0
- xhs_cli/commands/auth.py +231 -0
- xhs_cli/commands/creator.py +173 -0
- xhs_cli/commands/interactions.py +120 -0
- xhs_cli/commands/notifications.py +60 -0
- xhs_cli/commands/reading.py +304 -0
- xhs_cli/commands/social.py +112 -0
- xhs_cli/constants.py +33 -0
- xhs_cli/cookies.py +512 -0
- xhs_cli/creator_signing.py +73 -0
- xhs_cli/dashboard/__init__.py +5 -0
- xhs_cli/dashboard/__main__.py +24 -0
- xhs_cli/dashboard/ai.py +291 -0
- xhs_cli/dashboard/analytics.py +142 -0
- xhs_cli/dashboard/api_health.py +100 -0
- xhs_cli/dashboard/app.py +434 -0
- xhs_cli/dashboard/browser.py +389 -0
- xhs_cli/dashboard/collector.py +323 -0
- xhs_cli/dashboard/config.py +49 -0
- xhs_cli/dashboard/db.py +234 -0
- xhs_cli/dashboard/engagement.py +364 -0
- xhs_cli/dashboard/exporter.py +120 -0
- xhs_cli/dashboard/extension.py +368 -0
- xhs_cli/dashboard/governance.py +80 -0
- xhs_cli/dashboard/image_gen.py +140 -0
- xhs_cli/dashboard/importer.py +122 -0
- xhs_cli/dashboard/materials.py +95 -0
- xhs_cli/dashboard/migrate.py +54 -0
- xhs_cli/dashboard/operation_queue.py +61 -0
- xhs_cli/dashboard/operations.py +542 -0
- xhs_cli/dashboard/orchestrator.py +536 -0
- xhs_cli/dashboard/persistence.py +339 -0
- xhs_cli/dashboard/publisher.py +476 -0
- xhs_cli/dashboard/queue.py +143 -0
- xhs_cli/dashboard/rate_limit.py +24 -0
- xhs_cli/dashboard/research_extension.py +124 -0
- xhs_cli/dashboard/static/style.css +1 -0
- xhs_cli/dashboard/templates/accounts.html +8 -0
- xhs_cli/dashboard/templates/analytics.html +111 -0
- xhs_cli/dashboard/templates/api_health.html +76 -0
- xhs_cli/dashboard/templates/base.html +30 -0
- xhs_cli/dashboard/templates/dashboard.html +18 -0
- xhs_cli/dashboard/templates/engagement.html +29 -0
- xhs_cli/dashboard/templates/library.html +4 -0
- xhs_cli/dashboard/templates/materials.html +15 -0
- xhs_cli/dashboard/templates/personas.html +22 -0
- xhs_cli/dashboard/templates/publish.html +8 -0
- xhs_cli/dashboard/templates/research.html +17 -0
- xhs_cli/dashboard/templates/rules.html +11 -0
- xhs_cli/dashboard/templates/searches.html +12 -0
- xhs_cli/dashboard/utils.py +81 -0
- xhs_cli/dashboard/vision.py +124 -0
- xhs_cli/error_codes.py +39 -0
- xhs_cli/exceptions.py +64 -0
- xhs_cli/formatter.py +68 -0
- xhs_cli/formatter_normalizers.py +199 -0
- xhs_cli/formatter_renderers.py +313 -0
- xhs_cli/formatter_utils.py +192 -0
- xhs_cli/html_parser.py +73 -0
- xhs_cli/note_refs.py +60 -0
- xhs_cli/py.typed +0 -0
- xhs_cli/qr_login.py +550 -0
- xhs_cli/signing.py +93 -0
- xiaohongshu_matrices_cli-0.9.0.dist-info/METADATA +599 -0
- xiaohongshu_matrices_cli-0.9.0.dist-info/RECORD +77 -0
- xiaohongshu_matrices_cli-0.9.0.dist-info/WHEEL +4 -0
- xiaohongshu_matrices_cli-0.9.0.dist-info/entry_points.txt +3 -0
- xiaohongshu_matrices_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
- xiaohongshu_matrices_cli-0.9.0.dist-info/licenses/NOTICE +21 -0
xhs_cli/__init__.py
ADDED
xhs_cli/__main__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Bridge between the dashboard (multi-account) and the CLI client.
|
|
2
|
+
|
|
3
|
+
The dashboard stores each account's
|
|
4
|
+
cookies in a Camoufox (Firefox) profile as an *encrypted* ``cookies.sqlite`` +
|
|
5
|
+
``key4.db`` pair, while the CLI ``XhsClient`` only understands the flat
|
|
6
|
+
``cookies.json`` file. There was no way to run CLI commands against a specific
|
|
7
|
+
dashboard account without overwriting the global ``cookies.json`` (risk of
|
|
8
|
+
cross-posting).
|
|
9
|
+
|
|
10
|
+
This module closes the gap: given a dashboard account identifier it reads the
|
|
11
|
+
profile's cookie database **offline** (``browser_cookie3.firefox`` does the
|
|
12
|
+
DPAPI/NSS decryption in-process — no browser launch, no network) and returns a
|
|
13
|
+
plain ``{name: value}`` dict the client can consume. It never writes
|
|
14
|
+
``cookies.json``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .dashboard.config import DashboardConfig
|
|
23
|
+
from .dashboard.db import Database
|
|
24
|
+
from .exceptions import NoCookieError
|
|
25
|
+
|
|
26
|
+
# Firefox-style profile cookie container used by Camoufox.
|
|
27
|
+
_COOKIE_FILE = "cookies.sqlite"
|
|
28
|
+
_KEY_FILE = "key4.db"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_database() -> Database | None:
|
|
32
|
+
"""Best-effort open of the dashboard DB; None if dashboard is unused."""
|
|
33
|
+
try:
|
|
34
|
+
cfg = DashboardConfig.load()
|
|
35
|
+
except Exception: # dashboard not configured / unreadable
|
|
36
|
+
return None
|
|
37
|
+
# Guard: do not *create* a dashboard just because --account was passed.
|
|
38
|
+
if not cfg.database_path.exists():
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return Database(cfg.database_path)
|
|
42
|
+
except Exception:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_account(db: Database, identifier: str | int) -> dict[str, Any]:
|
|
47
|
+
"""Find an account row by int id, alias, or xhs_user_id (in that order)."""
|
|
48
|
+
# Numeric id lookup first (most precise).
|
|
49
|
+
if isinstance(identifier, int) or (isinstance(identifier, str) and identifier.isdigit()):
|
|
50
|
+
row = db.fetchone("SELECT * FROM accounts WHERE id=?", (int(identifier),))
|
|
51
|
+
if row:
|
|
52
|
+
return row
|
|
53
|
+
ident = str(identifier)
|
|
54
|
+
row = db.fetchone(
|
|
55
|
+
"SELECT * FROM accounts WHERE alias=? OR xhs_user_id=?",
|
|
56
|
+
(ident, ident),
|
|
57
|
+
)
|
|
58
|
+
if not row:
|
|
59
|
+
raise NoCookieError(
|
|
60
|
+
f"dashboard 账号不存在: {identifier!r} "
|
|
61
|
+
f"(可用 `xhs status` 查看已登录账号,或用 --account <id/别名/xhs_user_id>)"
|
|
62
|
+
)
|
|
63
|
+
return row
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_dashboard_account_cookies(identifier: str | int) -> dict[str, str]:
|
|
67
|
+
"""Return cookies for a dashboard account, decrypting its profile offline.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
identifier: dashboard account ``id`` (int/str), ``alias``, or
|
|
71
|
+
``xhs_user_id``.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
``{cookie_name: cookie_value}`` dict suitable for ``XhsClient(cookies)``.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
NoCookieError: if the account is unknown, has no cookie files, or the
|
|
78
|
+
cookies lack the ``a1`` session token (i.e. the login has expired).
|
|
79
|
+
"""
|
|
80
|
+
db = _load_database()
|
|
81
|
+
if db is None:
|
|
82
|
+
raise NoCookieError(
|
|
83
|
+
"未找到 dashboard 数据(~/.xiaohongshu-cli/dashboard)。"
|
|
84
|
+
"请先在 http://127.0.0.1:8765 扫码登录,或改用 cookies.json 登录。"
|
|
85
|
+
)
|
|
86
|
+
row = _resolve_account(db, identifier)
|
|
87
|
+
account_label = row.get("alias") or row.get("xhs_user_id") or str(row.get("id"))
|
|
88
|
+
|
|
89
|
+
profile_dir = row.get("profile_dir")
|
|
90
|
+
if not profile_dir:
|
|
91
|
+
raise NoCookieError(f"账号 {account_label} 没有关联浏览器 profile,请重新扫码登录")
|
|
92
|
+
base = Path(profile_dir).expanduser().resolve()
|
|
93
|
+
cookie_file = base / _COOKIE_FILE
|
|
94
|
+
key_file = base / _KEY_FILE
|
|
95
|
+
if not (cookie_file.exists() and key_file.exists()):
|
|
96
|
+
raise NoCookieError(
|
|
97
|
+
f"账号 {account_label} 的 cookie 文件缺失,请先在 dashboard 重新扫码登录"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
import browser_cookie3
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
jar = browser_cookie3.firefox(
|
|
104
|
+
cookie_file=str(cookie_file),
|
|
105
|
+
key_file=str(key_file),
|
|
106
|
+
domain_name="xiaohongshu.com",
|
|
107
|
+
)
|
|
108
|
+
except Exception as exc: # decryption failure (e.g. profile locked)
|
|
109
|
+
raise NoCookieError(f"账号 {account_label} 的 cookie 解密失败:{exc}") from exc
|
|
110
|
+
|
|
111
|
+
cookies = {c.name: c.value for c in jar}
|
|
112
|
+
if not cookies.get("a1"):
|
|
113
|
+
raise NoCookieError(f"账号 {account_label} 的登录态已失效(缺少 a1 token),请重新扫码登录")
|
|
114
|
+
return cookies
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def list_dashboard_accounts() -> list[dict[str, Any]]:
|
|
118
|
+
"""Read-only listing of dashboard accounts (for `xhs status`).
|
|
119
|
+
|
|
120
|
+
Returns an empty list when the dashboard is not initialised, so callers can
|
|
121
|
+
treat absence gracefully without side effects.
|
|
122
|
+
"""
|
|
123
|
+
db = _load_database()
|
|
124
|
+
if db is None:
|
|
125
|
+
return []
|
|
126
|
+
rows = db.fetchall(
|
|
127
|
+
"SELECT id, alias, xhs_user_id, nickname, login_status, profile_dir "
|
|
128
|
+
"FROM accounts ORDER BY id"
|
|
129
|
+
)
|
|
130
|
+
accounts = []
|
|
131
|
+
for r in rows:
|
|
132
|
+
cookie_file = Path(r.get("profile_dir", "")) / _COOKIE_FILE
|
|
133
|
+
accounts.append(
|
|
134
|
+
{
|
|
135
|
+
"id": r.get("id"),
|
|
136
|
+
"alias": r.get("alias"),
|
|
137
|
+
"xhs_user_id": r.get("xhs_user_id"),
|
|
138
|
+
"nickname": r.get("nickname"),
|
|
139
|
+
"login_status": r.get("login_status"),
|
|
140
|
+
"has_cookie_file": cookie_file.exists(),
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
return accounts
|
xhs_cli/cli.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""CLI entry point for xiaohongshu-matrices-cli.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
xhs login / status / logout
|
|
5
|
+
xhs search <keyword> [--sort popular|latest] [--type video|image] [--page N]
|
|
6
|
+
xhs read <id_or_url> [--xsec-token TOKEN]
|
|
7
|
+
xhs comments <id_or_url>
|
|
8
|
+
xhs user <user_id>
|
|
9
|
+
xhs user-posts <user_id> [--cursor CURSOR]
|
|
10
|
+
xhs feed
|
|
11
|
+
xhs hot [--category CATEGORY]
|
|
12
|
+
xhs topics <keyword>
|
|
13
|
+
xhs like <id_or_url> [--undo]
|
|
14
|
+
xhs favorite <id_or_url>
|
|
15
|
+
xhs unfavorite <id_or_url>
|
|
16
|
+
xhs comment <id_or_url> --content "..."
|
|
17
|
+
xhs reply <id_or_url> --comment-id ID --content "..."
|
|
18
|
+
xhs favorites [user_id]
|
|
19
|
+
xhs my-notes [--page N]
|
|
20
|
+
xhs notifications [--type mentions|likes|connections]
|
|
21
|
+
xhs unread
|
|
22
|
+
xhs post --title "..." --body "..." --images img.png
|
|
23
|
+
xhs delete <id_or_url> [-y]
|
|
24
|
+
|
|
25
|
+
Global options:
|
|
26
|
+
--account <id|别名|xhs_user_id> 指定 dashboard 账号,自动桥接其 cookie
|
|
27
|
+
--cookie-source <browser> 从浏览器读取 cookie(默认 auto)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
import click
|
|
36
|
+
|
|
37
|
+
from . import __version__
|
|
38
|
+
from .commands import auth, creator, interactions, notifications, reading, social
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fix_windows_encoding() -> None:
|
|
42
|
+
"""Force UTF-8 on Windows where the default codepage (936/GBK) garbles output."""
|
|
43
|
+
if sys.platform != "win32":
|
|
44
|
+
return
|
|
45
|
+
for stream in (sys.stdout, sys.stderr):
|
|
46
|
+
if hasattr(stream, "reconfigure"):
|
|
47
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_signing_libs() -> None:
|
|
51
|
+
"""Warn if signing dependencies look outdated or broken (non-blocking)."""
|
|
52
|
+
try:
|
|
53
|
+
import pkg_resources
|
|
54
|
+
except ImportError:
|
|
55
|
+
return
|
|
56
|
+
for pkg, min_version in [("xhshow", "0.1.9"), ("pycryptodome", "3.20")]:
|
|
57
|
+
try:
|
|
58
|
+
dist = pkg_resources.get_distribution(pkg)
|
|
59
|
+
parsed = pkg_resources.parse_version
|
|
60
|
+
if parsed(dist.version) < parsed(min_version):
|
|
61
|
+
logging.warning(
|
|
62
|
+
"%s %s 低于建议版本 %s,签名可能失效", pkg, dist.version, min_version
|
|
63
|
+
)
|
|
64
|
+
except pkg_resources.DistributionNotFound:
|
|
65
|
+
logging.warning("缺少签名依赖 %s,功能可能不可用", pkg)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_fix_windows_encoding()
|
|
69
|
+
try:
|
|
70
|
+
_check_signing_libs()
|
|
71
|
+
except Exception:
|
|
72
|
+
pass # 非关键路径,不影响功能
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@click.group()
|
|
76
|
+
@click.version_option(version=__version__, prog_name="xhs")
|
|
77
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging")
|
|
78
|
+
@click.option(
|
|
79
|
+
"--cookie-source",
|
|
80
|
+
type=str,
|
|
81
|
+
default="auto",
|
|
82
|
+
show_default=True,
|
|
83
|
+
help="Browser to read cookies from (auto = try all installed browsers)",
|
|
84
|
+
)
|
|
85
|
+
@click.option(
|
|
86
|
+
"--account",
|
|
87
|
+
type=str,
|
|
88
|
+
default=None,
|
|
89
|
+
help="目标 dashboard 账号(id / 别名 / xhs_user_id)。指定后自动桥接该账号的 cookie,"
|
|
90
|
+
"无需修改全局 cookies.json",
|
|
91
|
+
)
|
|
92
|
+
@click.pass_context
|
|
93
|
+
def cli(ctx, verbose: bool, cookie_source: str, account: str | None):
|
|
94
|
+
"""xhs — Xiaohongshu CLI via reverse-engineered API 📕"""
|
|
95
|
+
ctx.ensure_object(dict)
|
|
96
|
+
ctx.obj["cookie_source"] = cookie_source
|
|
97
|
+
ctx.obj["account"] = account
|
|
98
|
+
|
|
99
|
+
if verbose:
|
|
100
|
+
logging.basicConfig(level=logging.DEBUG, format="%(name)s %(message)s")
|
|
101
|
+
else:
|
|
102
|
+
logging.basicConfig(level=logging.WARNING)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ─── Auth commands ───────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
cli.add_command(auth.login)
|
|
108
|
+
cli.add_command(auth.status)
|
|
109
|
+
cli.add_command(auth.logout)
|
|
110
|
+
cli.add_command(auth.whoami)
|
|
111
|
+
|
|
112
|
+
# ─── Reading commands ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
cli.add_command(reading.search)
|
|
115
|
+
cli.add_command(reading.read)
|
|
116
|
+
cli.add_command(reading.comments)
|
|
117
|
+
cli.add_command(reading.sub_comments)
|
|
118
|
+
cli.add_command(reading.user)
|
|
119
|
+
cli.add_command(reading.user_posts)
|
|
120
|
+
cli.add_command(reading.feed)
|
|
121
|
+
cli.add_command(reading.hot)
|
|
122
|
+
cli.add_command(reading.topics)
|
|
123
|
+
cli.add_command(reading.search_user)
|
|
124
|
+
|
|
125
|
+
# ─── Interaction commands ────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
cli.add_command(interactions.like)
|
|
128
|
+
cli.add_command(interactions.favorite)
|
|
129
|
+
cli.add_command(interactions.unfavorite)
|
|
130
|
+
cli.add_command(interactions.comment)
|
|
131
|
+
cli.add_command(interactions.reply)
|
|
132
|
+
cli.add_command(interactions.delete_comment)
|
|
133
|
+
|
|
134
|
+
# ─── Social commands ────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
cli.add_command(social.follow)
|
|
137
|
+
cli.add_command(social.unfollow)
|
|
138
|
+
cli.add_command(social.favorites)
|
|
139
|
+
cli.add_command(social.likes)
|
|
140
|
+
|
|
141
|
+
# ─── Creator commands ───────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
cli.add_command(creator.post)
|
|
144
|
+
cli.add_command(creator.my_notes)
|
|
145
|
+
cli.add_command(creator.delete)
|
|
146
|
+
|
|
147
|
+
# ─── Notification commands ──────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
cli.add_command(notifications.notifications)
|
|
150
|
+
cli.add_command(notifications.unread)
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
cli()
|
xhs_cli/client.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
XHS API client transport, signing, and retry primitives.
|
|
3
|
+
|
|
4
|
+
Domain-specific endpoint methods live in ``client_mixins.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .client_mixins import (
|
|
18
|
+
AuthEndpointsMixin,
|
|
19
|
+
CreatorEndpointsMixin,
|
|
20
|
+
InteractionEndpointsMixin,
|
|
21
|
+
NotificationEndpointsMixin,
|
|
22
|
+
ReadingEndpointsMixin,
|
|
23
|
+
SocialEndpointsMixin,
|
|
24
|
+
)
|
|
25
|
+
from .constants import CHROME_VERSION, CREATOR_HOST, EDITH_HOST, HOME_URL, USER_AGENT
|
|
26
|
+
from .cookies import cookies_to_string
|
|
27
|
+
from .creator_signing import sign_creator
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
IpBlockedError,
|
|
30
|
+
NeedVerifyError,
|
|
31
|
+
SessionExpiredError,
|
|
32
|
+
SignatureError,
|
|
33
|
+
XhsApiError,
|
|
34
|
+
)
|
|
35
|
+
from .signing import build_get_uri, sign_main_api
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class XhsClient(
|
|
41
|
+
ReadingEndpointsMixin,
|
|
42
|
+
InteractionEndpointsMixin,
|
|
43
|
+
CreatorEndpointsMixin,
|
|
44
|
+
SocialEndpointsMixin,
|
|
45
|
+
NotificationEndpointsMixin,
|
|
46
|
+
AuthEndpointsMixin,
|
|
47
|
+
):
|
|
48
|
+
"""Xiaohongshu API client with automatic signing, rate limiting, and retry."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
cookies: dict[str, str],
|
|
53
|
+
timeout: float = 30.0,
|
|
54
|
+
request_delay: float = 1.0,
|
|
55
|
+
max_retries: int = 3,
|
|
56
|
+
):
|
|
57
|
+
self.cookies = cookies
|
|
58
|
+
self._http = httpx.Client(timeout=timeout, follow_redirects=True)
|
|
59
|
+
self._request_delay = request_delay
|
|
60
|
+
self._base_request_delay = request_delay
|
|
61
|
+
self._max_retries = max_retries
|
|
62
|
+
self._last_request_time = 0.0
|
|
63
|
+
self._verify_count = 0
|
|
64
|
+
self._request_count = 0
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._http.close()
|
|
68
|
+
|
|
69
|
+
def __enter__(self):
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *args):
|
|
73
|
+
self.close()
|
|
74
|
+
|
|
75
|
+
def _rate_limit_delay(self) -> None:
|
|
76
|
+
"""Enforce minimum delay with Gaussian jitter to mimic human browsing."""
|
|
77
|
+
if self._request_delay <= 0:
|
|
78
|
+
return
|
|
79
|
+
elapsed = time.time() - self._last_request_time
|
|
80
|
+
if elapsed < self._request_delay:
|
|
81
|
+
jitter = max(0, random.gauss(0.3, 0.15))
|
|
82
|
+
if random.random() < 0.05:
|
|
83
|
+
jitter += random.uniform(2.0, 5.0)
|
|
84
|
+
sleep_time = self._request_delay - elapsed + jitter
|
|
85
|
+
logger.debug("Rate-limit delay: %.2fs", sleep_time)
|
|
86
|
+
time.sleep(sleep_time)
|
|
87
|
+
|
|
88
|
+
def _mark_request(self) -> None:
|
|
89
|
+
self._last_request_time = time.time()
|
|
90
|
+
self._request_count += 1
|
|
91
|
+
|
|
92
|
+
def _base_headers(self) -> dict[str, str]:
|
|
93
|
+
return {
|
|
94
|
+
"user-agent": USER_AGENT,
|
|
95
|
+
"content-type": "application/json;charset=UTF-8",
|
|
96
|
+
"cookie": cookies_to_string(self.cookies),
|
|
97
|
+
"origin": HOME_URL,
|
|
98
|
+
"referer": f"{HOME_URL}/",
|
|
99
|
+
"sec-ch-ua": f'"Not:A-Brand";v="99", "Google Chrome";v="{CHROME_VERSION}", "Chromium";v="{CHROME_VERSION}"',
|
|
100
|
+
"sec-ch-ua-mobile": "?0",
|
|
101
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
102
|
+
"sec-fetch-dest": "empty",
|
|
103
|
+
"sec-fetch-mode": "cors",
|
|
104
|
+
"sec-fetch-site": "same-site",
|
|
105
|
+
"accept": "application/json, text/plain, */*",
|
|
106
|
+
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
107
|
+
"dnt": "1",
|
|
108
|
+
"priority": "u=1, i",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def _handle_response(self, resp: httpx.Response) -> Any:
|
|
112
|
+
if resp.status_code in (461, 471):
|
|
113
|
+
self._verify_count += 1
|
|
114
|
+
cooldown = min(30, 5 * (2 ** (self._verify_count - 1)))
|
|
115
|
+
logger.warning(
|
|
116
|
+
"Captcha triggered (count=%d), cooling down %.0fs before raising",
|
|
117
|
+
self._verify_count,
|
|
118
|
+
cooldown,
|
|
119
|
+
)
|
|
120
|
+
self._request_delay = max(self._request_delay, self._base_request_delay * 2)
|
|
121
|
+
time.sleep(cooldown)
|
|
122
|
+
raise NeedVerifyError(
|
|
123
|
+
verify_type=resp.headers.get("verifytype", "unknown"),
|
|
124
|
+
verify_uuid=resp.headers.get("verifyuuid", "unknown"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self._verify_count = 0
|
|
128
|
+
text = resp.text
|
|
129
|
+
if not text:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
data = json.loads(text)
|
|
134
|
+
except json.JSONDecodeError:
|
|
135
|
+
raise XhsApiError(f"Non-JSON response: {text[:200]}") from None
|
|
136
|
+
|
|
137
|
+
if data.get("success"):
|
|
138
|
+
return data.get("data", data.get("success"))
|
|
139
|
+
|
|
140
|
+
code = data.get("code")
|
|
141
|
+
if code == 300012:
|
|
142
|
+
raise IpBlockedError()
|
|
143
|
+
if code == 300015:
|
|
144
|
+
raise SignatureError()
|
|
145
|
+
if code == -100:
|
|
146
|
+
raise SessionExpiredError()
|
|
147
|
+
|
|
148
|
+
raise XhsApiError(
|
|
149
|
+
f"API error: {json.dumps(data)[:300]}",
|
|
150
|
+
code=code,
|
|
151
|
+
response=data,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _merge_response_cookies(self, resp: httpx.Response) -> None:
|
|
155
|
+
"""Persist response cookies back into the in-memory session jar."""
|
|
156
|
+
for name, value in resp.cookies.items():
|
|
157
|
+
if not value:
|
|
158
|
+
continue
|
|
159
|
+
self.cookies[name] = value
|
|
160
|
+
|
|
161
|
+
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
162
|
+
self._rate_limit_delay()
|
|
163
|
+
last_exc: Exception | None = None
|
|
164
|
+
|
|
165
|
+
for attempt in range(self._max_retries):
|
|
166
|
+
try:
|
|
167
|
+
resp = self._http.request(method, url, **kwargs)
|
|
168
|
+
self._merge_response_cookies(resp)
|
|
169
|
+
self._mark_request()
|
|
170
|
+
if resp.status_code in (429, 500, 502, 503, 504):
|
|
171
|
+
wait = (2**attempt) + random.uniform(0, 1)
|
|
172
|
+
logger.warning(
|
|
173
|
+
"HTTP %d from %s, retrying in %.1fs (attempt %d/%d)",
|
|
174
|
+
resp.status_code,
|
|
175
|
+
url[:80],
|
|
176
|
+
wait,
|
|
177
|
+
attempt + 1,
|
|
178
|
+
self._max_retries,
|
|
179
|
+
)
|
|
180
|
+
time.sleep(wait)
|
|
181
|
+
continue
|
|
182
|
+
return resp
|
|
183
|
+
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
184
|
+
last_exc = exc
|
|
185
|
+
wait = (2**attempt) + random.uniform(0, 1)
|
|
186
|
+
logger.warning(
|
|
187
|
+
"Network error: %s, retrying in %.1fs (attempt %d/%d)",
|
|
188
|
+
exc,
|
|
189
|
+
wait,
|
|
190
|
+
attempt + 1,
|
|
191
|
+
self._max_retries,
|
|
192
|
+
)
|
|
193
|
+
time.sleep(wait)
|
|
194
|
+
|
|
195
|
+
if last_exc:
|
|
196
|
+
raise XhsApiError(f"Request failed after {self._max_retries} retries: {last_exc}") from last_exc
|
|
197
|
+
raise XhsApiError(f"Request failed after {self._max_retries} retries: HTTP {resp.status_code}")
|
|
198
|
+
|
|
199
|
+
def _main_api_get(
|
|
200
|
+
self,
|
|
201
|
+
uri: str,
|
|
202
|
+
params: dict[str, str | int | list[str]] | None = None,
|
|
203
|
+
) -> Any:
|
|
204
|
+
sign_headers = sign_main_api("GET", uri, self.cookies, params=params)
|
|
205
|
+
full_uri = build_get_uri(uri, params)
|
|
206
|
+
url = f"{EDITH_HOST}{full_uri}"
|
|
207
|
+
logger.debug("GET %s", url)
|
|
208
|
+
resp = self._request_with_retry("GET", url, headers={**self._base_headers(), **sign_headers})
|
|
209
|
+
return self._handle_response(resp)
|
|
210
|
+
|
|
211
|
+
def _main_api_post(
|
|
212
|
+
self,
|
|
213
|
+
uri: str,
|
|
214
|
+
data: dict[str, Any],
|
|
215
|
+
header_overrides: dict[str, str] | None = None,
|
|
216
|
+
) -> Any:
|
|
217
|
+
sign_headers = sign_main_api("POST", uri, self.cookies, payload=data)
|
|
218
|
+
url = f"{EDITH_HOST}{uri}"
|
|
219
|
+
headers = {**self._base_headers(), **sign_headers}
|
|
220
|
+
if header_overrides:
|
|
221
|
+
headers.update(header_overrides)
|
|
222
|
+
logger.debug("POST %s", url)
|
|
223
|
+
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
|
224
|
+
resp = self._request_with_retry("POST", url, headers=headers, content=body)
|
|
225
|
+
return self._handle_response(resp)
|
|
226
|
+
|
|
227
|
+
def _creator_host(self, uri: str) -> str:
|
|
228
|
+
return CREATOR_HOST if uri.startswith("/api/galaxy/") else EDITH_HOST
|
|
229
|
+
|
|
230
|
+
def _creator_get(
|
|
231
|
+
self,
|
|
232
|
+
uri: str,
|
|
233
|
+
params: dict[str, str | int] | None = None,
|
|
234
|
+
) -> Any:
|
|
235
|
+
full_uri = build_get_uri(uri, params)
|
|
236
|
+
sign = sign_creator(f"url={full_uri}", None, self.cookies["a1"])
|
|
237
|
+
host = self._creator_host(uri)
|
|
238
|
+
url = f"{host}{full_uri}"
|
|
239
|
+
headers = {
|
|
240
|
+
**self._base_headers(),
|
|
241
|
+
"x-s": sign["x-s"],
|
|
242
|
+
"x-t": sign["x-t"],
|
|
243
|
+
"origin": CREATOR_HOST,
|
|
244
|
+
"referer": f"{CREATOR_HOST}/",
|
|
245
|
+
}
|
|
246
|
+
logger.debug("Creator GET %s", url)
|
|
247
|
+
resp = self._request_with_retry("GET", url, headers=headers)
|
|
248
|
+
return self._handle_response(resp)
|
|
249
|
+
|
|
250
|
+
def _creator_post(self, uri: str, data: dict[str, Any]) -> Any:
|
|
251
|
+
sign = sign_creator(f"url={uri}", data, self.cookies["a1"])
|
|
252
|
+
host = self._creator_host(uri)
|
|
253
|
+
url = f"{host}{uri}"
|
|
254
|
+
headers = {
|
|
255
|
+
**self._base_headers(),
|
|
256
|
+
"x-s": sign["x-s"],
|
|
257
|
+
"x-t": sign["x-t"],
|
|
258
|
+
"origin": CREATOR_HOST,
|
|
259
|
+
"referer": f"{CREATOR_HOST}/",
|
|
260
|
+
}
|
|
261
|
+
logger.debug("Creator POST %s", url)
|
|
262
|
+
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
|
263
|
+
resp = self._request_with_retry("POST", url, headers=headers, content=body)
|
|
264
|
+
return self._handle_response(resp)
|