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.
Files changed (77) hide show
  1. xhs_cli/__init__.py +8 -0
  2. xhs_cli/__main__.py +6 -0
  3. xhs_cli/account_bridge.py +143 -0
  4. xhs_cli/cli.py +153 -0
  5. xhs_cli/client.py +264 -0
  6. xhs_cli/client_mixins.py +786 -0
  7. xhs_cli/command_normalizers.py +112 -0
  8. xhs_cli/commands/__init__.py +0 -0
  9. xhs_cli/commands/_common.py +116 -0
  10. xhs_cli/commands/auth.py +231 -0
  11. xhs_cli/commands/creator.py +173 -0
  12. xhs_cli/commands/interactions.py +120 -0
  13. xhs_cli/commands/notifications.py +60 -0
  14. xhs_cli/commands/reading.py +304 -0
  15. xhs_cli/commands/social.py +112 -0
  16. xhs_cli/constants.py +33 -0
  17. xhs_cli/cookies.py +512 -0
  18. xhs_cli/creator_signing.py +73 -0
  19. xhs_cli/dashboard/__init__.py +5 -0
  20. xhs_cli/dashboard/__main__.py +24 -0
  21. xhs_cli/dashboard/ai.py +291 -0
  22. xhs_cli/dashboard/analytics.py +142 -0
  23. xhs_cli/dashboard/api_health.py +100 -0
  24. xhs_cli/dashboard/app.py +434 -0
  25. xhs_cli/dashboard/browser.py +389 -0
  26. xhs_cli/dashboard/collector.py +323 -0
  27. xhs_cli/dashboard/config.py +49 -0
  28. xhs_cli/dashboard/db.py +234 -0
  29. xhs_cli/dashboard/engagement.py +364 -0
  30. xhs_cli/dashboard/exporter.py +120 -0
  31. xhs_cli/dashboard/extension.py +368 -0
  32. xhs_cli/dashboard/governance.py +80 -0
  33. xhs_cli/dashboard/image_gen.py +140 -0
  34. xhs_cli/dashboard/importer.py +122 -0
  35. xhs_cli/dashboard/materials.py +95 -0
  36. xhs_cli/dashboard/migrate.py +54 -0
  37. xhs_cli/dashboard/operation_queue.py +61 -0
  38. xhs_cli/dashboard/operations.py +542 -0
  39. xhs_cli/dashboard/orchestrator.py +536 -0
  40. xhs_cli/dashboard/persistence.py +339 -0
  41. xhs_cli/dashboard/publisher.py +476 -0
  42. xhs_cli/dashboard/queue.py +143 -0
  43. xhs_cli/dashboard/rate_limit.py +24 -0
  44. xhs_cli/dashboard/research_extension.py +124 -0
  45. xhs_cli/dashboard/static/style.css +1 -0
  46. xhs_cli/dashboard/templates/accounts.html +8 -0
  47. xhs_cli/dashboard/templates/analytics.html +111 -0
  48. xhs_cli/dashboard/templates/api_health.html +76 -0
  49. xhs_cli/dashboard/templates/base.html +30 -0
  50. xhs_cli/dashboard/templates/dashboard.html +18 -0
  51. xhs_cli/dashboard/templates/engagement.html +29 -0
  52. xhs_cli/dashboard/templates/library.html +4 -0
  53. xhs_cli/dashboard/templates/materials.html +15 -0
  54. xhs_cli/dashboard/templates/personas.html +22 -0
  55. xhs_cli/dashboard/templates/publish.html +8 -0
  56. xhs_cli/dashboard/templates/research.html +17 -0
  57. xhs_cli/dashboard/templates/rules.html +11 -0
  58. xhs_cli/dashboard/templates/searches.html +12 -0
  59. xhs_cli/dashboard/utils.py +81 -0
  60. xhs_cli/dashboard/vision.py +124 -0
  61. xhs_cli/error_codes.py +39 -0
  62. xhs_cli/exceptions.py +64 -0
  63. xhs_cli/formatter.py +68 -0
  64. xhs_cli/formatter_normalizers.py +199 -0
  65. xhs_cli/formatter_renderers.py +313 -0
  66. xhs_cli/formatter_utils.py +192 -0
  67. xhs_cli/html_parser.py +73 -0
  68. xhs_cli/note_refs.py +60 -0
  69. xhs_cli/py.typed +0 -0
  70. xhs_cli/qr_login.py +550 -0
  71. xhs_cli/signing.py +93 -0
  72. xiaohongshu_matrices_cli-0.9.0.dist-info/METADATA +599 -0
  73. xiaohongshu_matrices_cli-0.9.0.dist-info/RECORD +77 -0
  74. xiaohongshu_matrices_cli-0.9.0.dist-info/WHEEL +4 -0
  75. xiaohongshu_matrices_cli-0.9.0.dist-info/entry_points.txt +3 -0
  76. xiaohongshu_matrices_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
  77. xiaohongshu_matrices_cli-0.9.0.dist-info/licenses/NOTICE +21 -0
xhs_cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """xiaohongshu-matrices-cli: Xiaohongshu CLI via reverse-engineered API."""
2
+
3
+ try:
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("xiaohongshu-matrices-cli")
7
+ except Exception:
8
+ __version__ = "0.0.0"
xhs_cli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Module entry point for ``python -m xhs_cli``."""
2
+
3
+ from .cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -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)