diff --git a/README.md b/README.md index ca1082f..dae5524 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ A CLI for Xiaohongshu (ๅฐ็บขไนฆ) โ€” search, read, interact, and post via rever - ๐Ÿ“ฐ **Feed** โ€” recommendation feed, hot/trending by category - ๐Ÿ‘ฅ **Social** โ€” follow/unfollow, favorites - ๐Ÿ‘ **Interactions** โ€” like, favorite, comment, reply, delete -- โœ๏ธ **Creator** โ€” post image notes, my-notes list, delete +- โœ๏ธ **Creator** โ€” post image / video notes, my-notes list, delete - ๐Ÿ”” **Notifications** โ€” unread count, mentions, likes, new followers - ๐Ÿ›ก๏ธ **Anti-detection** โ€” consistent macOS Chrome fingerprint, `sec-ch-ua` alignment, session-stable browser identity, Gaussian jitter, captcha cooldown, exponential backoff - ๐Ÿ“Š **Structured output** โ€” commands support `--yaml` and `--json`; non-TTY stdout defaults to YAML @@ -133,7 +133,9 @@ xhs delete-comment # Delete own comment # โ”€โ”€โ”€ Creator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ xhs my-notes # List own notes (v2 creator endpoint) xhs my-notes --page 1 # Next page -xhs post --title "ๆ ‡้ข˜" --body "ๆญฃๆ–‡" --images img.jpg # Post note +xhs post --title "ๆ ‡้ข˜" --body "ๆญฃๆ–‡" --images img.jpg # Post image note +xhs post-video --title "Trip" --body "..." --video clip.mp4 # Post video note (.mp4/.mov) +xhs post-video --title "Trip" --body "..." --video clip.mov --cover cover.jpg # With custom cover xhs delete # Delete note xhs delete -y # Skip confirmation @@ -341,7 +343,7 @@ The built-in Gaussian jitter delay (~1-1.5s between requests) is intentional to - ๐Ÿ“ฐ **ๅ‘็Žฐ** โ€” ๆŽจ่ Feedใ€ๆŒ‰ๅˆ†็ฑปๆต่งˆ็ƒญ้—จ - ๐Ÿ‘ฅ **็คพไบค** โ€” ๅ…ณๆณจ/ๅ–ๅ…ณใ€ๆ”ถ่—ๅคน - ๐Ÿ‘ **ไบ’ๅŠจ** โ€” ็‚น่ตžใ€ๆ”ถ่—ใ€่ฏ„่ฎบใ€ๅ›žๅคใ€ๅˆ ้™ค -- โœ๏ธ **ๅˆ›ไฝœ่€…** โ€” ๅ‘ๅธƒๅ›พๆ–‡็ฌ”่ฎฐใ€ๆˆ‘็š„็ฌ”่ฎฐๅˆ—่กจใ€ๅˆ ้™ค +- โœ๏ธ **ๅˆ›ไฝœ่€…** โ€” ๅ‘ๅธƒๅ›พๆ–‡ / ่ง†้ข‘็ฌ”่ฎฐใ€ๆˆ‘็š„็ฌ”่ฎฐๅˆ—่กจใ€ๅˆ ้™ค - ๐Ÿ”” **้€š็Ÿฅ** โ€” ๆœช่ฏปๆ•ฐใ€@ใ€็‚น่ตžใ€ๆ–ฐๅ…ณๆณจ - ๐Ÿ›ก๏ธ **ๅ้ฃŽๆŽง** โ€” macOS Chrome ๆŒ‡็บนไธ€่‡ดๆ€งใ€session ็บงๆต่งˆๅ™จ่บซไปฝๆŒไน…ๅŒ–ใ€้ซ˜ๆ–ฏๆŠ–ๅŠจๅปถ่ฟŸใ€้ชŒ่ฏ็ ่‡ชๅŠจๅ†ทๅดใ€ๆŒ‡ๆ•ฐ้€€้ฟ้‡่ฏ• - ๐Ÿ“Š **็ป“ๆž„ๅŒ–่พ“ๅ‡บ** โ€” `--yaml` / `--json`๏ผŒ้ž TTY ้ป˜่ฎค่พ“ๅ‡บ YAML @@ -434,7 +436,9 @@ xhs delete-comment # ๅˆ ้™ค่‡ชๅทฑ็š„่ฏ„่ฎบ # ๅˆ›ไฝœ่€… xhs my-notes # ๆˆ‘็š„็ฌ”่ฎฐๅˆ—่กจ -xhs post --title "ๆ ‡้ข˜" --body "ๆญฃๆ–‡" --images img.jpg # ๅ‘ๅธƒ็ฌ”่ฎฐ +xhs post --title "ๆ ‡้ข˜" --body "ๆญฃๆ–‡" --images img.jpg # ๅ‘ๅธƒๅ›พๆ–‡็ฌ”่ฎฐ +xhs post-video --title "ๆ ‡้ข˜" --body "ๆญฃๆ–‡" --video clip.mp4 # ๅ‘ๅธƒ่ง†้ข‘็ฌ”่ฎฐ๏ผˆ.mp4/.mov๏ผ‰ +xhs post-video --title "ๆ ‡้ข˜" --body "ๆญฃๆ–‡" --video clip.mov --cover cover.jpg # ่‡ชๅฎšไน‰ๅฐ้ข xhs delete # ๅˆ ้™ค็ฌ”่ฎฐ xhs delete -y # ่ทณ่ฟ‡็กฎ่ฎค diff --git a/xhs_cli/cli.py b/xhs_cli/cli.py index a33a08e..5c592b5 100644 --- a/xhs_cli/cli.py +++ b/xhs_cli/cli.py @@ -107,6 +107,7 @@ def cli(ctx, verbose: bool, cookie_source: str): # โ”€โ”€โ”€ Creator commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ cli.add_command(creator.post) +cli.add_command(creator.post_video) cli.add_command(creator.my_notes) cli.add_command(creator.delete) diff --git a/xhs_cli/client_mixins.py b/xhs_cli/client_mixins.py index b4dada7..3b7df5b 100644 --- a/xhs_cli/client_mixins.py +++ b/xhs_cli/client_mixins.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any -from .constants import CREATOR_HOST, HOME_URL, UPLOAD_HOST, USER_AGENT +from .constants import HOME_URL, UPLOAD_HOST, USER_AGENT from .cookies import ( cache_note_context, cookies_to_string, @@ -544,7 +544,14 @@ def get_upload_permit(self, file_type: str = "image", count: int = 1) -> dict[st "source": "web", }) permit = data["uploadTempPermits"][0] - return {"fileId": permit["fileIds"][0], "token": permit["token"]} + return { + "fileId": permit["fileIds"][0], + "token": permit["token"], + "uploadAddr": permit.get("uploadAddr", ""), + } + + def get_video_upload_permit(self, count: int = 1) -> dict[str, str]: + return self.get_upload_permit(file_type="video", count=count) def upload_file( self, @@ -552,11 +559,13 @@ def upload_file( token: str, file_path: str, content_type: str | None = None, + upload_addr: str | None = None, ) -> None: with open(file_path, "rb") as f: file_data = f.read() - url = f"{UPLOAD_HOST}/{file_id}" + host = f"https://{upload_addr}" if upload_addr else UPLOAD_HOST + url = f"{host}/{file_id}" content_type = content_type or mimetypes.guess_type(file_path)[0] or "application/octet-stream" resp = self._request_with_retry( "PUT", @@ -570,6 +579,23 @@ def upload_file( if resp.status_code >= 400: raise XhsApiError(f"Upload failed: {resp.status_code} {resp.reason_phrase}") + def upload_video( + self, + file_id: str, + token: str, + file_path: str, + content_type: str | None = None, + upload_addr: str | None = None, + ) -> None: + content_type = content_type or mimetypes.guess_type(file_path)[0] or "video/mp4" + self.upload_file( + file_id, + token, + file_path, + content_type=content_type, + upload_addr=upload_addr, + ) + def create_image_note( self, title: str, @@ -579,6 +605,50 @@ def create_image_note( is_private: bool = False, ) -> Any: images = [{"file_id": fid, "metadata": {"source": -1}} for fid in image_file_ids] + return self._post_note( + note_type="normal", + title=title, + desc=desc, + topics=topics, + is_private=is_private, + image_info={"images": images}, + video_info=None, + ) + + def create_video_note( + self, + title: str, + desc: str, + video_file_id: str, + cover_file_id: str | None = None, + topics: list[dict[str, str]] | None = None, + is_private: bool = False, + ) -> Any: + video_info = {"file_id": video_file_id, "metadata": {"source": -1}} + image_info: dict[str, Any] | None = None + if cover_file_id: + image_info = {"images": [{"file_id": cover_file_id, "metadata": {"source": -1}}]} + return self._post_note( + note_type="video", + title=title, + desc=desc, + topics=topics, + is_private=is_private, + image_info=image_info, + video_info=video_info, + ) + + def _post_note( + self, + *, + note_type: str, + title: str, + desc: str, + topics: list[dict[str, str]] | None, + is_private: bool, + image_info: dict[str, Any] | None, + video_info: dict[str, Any] | None, + ) -> Any: business_binds = { "version": 1, "noteId": 0, @@ -588,24 +658,21 @@ def create_image_note( } data = { "common": { - "type": "normal", + "type": note_type, "title": title, "note_id": "", "desc": desc, "source": '{"type":"web","ids":"","extraInfo":"{\\"subType\\":\\"official\\"}"}', - "business_binds": json.dumps(business_binds), + "business_binds": json.dumps(business_binds, ensure_ascii=False), "ats": [], "hash_tag": topics or [], "post_loc": {}, "privacy_info": {"op_type": 1, "type": 1 if is_private else 0}, }, - "image_info": {"images": images}, - "video_info": None, + "image_info": image_info, + "video_info": video_info, } - return self._main_api_post("/web_api/sns/v2/note", data, { - "origin": CREATOR_HOST, - "referer": f"{CREATOR_HOST}/", - }) + return self._creator_post("/web_api/sns/v2/note", data) def delete_note(self, note_id: str) -> dict[str, Any]: try: diff --git a/xhs_cli/commands/creator.py b/xhs_cli/commands/creator.py index 63237f5..083db99 100644 --- a/xhs_cli/commands/creator.py +++ b/xhs_cli/commands/creator.py @@ -1,5 +1,6 @@ -"""Creator commands: post, my-notes, delete.""" +"""Creator commands: post, post-video, my-notes, delete.""" +import os import re import click @@ -49,7 +50,12 @@ def _publish(client): for img_path in images: print_info(f"Uploading {img_path}...") permit = client.get_upload_permit() - client.upload_file(permit["fileId"], permit["token"], img_path) + client.upload_file( + permit["fileId"], + permit["token"], + img_path, + upload_addr=permit.get("uploadAddr"), + ) file_ids.append(permit["fileId"]) print_success(f"Uploaded: {img_path}") @@ -84,6 +90,97 @@ def _publish(client): ) +SUPPORTED_VIDEO_EXTS = {".mp4", ".mov"} + + +@click.command("post-video") +@click.option("--title", required=True, help="Video note title") +@click.option("--body", required=True, help="Video note body text") +@click.option("--video", required=True, help="Video file path (.mp4 / .mov)") +@click.option("--cover", default=None, help="Cover image file path (optional)") +@click.option("--topic", "topics_flag", multiple=True, help="Topic(s)/hashtag(s) to search and attach") +@click.option("--private", "is_private", is_flag=True, help="Publish as private note") +@structured_output_options +@click.pass_context +def post_video( + ctx, + title: str, + body: str, + video: str, + cover: str | None, + topics_flag: tuple[str, ...], + is_private: bool, + as_json: bool, + as_yaml: bool, +): + """Publish a video note.""" + if not os.path.exists(video): + raise click.BadParameter(f"Video file not found: {video}", param_hint="--video") + ext = os.path.splitext(video)[1].lower() + if ext not in SUPPORTED_VIDEO_EXTS: + raise click.BadParameter( + f"Unsupported video format {ext!r}. Supported: {', '.join(sorted(SUPPORTED_VIDEO_EXTS))}", + param_hint="--video", + ) + if cover and not os.path.exists(cover): + raise click.BadParameter(f"Cover image not found: {cover}", param_hint="--cover") + + def _publish(client): + print_info(f"Uploading video {video}...") + video_permit = client.get_video_upload_permit() + client.upload_video( + video_permit["fileId"], + video_permit["token"], + video, + upload_addr=video_permit.get("uploadAddr"), + ) + print_success(f"Uploaded video: {video}") + + cover_file_id = None + if cover: + print_info(f"Uploading cover {cover}...") + cover_permit = client.get_upload_permit() + client.upload_file( + cover_permit["fileId"], + cover_permit["token"], + cover, + upload_addr=cover_permit.get("uploadAddr"), + ) + cover_file_id = cover_permit["fileId"] + print_success(f"Uploaded cover: {cover}") + + body_hashtags = extract_hashtags(body) + all_topics = list(topics_flag) + body_hashtags + unique_topics = list(dict.fromkeys(all_topics)) + if len(unique_topics) > 10: + print_info(f"Found {len(unique_topics)} topics, using first 10") + unique_topics = unique_topics[:10] + + resolved_topics = [] + for t in unique_topics: + topic_data = client.search_topics(t) + resolved_topics.extend(select_topic_payload(topic_data, t)) + + return client.create_video_note( + title=title, + desc=body, + video_file_id=video_permit["fileId"], + cover_file_id=cover_file_id, + topics=resolved_topics, + is_private=is_private, + ) + + handle_command( + ctx, + action=_publish, + render=lambda _data: print_success( + f"Video note published: {title}" + (" (private)" if is_private else "") + ), + as_json=as_json, + as_yaml=as_yaml, + ) + + @click.command("my-notes") @click.option("--page", default=0, help="Page number (0-indexed)") @structured_output_options diff --git a/xhs_cli/formatter_renderers.py b/xhs_cli/formatter_renderers.py index 0c680fc..badf40a 100644 --- a/xhs_cli/formatter_renderers.py +++ b/xhs_cli/formatter_renderers.py @@ -7,6 +7,7 @@ from rich.panel import Panel from rich.table import Table +from .constants import HOME_URL from .formatter_normalizers import ( normalize_comments, normalize_creator_notes, @@ -19,7 +20,6 @@ normalize_user_posts, normalize_users, ) -from .constants import HOME_URL from .formatter_utils import coerce_int, console, format_count, print_error, print_info