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.
@@ -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
+ })