edea402b7c
Two related fixes that surface when running last30days with multi-keyword
themed queries (e.g. "claude, personal agents, agentic infra"). Both bugs
caused entire sources to silently return zero items.
YouTube (ScrapeCreators)
SC's /v1/youtube/search rejects ?keyword= with HTTP 400:
{"error":"missing_parameter","message":"You must provide a query"}
The canonical SC parameter for that endpoint is `query`. Other SC
endpoints we use (Reddit, TikTok, Instagram) happened to work because
they use their own per-endpoint parameter names — YouTube was the lone
outlier.
Hacker News (Algolia)
Multi-keyword theme queries returned zero hits across every theme.
Algolia treats query= as strict AND across tokens, so a 4-5 word query
like "claude, personal agents, agentic infra" matches no stories.
Three changes in hackernews.py:
1. Hoist comma/hyphen flattening into _flatten_query_for_algolia() so
search_hackernews and _title_matches_query normalize the query the
same way — addresses Greptile P2 #2 about the two callsites needing
to stay in sync.
2. Pass `optionalWords` for all-but-the-first token so Algolia ranks
by token-overlap instead of requiring every token.
3. Relax _title_matches_query from all-words to any-word, *but match
on word boundaries (\b<word>\b) rather than naive substring* —
addresses Greptile P2 #1, which flagged that the previous any-word
relaxation would let "ai" falsely match "email" or "rail".
Token-overlap relevance scoring at parse time already demotes weak
matches, so word-boundary any-word matching is safe.
Tests: added coverage for no-token-in-title rejection, word-boundary vs
substring, and hyphen/comma flattening alignment between the search
parameter and the post-filter.
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
339 lines
11 KiB
Python
339 lines
11 KiB
Python
"""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 datetime
|
|
import html
|
|
import math
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import re
|
|
|
|
from . import http, log
|
|
from .query import extract_core_subject
|
|
from .relevance import token_overlap_relevance
|
|
|
|
# Common HN prefixes that can cause false-positive keyword matches
|
|
_HN_PREFIXES = re.compile(r"^(Tell HN|Show HN|Ask HN|Launch HN)\s*:\s*", re.IGNORECASE)
|
|
|
|
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.source_log("HN", msg)
|
|
|
|
|
|
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])
|
|
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."""
|
|
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
|
|
|
|
# Use extracted core subject instead of raw topic for cleaner Algolia matching
|
|
core = extract_core_subject(topic)
|
|
# Hyphens and commas tokenize awkwardly in Algolia; flatten them so themed
|
|
# queries like "ts-bun-node" or "claude, personal agents" become plain words.
|
|
core_flat = _flatten_query_for_algolia(core)
|
|
_log(f"Searching for '{core_flat}' (raw: '{topic}', since {from_date}, count={count})")
|
|
|
|
# Use relevance-sorted search with minimum engagement filter.
|
|
# NOTE: restrictSearchableAttributes=title omitted intentionally — it would
|
|
# miss Ask HN/Show HN threads where the topic appears in the body.
|
|
params = {
|
|
"query": core_flat,
|
|
"tags": "story",
|
|
"numericFilters": f"created_at_i>{from_ts},created_at_i<{to_ts},points>2",
|
|
"hitsPerPage": str(count),
|
|
}
|
|
# Algolia defaults to AND across query tokens, so a 4-5 word theme query
|
|
# matches no stories. Mark all-but-the-first token as optional so Algolia
|
|
# ranks by how many tokens match instead of requiring every one.
|
|
tokens = core_flat.split()
|
|
if len(tokens) > 1:
|
|
params["optionalWords"] = " ".join(tokens[1:])
|
|
|
|
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
|
|
|
|
|
|
_WORD_BOUNDARY_RE_CACHE: Dict[str, "re.Pattern[str]"] = {}
|
|
|
|
|
|
def _flatten_query_for_algolia(text: str) -> str:
|
|
"""Normalise query for Algolia + post-filter comparison.
|
|
|
|
Multi-keyword theme queries frequently contain commas (delimiters) or
|
|
hyphens (compound terms like ``ts-bun-node``); both tokenize awkwardly.
|
|
Flatten them to spaces and collapse runs of whitespace so the search
|
|
parameter and the post-filter operate on the same shape.
|
|
"""
|
|
return " ".join(text.replace(",", " ").replace("-", " ").split())
|
|
|
|
|
|
def _title_matches_query(title: str, query: str, author: str = "") -> bool:
|
|
"""Check if any query token appears as a whole word in the title.
|
|
|
|
Returns True when the query is empty (no filter), or when at least one
|
|
query token matches as a whole word in the title after stripping
|
|
"Tell HN:", "Show HN:", "Ask HN:", "Launch HN:" prefixes.
|
|
|
|
We previously required *every* token to appear (all-words), which killed
|
|
every Algolia hit on multi-keyword themes like "claude, personal agents,
|
|
agentic infra" because real HN titles never contain all five tokens
|
|
verbatim. Relaxing to any-word matches Algolia's `optionalWords` behaviour
|
|
in `search_hackernews`. Token-overlap relevance scoring at parse time
|
|
demotes hits where only one weak token matched, so the loosened gate
|
|
won't surface noise to the top of the ranking.
|
|
|
|
Word-boundary matching (rather than naive substring) prevents short
|
|
tokens like ``ai`` or ``ts`` from matching unrelated words like
|
|
``email`` or ``artists``.
|
|
"""
|
|
if not query:
|
|
return True
|
|
stripped = _HN_PREFIXES.sub("", title).strip()
|
|
check_text = stripped.lower()
|
|
# Normalise the query the same way search_hackernews does so post-filter
|
|
# tokens line up with what Algolia actually saw.
|
|
query_words = [w for w in _flatten_query_for_algolia(query.lower()).split() if w]
|
|
if not query_words:
|
|
return True
|
|
for word in query_words:
|
|
pattern = _WORD_BOUNDARY_RE_CACHE.get(word)
|
|
if pattern is None:
|
|
pattern = re.compile(rf"\b{re.escape(word)}\b")
|
|
_WORD_BOUNDARY_RE_CACHE[word] = pattern
|
|
if pattern.search(check_text):
|
|
return True
|
|
return False
|
|
|
|
|
|
def parse_hackernews_response(response: Dict[str, Any], query: str = "") -> List[Dict[str, Any]]:
|
|
"""Parse Algolia response into normalized item dicts.
|
|
|
|
Args:
|
|
response: Algolia search response
|
|
query: Original search query for token-overlap relevance scoring
|
|
|
|
Returns:
|
|
List of item dicts ready for normalization.
|
|
"""
|
|
hits = response.get("hits", [])
|
|
# Post-filter: remove items where query only matched an HN prefix like "Tell HN:"
|
|
if query:
|
|
before = len(hits)
|
|
hits = [
|
|
h for h in hits
|
|
if _title_matches_query(h.get("title", ""), query, h.get("author", ""))
|
|
]
|
|
dropped = before - len(hits)
|
|
if dropped:
|
|
_log(f"Prefix filter removed {dropped}/{before} false-positive hits for '{query}'")
|
|
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: blend Algolia rank with token-overlap content matching
|
|
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)
|
|
if query:
|
|
content_score = token_overlap_relevance(query, hit.get("title", ""))
|
|
relevance = min(1.0, 0.6 * rank_score + 0.4 * content_score + engagement_boost)
|
|
else:
|
|
relevance = min(1.0, rank_score * 0.7 + engagement_boost + 0.1)
|
|
|
|
items.append({
|
|
"id": object_id,
|
|
"title": hit.get("title", ""),
|
|
"url": article_url,
|
|
"hn_url": hn_url,
|
|
"author": hit.get("author", ""),
|
|
"date": date_str,
|
|
"engagement": {
|
|
"points": points,
|
|
"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]["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 (KeyError, TypeError, OSError) as exc:
|
|
_log(f"Comment enrichment failed for story {items[idx].get('id', '?')}: {type(exc).__name__}: {exc}")
|
|
items[idx]["top_comments"] = []
|
|
items[idx]["comment_insights"] = []
|
|
|
|
return items
|