#!/usr/bin/env python3 """Xiaohongshu notes into folders: text, author, counts and every original image; video for real videos. Run `python3 xiaohongshu.py ... --out `. Standard library only; needs share_common.py beside it and MY_API_KEY in the environment. TikHub get_image_note_detail once per note (0.01 USD): text, author, counts, topics, original images. A real video (one cover under spectrum/) then calls xiazaitool for the video (0.005 CNY); a carousel (pictures stitched into a video) already has its originals, so it gets no video. If TikHub fails the note falls back to xiazaitool: title and media only. Folder layout and flags: see share_common.py. A profile link (xiaohongshu.com/user/profile/, or a short link landing there) pages through get_user_posted_notes, 20 notes and 0.01 USD a page. Its entries cut the text at 100 characters and carry no originals, so every note still costs one get_image_note_detail. """ from __future__ import annotations import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from share_common import Profile, cli, host_in, https, iso, last_segment, media_only # noqa: E402 DOMAINS = ("xiaohongshu.com", "xhslink.cn", "xhslink.com") USER_NOTES = "/api/v1/xiaohongshu/app_v2/get_user_posted_notes" def note_id(url: str) -> str: match = re.search(r"/(?:explore|item)/([0-9a-f]{24})", url) return match.group(1) if match else "" def fetch(post, args) -> None: nid = note_id(post.url) params = {"note_id": nid} if nid else {"share_text": post.url} try: note = post.tikhub("/api/v1/xiaohongshu/app_v2/get_image_note_detail", params)["data"][0]["note_list"][0] except (RuntimeError, KeyError, IndexError, TypeError) as exc: post.warn(f"TikHub failed, fell back to xiazaitool (no text, author or counts): {exc}") media_only(post, args) return images = note.get("images_list") or [] first = str((images[0] if images else {}).get("original") or "") if note.get("type") != "video": kind = "image" elif len(images) <= 1 or "/spectrum/" in first: kind = "video" # a real video carries just its cover, stored under spectrum/ else: kind = "carousel" # several pictures stitched into a video; the originals are all here user = note.get("user") or {} post.meta.update( id=note.get("id") or nid, kind=kind, title=note.get("title") or "", desc=note.get("desc") or "", author={"name": user.get("nickname") or user.get("name"), "id": user.get("userid") or user.get("id"), "red_id": user.get("red_id")}, stats={ "like": note.get("liked_count"), "collect": note.get("collected_count"), "comment": note.get("comments_count"), "share": note.get("shared_count"), }, published=iso(note.get("time")), topics=[t["name"] for t in note.get("hash_tag") or [] if t.get("name")], ip_location=note.get("ip_location"), ) for i, image in enumerate(images, 1): url = str(image.get("original") or image.get("url_size_large") or image.get("url") or "") if not url: continue # The signature does not cover the format step, so ask the CDN for JPEG instead of WebP. url = https(url).replace("format/webp", "format/jpg") if kind == "video": post.meta["files"]["cover"] = post.download(url, "cover.jpg") else: post.meta["files"]["images"].append(post.download(url, f"images/{i:02d}.jpg")) if kind == "video": part = {"page": 1, "title": post.meta["title"], "video": None} post.meta["parts"] = [part] if not args.no_video: post.video_via_xiazaitool(part) class XiaohongshuProfile(Profile): short = ("xhslink.cn", "xhslink.com") detail_usd = 0.01 def user_id(self, url: str) -> str: match = re.search(r"/user/profile/([0-9a-f]{24})", url) return match.group(1) if match else "" def page(self, lister, uid, cursor, n): params = {"user_id": uid, **({"cursor": cursor} if cursor else {})} data = (lister.tikhub(USER_NOTES, params, raw_name=f"tikhub_get_user_posted_notes_p{n:02d}") or {}).get("data") if not isinstance(data, dict) or "notes" not in data: # a wrong user_id lands here, and is still billed raise ValueError(f"no notes in the reply: {str(data)[:200]}") notes = data.get("notes") or [] last = notes[-1] if notes else {} return notes, last.get("cursor") or last.get("id"), bool(data.get("has_more")) def item(self, raw: dict) -> dict: return { "id": raw.get("id"), "url": f"https://www.xiaohongshu.com/explore/{raw.get('id')}", "title": raw.get("title") or raw.get("display_title") or "", "published": iso(raw.get("create_time")), "pinned": bool(raw.get("sticky")), "kind": "image" if raw.get("type") == "normal" else "video", "stats": {"like": raw.get("likes"), "collect": raw.get("collected_count"), "comment": raw.get("comments_count"), "share": raw.get("share_count")}, } def author(self, raw: dict) -> dict: user = raw.get("user") or {} return {"name": user.get("nickname") or user.get("name"), "id": user.get("userid") or user.get("id")} def fetch(self, post, raw, args) -> None: fetch(post, args) if __name__ == "__main__": raise SystemExit(cli(__doc__, "xiaohongshu", lambda u: host_in(u, DOMAINS), lambda u: note_id(u) or last_segment(u), fetch, profile=XiaohongshuProfile()))