feat(hackernews): add Hacker News as 5th research source

Add HN search via free Algolia API (no key needed). Two-phase approach:
search for stories, then enrich top ones with comments. Integrated into
the full pipeline (normalize, score, dedupe, render) running in parallel
with Reddit/X/YouTube. Source priority: Reddit > X > HN > YouTube > Web.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-02-24 18:33:31 -08:00
parent 427a4e453d
commit 38a7ea253e
12 changed files with 1119 additions and 25 deletions
+11 -1
View File
@@ -36,10 +36,12 @@ def jaccard_similarity(set1: Set[str], set2: Set[str]) -> float:
return intersection / union if union > 0 else 0.0
def get_item_text(item: Union[schema.RedditItem, schema.XItem, schema.YouTubeItem]) -> str:
def get_item_text(item: Union[schema.RedditItem, schema.XItem, schema.YouTubeItem, schema.HackerNewsItem]) -> str:
"""Get comparable text from an item."""
if isinstance(item, schema.RedditItem):
return item.title
elif isinstance(item, schema.HackerNewsItem):
return item.title
elif isinstance(item, schema.YouTubeItem):
return f"{item.title} {item.channel_name}"
else:
@@ -128,3 +130,11 @@ def dedupe_youtube(
) -> List[schema.YouTubeItem]:
"""Dedupe YouTube items."""
return dedupe_items(items, threshold)
def dedupe_hackernews(
items: List[schema.HackerNewsItem],
threshold: float = 0.7,
) -> List[schema.HackerNewsItem]:
"""Dedupe Hacker News items."""
return dedupe_items(items, threshold)
+8
View File
@@ -246,6 +246,14 @@ def is_ytdlp_available() -> bool:
return youtube_yt.is_ytdlp_installed()
def is_hackernews_available() -> bool:
"""Check if Hacker News source is available.
Always returns True - HN uses free Algolia API, no key needed.
"""
return True
def get_x_source_status(config: Dict[str, Any]) -> Dict[str, Any]:
"""Get detailed X source status for UI decisions.
+252
View File
@@ -0,0 +1,252 @@
"""Hacker News search via Algolia API (free, no auth required).
Uses hn.algolia.com/api/v1 for story discovery and comment enrichment.
No API key needed - just HTTP calls via stdlib urllib.
"""
import html
import math
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
from . import http
ALGOLIA_SEARCH_URL = "https://hn.algolia.com/api/v1/search"
ALGOLIA_SEARCH_BY_DATE_URL = "https://hn.algolia.com/api/v1/search_by_date"
ALGOLIA_ITEM_URL = "https://hn.algolia.com/api/v1/items"
DEPTH_CONFIG = {
"quick": 15,
"default": 30,
"deep": 60,
}
ENRICH_LIMITS = {
"quick": 3,
"default": 5,
"deep": 10,
}
def _log(msg: str):
"""Log to stderr."""
sys.stderr.write(f"[HN] {msg}\n")
sys.stderr.flush()
def _date_to_unix(date_str: str) -> int:
"""Convert YYYY-MM-DD to Unix timestamp (start of day UTC)."""
parts = date_str.split("-")
year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
import calendar
import datetime
dt = datetime.datetime(year, month, day, tzinfo=datetime.timezone.utc)
return int(dt.timestamp())
def _unix_to_date(ts: int) -> str:
"""Convert Unix timestamp to YYYY-MM-DD."""
import datetime
dt = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc)
return dt.strftime("%Y-%m-%d")
def _strip_html(text: str) -> str:
"""Strip HTML tags and decode entities from HN comment text."""
import re
text = html.unescape(text)
text = re.sub(r'<p>', '\n', text)
text = re.sub(r'<[^>]+>', '', text)
return text.strip()
def search_hackernews(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
) -> Dict[str, Any]:
"""Search Hacker News via Algolia API.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD)
depth: 'quick', 'default', or 'deep'
Returns:
Dict with Algolia response (contains 'hits' list).
"""
count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
from_ts = _date_to_unix(from_date)
to_ts = _date_to_unix(to_date) + 86400 # Include the end date
_log(f"Searching for '{topic}' (since {from_date}, count={count})")
# Use relevance-sorted search (better for topic matching)
params = {
"query": topic,
"tags": "story",
"numericFilters": f"created_at_i>{from_ts},created_at_i<{to_ts}",
"hitsPerPage": str(count),
}
from urllib.parse import urlencode
url = f"{ALGOLIA_SEARCH_URL}?{urlencode(params)}"
try:
response = http.request("GET", url, timeout=30)
except http.HTTPError as e:
_log(f"Search failed: {e}")
return {"hits": [], "error": str(e)}
except Exception as e:
_log(f"Search failed: {e}")
return {"hits": [], "error": str(e)}
hits = response.get("hits", [])
_log(f"Found {len(hits)} stories")
return response
def parse_hackernews_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Parse Algolia response into normalized item dicts.
Returns:
List of item dicts ready for normalization.
"""
hits = response.get("hits", [])
items = []
for i, hit in enumerate(hits):
object_id = hit.get("objectID", "")
points = hit.get("points") or 0
num_comments = hit.get("num_comments") or 0
created_at_i = hit.get("created_at_i")
date_str = None
if created_at_i:
date_str = _unix_to_date(created_at_i)
# Article URL vs HN discussion URL
article_url = hit.get("url") or ""
hn_url = f"https://news.ycombinator.com/item?id={object_id}"
# Relevance: Algolia rank position gives a base, engagement boosts it
# Position 0 = most relevant from Algolia
rank_score = max(0.3, 1.0 - (i * 0.02)) # 1.0 -> 0.3 over 35 items
engagement_boost = min(0.2, math.log1p(points) / 40)
relevance = min(1.0, rank_score * 0.7 + engagement_boost + 0.1)
items.append({
"object_id": object_id,
"title": hit.get("title", ""),
"url": article_url,
"hn_url": hn_url,
"author": hit.get("author", ""),
"date": date_str,
"engagement": {
"points": points,
"num_comments": num_comments,
},
"relevance": round(relevance, 2),
"why_relevant": f"HN story about {hit.get('title', 'topic')[:60]}",
})
return items
def _fetch_item_comments(object_id: str, max_comments: int = 5) -> Dict[str, Any]:
"""Fetch top-level comments for a story from Algolia items endpoint.
Args:
object_id: HN story ID
max_comments: Max comments to return
Returns:
Dict with 'comments' list and 'comment_insights' list.
"""
url = f"{ALGOLIA_ITEM_URL}/{object_id}"
try:
data = http.request("GET", url, timeout=15)
except Exception as e:
_log(f"Failed to fetch comments for {object_id}: {e}")
return {"comments": [], "comment_insights": []}
children = data.get("children", [])
# Sort by points (highest first), filter to actual comments
real_comments = [
c for c in children
if c.get("text") and c.get("author")
]
real_comments.sort(key=lambda c: c.get("points") or 0, reverse=True)
comments = []
insights = []
for c in real_comments[:max_comments]:
text = _strip_html(c.get("text", ""))
excerpt = text[:300] + "..." if len(text) > 300 else text
comments.append({
"author": c.get("author", ""),
"text": excerpt,
"points": c.get("points") or 0,
})
# First sentence as insight
first_sentence = text.split(". ")[0].split("\n")[0][:200]
if first_sentence:
insights.append(first_sentence)
return {"comments": comments, "comment_insights": insights}
def enrich_top_stories(
items: List[Dict[str, Any]],
depth: str = "default",
) -> List[Dict[str, Any]]:
"""Fetch comments for top N stories by points.
Args:
items: Parsed HN items
depth: Research depth (controls how many to enrich)
Returns:
Items with top_comments and comment_insights added.
"""
if not items:
return items
limit = ENRICH_LIMITS.get(depth, ENRICH_LIMITS["default"])
# Sort by points to enrich the most popular stories
by_points = sorted(
range(len(items)),
key=lambda i: items[i].get("engagement", {}).get("points", 0),
reverse=True,
)
to_enrich = by_points[:limit]
_log(f"Enriching top {len(to_enrich)} stories with comments")
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {
executor.submit(
_fetch_item_comments,
items[idx]["object_id"],
): idx
for idx in to_enrich
}
for future in as_completed(futures):
idx = futures[future]
try:
result = future.result(timeout=15)
items[idx]["top_comments"] = result["comments"]
items[idx]["comment_insights"] = result["comment_insights"]
except Exception:
items[idx]["top_comments"] = []
items[idx]["comment_insights"] = []
return items
+58 -1
View File
@@ -4,7 +4,7 @@ from typing import Any, Dict, List, TypeVar, Union
from . import dates, schema
T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem)
T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.HackerNewsItem)
def filter_by_date_range(
@@ -200,6 +200,63 @@ def normalize_youtube_items(
return normalized
def normalize_hackernews_items(
items: List[Dict[str, Any]],
from_date: str,
to_date: str,
) -> List[schema.HackerNewsItem]:
"""Normalize raw Hacker News items to schema.
Args:
items: Raw HN items from Algolia API
from_date: Start of date range
to_date: End of date range
Returns:
List of HackerNewsItem objects
"""
normalized = []
for i, item in enumerate(items):
# Parse engagement
eng_raw = item.get("engagement") or {}
engagement = schema.Engagement(
score=eng_raw.get("points"),
num_comments=eng_raw.get("num_comments"),
)
# Parse comments (from enrichment)
top_comments = []
for c in item.get("top_comments", []):
top_comments.append(schema.Comment(
score=c.get("points", 0),
date=None,
author=c.get("author", ""),
excerpt=c.get("text", ""),
url="",
))
# HN dates are always high confidence (exact timestamps from Algolia)
date_str = item.get("date")
normalized.append(schema.HackerNewsItem(
id=f"HN{i+1}",
title=item.get("title", ""),
url=item.get("url", ""),
hn_url=item.get("hn_url", ""),
author=item.get("author", ""),
date=date_str,
date_confidence="high",
engagement=engagement,
top_comments=top_comments,
comment_insights=item.get("comment_insights", []),
relevance=item.get("relevance", 0.5),
why_relevant=item.get("why_relevant", ""),
))
return normalized
def items_to_dicts(items: List) -> List[Dict[str, Any]]:
"""Convert schema items to dicts for JSON serialization."""
return [item.to_dict() for item in items]
+76 -2
View File
@@ -30,9 +30,10 @@ def _assess_data_freshness(report: schema.Report) -> dict:
reddit_recent = sum(1 for r in report.reddit if r.date and r.date >= report.range_from)
x_recent = sum(1 for x in report.x if x.date and x.date >= report.range_from)
web_recent = sum(1 for w in report.web if w.date and w.date >= report.range_from)
hn_recent = sum(1 for h in report.hackernews if h.date and h.date >= report.range_from)
total_recent = reddit_recent + x_recent + web_recent
total_items = len(report.reddit) + len(report.x) + len(report.web)
total_recent = reddit_recent + x_recent + web_recent + hn_recent
total_items = len(report.reddit) + len(report.x) + len(report.web) + len(report.hackernews)
return {
"reddit_recent": reddit_recent,
@@ -215,6 +216,42 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
lines.append(f" *{item.why_relevant}*")
lines.append("")
# Hacker News items
if report.hackernews_error:
lines.append("### Hacker News Stories")
lines.append("")
lines.append(f"**ERROR:** {report.hackernews_error}")
lines.append("")
elif report.hackernews:
lines.append("### Hacker News Stories")
lines.append("")
for item in report.hackernews[:limit]:
eng_str = ""
if item.engagement:
eng = item.engagement
parts = []
if eng.score is not None:
parts.append(f"{eng.score}pts")
if eng.num_comments is not None:
parts.append(f"{eng.num_comments}cmt")
if parts:
eng_str = f" [{', '.join(parts)}]"
date_str = f" ({item.date})" if item.date else ""
lines.append(f"**{item.id}** (score:{item.score}) hn/{item.author}{date_str}{eng_str}")
lines.append(f" {item.title}")
lines.append(f" {item.hn_url}")
lines.append(f" *{item.why_relevant}*")
# Comment insights
if item.comment_insights:
lines.append(f" Insights:")
for insight in item.comment_insights[:3]:
lines.append(f" - {insight}")
lines.append("")
# Web items (if any - populated by the assistant)
if report.web_error:
lines.append("### Web Results")
@@ -278,6 +315,14 @@ def render_source_status(report: schema.Report, source_info: dict = None) -> str
reason = source_info.get("x_skip_reason", "No Bird CLI or XAI_API_KEY")
lines.append(f" ⏭️ X: skipped — {reason}")
# Hacker News
if report.hackernews_error:
lines.append(f" ❌ HN: error - {report.hackernews_error}")
elif report.hackernews:
lines.append(f" ✅ HN: {len(report.hackernews)} stories")
else:
lines.append(" ⏭️ HN: 0 stories found")
# YouTube
if report.youtube_error:
lines.append(f" ❌ YouTube: error — {report.youtube_error}")
@@ -325,6 +370,8 @@ def render_context_snippet(report: schema.Report) -> str:
all_items.append((item.score, "Reddit", item.title, item.url))
for item in report.x[:5]:
all_items.append((item.score, "X", item.text[:50] + "...", item.url))
for item in report.hackernews[:5]:
all_items.append((item.score, "HN", item.title[:50] + "...", item.hn_url))
for item in report.web[:5]:
all_items.append((item.score, "Web", item.title[:50] + "...", item.url))
@@ -414,6 +461,33 @@ def render_full_report(report: schema.Report) -> str:
lines.append(f"> {item.text}")
lines.append("")
# HN section
if report.hackernews:
lines.append("## Hacker News Stories")
lines.append("")
for item in report.hackernews:
lines.append(f"### {item.id}: {item.title}")
lines.append("")
lines.append(f"- **Author:** {item.author}")
lines.append(f"- **HN URL:** {item.hn_url}")
if item.url:
lines.append(f"- **Article URL:** {item.url}")
lines.append(f"- **Date:** {item.date or 'Unknown'}")
lines.append(f"- **Score:** {item.score}/100")
lines.append(f"- **Relevance:** {item.why_relevant}")
if item.engagement:
eng = item.engagement
lines.append(f"- **Engagement:** {eng.score or '?'} points, {eng.num_comments or '?'} comments")
if item.comment_insights:
lines.append("")
lines.append("**Key Insights from Comments:**")
for insight in item.comment_insights:
lines.append(f"- {insight}")
lines.append("")
# Web section
if report.web:
lines.append("## Web Results")
+69
View File
@@ -207,6 +207,43 @@ class YouTubeItem:
}
@dataclass
class HackerNewsItem:
"""Normalized Hacker News item."""
id: str # "HN1", "HN2", ...
title: str
url: str # Original article URL
hn_url: str # news.ycombinator.com/item?id=...
author: str # HN username
date: Optional[str] = None
date_confidence: str = "high" # Algolia provides exact timestamps
engagement: Optional[Engagement] = None # points + num_comments
top_comments: List[Comment] = field(default_factory=list)
comment_insights: List[str] = field(default_factory=list)
relevance: float = 0.5
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'title': self.title,
'url': self.url,
'hn_url': self.hn_url,
'author': self.author,
'date': self.date,
'date_confidence': self.date_confidence,
'engagement': self.engagement.to_dict() if self.engagement else None,
'top_comments': [c.to_dict() for c in self.top_comments],
'comment_insights': self.comment_insights,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
@dataclass
class Report:
"""Full research report."""
@@ -221,6 +258,7 @@ class Report:
x: List[XItem] = field(default_factory=list)
web: List[WebSearchItem] = field(default_factory=list)
youtube: List[YouTubeItem] = field(default_factory=list)
hackernews: List[HackerNewsItem] = field(default_factory=list)
best_practices: List[str] = field(default_factory=list)
prompt_pack: List[str] = field(default_factory=list)
context_snippet_md: str = ""
@@ -229,6 +267,7 @@ class Report:
x_error: Optional[str] = None
web_error: Optional[str] = None
youtube_error: Optional[str] = None
hackernews_error: Optional[str] = None
# Cache info
from_cache: bool = False
cache_age_hours: Optional[float] = None
@@ -248,6 +287,7 @@ class Report:
'x': [x.to_dict() for x in self.x],
'web': [w.to_dict() for w in self.web],
'youtube': [y.to_dict() for y in self.youtube],
'hackernews': [h.to_dict() for h in self.hackernews],
'best_practices': self.best_practices,
'prompt_pack': self.prompt_pack,
'context_snippet_md': self.context_snippet_md,
@@ -260,6 +300,8 @@ class Report:
d['web_error'] = self.web_error
if self.youtube_error:
d['youtube_error'] = self.youtube_error
if self.hackernews_error:
d['hackernews_error'] = self.hackernews_error
if self.from_cache:
d['from_cache'] = self.from_cache
if self.cache_age_hours is not None:
@@ -359,6 +401,31 @@ class Report:
score=y.get('score', 0),
))
# Reconstruct HackerNews items
hn_items = []
for h in data.get('hackernews', []):
eng = None
if h.get('engagement'):
eng = Engagement(**h['engagement'])
comments = [Comment(**c) for c in h.get('top_comments', [])]
subs = SubScores(**h.get('subs', {})) if h.get('subs') else SubScores()
hn_items.append(HackerNewsItem(
id=h['id'],
title=h['title'],
url=h.get('url', ''),
hn_url=h.get('hn_url', ''),
author=h.get('author', ''),
date=h.get('date'),
date_confidence=h.get('date_confidence', 'high'),
engagement=eng,
top_comments=comments,
comment_insights=h.get('comment_insights', []),
relevance=h.get('relevance', 0.5),
why_relevant=h.get('why_relevant', ''),
subs=subs,
score=h.get('score', 0),
))
return cls(
topic=data['topic'],
range_from=range_from,
@@ -371,6 +438,7 @@ class Report:
x=x_items,
web=web_items,
youtube=youtube_items,
hackernews=hn_items,
best_practices=data.get('best_practices', []),
prompt_pack=data.get('prompt_pack', []),
context_snippet_md=data.get('context_snippet_md', ''),
@@ -378,6 +446,7 @@ class Report:
x_error=data.get('x_error'),
web_error=data.get('web_error'),
youtube_error=data.get('youtube_error'),
hackernews_error=data.get('hackernews_error'),
from_cache=data.get('from_cache', False),
cache_age_hours=data.get('cache_age_hours'),
)
+64 -4
View File
@@ -280,6 +280,64 @@ def score_youtube_items(items: List[schema.YouTubeItem]) -> List[schema.YouTubeI
return items
def compute_hackernews_engagement_raw(engagement: Optional[schema.Engagement]) -> Optional[float]:
"""Compute raw engagement score for Hacker News item.
Formula: 0.55*log1p(points) + 0.45*log1p(num_comments)
Points are the primary signal on HN; comments indicate depth of discussion.
"""
if engagement is None:
return None
if engagement.score is None and engagement.num_comments is None:
return None
points = log1p_safe(engagement.score)
comments = log1p_safe(engagement.num_comments)
return 0.55 * points + 0.45 * comments
def score_hackernews_items(items: List[schema.HackerNewsItem]) -> List[schema.HackerNewsItem]:
"""Compute scores for Hacker News items.
Uses same weight structure as Reddit/X (relevance + recency + engagement).
"""
if not items:
return items
eng_raw = [compute_hackernews_engagement_raw(item.engagement) for item in items]
eng_normalized = normalize_to_100(eng_raw)
for i, item in enumerate(items):
rel_score = int(item.relevance * 100)
rec_score = dates.recency_score(item.date)
if eng_normalized[i] is not None:
eng_score = int(eng_normalized[i])
else:
eng_score = DEFAULT_ENGAGEMENT
item.subs = schema.SubScores(
relevance=rel_score,
recency=rec_score,
engagement=eng_score,
)
overall = (
WEIGHT_RELEVANCE * rel_score +
WEIGHT_RECENCY * rec_score +
WEIGHT_ENGAGEMENT * eng_score
)
if eng_raw[i] is None:
overall -= UNKNOWN_ENGAGEMENT_PENALTY
item.score = max(0, min(100, int(overall)))
return items
def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
"""Compute scores for WebSearch items WITHOUT engagement metrics.
@@ -337,7 +395,7 @@ def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebS
return items
def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem]]) -> List:
def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.HackerNewsItem]]) -> List:
"""Sort items by score (descending), then date, then source priority.
Args:
@@ -354,15 +412,17 @@ def sort_items(items: List[Union[schema.RedditItem, schema.XItem, schema.WebSear
date = item.date or "0000-00-00"
date_key = -int(date.replace("-", ""))
# Tertiary: source priority (Reddit > X > YouTube > WebSearch)
# Tertiary: source priority (Reddit > X > HN > YouTube > WebSearch)
if isinstance(item, schema.RedditItem):
source_priority = 0
elif isinstance(item, schema.XItem):
source_priority = 1
elif isinstance(item, schema.YouTubeItem):
elif isinstance(item, schema.HackerNewsItem):
source_priority = 2
else: # WebSearchItem
elif isinstance(item, schema.YouTubeItem):
source_priority = 3
else: # WebSearchItem
source_priority = 4
# Quaternary: title/text for stability
text = getattr(item, "title", "") or getattr(item, "text", "")
+21 -1
View File
@@ -72,6 +72,13 @@ YOUTUBE_MESSAGES = [
"Fetching transcripts...",
]
HN_MESSAGES = [
"Searching Hacker News...",
"Scanning HN front page stories...",
"Finding technical discussions...",
"Discovering developer conversations...",
]
PROCESSING_MESSAGES = [
"Crunching the data...",
"Scoring and ranking...",
@@ -257,6 +264,15 @@ class ProgressDisplay:
if self.spinner:
self.spinner.stop(f"{Colors.RED}YouTube{Colors.RESET} Found {count} videos")
def start_hackernews(self):
msg = random.choice(HN_MESSAGES)
self.spinner = Spinner(f"{Colors.YELLOW}HN{Colors.RESET} {msg}", Colors.YELLOW)
self.spinner.start()
def end_hackernews(self, count: int):
if self.spinner:
self.spinner.stop(f"{Colors.YELLOW}HN{Colors.RESET} Found {count} stories")
def start_processing(self):
msg = random.choice(PROCESSING_MESSAGES)
self.spinner = Spinner(f"{Colors.PURPLE}Processing{Colors.RESET} {msg}", Colors.PURPLE)
@@ -266,18 +282,22 @@ class ProgressDisplay:
if self.spinner:
self.spinner.stop()
def show_complete(self, reddit_count: int, x_count: int, youtube_count: int = 0):
def show_complete(self, reddit_count: int, x_count: int, youtube_count: int = 0, hn_count: int = 0):
elapsed = time.time() - self.start_time
if IS_TTY:
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Research complete{Colors.RESET} ")
sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n")
sys.stderr.write(f" {Colors.YELLOW}Reddit:{Colors.RESET} {reddit_count} threads ")
sys.stderr.write(f"{Colors.CYAN}X:{Colors.RESET} {x_count} posts")
if hn_count:
sys.stderr.write(f" {Colors.YELLOW}HN:{Colors.RESET} {hn_count} stories")
if youtube_count:
sys.stderr.write(f" {Colors.RED}YouTube:{Colors.RESET} {youtube_count} videos")
sys.stderr.write("\n\n")
else:
parts = [f"Reddit: {reddit_count} threads", f"X: {x_count} posts"]
if hn_count:
parts.append(f"HN: {hn_count} stories")
if youtube_count:
parts.append(f"YouTube: {youtube_count} videos")
sys.stderr.write(f"✓ Research complete ({elapsed:.1f}s) - {', '.join(parts)}\n")