298 lines
9.4 KiB
Python
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,
|
|
)
|