788514ce8e
- add xiaohongshu/xhs source path via xiaohongshu-mcp HTTP API\n- add Reddit public JSON fallback when OpenAI auth is unavailable\n- update diagnostics/UI rendering for new source availability states\n- harden Xiaohongshu availability probe to reduce false negatives\n- include source status reporting for Xiaohongshu
163 lines
5.2 KiB
Python
163 lines
5.2 KiB
Python
"""Xiaohongshu HTTP API search client for last30days.
|
|
|
|
Uses xpzouying/xiaohongshu-mcp REST endpoints:
|
|
- GET/POST /api/v1/feeds/search
|
|
- GET /api/v1/login/status
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from . import http
|
|
|
|
|
|
def _to_int(value: Any) -> int:
|
|
"""Convert Xiaohongshu count strings to int.
|
|
|
|
Supports plain ints and Chinese suffixes like 1.2万 / 3亿.
|
|
"""
|
|
if value is None:
|
|
return 0
|
|
if isinstance(value, (int, float)):
|
|
return int(value)
|
|
|
|
text = str(value).strip().lower().replace(",", "")
|
|
if not text:
|
|
return 0
|
|
|
|
try:
|
|
if text.endswith("万"):
|
|
return int(float(text[:-1]) * 10000)
|
|
if text.endswith("亿"):
|
|
return int(float(text[:-1]) * 100000000)
|
|
return int(float(text))
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _timestamp_to_date_ms(ts: Any) -> Optional[str]:
|
|
"""Convert millisecond timestamp to YYYY-MM-DD."""
|
|
try:
|
|
iv = int(ts)
|
|
if iv <= 0:
|
|
return None
|
|
# API examples use milliseconds.
|
|
dt = datetime.fromtimestamp(iv / 1000.0, tz=timezone.utc)
|
|
return dt.strftime("%Y-%m-%d")
|
|
except (TypeError, ValueError, OSError):
|
|
return None
|
|
|
|
|
|
def _relevance_from_interactions(likes: int, comments: int, favorites: int) -> float:
|
|
"""Heuristic relevance score from engagement metrics."""
|
|
# Weighted engagement with soft caps to [0, 1].
|
|
weighted = (likes * 1.0) + (comments * 2.5) + (favorites * 1.5)
|
|
# 5000 weighted engagement ~= strong relevance.
|
|
score = min(1.0, max(0.05, weighted / 5000.0))
|
|
return round(score, 3)
|
|
|
|
|
|
def _build_note_url(feed_id: str, xsec_token: str) -> str:
|
|
"""Build a stable Xiaohongshu note URL."""
|
|
if xsec_token:
|
|
return f"https://www.xiaohongshu.com/explore/{feed_id}?xsec_token={xsec_token}"
|
|
return f"https://www.xiaohongshu.com/explore/{feed_id}"
|
|
|
|
|
|
def search_feeds(
|
|
topic: str,
|
|
from_date: str,
|
|
to_date: str,
|
|
base_url: str,
|
|
depth: str = "default",
|
|
) -> List[Dict[str, Any]]:
|
|
"""Search Xiaohongshu feeds and normalize to web-item shape."""
|
|
base = (base_url or "").rstrip("/")
|
|
if not base:
|
|
raise ValueError("Missing Xiaohongshu API base URL")
|
|
|
|
# Quick login sanity check.
|
|
login = http.get(f"{base}/api/v1/login/status", timeout=8, retries=1)
|
|
is_logged_in = (
|
|
login.get("data", {}).get("is_logged_in")
|
|
if isinstance(login, dict) else False
|
|
)
|
|
if not is_logged_in:
|
|
raise http.HTTPError("Xiaohongshu API reachable but not logged in")
|
|
|
|
# API supports filters; use recency-oriented defaults.
|
|
publish_time = "一天内" if depth == "quick" else "一周内" if depth == "default" else "半年内"
|
|
payload = {
|
|
"keyword": topic,
|
|
"filters": {
|
|
"sort_by": "综合",
|
|
"note_type": "不限",
|
|
"publish_time": publish_time,
|
|
"search_scope": "不限",
|
|
"location": "不限",
|
|
},
|
|
}
|
|
|
|
resp = http.post(f"{base}/api/v1/feeds/search", payload, timeout=20, retries=1)
|
|
feeds = resp.get("data", {}).get("feeds", []) if isinstance(resp, dict) else []
|
|
if not isinstance(feeds, list):
|
|
feeds = []
|
|
|
|
# Cap source volume similarly to other web sources.
|
|
limit = {"quick": 8, "default": 15, "deep": 25}.get(depth, 15)
|
|
items: List[Dict[str, Any]] = []
|
|
|
|
for i, feed in enumerate(feeds[:limit]):
|
|
if not isinstance(feed, dict):
|
|
continue
|
|
note = feed.get("noteCard") or {}
|
|
if not isinstance(note, dict):
|
|
note = {}
|
|
interact = note.get("interactInfo") or {}
|
|
if not isinstance(interact, dict):
|
|
interact = {}
|
|
|
|
feed_id = str(feed.get("id") or note.get("noteId") or "").strip()
|
|
if not feed_id:
|
|
continue
|
|
|
|
xsec_token = str(feed.get("xsecToken") or note.get("xsecToken") or "").strip()
|
|
title = str(
|
|
note.get("displayTitle")
|
|
or note.get("title")
|
|
or ""
|
|
).strip()
|
|
snippet = str(
|
|
note.get("desc")
|
|
or note.get("displayDesc")
|
|
or title
|
|
or ""
|
|
).strip()
|
|
|
|
likes = _to_int(interact.get("likedCount"))
|
|
comments = _to_int(interact.get("commentCount"))
|
|
favorites = _to_int(interact.get("collectedCount"))
|
|
|
|
date_value = _timestamp_to_date_ms(note.get("time"))
|
|
why = f"Xiaohongshu engagement: likes={likes}, comments={comments}, favorites={favorites}"
|
|
|
|
items.append({
|
|
"id": f"XHS{i+1}",
|
|
"title": title[:200] if title else f"Xiaohongshu note {feed_id}",
|
|
"url": _build_note_url(feed_id, xsec_token),
|
|
"source_domain": "xiaohongshu.com",
|
|
"snippet": snippet[:500],
|
|
"date": date_value,
|
|
"date_confidence": "high" if date_value else "low",
|
|
"relevance": _relevance_from_interactions(likes, comments, favorites),
|
|
"why_relevant": why,
|
|
# Keep raw engagement for debugging/possible future rendering.
|
|
"engagement": {
|
|
"likes": likes,
|
|
"comments": comments,
|
|
"favorites": favorites,
|
|
},
|
|
})
|
|
|
|
return items
|