Files
nanocode/skills/xiaohongshu-cli/xhs_cli/commands/reading.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

298 lines
9.4 KiB
Python

"""Reading commands: search, read, comments, sub-comments, user, user-posts, feed, hot, topics, search-user."""
import click
from ..command_normalizers import normalize_paged_notes
from ..cookies import cache_note_context
from ..formatter import (
maybe_print_structured,
print_info,
render_comments,
render_feed,
render_note,
render_search_results,
render_topics,
render_user_info,
render_user_posts,
render_users,
)
from ..note_refs import resolve_note_reference, save_index_from_items, save_index_from_notes
from ._common import exit_for_error, handle_command, run_client_action, structured_output_options
# ─── Token propagation ─────────────────────────────────────────────────────
def _cache_tokens_from_items(data: dict, *, xsec_source: str) -> None:
"""Auto-cache xsec_token from search/feed API results.
Each note item may carry its own xsec_token bound to the source
(search, feed, explore). Caching them lets a subsequent
`xhs read <note_id>` use the correct token automatically.
"""
for item in data.get("items", []):
note_card = item.get("note_card", {})
note_id = item.get("id", note_card.get("note_id", ""))
token = item.get("xsec_token", note_card.get("xsec_token", ""))
if note_id and token:
cache_note_context(note_id, token, xsec_source)
# ─── Sort mapping ────────────────────────────────────────────────────────────
SORT_MAP = {
"general": "general",
"popular": "popularity_descending",
"latest": "time_descending",
}
TYPE_MAP = {
"all": 0,
"video": 1,
"image": 2,
}
@click.command()
@click.argument("keyword")
@click.option("--sort", type=click.Choice(["general", "popular", "latest"]), default="general", help="Sort order")
@click.option("--type", "note_type", type=click.Choice(["all", "video", "image"]), default="all", help="Note type")
@click.option("--page", default=1, help="Page number")
@structured_output_options
@click.pass_context
def search(ctx, keyword: str, sort: str, note_type: str, page: int, as_json: bool, as_yaml: bool):
"""Search notes by keyword."""
def _search_action(client):
result = client.search_notes(
keyword=keyword,
page=page,
sort=SORT_MAP[sort],
note_type=TYPE_MAP[note_type],
)
_cache_tokens_from_items(result, xsec_source="pc_search")
save_index_from_items(result, xsec_source="pc_search")
return result
handle_command(
ctx,
action=_search_action,
render=render_search_results,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("id_or_url")
@click.option("--xsec-token", default="", help="Security token (or reuse a cached token for this note)")
@structured_output_options
@click.pass_context
def read(ctx, id_or_url: str, xsec_token: str, as_json: bool, as_yaml: bool):
"""Read a note by ID, URL, or short index."""
note_id, token, url_source = resolve_note_reference(id_or_url, xsec_token=xsec_token)
xsec_source = url_source or "pc_feed"
if token:
cache_note_context(note_id, token, xsec_source)
def _read_action(client):
kwargs = {"xsec_token": token}
if url_source:
kwargs["xsec_source"] = url_source
return client.get_note_detail(note_id, **kwargs)
handle_command(
ctx,
action=_read_action,
render=render_note,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("id_or_url")
@click.option("--cursor", default="", help="Pagination cursor")
@click.option("--xsec-token", default="", help="Security token")
@click.option("--all", "fetch_all", is_flag=True, help="Auto-paginate to fetch ALL comments")
@structured_output_options
@click.pass_context
def comments(ctx, id_or_url: str, cursor: str, xsec_token: str, fetch_all: bool, as_json: bool, as_yaml: bool):
"""View comments on a note by ID, URL, or short index."""
note_id, token, url_source = resolve_note_reference(id_or_url, xsec_token=xsec_token)
xsec_source = url_source or "pc_feed"
if token:
cache_note_context(note_id, token, xsec_source)
def _load_comments(client):
common_kwargs = {"xsec_token": token}
if url_source:
common_kwargs["xsec_source"] = url_source
if fetch_all:
return client.get_all_comments(note_id, **common_kwargs)
return client.get_comments(
note_id,
cursor=cursor,
**common_kwargs,
)
def _render_comments(data):
render_comments(data)
if fetch_all and isinstance(data, dict):
total = data.get("total_fetched", 0)
pages = data.get("pages_fetched", 0)
print_info(f"Fetched {total} comments across {pages} pages")
try:
data = run_client_action(ctx, _load_comments)
if not maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml):
_render_comments(data)
except Exception as exc:
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.argument("user_id")
@structured_output_options
@click.pass_context
def user(ctx, user_id: str, as_json: bool, as_yaml: bool):
"""View user profile info."""
handle_command(
ctx,
action=lambda client: client.get_user_info(user_id),
render=render_user_info,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command("user-posts")
@click.argument("user_id")
@click.option("--cursor", default="", help="Pagination cursor")
@structured_output_options
@click.pass_context
def user_posts(ctx, user_id: str, cursor: str, as_json: bool, as_yaml: bool):
"""List a user's published notes."""
def _user_posts_action(client):
data = client.get_user_notes(user_id, cursor=cursor)
page = normalize_paged_notes(data)
save_index_from_notes(page["notes"])
return data
def _render_user_posts(data):
page = normalize_paged_notes(data)
render_user_posts(page["notes"])
if page["has_more"]:
print_info(f"More notes available — use --cursor {page['cursor']}")
handle_command(
ctx,
action=_user_posts_action,
render=_render_user_posts,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@structured_output_options
@click.pass_context
def feed(ctx, as_json: bool, as_yaml: bool):
"""Browse the recommendation feed."""
def _feed_action(client):
result = client.get_home_feed()
_cache_tokens_from_items(result, xsec_source="pc_feed")
save_index_from_items(result, xsec_source="pc_feed")
return result
handle_command(
ctx,
action=_feed_action,
render=render_feed,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("keyword")
@structured_output_options
@click.pass_context
def topics(ctx, keyword: str, as_json: bool, as_yaml: bool):
"""Search for topics/hashtags."""
handle_command(
ctx,
action=lambda client: client.search_topics(keyword),
render=render_topics,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command("sub-comments")
@click.argument("note_id")
@click.argument("comment_id")
@click.option("--cursor", default="", help="Pagination cursor")
@structured_output_options
@click.pass_context
def sub_comments(ctx, note_id: str, comment_id: str, cursor: str, as_json: bool, as_yaml: bool):
"""View replies to a specific comment."""
handle_command(
ctx,
action=lambda client: client.get_sub_comments(note_id, comment_id, cursor=cursor),
render=render_comments,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command("search-user")
@click.argument("keyword")
@structured_output_options
@click.pass_context
def search_user(ctx, keyword: str, as_json: bool, as_yaml: bool):
"""Search for users by keyword."""
handle_command(
ctx,
action=lambda client: client.search_users(keyword),
render=render_users,
as_json=as_json,
as_yaml=as_yaml,
)
HOT_CATEGORIES = {
"fashion": "homefeed.fashion_v3",
"food": "homefeed.food_v3",
"cosmetics": "homefeed.cosmetics_v3",
"movie": "homefeed.movie_and_tv_v3",
"career": "homefeed.career_v3",
"love": "homefeed.love_v3",
"home": "homefeed.household_product_v3",
"gaming": "homefeed.gaming_v3",
"travel": "homefeed.travel_v3",
"fitness": "homefeed.fitness_v3",
}
@click.command()
@click.option(
"--category", "-c",
type=click.Choice(list(HOT_CATEGORIES.keys())),
default="food",
help="Category (fashion, food, cosmetics, movie, career, love, home, gaming, travel, fitness)",
)
@structured_output_options
@click.pass_context
def hot(ctx, category: str, as_json: bool, as_yaml: bool):
"""Browse hot/trending notes by category."""
def _hot_action(client):
result = client.get_hot_feed(HOT_CATEGORIES[category])
_cache_tokens_from_items(result, xsec_source="pc_feed")
save_index_from_items(result, xsec_source="pc_feed")
return result
handle_command(
ctx,
action=_hot_action,
render=render_feed,
as_json=as_json,
as_yaml=as_yaml,
)