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:
+157
-24
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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/"
|
||||
|
||||
Executable
+219
@@ -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"
|
||||
Reference in New Issue
Block a user