返回 last30days-skill
hackernews.py
根目录 / skills / last30days / scripts / lib / hackernews.py
1 """Hacker News search via Algolia API (free, no auth required).
2
3 Uses hn.algolia.com/api/v1 for story discovery and comment enrichment.
4 No API key needed - just HTTP calls via stdlib urllib.
5 """
6
7 import datetime
8 import html
9 import math
10 import sys
11 import time
12 from concurrent.futures import ThreadPoolExecutor, as_completed
13 from typing import Any, Dict, List, Optional
14
15 import re
16
17 from . import http, log
18 from .query import extract_core_subject
19 from .relevance import token_overlap_relevance
20
21 # Common HN prefixes that can cause false-positive keyword matches
22 _HN_PREFIXES = re.compile(r"^(Tell HN|Show HN|Ask HN|Launch HN)\s*:\s*", re.IGNORECASE)
23
24 ALGOLIA_SEARCH_URL = "https://hn.algolia.com/api/v1/search"
25 ALGOLIA_SEARCH_BY_DATE_URL = "https://hn.algolia.com/api/v1/search_by_date"
26 ALGOLIA_ITEM_URL = "https://hn.algolia.com/api/v1/items"
27
28 DEPTH_CONFIG = {
29 "quick": 15,
30 "default": 30,
31 "deep": 60,
32 }
33
34 MIN_STORY_POINTS = 2
35 HN_OVERFETCH_MULTIPLIER = 2
36
37 ENRICH_LIMITS = {
38 "quick": 3,
39 "default": 5,
40 "deep": 10,
41 }
42
43 DISCOVERY_LIMITS = {"quick": 20, "default": 40, "deep": 60}
44
45
46 def _log(msg: str):
47 log.source_log("HN", msg, tty_only=False)
48
49
50 def _date_to_unix(date_str: str) -> int:
51 """Convert YYYY-MM-DD to Unix timestamp (start of day UTC)."""
52 parts = date_str.split("-")
53 year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
54 dt = datetime.datetime(year, month, day, tzinfo=datetime.timezone.utc)
55 return int(dt.timestamp())
56
57
58 def _unix_to_date(ts: int) -> str:
59 """Convert Unix timestamp to YYYY-MM-DD."""
60 dt = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc)
61 return dt.strftime("%Y-%m-%d")
62
63
64 def _strip_html(text: str) -> str:
65 """Strip HTML tags and decode entities from HN comment text."""
66 import re
67 text = html.unescape(text)
68 text = re.sub(r'<p>', '\n', text)
69 text = re.sub(r'<[^>]+>', '', text)
70 return text.strip()
71
72
73 def search_hackernews(
74 topic: str,
75 from_date: str,
76 to_date: str,
77 depth: str = "default",
78 ) -> Dict[str, Any]:
79 """Search Hacker News via Algolia API.
80
81 Args:
82 topic: Search topic
83 from_date: Start date (YYYY-MM-DD)
84 to_date: End date (YYYY-MM-DD)
85 depth: 'quick', 'default', or 'deep'
86
87 Returns:
88 Dict with Algolia response (contains 'hits' list).
89 """
90 count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
91 fetch_count = count * HN_OVERFETCH_MULTIPLIER
92 from_ts = _date_to_unix(from_date)
93 to_ts = _date_to_unix(to_date) + 86400 # Include the end date
94
95 # Use extracted core subject instead of raw topic for cleaner Algolia matching
96 core = extract_core_subject(topic)
97 # Hyphens and commas tokenize awkwardly in Algolia; flatten them so themed
98 # queries like "ts-bun-node" or "claude, personal agents" become plain words.
99 core_flat = _flatten_query_for_algolia(core)
100 _log(f"Searching for '{core_flat}' (raw: '{topic}', since {from_date}, count={count})")
101
102 # Use relevance-sorted search. The HN Algolia index only allows
103 # `created_at_i` in numericFilters; `points` is NOT in its
104 # `numericAttributesForFiltering`, so a `points>N` clause makes the API
105 # return HTTP 400 ("invalid numeric attribute(points)") and zero stories.
106 # Low-engagement stories are filtered client-side after overfetching so the
107 # invalid numeric filter is not reintroduced.
108 # NOTE: restrictSearchableAttributes=title omitted intentionally — it would
109 # miss Ask HN/Show HN threads where the topic appears in the body.
110 params = {
111 "query": core_flat,
112 "tags": "story",
113 "numericFilters": f"created_at_i>{from_ts},created_at_i<{to_ts}",
114 "hitsPerPage": str(fetch_count),
115 }
116 # Algolia defaults to AND across query tokens, so a 4-5 word theme query
117 # matches no stories. Mark all-but-the-first token as optional so Algolia
118 # ranks by how many tokens match instead of requiring every one.
119 tokens = core_flat.split()
120 if len(tokens) > 1:
121 params["optionalWords"] = " ".join(tokens[1:])
122
123 from urllib.parse import urlencode
124 url = f"{ALGOLIA_SEARCH_URL}?{urlencode(params)}"
125
126 try:
127 response = http.request("GET", url, timeout=30)
128 except http.HTTPError as e:
129 _log(f"Search failed: {e}")
130 return {"hits": [], "error": str(e)}
131 except Exception as e:
132 _log(f"Search failed: {e}")
133 return {"hits": [], "error": str(e)}
134
135 raw_hits = response.get("hits", [])
136 qualifying_hits = [
137 hit for hit in raw_hits
138 if (hit.get("points") or 0) > MIN_STORY_POINTS
139 ]
140 hits = qualifying_hits[:count]
141 dropped_low_engagement = len(raw_hits) - len(qualifying_hits)
142 if dropped_low_engagement:
143 _log(f"Filtered {dropped_low_engagement}/{len(raw_hits)} low-engagement stories")
144 if len(hits) != len(raw_hits):
145 response = {**response, "hits": hits}
146 _log(f"Found {len(hits)} stories")
147 return response
148
149
150 def fetch_discovery_listings(
151 from_date: str,
152 to_date: str,
153 depth: str = "default",
154 ) -> Dict[str, Any]:
155 """Fetch topic-less HN front-page and best-in-window story listings."""
156 limit = DISCOVERY_LIMITS.get(depth, DISCOVERY_LIMITS["default"])
157 from_ts = _date_to_unix(from_date)
158 to_ts = _date_to_unix(to_date) + 86400
159 from urllib.parse import urlencode
160
161 urls = [
162 f"{ALGOLIA_SEARCH_URL}?{urlencode({'tags': 'front_page', 'hitsPerPage': str(limit)})}",
163 f"{ALGOLIA_SEARCH_URL}?{urlencode({
164 'tags': 'story',
165 'numericFilters': f'created_at_i>{from_ts},created_at_i<{to_ts}',
166 'hitsPerPage': str(limit),
167 })}",
168 ]
169 hits: list[dict[str, Any]] = []
170 errors: list[str] = []
171 for url in urls:
172 try:
173 response = http.request("GET", url, timeout=30)
174 hits.extend(response.get("hits") or [])
175 except Exception as exc:
176 errors.append(str(exc))
177
178 seen: set[str] = set()
179 unique_hits: list[dict[str, Any]] = []
180 for hit in hits:
181 object_id = str(hit.get("objectID") or "")
182 if not object_id or object_id in seen:
183 continue
184 seen.add(object_id)
185 unique_hits.append(hit)
186
187 items = parse_hackernews_response({"hits": unique_hits}, query="")
188 return {"items": items, "errors": errors}
189
190
191 _WORD_BOUNDARY_RE_CACHE: Dict[str, "re.Pattern[str]"] = {}
192
193
194 def _flatten_query_for_algolia(text: str) -> str:
195 """Normalise query for Algolia + post-filter comparison.
196
197 Multi-keyword theme queries frequently contain commas (delimiters) or
198 hyphens (compound terms like ``ts-bun-node``); both tokenize awkwardly.
199 Flatten them to spaces and collapse runs of whitespace so the search
200 parameter and the post-filter operate on the same shape.
201 """
202 return " ".join(text.replace(",", " ").replace("-", " ").split())
203
204
205 def _title_matches_query(title: str, query: str, author: str = "") -> bool:
206 """Check if any query token appears as a whole word in the title.
207
208 Returns True when the query is empty (no filter), or when at least one
209 query token matches as a whole word in the title after stripping
210 "Tell HN:", "Show HN:", "Ask HN:", "Launch HN:" prefixes.
211
212 We previously required *every* token to appear (all-words), which killed
213 every Algolia hit on multi-keyword themes like "claude, personal agents,
214 agentic infra" because real HN titles never contain all five tokens
215 verbatim. Relaxing to any-word matches Algolia's `optionalWords` behaviour
216 in `search_hackernews`. Token-overlap relevance scoring at parse time
217 demotes hits where only one weak token matched, so the loosened gate
218 won't surface noise to the top of the ranking.
219
220 Word-boundary matching (rather than naive substring) prevents short
221 tokens like ``ai`` or ``ts`` from matching unrelated words like
222 ``email`` or ``artists``.
223 """
224 if not query:
225 return True
226 stripped = _HN_PREFIXES.sub("", title).strip()
227 check_text = stripped.lower()
228 # Normalise the query the same way search_hackernews does so post-filter
229 # tokens line up with what Algolia actually saw.
230 query_words = [w for w in _flatten_query_for_algolia(query.lower()).split() if w]
231 if not query_words:
232 return True
233 for word in query_words:
234 pattern = _WORD_BOUNDARY_RE_CACHE.get(word)
235 if pattern is None:
236 pattern = re.compile(rf"\b{re.escape(word)}\b")
237 _WORD_BOUNDARY_RE_CACHE[word] = pattern
238 if pattern.search(check_text):
239 return True
240 return False
241
242
243 def parse_hackernews_response(response: Dict[str, Any], query: str = "") -> List[Dict[str, Any]]:
244 """Parse Algolia response into normalized item dicts.
245
246 Args:
247 response: Algolia search response
248 query: Original search query for token-overlap relevance scoring
249
250 Returns:
251 List of item dicts ready for normalization.
252 """
253 hits = response.get("hits", [])
254 # Post-filter: remove items where query only matched an HN prefix like "Tell HN:"
255 if query:
256 before = len(hits)
257 hits = [
258 h for h in hits
259 if _title_matches_query(h.get("title", ""), query, h.get("author", ""))
260 ]
261 dropped = before - len(hits)
262 if dropped:
263 _log(f"Prefix filter removed {dropped}/{before} false-positive hits for '{query}'")
264 items = []
265
266 for i, hit in enumerate(hits):
267 object_id = hit.get("objectID", "")
268 points = hit.get("points") or 0
269 num_comments = hit.get("num_comments") or 0
270 created_at_i = hit.get("created_at_i")
271
272 date_str = None
273 if created_at_i:
274 date_str = _unix_to_date(created_at_i)
275
276 # Article URL vs HN discussion URL
277 article_url = hit.get("url") or ""
278 hn_url = f"https://news.ycombinator.com/item?id={object_id}"
279
280 # Relevance: blend Algolia rank with token-overlap content matching
281 rank_score = max(0.3, 1.0 - (i * 0.02)) # 1.0 -> 0.3 over 35 items
282 engagement_boost = min(0.2, math.log1p(points) / 40)
283 if query:
284 content_score = token_overlap_relevance(query, hit.get("title", ""))
285 relevance = min(1.0, 0.6 * rank_score + 0.4 * content_score + engagement_boost)
286 else:
287 relevance = min(1.0, rank_score * 0.7 + engagement_boost + 0.1)
288
289 items.append({
290 "id": object_id,
291 "title": hit.get("title", ""),
292 "url": article_url,
293 "hn_url": hn_url,
294 "author": hit.get("author", ""),
295 "date": date_str,
296 "engagement": {
297 "points": points,
298 "comments": num_comments,
299 },
300 "relevance": round(relevance, 2),
301 "why_relevant": f"HN story about {hit.get('title', 'topic')[:60]}",
302 })
303
304 return items
305
306
307 def _fetch_item_comments(object_id: str, max_comments: int = 5) -> Dict[str, Any]:
308 """Fetch top-level comments for a story from Algolia items endpoint.
309
310 Args:
311 object_id: HN story ID
312 max_comments: Max comments to return
313
314 Returns:
315 Dict with 'comments' list and 'comment_insights' list.
316 """
317 url = f"{ALGOLIA_ITEM_URL}/{object_id}"
318
319 try:
320 data = http.request("GET", url, timeout=15)
321 except Exception as e:
322 _log(f"Failed to fetch comments for {object_id}: {e}")
323 return {"comments": [], "comment_insights": []}
324
325 children = data.get("children", [])
326
327 # Sort by points (highest first), filter to actual comments
328 real_comments = [
329 c for c in children
330 if c.get("text") and c.get("author")
331 ]
332 real_comments.sort(key=lambda c: c.get("points") or 0, reverse=True)
333
334 comments = []
335 insights = []
336 for c in real_comments[:max_comments]:
337 text = _strip_html(c.get("text", ""))
338 excerpt = text[:300] + "..." if len(text) > 300 else text
339 comments.append({
340 "author": c.get("author", ""),
341 "text": excerpt,
342 "points": c.get("points"),
343 })
344 # First sentence as insight
345 first_sentence = text.split(". ")[0].split("\n")[0][:200]
346 if first_sentence:
347 insights.append(first_sentence)
348
349 return {"comments": comments, "comment_insights": insights}
350
351
352 def enrich_top_stories(
353 items: List[Dict[str, Any]],
354 depth: str = "default",
355 ) -> List[Dict[str, Any]]:
356 """Fetch comments for top N stories by points.
357
358 Args:
359 items: Parsed HN items
360 depth: Research depth (controls how many to enrich)
361
362 Returns:
363 Items with top_comments and comment_insights added.
364 """
365 if not items:
366 return items
367
368 limit = ENRICH_LIMITS.get(depth, ENRICH_LIMITS["default"])
369
370 # Sort by points to enrich the most popular stories
371 by_points = sorted(
372 range(len(items)),
373 key=lambda i: items[i].get("engagement", {}).get("points") or 0,
374 reverse=True,
375 )
376 to_enrich = by_points[:limit]
377
378 _log(f"Enriching top {len(to_enrich)} stories with comments")
379
380 with ThreadPoolExecutor(max_workers=5) as executor:
381 futures = {
382 executor.submit(
383 _fetch_item_comments,
384 items[idx]["id"],
385 ): idx
386 for idx in to_enrich
387 }
388
389 for future in as_completed(futures):
390 idx = futures[future]
391 try:
392 result = future.result(timeout=15)
393 items[idx]["top_comments"] = result["comments"]
394 items[idx]["comment_insights"] = result["comment_insights"]
395 except (KeyError, TypeError, OSError) as exc:
396 _log(f"Comment enrichment failed for story {items[idx].get('id', '?')}: {type(exc).__name__}: {exc}")
397 items[idx]["top_comments"] = []
398 items[idx]["comment_insights"] = []
399
400 return items
401
401 lines PYTHON