Merge PR #48: feat: add Xiaohongshu source + Reddit public fallback

- Xiaohongshu search via local MCP service (opt-in, zero impact if service not running)
- Reddit public JSON fallback (works with zero API keys)
- Reddit priority: ScrapeCreators -> OpenAI -> public fallback
- Updated env.py: Reddit always available via public fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-03-07 16:11:35 -08:00
254 changed files with 18608 additions and 87 deletions
+157 -24
View File
@@ -44,7 +44,10 @@ TIMEOUT_PROFILES = {
}
# Valid source names for the --search flag
VALID_SEARCH_SOURCES = {"reddit", "x", "hn", "youtube", "tiktok", "instagram", "polymarket", "web"}
VALID_SEARCH_SOURCES = {
"reddit", "x", "hn", "youtube", "tiktok", "instagram",
"polymarket", "web", "xiaohongshu", "xhs",
}
def parse_search_flag(search_str: str) -> set:
@@ -64,6 +67,8 @@ def parse_search_flag(search_str: str) -> set:
s = s.strip().lower()
if not s:
continue
if s == "xhs":
s = "xiaohongshu"
if s not in VALID_SEARCH_SOURCES:
print(
f"Error: Unknown search source '{s}'. "
@@ -133,6 +138,7 @@ from lib import (
dates,
dedupe,
hackernews,
xiaohongshu_api,
polymarket,
entity_extract,
env,
@@ -208,36 +214,60 @@ def _search_reddit(
sys.stderr.flush()
# Fall through to OpenAI if we have that key
if not config.get("OPENAI_API_KEY"):
return [], {"error": str(e)}, reddit_error, used_scrapecreators
# No OpenAI either: try public Reddit fallback.
try:
reddit_items = openai_reddit.search_reddit_public(
topic, from_date, to_date, depth=depth,
)
raw_response = {"source": "reddit_public", "items": reddit_items}
return reddit_items, raw_response, None, False
except Exception as e2:
return [], {"error": str(e)}, reddit_error, used_scrapecreators
used_scrapecreators = False
sys.stderr.write("[Reddit] Falling back to OpenAI\n")
sys.stderr.flush()
# === OpenAI path (fallback) ===
if not mock:
try:
raw_response = openai_reddit.search_reddit(
config["OPENAI_API_KEY"],
selected_models["openai"],
topic,
from_date,
to_date,
depth=depth,
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
)
except http.HTTPError as e:
raw_response = {"error": str(e)}
reddit_error = f"API error: {e}"
except Exception as e:
raw_response = {"error": str(e)}
reddit_error = f"{type(e).__name__}: {e}"
if config.get("OPENAI_API_KEY"):
try:
raw_response = openai_reddit.search_reddit(
config["OPENAI_API_KEY"],
selected_models["openai"],
topic,
from_date,
to_date,
depth=depth,
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
)
except http.HTTPError as e:
raw_response = {"error": str(e)}
reddit_error = f"API error: {e}"
except Exception as e:
raw_response = {"error": str(e)}
reddit_error = f"{type(e).__name__}: {e}"
else:
# No OpenAI auth: direct Reddit public JSON fallback.
try:
reddit_items = openai_reddit.search_reddit_public(
topic, from_date, to_date, depth=depth,
)
raw_response = {"source": "reddit_public", "items": reddit_items}
except http.HTTPError as e:
reddit_items = []
raw_response = {"error": str(e), "source": "reddit_public"}
reddit_error = f"Reddit public API error: {e}"
except Exception as e:
reddit_items = []
raw_response = {"error": str(e), "source": "reddit_public"}
reddit_error = f"Reddit public search error: {type(e).__name__}: {e}"
# Parse response
reddit_items = openai_reddit.parse_reddit_response(raw_response or {})
# Quick retry with simpler query if few results
if len(reddit_items) < 5 and not mock and not reddit_error:
if len(reddit_items) < 5 and not mock and not reddit_error and config.get("OPENAI_API_KEY"):
core = openai_reddit._extract_core_subject(topic)
if core.lower() != topic.lower():
try:
@@ -259,7 +289,7 @@ def _search_reddit(
pass
# Subreddit-targeted fallback if still < 3 results
if len(reddit_items) < 3 and not mock and not reddit_error:
if len(reddit_items) < 3 and not mock and not reddit_error and config.get("OPENAI_API_KEY"):
sub_query = openai_reddit._build_subreddit_query(topic)
try:
sub_raw = openai_reddit.search_reddit(
@@ -543,6 +573,48 @@ def _search_web(
return raw_results, web_error
def _search_xiaohongshu(
topic: str,
config: dict,
from_date: str,
to_date: str,
depth: str,
) -> tuple:
"""Search Xiaohongshu via xiaohongshu-mcp HTTP API (runs in thread).
Returns:
Tuple of (xiaohongshu_items, xiaohongshu_error)
Items are in web-item dict shape and can be normalized with websearch module.
"""
base_url = env.get_xiaohongshu_api_base(config)
try:
items = xiaohongshu_api.search_feeds(
topic=topic,
from_date=from_date,
to_date=to_date,
base_url=base_url,
depth=depth,
)
except Exception as e:
return [], f"{type(e).__name__}: {e}"
# Ensure all required keys exist for normalize_websearch_items()
for i, item in enumerate(items):
item.setdefault("id", f"XHS{i+1}")
item.setdefault("title", "")
item.setdefault("url", "")
item.setdefault("source_domain", "xiaohongshu.com")
item.setdefault("snippet", "")
if item.get("date") and not item.get("date_confidence"):
item["date_confidence"] = "med"
elif not item.get("date"):
item["date_confidence"] = "low"
item.setdefault("relevance", 0.5)
item.setdefault("why_relevant", "")
return items, None
def _run_supplemental(
topic: str,
reddit_items: list,
@@ -727,6 +799,7 @@ def run_research(
run_youtube: bool = False,
run_tiktok: bool = False,
run_instagram: bool = False,
run_xiaohongshu: bool = False,
timeouts: dict = None,
resolved_handle: str = None,
do_hackernews: bool = True,
@@ -769,6 +842,7 @@ def run_research(
hackernews_error = None
polymarket_error = None
web_error = None
xiaohongshu_error = None
# Determine web search mode
do_web = sources in ("all", "web", "reddit-web", "x-web")
@@ -796,6 +870,19 @@ def run_research(
if progress:
progress.start_web_only()
progress.end_web_only()
# Optional Xiaohongshu search in web-only mode.
if run_xiaohongshu:
try:
xhs_items, xiaohongshu_error = _search_xiaohongshu(
topic, config, from_date, to_date, depth,
)
web_items.extend(xhs_items)
if xiaohongshu_error and progress:
progress.show_error(f"Xiaohongshu error: {xiaohongshu_error}")
except Exception as e:
xiaohongshu_error = f"{type(e).__name__}: {e}"
if progress:
progress.show_error(f"Xiaohongshu error: {e}")
# Still run YouTube/TikTok/Instagram in web-only mode if available
if run_youtube:
if progress:
@@ -851,10 +938,20 @@ def run_research(
youtube_future = None
tiktok_future = None
instagram_future = None
xiaohongshu_future = None
hackernews_future = None
polymarket_future = None
web_future = None
max_workers = 2 + (1 if run_youtube else 0) + (1 if run_tiktok else 0) + (1 if run_instagram else 0) + (1 if do_hackernews else 0) + (1 if do_polymarket else 0) + (1 if web_backend else 0)
max_workers = (
2
+ (1 if run_youtube else 0)
+ (1 if run_tiktok else 0)
+ (1 if run_instagram else 0)
+ (1 if run_xiaohongshu else 0)
+ (1 if do_hackernews else 0)
+ (1 if do_polymarket else 0)
+ (1 if web_backend else 0)
)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit searches
@@ -897,6 +994,11 @@ def run_research(
env.get_instagram_token(config),
)
if run_xiaohongshu:
xiaohongshu_future = executor.submit(
_search_xiaohongshu, topic, config, from_date, to_date, depth,
)
if do_hackernews:
if progress:
progress.start_hackernews()
@@ -1004,6 +1106,21 @@ def run_research(
if progress:
progress.end_instagram(len(instagram_items))
if xiaohongshu_future:
try:
xhs_items, xiaohongshu_error = xiaohongshu_future.result(timeout=future_timeout)
web_items.extend(xhs_items)
if xiaohongshu_error and progress:
progress.show_error(f"Xiaohongshu error: {xiaohongshu_error}")
except TimeoutError:
xiaohongshu_error = f"Xiaohongshu search timed out after {future_timeout}s"
if progress:
progress.show_error(xiaohongshu_error)
except Exception as e:
xiaohongshu_error = f"{type(e).__name__}: {e}"
if progress:
progress.show_error(f"Xiaohongshu error: {e}")
if hackernews_future:
hn_timeout = timeouts.get("hackernews_future", future_timeout)
try:
@@ -1303,11 +1420,15 @@ def main():
# Auto-detect ScrapeCreators for Instagram
has_instagram = env.is_instagram_available(config)
# Auto-detect Xiaohongshu HTTP API (requires service + login)
has_xiaohongshu = env.is_xiaohongshu_available(config)
# --diagnose: show source availability and exit
if args.diagnose:
web_source = env.get_web_search_source(config)
diag = {
"openai": bool(config.get("OPENAI_API_KEY")),
"reddit_public": True,
"xai": bool(config.get("XAI_API_KEY")),
"x_source": x_source_status["source"],
"bird_installed": x_source_status["bird_installed"],
@@ -1316,6 +1437,8 @@ def main():
"youtube": has_ytdlp,
"tiktok": has_tiktok,
"instagram": has_instagram,
"xiaohongshu": has_xiaohongshu,
"xiaohongshu_api_base": env.get_xiaohongshu_api_base(config),
"hackernews": True,
"polymarket": True,
"web_search_backend": web_source,
@@ -1339,6 +1462,7 @@ def main():
web_source = env.get_web_search_source(config)
diag = {
"openai": bool(config.get("OPENAI_API_KEY")),
"reddit_public": True,
"xai": bool(config.get("XAI_API_KEY")),
"x_source": x_source_status["source"],
"bird_installed": x_source_status["bird_installed"],
@@ -1346,6 +1470,8 @@ def main():
"bird_username": x_source_status.get("bird_username"),
"youtube": has_ytdlp,
"tiktok": has_tiktok,
"instagram": has_instagram,
"xiaohongshu": has_xiaohongshu,
"hackernews": True,
"polymarket": True,
"web_search_backend": "deferred to assistant" if args.no_native_web else web_source,
@@ -1432,6 +1558,7 @@ def main():
search_run_youtube = has_ytdlp
search_run_tiktok = has_tiktok
search_run_instagram = has_instagram
search_run_xiaohongshu = has_xiaohongshu
if args.search:
search_sources = parse_search_flag(args.search)
has_reddit = "reddit" in search_sources
@@ -1441,6 +1568,8 @@ def main():
search_run_youtube = "youtube" in search_sources and has_ytdlp
search_run_tiktok = "tiktok" in search_sources and has_tiktok
search_run_instagram = "instagram" in search_sources and has_instagram
# If explicitly requested, attempt Xiaohongshu even when preflight says unavailable.
search_run_xiaohongshu = "xiaohongshu" in search_sources
include_search_web = "web" in search_sources
# Map to existing sources string
if has_reddit and has_x:
@@ -1468,6 +1597,7 @@ def main():
run_youtube=search_run_youtube,
run_tiktok=search_run_tiktok,
run_instagram=search_run_instagram,
run_xiaohongshu=search_run_xiaohongshu,
timeouts=timeouts,
resolved_handle=args.x_handle,
do_hackernews=search_do_hackernews,
@@ -1590,8 +1720,6 @@ def main():
# Build source info for status footer
source_info = {}
if not bool(config.get("OPENAI_API_KEY")):
source_info["reddit_skip_reason"] = "No OPENAI_API_KEY (add to ~/.config/last30days/.env)"
if not x_source:
if x_source_status["bird_installed"]:
source_info["x_skip_reason"] = "Bird installed but not authenticated — log into x.com in browser"
@@ -1605,6 +1733,11 @@ def main():
source_info["tiktok_skip_reason"] = "No SCRAPECREATORS_API_KEY - sign up at scrapecreators.com (100 free credits)"
if not has_instagram:
source_info["instagram_skip_reason"] = "No SCRAPECREATORS_API_KEY - sign up at scrapecreators.com (100 free credits)"
if not has_xiaohongshu:
source_info["xiaohongshu_skip_reason"] = (
f"Xiaohongshu API unavailable or not logged in - start xiaohongshu-mcp and login "
f"(base: {env.get_xiaohongshu_api_base(config)})"
)
if not web_source:
source_info["web_skip_reason"] = "assistant will use WebSearch (add BRAVE_API_KEY for native search)"
+67 -42
View File
@@ -199,6 +199,7 @@ def get_config() -> Dict[str, Any]:
('OPENROUTER_API_KEY', None),
('PARALLEL_API_KEY', None),
('BRAVE_API_KEY', None),
('XIAOHONGSHU_API_BASE', None),
('OPENAI_MODEL_POLICY', 'auto'),
('OPENAI_MODEL_PIN', None),
('XAI_MODEL_POLICY', 'latest'),
@@ -245,11 +246,12 @@ def get_reddit_source(config: Dict[str, Any]) -> Optional[str]:
def get_available_sources(config: Dict[str, Any]) -> str:
"""Determine which sources are available based on API keys.
"""Determine which sources are available.
Returns: 'all', 'both', 'reddit', 'reddit-web', 'x', 'x-web', 'web', or 'none'
"""
has_reddit = is_reddit_available(config)
# Reddit is available via public JSON fallback even without OpenAI auth.
has_reddit = True
has_xai = bool(config.get('XAI_API_KEY'))
has_web = has_web_search_keys(config)
@@ -257,12 +259,7 @@ def get_available_sources(config: Dict[str, Any]) -> str:
return 'all' if has_web else 'both'
elif has_reddit:
return 'reddit-web' if has_web else 'reddit'
elif has_xai:
return 'x-web' if has_web else 'x'
elif has_web:
return 'web'
else:
return 'web' # Fallback: assistant WebSearch (no API keys needed)
return 'web' if has_web else 'none'
def has_web_search_keys(config: Dict[str, Any]) -> bool:
@@ -291,7 +288,7 @@ def get_missing_keys(config: Dict[str, Any]) -> str:
Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none'
"""
has_reddit = is_reddit_available(config)
has_reddit = True
has_xai = bool(config.get('XAI_API_KEY'))
has_web = has_web_search_keys(config)
@@ -305,12 +302,11 @@ def get_missing_keys(config: Dict[str, Any]) -> str:
return 'none'
elif has_reddit and has_x:
return 'web' # Missing web search keys
elif has_reddit and has_web:
return 'x' # Missing X source
elif has_reddit:
return 'x' # Missing X source (and possibly web)
elif has_x:
return 'reddit' # Missing Reddit source (and possibly web)
else:
return 'all' # Missing everything
return 'all'
def validate_sources(requested: str, available: str, include_web: bool = False) -> tuple[str, Optional[str]]:
@@ -324,56 +320,51 @@ def validate_sources(requested: str, available: str, include_web: bool = False)
Returns:
Tuple of (effective_sources, error_message)
"""
# No API keys at all
if available == 'none':
if requested == 'auto':
return 'web', "No API keys configured. The assistant can still search the web if it has a search tool."
elif requested == 'web':
return 'web', None
else:
return 'web', f"No API keys configured. Add keys to ~/.config/last30days/.env for Reddit/X."
# Web-only mode (only web search API keys)
if available == 'web':
if requested == 'auto':
return 'web', None
elif requested == 'web':
return 'web', None
else:
return 'web', "Only web search keys configured. Add OPENAI_API_KEY (or run codex login) for Reddit, XAI_API_KEY for X."
has_reddit = available in ('reddit', 'both', 'reddit-web', 'all')
has_x = available in ('x', 'both', 'x-web', 'all')
has_web = available in ('web', 'reddit-web', 'x-web', 'all')
if requested == 'auto':
# Add web to sources if include_web is set
if has_reddit and has_x:
base = 'both'
elif has_reddit:
base = 'reddit'
elif has_x:
base = 'x'
elif has_web:
base = 'web'
else:
return 'none', "No sources are available."
if include_web:
if available == 'both':
return 'all', None # reddit + x + web
elif available == 'reddit':
if base == 'both':
return 'all', None
if base == 'reddit':
return 'reddit-web', None
elif available == 'x':
if base == 'x':
return 'x-web', None
return available, None
return base, None
if requested == 'web':
return 'web', None
if requested == 'both':
if available not in ('both',):
missing = 'xAI' if available == 'reddit' else 'OpenAI'
return 'none', f"Requested both sources but {missing} key is missing. Use --sources=auto to use available keys."
if not (has_reddit and has_x):
return 'none', "Requested both sources but X source is missing."
if include_web:
return 'all', None
return 'both', None
if requested == 'reddit':
if available == 'x':
if not has_reddit:
return 'none', "Requested Reddit but only xAI key is available."
if include_web:
return 'reddit-web', None
return 'reddit', None
if requested == 'x':
if available == 'reddit':
return 'none', "Requested X but only OpenAI key is available."
if not has_x:
return 'none', "Requested X but no X source is available (need Bird auth or XAI_API_KEY)."
if include_web:
return 'x-web', None
return 'x', None
@@ -459,6 +450,40 @@ def get_instagram_token(config: Dict[str, Any]) -> str:
return config.get('SCRAPECREATORS_API_KEY') or ''
def get_xiaohongshu_api_base(config: Dict[str, Any]) -> str:
"""Get Xiaohongshu HTTP API base URL.
Defaults to host.docker.internal so OpenClaw Docker can reach host service.
"""
return (config.get('XIAOHONGSHU_API_BASE') or "http://host.docker.internal:18060").rstrip("/")
def is_xiaohongshu_available(config: Dict[str, Any]) -> bool:
"""Check whether Xiaohongshu HTTP API is reachable and logged in."""
# Import here to avoid heavy imports at module load.
from . import http
base = get_xiaohongshu_api_base(config)
try:
# Keep health probe snappy, but allow one retry for transient hiccups.
health = http.get(f"{base}/health", timeout=3, retries=2)
if not isinstance(health, dict):
return False
if not health.get("success"):
return False
# Login probe can be slower on some deployments (browser/session checks),
# so use a slightly longer timeout to avoid false negatives.
login = http.get(f"{base}/api/v1/login/status", timeout=8, retries=2)
is_logged_in = (
login.get("data", {}).get("is_logged_in")
if isinstance(login, dict) else False
)
return bool(is_logged_in)
except Exception:
return False
# Backward compat alias
is_apify_available = is_tiktok_available
+99
View File
@@ -355,6 +355,105 @@ def search_reddit(
raise http.HTTPError("No models available")
def _public_relevance(score: int, num_comments: int) -> float:
"""Estimate relevance for public Reddit search results."""
# Lightweight heuristic: blend normalized score + comments.
score_component = min(1.0, max(0.0, score / 500.0))
comments_component = min(1.0, max(0.0, num_comments / 200.0))
return round((score_component * 0.6) + (comments_component * 0.4), 3)
def search_reddit_public(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
) -> List[Dict[str, Any]]:
"""Search Reddit directly via public JSON endpoint (no OpenAI key required).
This is a fallback mode for environments where OpenAI auth is unavailable.
It uses reddit.com/search/.json with recency filter (t=month).
"""
_, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
limit = min(100, max(20, max_items))
core = _extract_core_subject(topic)
queries = [topic]
if core and core.lower() != topic.lower():
queries.append(core)
queries.append(f'"{core}"')
seen_urls = set()
all_items: List[Dict[str, Any]] = []
headers = {
"User-Agent": http.USER_AGENT,
"Accept": "application/json",
}
for query in queries:
try:
url = (
"https://www.reddit.com/search/.json"
f"?q={_url_encode(query)}&sort=new&t=month&limit={limit}&raw_json=1"
)
data = http.get(url, headers=headers, timeout=20, retries=2)
children = data.get("data", {}).get("children", [])
for child in children:
if child.get("kind") != "t3":
continue
post = child.get("data", {})
permalink = str(post.get("permalink", "")).strip()
if not permalink or "/comments/" not in permalink:
continue
full_url = f"https://www.reddit.com{permalink}"
if full_url in seen_urls:
continue
seen_urls.add(full_url)
score = int(post.get("score", 0) or 0)
num_comments = int(post.get("num_comments", 0) or 0)
# Parse date from created_utc
created_utc = post.get("created_utc")
date_value = None
if created_utc:
from . import dates as dates_mod
date_value = dates_mod.timestamp_to_date(created_utc)
all_items.append({
"id": f"R{len(all_items)+1}",
"title": str(post.get("title", "")).strip(),
"url": full_url,
"subreddit": str(post.get("subreddit", "")).strip(),
"date": date_value,
"why_relevant": "Found via Reddit public search",
"relevance": _public_relevance(score, num_comments),
"engagement": {
"score": score,
"num_comments": num_comments,
"upvote_ratio": post.get("upvote_ratio"),
},
})
except http.HTTPError as e:
_log_info(f"Public Reddit search failed for query '{query}': {e}")
# Continue with next query; partial results are still useful.
continue
except Exception as e:
_log_info(f"Public Reddit search error for query '{query}': {e}")
continue
# Sort by date (desc, unknown dates last), then relevance desc
def _sort_key(item: Dict[str, Any]):
date_str = item.get("date") or ""
return (date_str, float(item.get("relevance", 0.0)))
all_items.sort(key=_sort_key, reverse=True)
return all_items[: max_items * 2]
def search_subreddits(
subreddits: List[str],
topic: str,
+14
View File
@@ -510,6 +510,20 @@ def render_source_status(report: schema.Report, source_info: dict = None) -> str
lines.append(f" ✅ Instagram: {len(report.instagram)} reels ({with_captions} with captions)")
# Hide when zero results
# Xiaohongshu (from Web source bucket)
xhs_count = 0
if report.web:
xhs_count = sum(
1 for w in report.web
if getattr(w, "source_domain", "").lower().endswith("xiaohongshu.com")
)
if xhs_count > 0:
lines.append(f" ✅ Xiaohongshu: {xhs_count} notes")
else:
reason = source_info.get("xiaohongshu_skip_reason")
if reason:
lines.append(f" ⚡ Xiaohongshu: {reason}")
# Hacker News
if report.hackernews_error:
lines.append(f" ❌ HN: error - {report.hackernews_error}")
+21 -3
View File
@@ -426,12 +426,15 @@ def show_diagnostic_banner(diag: dict):
bird_username, youtube, web_search_backend
"""
has_openai = diag.get("openai", False)
has_reddit_public = diag.get("reddit_public", False)
has_reddit = has_openai or has_reddit_public
has_x = diag.get("x_source") is not None
has_youtube = diag.get("youtube", False)
has_xiaohongshu = diag.get("xiaohongshu", False)
has_web = diag.get("web_search_backend") is not None
# If everything is available, no banner needed
if has_openai and has_x and has_youtube and has_web:
if has_reddit and has_x and has_youtube and has_web:
return
lines = []
@@ -443,7 +446,9 @@ def show_diagnostic_banner(diag: dict):
# Reddit
if has_openai:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — OPENAI_API_KEY found {Colors.DIM}{Colors.RESET}")
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — OpenAI/Codex auth found {Colors.DIM}{Colors.RESET}")
elif has_reddit_public:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — Public Reddit search (no key) {Colors.DIM}{Colors.RESET}")
else:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.RED}❌ Reddit{Colors.RESET} — No OPENAI_API_KEY {Colors.DIM}{Colors.RESET}")
lines.append(f"{Colors.DIM}{Colors.RESET} └─ Add to ~/.config/last30days/.env {Colors.DIM}{Colors.RESET}")
@@ -469,6 +474,12 @@ def show_diagnostic_banner(diag: dict):
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.RED}❌ YouTube{Colors.RESET} — yt-dlp not installed {Colors.DIM}{Colors.RESET}")
lines.append(f"{Colors.DIM}{Colors.RESET} └─ Fix: brew install yt-dlp (free) {Colors.DIM}{Colors.RESET}")
# Xiaohongshu
if has_xiaohongshu:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.GREEN}✅ Xiaohongshu{Colors.RESET} — API connected + logged in {Colors.DIM}{Colors.RESET}")
else:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.YELLOW}⚡ Xiaohongshu{Colors.RESET} — API not connected/logged in {Colors.DIM}{Colors.RESET}")
# Web
if has_web:
backend = diag.get("web_search_backend", "")
@@ -486,7 +497,9 @@ def show_diagnostic_banner(diag: dict):
lines.append("│ │")
if has_openai:
lines.append("│ ✅ Reddit — OPENAI_API_KEY found ")
lines.append("│ ✅ Reddit — OpenAI/Codex auth found │")
elif has_reddit_public:
lines.append("│ ✅ Reddit — Public Reddit search (no key) │")
else:
lines.append("│ ❌ Reddit — No OPENAI_API_KEY │")
lines.append("│ └─ Add to ~/.config/last30days/.env │")
@@ -506,6 +519,11 @@ def show_diagnostic_banner(diag: dict):
lines.append("│ ❌ YouTube — yt-dlp not installed │")
lines.append("│ └─ Fix: brew install yt-dlp (free) │")
if has_xiaohongshu:
lines.append("│ ✅ Xiaohongshu — API connected + logged in │")
else:
lines.append("│ ⚡ Xiaohongshu — API not connected/logged in │")
if has_web:
lines.append("│ ✅ Web — API search available │")
else:
+162
View File
@@ -0,0 +1,162 @@
"""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
+1 -13
View File
@@ -8,7 +8,6 @@ echo "Source: $SRC"
TARGETS=(
"$HOME/.claude/skills/last30days"
"$HOME/.claude/skills/last30daysCROSS"
"$HOME/.agents/skills/last30days"
"$HOME/.codex/skills/last30days"
)
@@ -18,18 +17,7 @@ for t in "${TARGETS[@]}"; do
echo "--- Syncing to $t ---"
mkdir -p "$t/scripts/lib"
# SKILL.md — CROSS gets patched frontmatter + skill root, others get verbatim copy
if [[ "$t" == *"last30daysCROSS"* ]]; then
sed \
-e 's/^name: last30days$/name: last30daysCROSS/' \
-e 's/^version: "2\.1"/version: "2.2-cross"/' \
-e "s|^description: .*|description: \"TEST BUILD with outcome-aware Polymarket scoring + cross-source linking. Research a topic from the last 30 days. Sources: Reddit, X, YouTube, Hacker News, Polymarket, web.\"|" \
-e "s/^argument-hint: .*/argument-hint: 'last30daysCROSS AI video tools'/" \
-e 's|"\$HOME/.claude/skills/last30days"|"$HOME/.claude/skills/last30daysCROSS" \\\n "$HOME/.claude/skills/last30days"|' \
"$SRC/SKILL.md" > "$t/SKILL.md"
else
cp "$SRC/SKILL.md" "$t/"
fi
cp "$SRC/SKILL.md" "$t/"
# Main script + lib modules (rsync handles identical files gracefully)
rsync -a "$SRC/scripts/last30days.py" "$t/scripts/"
+219
View File
@@ -0,0 +1,219 @@
#!/bin/bash
set -euo pipefail
# === V1 vs V2 Skill Test Harness ===
# Runs all 17 test queries through both v1 and v2 SKILL.md
# using `claude --print` to capture real end-to-end output.
SKILL_DIR="$HOME/.claude/skills/last30days"
REPO_DIR="/Users/mvanhorn/last30days-skill-private"
# Safety: always restore V2 SKILL.md on exit/crash
cleanup() {
if [ -f "$SKILL_DIR/SKILL.md.v2.bak" ]; then
echo ""
echo "⚠️ Restoring V2 SKILL.md from backup (script interrupted)..."
cp "$SKILL_DIR/SKILL.md.v2.bak" "$SKILL_DIR/SKILL.md"
rm -f "$SKILL_DIR/SKILL.md.v2.bak"
echo " ✅ V2 restored"
fi
}
trap cleanup EXIT
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
OUT_DIR="$REPO_DIR/docs/test-results/v1-vs-v2-${TIMESTAMP}"
V1_DIR="$OUT_DIR/v1"
V2_DIR="$OUT_DIR/v2"
mkdir -p "$V1_DIR" "$V2_DIR"
echo "📁 Output directory: $OUT_DIR"
echo ""
# All 17 test queries
QUERIES=(
"prompting techniques for chatgpt for legal questions"
"best clawdbot use cases"
"how to best setup clawdbot"
"prompting tips for nano banana pro for ios designs"
"top claude code skills"
"using ChatGPT to make images of dogs"
"research best practices for beautiful remotion animation videos in claude code"
"photorealistic people in nano banana pro"
"What are the best rap songs lately"
"what are people saying about DeepSeek R1"
"best practices for cursor rules files for Cursor"
"prompt advice for using suno to make killer songs in simple mode"
"how do I use Codex with Claude Code on same app to make it better"
"kanye west"
"howie.ai"
"open claw"
"nano banana pro prompting"
)
TYPES=(
"PROMPTING+TOOL"
"RECOMMENDATIONS"
"HOW-TO"
"PROMPTING+TOOL"
"RECOMMENDATIONS"
"GENERAL"
"PROMPTING"
"PROMPTING"
"RECOMMENDATIONS"
"NEWS"
"PROMPTING"
"PROMPTING"
"HOW-TO"
"NEWS"
"GENERAL"
"GENERAL"
"PROMPTING"
)
slugify() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | cut -c1-50
}
run_version() {
local version="$1"
local outdir="$2"
local total=${#QUERIES[@]}
echo ""
echo "=========================================="
echo " Running $version$total queries"
echo "=========================================="
echo ""
for i in "${!QUERIES[@]}"; do
local query="${QUERIES[$i]}"
local type="${TYPES[$i]}"
local slug
slug=$(slugify "$query")
local num=$((i + 1))
local outfile="$outdir/${num}-${slug}.txt"
local errfile="$outdir/${num}-${slug}.stderr.txt"
echo "[$version] ($num/$total) $query [$type]"
local start_time
start_time=$(date +%s)
# Run claude --print with the skill invocation
# No timeout — claude --print exits on its own; kill manually if stuck
if /Users/mvanhorn/.local/bin/claude --print \
"/last30days $query" \
> "$outfile" 2>"$errfile"; then
local end_time
end_time=$(date +%s)
local duration=$((end_time - start_time))
local lines
lines=$(wc -l < "$outfile")
echo " ✅ Done — ${lines} lines, ${duration}s"
else
local exit_code=$?
echo " ❌ Failed (exit $exit_code)" | tee -a "$outfile"
fi
# Brief pause between queries to avoid rate limits
sleep 3
done
}
# === Phase 1: Test V1 ===
echo "📦 Backing up current V2 SKILL.md..."
cp "$SKILL_DIR/SKILL.md" "$SKILL_DIR/SKILL.md.v2.bak"
echo "📥 Installing V1 SKILL.md from upstream..."
cd "$REPO_DIR"
git show upstream/main:SKILL.md | sed '/^context: fork$/d; /^agent: Explore$/d; /^disable-model-invocation: true$/d' > "$SKILL_DIR/SKILL.md"
cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v1-SKILL.md"
echo " ✅ V1 installed (stripped: context:fork, agent:Explore, disable-model-invocation)"
run_version "V1" "$V1_DIR"
# === Phase 2: Test V2 ===
echo ""
echo "📥 Restoring V2 SKILL.md..."
cp "$SKILL_DIR/SKILL.md.v2.bak" "$SKILL_DIR/SKILL.md"
cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v2-SKILL.md"
echo " ✅ V2 restored"
run_version "V2" "$V2_DIR"
# === Phase 3: Generate summary ===
echo ""
echo "=========================================="
echo " Generating comparison summary"
echo "=========================================="
SUMMARY="$OUT_DIR/comparison-summary.md"
cat > "$SUMMARY" << EOF
# V1 vs V2 Comparison Results
Generated: $(date)
Output directory: $OUT_DIR
## Output Files
| # | Query | Type | V1 Lines | V2 Lines | V1 Time | V2 Time |
|---|-------|------|----------|----------|---------|---------|
EOF
for i in "${!QUERIES[@]}"; do
query="${QUERIES[$i]}"
type="${TYPES[$i]}"
slug=$(slugify "$query")
num=$((i + 1))
v1file="$V1_DIR/${num}-${slug}.txt"
v2file="$V2_DIR/${num}-${slug}.txt"
v1lines=$(wc -l < "$v1file" 2>/dev/null || echo "ERR")
v2lines=$(wc -l < "$v2file" 2>/dev/null || echo "ERR")
echo "| $num | \`$query\` | $type | $v1lines | $v2lines | — | — |" >> "$SUMMARY"
done
cat >> "$SUMMARY" << 'EOF'
## Quick Check: Key Features
For each query, check these v2 improvements:
- [ ] Query parsing display (`🔍 **{TOPIC}** · {QUERY_TYPE}`)
- [ ] Sparse citations (not every sentence)
- [ ] Bold topic headers in summary
- [ ] Emoji stats tree (`├─ 🟠 Reddit:`)
- [ ] Quality checklist applied to prompts
- [ ] Self-check (research grounding, not generic)
## Scoring Guide
Use the full scoring rubric from:
`docs/plans/2026-02-06-test-v1-vs-v2-comparison-plan.md`
## Next Step
Have Claude read all 34 output files and generate scored comparison:
```
Read all files in docs/test-results/v1-vs-v2-*/v1/ and v2/
Score each on the 7 dimensions from the test plan
Write the final analysis to docs/test-results/v1-vs-v2-*/analysis.md
```
EOF
# Cleanup backup
rm -f "$SKILL_DIR/SKILL.md.v2.bak"
echo ""
echo "✅ All done!"
echo ""
echo "📁 Results: $OUT_DIR"
echo "📊 Summary: $SUMMARY"
echo "📄 V1 files: $V1_DIR/"
echo "📄 V2 files: $V2_DIR/"
echo ""
echo "To review:"
echo " open $OUT_DIR"