xhs-cli-headless 0.8.4__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/cli.py +105 -0
- xhs_cli/client.py +257 -0
- xhs_cli/client_mixins.py +764 -0
- xhs_cli/command_normalizers.py +65 -0
- xhs_cli/commands/__init__.py +0 -0
- xhs_cli/commands/_common.py +99 -0
- xhs_cli/commands/auth.py +598 -0
- xhs_cli/commands/creator.py +125 -0
- xhs_cli/commands/interactions.py +120 -0
- xhs_cli/commands/notifications.py +57 -0
- xhs_cli/commands/reading.py +309 -0
- xhs_cli/commands/social.py +107 -0
- xhs_cli/constants.py +24 -0
- xhs_cli/cookies.py +572 -0
- xhs_cli/creator_signing.py +71 -0
- xhs_cli/error_codes.py +39 -0
- xhs_cli/exceptions.py +71 -0
- xhs_cli/formatter.py +67 -0
- xhs_cli/formatter_normalizers.py +187 -0
- xhs_cli/formatter_renderers.py +313 -0
- xhs_cli/formatter_utils.py +187 -0
- xhs_cli/html_parser.py +73 -0
- xhs_cli/note_refs.py +56 -0
- xhs_cli/py.typed +0 -0
- xhs_cli/qr_login.py +605 -0
- xhs_cli/signing.py +85 -0
- xhs_cli_headless-0.8.4.dist-info/METADATA +239 -0
- xhs_cli_headless-0.8.4.dist-info/RECORD +34 -0
- xhs_cli_headless-0.8.4.dist-info/WHEEL +4 -0
- xhs_cli_headless-0.8.4.dist-info/entry_points.txt +2 -0
- xhs_cli_headless-0.8.4.dist-info/licenses/LICENSE +201 -0
- xhs_cli_headless-0.8.4.dist-info/licenses/NOTICE +23 -0
xhs_cli/client_mixins.py
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
"""Domain-specific endpoint mixins for XhsClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import mimetypes
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .constants import CREATOR_HOST, HOME_URL, UPLOAD_HOST, USER_AGENT
|
|
17
|
+
from .cookies import (
|
|
18
|
+
cache_note_context,
|
|
19
|
+
cookies_to_string,
|
|
20
|
+
get_cached_note_context,
|
|
21
|
+
get_config_dir,
|
|
22
|
+
invalidate_note_context,
|
|
23
|
+
)
|
|
24
|
+
from .exceptions import NeedVerifyError, UnsupportedOperationError, XhsApiError
|
|
25
|
+
from .html_parser import extract_note_from_html
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_SEARCH_DEFAULT_FILTERS = [
|
|
30
|
+
{"tags": ["general"], "type": "sort_type"},
|
|
31
|
+
{"tags": ["不限"], "type": "filter_note_type"},
|
|
32
|
+
{"tags": ["不限"], "type": "filter_note_time"},
|
|
33
|
+
{"tags": ["不限"], "type": "filter_note_range"},
|
|
34
|
+
{"tags": ["不限"], "type": "filter_pos_distance"},
|
|
35
|
+
]
|
|
36
|
+
_SEARCH_SESSION_TTL_SECONDS = 600
|
|
37
|
+
_SEARCH_SESSION_MAX_SIZE = 128
|
|
38
|
+
_SEARCH_SESSION_LOCK = threading.RLock()
|
|
39
|
+
_SEARCH_SESSION_CACHE: OrderedDict[tuple[str, str, int], dict[str, Any]] = OrderedDict()
|
|
40
|
+
_SEARCH_SESSION_CACHE_PATH: Path | None = None
|
|
41
|
+
_SEARCH_SESSION_CACHE_LOADED = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _generate_search_id() -> str:
|
|
45
|
+
"""Generate a unique search ID (base36 of timestamp << 64 + random)."""
|
|
46
|
+
e = int(time.time() * 1000) << 64
|
|
47
|
+
t = random.randint(0, 2147483646)
|
|
48
|
+
num = e + t
|
|
49
|
+
|
|
50
|
+
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
51
|
+
if num == 0:
|
|
52
|
+
return "0"
|
|
53
|
+
result = ""
|
|
54
|
+
while num > 0:
|
|
55
|
+
result = alphabet[num % 36] + result
|
|
56
|
+
num //= 36
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _search_session_key(keyword: str, sort: str, note_type: int) -> tuple[str, str, int]:
|
|
61
|
+
return (keyword.strip(), sort, note_type)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _search_session_path() -> Path:
|
|
65
|
+
return get_config_dir() / "search_sessions.json"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _serialize_search_session_key(key: tuple[str, str, int]) -> str:
|
|
69
|
+
return json.dumps([key[0], key[1], key[2]], ensure_ascii=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _deserialize_search_session_key(value: str) -> tuple[str, str, int] | None:
|
|
73
|
+
try:
|
|
74
|
+
keyword, sort, note_type = json.loads(value)
|
|
75
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
76
|
+
return None
|
|
77
|
+
if not isinstance(keyword, str) or not isinstance(sort, str):
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
normalized_note_type = int(note_type)
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
return None
|
|
83
|
+
return (keyword, sort, normalized_note_type)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _load_search_session_cache_from_disk(path: Path) -> OrderedDict[tuple[str, str, int], dict[str, Any]]:
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return OrderedDict()
|
|
89
|
+
try:
|
|
90
|
+
data = json.loads(path.read_text())
|
|
91
|
+
except (OSError, json.JSONDecodeError):
|
|
92
|
+
return OrderedDict()
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
return OrderedDict()
|
|
95
|
+
|
|
96
|
+
normalized: list[tuple[tuple[str, str, int], dict[str, Any]]] = []
|
|
97
|
+
for raw_key, value in data.items():
|
|
98
|
+
key = _deserialize_search_session_key(raw_key)
|
|
99
|
+
if not key or not isinstance(value, dict):
|
|
100
|
+
continue
|
|
101
|
+
if not value.get("search_id"):
|
|
102
|
+
continue
|
|
103
|
+
normalized.append((key, {
|
|
104
|
+
"search_id": str(value["search_id"]),
|
|
105
|
+
"created_at": float(value.get("created_at", 0) or 0),
|
|
106
|
+
"last_used_at": float(value.get("last_used_at", 0) or 0),
|
|
107
|
+
}))
|
|
108
|
+
normalized.sort(key=lambda item: float(item[1].get("last_used_at", 0)))
|
|
109
|
+
return OrderedDict(normalized)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _save_search_session_cache(path: Path) -> None:
|
|
113
|
+
payload = OrderedDict(
|
|
114
|
+
(
|
|
115
|
+
_serialize_search_session_key(key),
|
|
116
|
+
dict(value),
|
|
117
|
+
)
|
|
118
|
+
for key, value in _SEARCH_SESSION_CACHE.items()
|
|
119
|
+
)
|
|
120
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
121
|
+
path.chmod(0o600)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _ensure_search_session_cache_loaded() -> None:
|
|
125
|
+
global _SEARCH_SESSION_CACHE_LOADED, _SEARCH_SESSION_CACHE_PATH, _SEARCH_SESSION_CACHE
|
|
126
|
+
path = _search_session_path()
|
|
127
|
+
if _SEARCH_SESSION_CACHE_LOADED and _SEARCH_SESSION_CACHE_PATH == path:
|
|
128
|
+
return
|
|
129
|
+
_SEARCH_SESSION_CACHE = _load_search_session_cache_from_disk(path)
|
|
130
|
+
_SEARCH_SESSION_CACHE_PATH = path
|
|
131
|
+
_SEARCH_SESSION_CACHE_LOADED = True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _prune_search_sessions(now: float) -> None:
|
|
135
|
+
expired_keys = [
|
|
136
|
+
key
|
|
137
|
+
for key, value in _SEARCH_SESSION_CACHE.items()
|
|
138
|
+
if now - float(value.get("last_used_at", 0)) > _SEARCH_SESSION_TTL_SECONDS
|
|
139
|
+
]
|
|
140
|
+
for key in expired_keys:
|
|
141
|
+
_SEARCH_SESSION_CACHE.pop(key, None)
|
|
142
|
+
|
|
143
|
+
while len(_SEARCH_SESSION_CACHE) > _SEARCH_SESSION_MAX_SIZE:
|
|
144
|
+
_SEARCH_SESSION_CACHE.popitem(last=False)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _acquire_search_session(keyword: str, sort: str, note_type: int) -> tuple[str, bool]:
|
|
148
|
+
now = time.time()
|
|
149
|
+
key = _search_session_key(keyword, sort, note_type)
|
|
150
|
+
|
|
151
|
+
with _SEARCH_SESSION_LOCK:
|
|
152
|
+
_ensure_search_session_cache_loaded()
|
|
153
|
+
_prune_search_sessions(now)
|
|
154
|
+
existing = _SEARCH_SESSION_CACHE.get(key)
|
|
155
|
+
if existing:
|
|
156
|
+
existing["last_used_at"] = now
|
|
157
|
+
_SEARCH_SESSION_CACHE.move_to_end(key)
|
|
158
|
+
_save_search_session_cache(_SEARCH_SESSION_CACHE_PATH or _search_session_path())
|
|
159
|
+
return str(existing["search_id"]), False
|
|
160
|
+
|
|
161
|
+
search_id = _generate_search_id()
|
|
162
|
+
_SEARCH_SESSION_CACHE[key] = {
|
|
163
|
+
"search_id": search_id,
|
|
164
|
+
"created_at": now,
|
|
165
|
+
"last_used_at": now,
|
|
166
|
+
}
|
|
167
|
+
_save_search_session_cache(_SEARCH_SESSION_CACHE_PATH or _search_session_path())
|
|
168
|
+
return search_id, True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_search_session_stats() -> dict[str, Any]:
|
|
172
|
+
"""Return lightweight debug stats for the in-memory search session cache."""
|
|
173
|
+
now = time.time()
|
|
174
|
+
with _SEARCH_SESSION_LOCK:
|
|
175
|
+
_ensure_search_session_cache_loaded()
|
|
176
|
+
_prune_search_sessions(now)
|
|
177
|
+
if not _SEARCH_SESSION_CACHE:
|
|
178
|
+
return {
|
|
179
|
+
"active_count": 0,
|
|
180
|
+
"last_keyword": "",
|
|
181
|
+
"last_sort": "",
|
|
182
|
+
"last_note_type": None,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
last_key = next(reversed(_SEARCH_SESSION_CACHE))
|
|
186
|
+
return {
|
|
187
|
+
"active_count": len(_SEARCH_SESSION_CACHE),
|
|
188
|
+
"last_keyword": last_key[0],
|
|
189
|
+
"last_sort": last_key[1],
|
|
190
|
+
"last_note_type": last_key[2],
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ReadingEndpointsMixin:
|
|
195
|
+
"""Read-only note, profile, and discovery endpoints."""
|
|
196
|
+
|
|
197
|
+
def _search_request_id(self) -> str:
|
|
198
|
+
return f"{random.randint(1_000_000_000, 2_147_483_647)}-{int(time.time() * 1000)}"
|
|
199
|
+
|
|
200
|
+
def _fetch_note_html(
|
|
201
|
+
self,
|
|
202
|
+
note_id: str,
|
|
203
|
+
xsec_token: str = "",
|
|
204
|
+
xsec_source: str = "pc_feed",
|
|
205
|
+
) -> str:
|
|
206
|
+
if xsec_token:
|
|
207
|
+
url = f"{HOME_URL}/explore/{note_id}?xsec_token={xsec_token}&xsec_source={xsec_source}"
|
|
208
|
+
else:
|
|
209
|
+
url = f"{HOME_URL}/explore/{note_id}"
|
|
210
|
+
|
|
211
|
+
resp = self._request_with_retry(
|
|
212
|
+
"GET",
|
|
213
|
+
url,
|
|
214
|
+
headers={
|
|
215
|
+
"user-agent": USER_AGENT,
|
|
216
|
+
"referer": f"{HOME_URL}/",
|
|
217
|
+
"cookie": cookies_to_string(self.cookies),
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
return resp.text
|
|
221
|
+
|
|
222
|
+
def resolve_xsec_context(
|
|
223
|
+
self,
|
|
224
|
+
note_id: str,
|
|
225
|
+
preferred_token: str = "",
|
|
226
|
+
preferred_source: str = "",
|
|
227
|
+
) -> tuple[str, str]:
|
|
228
|
+
"""Resolve xsec_token/xsec_source from input, cache, or note page metadata."""
|
|
229
|
+
if preferred_token:
|
|
230
|
+
cache_note_context(note_id, preferred_token, preferred_source)
|
|
231
|
+
return preferred_token, preferred_source
|
|
232
|
+
|
|
233
|
+
cached = get_cached_note_context(note_id)
|
|
234
|
+
if cached.get("token"):
|
|
235
|
+
return cached["token"], cached.get("source", "")
|
|
236
|
+
|
|
237
|
+
html = self._fetch_note_html(note_id)
|
|
238
|
+
patterns = [
|
|
239
|
+
r'"xsec_token"\s*:\s*"([^"]+)"',
|
|
240
|
+
r"xsec_token=([^&\"']+)",
|
|
241
|
+
r"'xsec_token':'([^']+)'",
|
|
242
|
+
]
|
|
243
|
+
for pattern in patterns:
|
|
244
|
+
match = re.search(pattern, html)
|
|
245
|
+
if match:
|
|
246
|
+
token = match.group(1)
|
|
247
|
+
source_match = re.search(r"xsec_source=([^&\"']+)", html)
|
|
248
|
+
source = source_match.group(1) if source_match else preferred_source
|
|
249
|
+
cache_note_context(note_id, token, source)
|
|
250
|
+
return token, source
|
|
251
|
+
return "", preferred_source
|
|
252
|
+
|
|
253
|
+
def resolve_xsec_token(self, note_id: str, preferred_token: str = "") -> str:
|
|
254
|
+
"""Resolve xsec_token from explicit input, cache, or note page metadata."""
|
|
255
|
+
token, _source = self.resolve_xsec_context(note_id, preferred_token)
|
|
256
|
+
return token
|
|
257
|
+
|
|
258
|
+
def get_self_info(self) -> dict[str, Any]:
|
|
259
|
+
return self._main_api_get("/api/sns/web/v2/user/me")
|
|
260
|
+
|
|
261
|
+
def get_user_info(self, user_id: str) -> dict[str, Any]:
|
|
262
|
+
try:
|
|
263
|
+
return self._main_api_get("/api/sns/web/v1/user/otherinfo", {
|
|
264
|
+
"target_user_id": user_id,
|
|
265
|
+
})
|
|
266
|
+
except XhsApiError as exc:
|
|
267
|
+
if exc.code == -1:
|
|
268
|
+
raise UnsupportedOperationError(
|
|
269
|
+
"User profile lookup is currently unavailable from the public web API "
|
|
270
|
+
"(server rejected /api/sns/web/v1/user/otherinfo with code -1). "
|
|
271
|
+
"Use `xhs search`, `xhs read`, or a note URL as a fallback entry point."
|
|
272
|
+
) from exc
|
|
273
|
+
raise
|
|
274
|
+
|
|
275
|
+
def get_user_notes(self, user_id: str, cursor: str = "") -> dict[str, Any]:
|
|
276
|
+
try:
|
|
277
|
+
return self._main_api_get("/api/sns/web/v1/user_posted", {
|
|
278
|
+
"num": 30,
|
|
279
|
+
"cursor": cursor,
|
|
280
|
+
"user_id": user_id,
|
|
281
|
+
"image_scenes": "FD_WM_WEBP",
|
|
282
|
+
})
|
|
283
|
+
except XhsApiError as exc:
|
|
284
|
+
if exc.code == -1:
|
|
285
|
+
raise UnsupportedOperationError(
|
|
286
|
+
"User posts lookup is currently unavailable from the public web API "
|
|
287
|
+
"(server rejected /api/sns/web/v1/user_posted with code -1). "
|
|
288
|
+
"Use `xhs search`, `xhs read`, or note-driven workflows as a fallback."
|
|
289
|
+
) from exc
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
def search_notes(
|
|
293
|
+
self,
|
|
294
|
+
keyword: str,
|
|
295
|
+
page: int = 1,
|
|
296
|
+
page_size: int = 20,
|
|
297
|
+
sort: str = "general",
|
|
298
|
+
note_type: int = 0,
|
|
299
|
+
) -> Any:
|
|
300
|
+
search_id, is_new_session = _acquire_search_session(keyword, sort, note_type)
|
|
301
|
+
if is_new_session:
|
|
302
|
+
request_id = self._search_request_id()
|
|
303
|
+
try:
|
|
304
|
+
self._main_api_post("/api/sns/web/v1/search/onebox", {
|
|
305
|
+
"keyword": keyword,
|
|
306
|
+
"search_id": search_id,
|
|
307
|
+
"biz_type": "web_search_user",
|
|
308
|
+
"request_id": request_id,
|
|
309
|
+
})
|
|
310
|
+
self._main_api_get("/api/sns/web/v1/search/filter", {
|
|
311
|
+
"keyword": keyword,
|
|
312
|
+
"search_id": search_id,
|
|
313
|
+
})
|
|
314
|
+
except XhsApiError as exc:
|
|
315
|
+
logger.debug("Search prewarm failed, continuing with search/notes: %s", exc)
|
|
316
|
+
|
|
317
|
+
result = self._main_api_post("/api/sns/web/v1/search/notes", {
|
|
318
|
+
"keyword": keyword,
|
|
319
|
+
"page": page,
|
|
320
|
+
"page_size": page_size,
|
|
321
|
+
"search_id": search_id,
|
|
322
|
+
"sort": sort,
|
|
323
|
+
"note_type": note_type,
|
|
324
|
+
"ext_flags": [],
|
|
325
|
+
"filters": _SEARCH_DEFAULT_FILTERS,
|
|
326
|
+
"geo": "",
|
|
327
|
+
"image_formats": ["jpg", "webp", "avif"],
|
|
328
|
+
})
|
|
329
|
+
if is_new_session:
|
|
330
|
+
try:
|
|
331
|
+
self._main_api_get("/api/sns/web/v1/search/recommend", {"keyword": keyword})
|
|
332
|
+
except XhsApiError as exc:
|
|
333
|
+
logger.debug("Search recommend prefetch failed: %s", exc)
|
|
334
|
+
return result
|
|
335
|
+
|
|
336
|
+
def get_note_by_id(
|
|
337
|
+
self,
|
|
338
|
+
note_id: str,
|
|
339
|
+
xsec_token: str = "",
|
|
340
|
+
xsec_source: str = "pc_feed",
|
|
341
|
+
) -> Any:
|
|
342
|
+
if xsec_token:
|
|
343
|
+
cache_note_context(note_id, xsec_token, xsec_source)
|
|
344
|
+
return self._main_api_post("/api/sns/web/v1/feed", {
|
|
345
|
+
"source_note_id": note_id,
|
|
346
|
+
"image_formats": ["jpg", "webp", "avif"],
|
|
347
|
+
"extra": {"need_body_topic": "1"},
|
|
348
|
+
"xsec_source": xsec_source,
|
|
349
|
+
"xsec_token": xsec_token,
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
def get_note_from_html(
|
|
353
|
+
self,
|
|
354
|
+
note_id: str,
|
|
355
|
+
xsec_token: str = "",
|
|
356
|
+
xsec_source: str = "pc_feed",
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Fetch note by parsing server-rendered HTML (no xsec_token required)."""
|
|
359
|
+
html = self._fetch_note_html(note_id, xsec_token=xsec_token, xsec_source=xsec_source)
|
|
360
|
+
return extract_note_from_html(html, note_id)
|
|
361
|
+
|
|
362
|
+
def get_note_detail(
|
|
363
|
+
self,
|
|
364
|
+
note_id: str,
|
|
365
|
+
xsec_token: str = "",
|
|
366
|
+
xsec_source: str = "",
|
|
367
|
+
) -> dict[str, Any]:
|
|
368
|
+
"""Read a note via the best available channel.
|
|
369
|
+
|
|
370
|
+
Strategy:
|
|
371
|
+
- Has xsec_token → try feed API first, fall back to HTML on error
|
|
372
|
+
- No xsec_token → go straight to HTML (feed API would reject)
|
|
373
|
+
"""
|
|
374
|
+
cached = get_cached_note_context(note_id)
|
|
375
|
+
token = xsec_token or cached.get("token", "")
|
|
376
|
+
source = xsec_source or cached.get("source", "") or "pc_feed"
|
|
377
|
+
used_cached_context = not xsec_token and bool(cached.get("token"))
|
|
378
|
+
if token:
|
|
379
|
+
try:
|
|
380
|
+
return self.get_note_by_id(note_id, xsec_token=token, xsec_source=source)
|
|
381
|
+
except (NeedVerifyError, XhsApiError) as exc:
|
|
382
|
+
logger.info("Feed API failed (%s), falling back to HTML", exc)
|
|
383
|
+
if used_cached_context:
|
|
384
|
+
invalidate_note_context(note_id)
|
|
385
|
+
token = ""
|
|
386
|
+
return self.get_note_from_html(note_id, xsec_token=token or "", xsec_source=source)
|
|
387
|
+
|
|
388
|
+
def get_home_feed(self, category: str = "homefeed_recommend") -> dict[str, Any]:
|
|
389
|
+
return self._main_api_post("/api/sns/web/v1/homefeed", {
|
|
390
|
+
"cursor_score": "",
|
|
391
|
+
"num": 40,
|
|
392
|
+
"refresh_type": 1,
|
|
393
|
+
"note_index": 0,
|
|
394
|
+
"unread_begin_note_id": "",
|
|
395
|
+
"unread_end_note_id": "",
|
|
396
|
+
"unread_note_count": 0,
|
|
397
|
+
"category": category,
|
|
398
|
+
"search_key": "",
|
|
399
|
+
"need_num": 40,
|
|
400
|
+
"image_scenes": ["FD_PRV_WEBP", "FD_WM_WEBP"],
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
def get_hot_feed(self, category: str = "homefeed.fashion_v3") -> dict[str, Any]:
|
|
404
|
+
return self.get_home_feed(category=category)
|
|
405
|
+
|
|
406
|
+
def get_comments(
|
|
407
|
+
self,
|
|
408
|
+
note_id: str,
|
|
409
|
+
cursor: str = "",
|
|
410
|
+
xsec_token: str = "",
|
|
411
|
+
top_comment_id: str = "",
|
|
412
|
+
xsec_source: str = "",
|
|
413
|
+
) -> Any:
|
|
414
|
+
cached = get_cached_note_context(note_id)
|
|
415
|
+
used_cached_context = not xsec_token and bool(cached.get("token"))
|
|
416
|
+
token, source = self.resolve_xsec_context(note_id, xsec_token, xsec_source)
|
|
417
|
+
if not token:
|
|
418
|
+
raise XhsApiError(
|
|
419
|
+
"Could not resolve xsec_token for comments. Pass a full note URL or --xsec-token explicitly."
|
|
420
|
+
)
|
|
421
|
+
if source:
|
|
422
|
+
cache_note_context(note_id, token, source)
|
|
423
|
+
try:
|
|
424
|
+
return self._main_api_get("/api/sns/web/v2/comment/page", {
|
|
425
|
+
"note_id": note_id,
|
|
426
|
+
"cursor": cursor,
|
|
427
|
+
"top_comment_id": top_comment_id,
|
|
428
|
+
"image_formats": "jpg,webp,avif",
|
|
429
|
+
"xsec_token": token,
|
|
430
|
+
})
|
|
431
|
+
except (NeedVerifyError, XhsApiError):
|
|
432
|
+
if not used_cached_context:
|
|
433
|
+
raise
|
|
434
|
+
invalidate_note_context(note_id)
|
|
435
|
+
refreshed_token, refreshed_source = self.resolve_xsec_context(note_id, "", xsec_source)
|
|
436
|
+
if not refreshed_token:
|
|
437
|
+
raise
|
|
438
|
+
if refreshed_source:
|
|
439
|
+
cache_note_context(note_id, refreshed_token, refreshed_source)
|
|
440
|
+
return self._main_api_get("/api/sns/web/v2/comment/page", {
|
|
441
|
+
"note_id": note_id,
|
|
442
|
+
"cursor": cursor,
|
|
443
|
+
"top_comment_id": top_comment_id,
|
|
444
|
+
"image_formats": "jpg,webp,avif",
|
|
445
|
+
"xsec_token": refreshed_token,
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
def get_all_comments(
|
|
449
|
+
self,
|
|
450
|
+
note_id: str,
|
|
451
|
+
xsec_token: str = "",
|
|
452
|
+
xsec_source: str = "",
|
|
453
|
+
max_pages: int = 20,
|
|
454
|
+
) -> dict[str, Any]:
|
|
455
|
+
all_comments: list[dict[str, Any]] = []
|
|
456
|
+
cursor = ""
|
|
457
|
+
pages = 0
|
|
458
|
+
|
|
459
|
+
while pages < max_pages:
|
|
460
|
+
data = self.get_comments(
|
|
461
|
+
note_id,
|
|
462
|
+
cursor=cursor,
|
|
463
|
+
xsec_token=xsec_token,
|
|
464
|
+
xsec_source=xsec_source,
|
|
465
|
+
)
|
|
466
|
+
if not isinstance(data, dict):
|
|
467
|
+
break
|
|
468
|
+
|
|
469
|
+
comments = data.get("comments", [])
|
|
470
|
+
all_comments.extend(comments)
|
|
471
|
+
pages += 1
|
|
472
|
+
|
|
473
|
+
has_more = data.get("has_more", False)
|
|
474
|
+
next_cursor = data.get("cursor", "")
|
|
475
|
+
if not has_more or not next_cursor:
|
|
476
|
+
break
|
|
477
|
+
cursor = next_cursor
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
"comments": all_comments,
|
|
481
|
+
"has_more": False,
|
|
482
|
+
"cursor": "",
|
|
483
|
+
"total_fetched": len(all_comments),
|
|
484
|
+
"pages_fetched": pages,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
def get_sub_comments(
|
|
488
|
+
self,
|
|
489
|
+
note_id: str,
|
|
490
|
+
root_comment_id: str,
|
|
491
|
+
num: int = 30,
|
|
492
|
+
cursor: str = "",
|
|
493
|
+
xsec_token: str = "",
|
|
494
|
+
xsec_source: str = "",
|
|
495
|
+
) -> Any:
|
|
496
|
+
token, source = self.resolve_xsec_context(note_id, xsec_token, xsec_source or "pc_feed")
|
|
497
|
+
return self._main_api_get("/api/sns/web/v2/comment/sub/page", {
|
|
498
|
+
"note_id": note_id,
|
|
499
|
+
"root_comment_id": root_comment_id,
|
|
500
|
+
"num": num,
|
|
501
|
+
"cursor": cursor,
|
|
502
|
+
"xsec_token": token,
|
|
503
|
+
"xsec_source": source,
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class InteractionEndpointsMixin:
|
|
508
|
+
"""Mutating note interaction endpoints."""
|
|
509
|
+
|
|
510
|
+
def post_comment(self, note_id: str, content: str) -> dict[str, Any]:
|
|
511
|
+
return self._main_api_post("/api/sns/web/v1/comment/post", {
|
|
512
|
+
"note_id": note_id,
|
|
513
|
+
"content": content,
|
|
514
|
+
"at_users": [],
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
def reply_comment(self, note_id: str, target_comment_id: str, content: str) -> Any:
|
|
518
|
+
payload = {
|
|
519
|
+
"note_id": note_id,
|
|
520
|
+
"content": content,
|
|
521
|
+
"target_comment_id": target_comment_id,
|
|
522
|
+
"at_users": [],
|
|
523
|
+
}
|
|
524
|
+
try:
|
|
525
|
+
return self._main_api_post("/api/sns/web/v1/comment/post", payload)
|
|
526
|
+
except XhsApiError as exc:
|
|
527
|
+
if exc.code == -9043:
|
|
528
|
+
time.sleep(3)
|
|
529
|
+
try:
|
|
530
|
+
return self._main_api_post("/api/sns/web/v1/comment/post", payload)
|
|
531
|
+
except XhsApiError as retry_exc:
|
|
532
|
+
if retry_exc.code == -9043:
|
|
533
|
+
raise XhsApiError(
|
|
534
|
+
"Reply was rate-limited by Xiaohongshu (code -9043). Wait a moment and retry.",
|
|
535
|
+
code="rate_limited",
|
|
536
|
+
response=retry_exc.response,
|
|
537
|
+
) from retry_exc
|
|
538
|
+
raise
|
|
539
|
+
raise
|
|
540
|
+
|
|
541
|
+
def like_note(self, note_id: str) -> dict[str, Any]:
|
|
542
|
+
return self._main_api_post("/api/sns/web/v1/note/like", {"note_oid": note_id})
|
|
543
|
+
|
|
544
|
+
def unlike_note(self, note_id: str) -> dict[str, Any]:
|
|
545
|
+
return self._main_api_post("/api/sns/web/v1/note/dislike", {"note_oid": note_id})
|
|
546
|
+
|
|
547
|
+
def favorite_note(self, note_id: str) -> dict[str, Any]:
|
|
548
|
+
return self._main_api_post("/api/sns/web/v1/note/collect", {"note_id": note_id})
|
|
549
|
+
|
|
550
|
+
def unfavorite_note(self, note_id: str) -> dict[str, Any]:
|
|
551
|
+
return self._main_api_post("/api/sns/web/v1/note/uncollect", {"note_ids": note_id})
|
|
552
|
+
|
|
553
|
+
def delete_comment(self, note_id: str, comment_id: str) -> dict[str, Any]:
|
|
554
|
+
return self._main_api_post("/api/sns/web/v1/comment/delete", {
|
|
555
|
+
"note_id": note_id,
|
|
556
|
+
"comment_id": comment_id,
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class CreatorEndpointsMixin:
|
|
561
|
+
"""Creator platform search, upload, and publishing endpoints."""
|
|
562
|
+
|
|
563
|
+
def search_topics(self, keyword: str) -> dict[str, Any]:
|
|
564
|
+
return self._creator_post("/web_api/sns/v1/search/topic", {
|
|
565
|
+
"keyword": keyword,
|
|
566
|
+
"suggest_topic_request": {"title": "", "desc": ""},
|
|
567
|
+
"page": {"page_size": 20, "page": 1},
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
def search_users(self, keyword: str) -> dict[str, Any]:
|
|
571
|
+
return self._creator_post("/web_api/sns/v1/search/user_info", {
|
|
572
|
+
"keyword": keyword,
|
|
573
|
+
"search_id": str(int(time.time() * 1000)),
|
|
574
|
+
"page": {"page_size": 20, "page": 1},
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
def get_upload_permit(self, file_type: str = "image", count: int = 1) -> dict[str, str]:
|
|
578
|
+
data = self._creator_get("/api/media/v1/upload/web/permit", {
|
|
579
|
+
"biz_name": "spectrum",
|
|
580
|
+
"scene": file_type,
|
|
581
|
+
"file_count": count,
|
|
582
|
+
"version": 1,
|
|
583
|
+
"source": "web",
|
|
584
|
+
})
|
|
585
|
+
permit = data["uploadTempPermits"][0]
|
|
586
|
+
return {"fileId": permit["fileIds"][0], "token": permit["token"]}
|
|
587
|
+
|
|
588
|
+
def upload_file(
|
|
589
|
+
self,
|
|
590
|
+
file_id: str,
|
|
591
|
+
token: str,
|
|
592
|
+
file_path: str,
|
|
593
|
+
content_type: str | None = None,
|
|
594
|
+
) -> None:
|
|
595
|
+
with open(file_path, "rb") as f:
|
|
596
|
+
file_data = f.read()
|
|
597
|
+
|
|
598
|
+
url = f"{UPLOAD_HOST}/{file_id}"
|
|
599
|
+
content_type = content_type or mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
|
600
|
+
resp = self._request_with_retry(
|
|
601
|
+
"PUT",
|
|
602
|
+
url,
|
|
603
|
+
headers={
|
|
604
|
+
"X-Cos-Security-Token": token,
|
|
605
|
+
"Content-Type": content_type,
|
|
606
|
+
},
|
|
607
|
+
content=file_data,
|
|
608
|
+
)
|
|
609
|
+
if resp.status_code >= 400:
|
|
610
|
+
raise XhsApiError(f"Upload failed: {resp.status_code} {resp.reason_phrase}")
|
|
611
|
+
|
|
612
|
+
def create_image_note(
|
|
613
|
+
self,
|
|
614
|
+
title: str,
|
|
615
|
+
desc: str,
|
|
616
|
+
image_file_ids: list[str],
|
|
617
|
+
topics: list[dict[str, str]] | None = None,
|
|
618
|
+
is_private: bool = False,
|
|
619
|
+
) -> Any:
|
|
620
|
+
images = [{"file_id": fid, "metadata": {"source": -1}} for fid in image_file_ids]
|
|
621
|
+
business_binds = {
|
|
622
|
+
"version": 1,
|
|
623
|
+
"noteId": 0,
|
|
624
|
+
"noteOrderBind": {},
|
|
625
|
+
"notePostTiming": {"postTime": None},
|
|
626
|
+
"noteCollectionBind": {"id": ""},
|
|
627
|
+
}
|
|
628
|
+
data = {
|
|
629
|
+
"common": {
|
|
630
|
+
"type": "normal",
|
|
631
|
+
"title": title,
|
|
632
|
+
"note_id": "",
|
|
633
|
+
"desc": desc,
|
|
634
|
+
"source": '{"type":"web","ids":"","extraInfo":"{\\"subType\\":\\"official\\"}"}',
|
|
635
|
+
"business_binds": json.dumps(business_binds),
|
|
636
|
+
"ats": [],
|
|
637
|
+
"hash_tag": topics or [],
|
|
638
|
+
"post_loc": {},
|
|
639
|
+
"privacy_info": {"op_type": 1, "type": 1 if is_private else 0},
|
|
640
|
+
},
|
|
641
|
+
"image_info": {"images": images},
|
|
642
|
+
"video_info": None,
|
|
643
|
+
}
|
|
644
|
+
return self._main_api_post("/web_api/sns/v2/note", data, {
|
|
645
|
+
"origin": CREATOR_HOST,
|
|
646
|
+
"referer": f"{CREATOR_HOST}/",
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
def delete_note(self, note_id: str) -> dict[str, Any]:
|
|
650
|
+
try:
|
|
651
|
+
return self._creator_post("/api/galaxy/creator/note/delete", {
|
|
652
|
+
"note_id": note_id,
|
|
653
|
+
})
|
|
654
|
+
except XhsApiError as exc:
|
|
655
|
+
response = exc.response if isinstance(exc.response, dict) else {}
|
|
656
|
+
if response.get("status") == 404 or "404" in str(exc):
|
|
657
|
+
raise UnsupportedOperationError(
|
|
658
|
+
"Delete note is currently unavailable from the public web API. "
|
|
659
|
+
"The command remains experimental until the new endpoint is re-captured."
|
|
660
|
+
) from None
|
|
661
|
+
raise
|
|
662
|
+
|
|
663
|
+
def get_creator_note_list(self, tab: int = 0, page: int = 0) -> dict[str, Any]:
|
|
664
|
+
return self._creator_get("/api/galaxy/v2/creator/note/user/posted", {
|
|
665
|
+
"tab": tab,
|
|
666
|
+
"page": page,
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class SocialEndpointsMixin:
|
|
671
|
+
"""Social graph and saved-content endpoints."""
|
|
672
|
+
|
|
673
|
+
def follow_user(self, user_id: str) -> dict[str, Any]:
|
|
674
|
+
return self._main_api_post("/api/sns/web/v1/user/follow", {"target_user_id": user_id})
|
|
675
|
+
|
|
676
|
+
def unfollow_user(self, user_id: str) -> dict[str, Any]:
|
|
677
|
+
return self._main_api_post("/api/sns/web/v1/user/unfollow", {"target_user_id": user_id})
|
|
678
|
+
|
|
679
|
+
def get_user_favorites(self, user_id: str, cursor: str = "") -> dict[str, Any]:
|
|
680
|
+
return self._main_api_get("/api/sns/web/v2/note/collect/page", {
|
|
681
|
+
"user_id": user_id,
|
|
682
|
+
"cursor": cursor,
|
|
683
|
+
"num": 30,
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
def get_user_likes(self, user_id: str, cursor: str = "") -> dict[str, Any]:
|
|
687
|
+
return self._main_api_get("/api/sns/web/v1/note/like/page", {
|
|
688
|
+
"user_id": user_id,
|
|
689
|
+
"cursor": cursor,
|
|
690
|
+
"num": 30,
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
class NotificationEndpointsMixin:
|
|
695
|
+
"""Notification and unread-count endpoints."""
|
|
696
|
+
|
|
697
|
+
def get_unread_count(self) -> dict[str, Any]:
|
|
698
|
+
return self._main_api_get("/api/sns/web/unread_count", {})
|
|
699
|
+
|
|
700
|
+
def get_notification_mentions(self, cursor: str = "", num: int = 20) -> dict[str, Any]:
|
|
701
|
+
try:
|
|
702
|
+
return self._main_api_get("/api/sns/web/v1/you/mentions", {
|
|
703
|
+
"num": num,
|
|
704
|
+
"cursor": cursor,
|
|
705
|
+
})
|
|
706
|
+
except XhsApiError as exc:
|
|
707
|
+
if exc.code == -1:
|
|
708
|
+
raise UnsupportedOperationError(
|
|
709
|
+
"Notification list is currently unavailable from the public web API. "
|
|
710
|
+
"Use `xhs unread` as a lightweight fallback."
|
|
711
|
+
) from exc
|
|
712
|
+
raise
|
|
713
|
+
|
|
714
|
+
def get_notification_likes(self, cursor: str = "", num: int = 20) -> dict[str, Any]:
|
|
715
|
+
try:
|
|
716
|
+
return self._main_api_get("/api/sns/web/v1/you/likes", {
|
|
717
|
+
"num": num,
|
|
718
|
+
"cursor": cursor,
|
|
719
|
+
})
|
|
720
|
+
except XhsApiError as exc:
|
|
721
|
+
if exc.code == -1:
|
|
722
|
+
raise UnsupportedOperationError(
|
|
723
|
+
"Notification list is currently unavailable from the public web API. "
|
|
724
|
+
"Use `xhs unread` as a lightweight fallback."
|
|
725
|
+
) from exc
|
|
726
|
+
raise
|
|
727
|
+
|
|
728
|
+
def get_notification_connections(self, cursor: str = "", num: int = 20) -> dict[str, Any]:
|
|
729
|
+
try:
|
|
730
|
+
return self._main_api_get("/api/sns/web/v1/you/connections", {
|
|
731
|
+
"num": num,
|
|
732
|
+
"cursor": cursor,
|
|
733
|
+
})
|
|
734
|
+
except XhsApiError as exc:
|
|
735
|
+
if exc.code == -1:
|
|
736
|
+
raise UnsupportedOperationError(
|
|
737
|
+
"Notification list is currently unavailable from the public web API. "
|
|
738
|
+
"Use `xhs unread` as a lightweight fallback."
|
|
739
|
+
) from exc
|
|
740
|
+
raise
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class AuthEndpointsMixin:
|
|
744
|
+
"""Authentication-specific endpoints."""
|
|
745
|
+
|
|
746
|
+
def login_activate(self) -> dict[str, Any]:
|
|
747
|
+
return self._main_api_post("/api/sns/web/v1/login/activate", {})
|
|
748
|
+
|
|
749
|
+
def create_qr_login(self) -> dict[str, Any]:
|
|
750
|
+
return self._main_api_post("/api/sns/web/v1/login/qrcode/create", {"qr_type": 1})
|
|
751
|
+
|
|
752
|
+
def check_qr_status(self, qr_id: str, code: str) -> dict[str, Any]:
|
|
753
|
+
return self._main_api_post("/api/qrcode/userinfo", {
|
|
754
|
+
"qrId": qr_id,
|
|
755
|
+
"code": code,
|
|
756
|
+
}, {
|
|
757
|
+
"service-tag": "webcn",
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
def complete_qr_login(self, qr_id: str, code: str) -> dict[str, Any]:
|
|
761
|
+
return self._main_api_get("/api/sns/web/v1/login/qrcode/status", {
|
|
762
|
+
"qr_id": qr_id,
|
|
763
|
+
"code": code,
|
|
764
|
+
})
|