Skip to content

Commit a5d2fa2

Browse files
committed
Merge branch 'main' into defillama
2 parents adf0eb2 + 763aa67 commit a5d2fa2

8 files changed

Lines changed: 2465 additions & 4461 deletions

File tree

docs/plans/2026-05-06-hn-yaml-phrases.md

Lines changed: 911 additions & 0 deletions
Large diffs are not rendered by default.

slopmortem/cli/_ingest_cmd.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ async def _run_ingest( # noqa: PLR0913, PLR0912, PLR0915, C901 - the ingest CLI
314314

315315
sources: list[Source] = [
316316
CuratedSource(yaml_path=_default_curated_yaml(), rps=3.0),
317-
HNAlgoliaSource(query="post-mortem", rps=5.0),
317+
HNAlgoliaSource(queries_yaml_path=_default_hn_queries_yaml(), rps=5.0),
318318
]
319319
if crunchbase_csv is not None:
320320
sources.append(CrunchbaseCsvSource(csv_path=crunchbase_csv))
@@ -404,6 +404,10 @@ def _default_curated_yaml() -> Path:
404404
return Path(__file__).parent.parent / "corpus" / "sources" / "curated" / "post_mortems_v0.yml"
405405

406406

407+
def _default_hn_queries_yaml() -> Path:
408+
return Path(__file__).parent.parent / "corpus" / "sources" / "hn_queries.yaml"
409+
410+
407411
async def _build_journal(config: Config, post_mortems_root: Path) -> MergeJournal:
408412
"""Build the merge journal, calling `MergeJournal.init`.
409413
Lines changed: 209 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
1-
"""HN Algolia source: chronological obituary coverage via the Algolia REST API.
1+
"""HN Algolia phrase-driven discovery source.
22
3-
Endpoint pinned to ``/search_by_date`` (chronological), not ``/search``
4-
(relevance-ranked) — relevance ranking would re-surface the same long-tail
5-
popular threads on every ingest.
3+
YAML-driven, multi-phrase, year-window-sliced, chronologically-paginated.
4+
For each phrase, the source iterates one (epoch_start, epoch_end) window
5+
per calendar year between ``date_from`` and ``date_to``, and within each
6+
window paginates ``/search_by_date`` (NOT ``/search`` — relevance ranking
7+
buries the long tail) up to ``pages_per_window``. Pagination terminates
8+
on an empty page within a window. Dedup is by HN ``objectID`` across all
9+
phrases and windows.
610
7-
Direct ``safe_get`` calls instead of the official ``algoliasearch`` client:
8-
HN's mirror is a public read-only REST endpoint (no app_id/api_key), the
9-
official client would bypass our single SSRF chokepoint, and vcrpy cassettes
10-
record cleanly without its retry/pooling layer.
11+
Year-window slicing is what makes the long tail reachable: a flat
12+
chronological pagination from "most recent" only covers ~6 weeks per page,
13+
so 20 pages = ~Nov 2023 onward — never reaching 2017's Mattermark
14+
obituary. Per-year windows give every year its own bounded budget.
1115
"""
1216

1317
from __future__ import annotations
1418

1519
import logging
1620
from datetime import UTC, datetime
17-
from typing import TYPE_CHECKING, Any, cast
21+
from typing import TYPE_CHECKING, Final, cast
1822
from urllib.parse import quote_plus
1923

24+
import yaml
25+
2026
from slopmortem.corpus.sources._names import SOURCE_HN_ALGOLIA
2127
from slopmortem.corpus.sources._throttle import (
2228
HTTP_BAD_REQUEST,
@@ -29,91 +35,225 @@
2935

3036
if TYPE_CHECKING:
3137
from collections.abc import AsyncIterator
38+
from pathlib import Path
3239

3340
logger = logging.getLogger(__name__)
3441

35-
ENDPOINT = "https://hn.algolia.com/api/v1/search_by_date"
42+
ENDPOINT: Final = "https://hn.algolia.com/api/v1/search_by_date"
43+
DEFAULT_PAGES_PER_WINDOW: Final = 3
44+
DEFAULT_HITS_PER_PAGE: Final = 30
45+
DEFAULT_LOOKBACK_YEARS: Final = 11 # fallback lookback when date_from is unset
46+
47+
48+
def _epoch(date_str: str, *, end_of_day: bool = False) -> int | None:
49+
"""Parse YYYY-MM-DD to UTC epoch seconds; return None for empty string.
50+
51+
With ``end_of_day=True``, returns 23:59:59 of the date instead of midnight —
52+
so an operator setting ``date_to: "2017-12-31"`` includes the full day, not
53+
just its midnight boundary.
54+
"""
55+
if not date_str:
56+
return None
57+
parsed = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC)
58+
if end_of_day:
59+
parsed = parsed.replace(hour=23, minute=59, second=59)
60+
return int(parsed.timestamp())
61+
62+
63+
def _coerce_int(name: str, raw: object, default: int) -> int:
64+
# ``bool`` is a subclass of ``int``, so check it first — otherwise
65+
# ``pages_per_window: true`` silently becomes ``1``.
66+
if isinstance(raw, bool):
67+
msg = f"hn_queries.yaml: 'defaults.{name}' must be an integer, got bool"
68+
raise TypeError(msg)
69+
if isinstance(raw, int):
70+
return raw
71+
if raw is None:
72+
return default
73+
msg = f"hn_queries.yaml: 'defaults.{name}' must be an integer, got {type(raw).__name__}"
74+
raise TypeError(msg)
75+
76+
77+
def _year_windows(
78+
date_from_epoch: int | None,
79+
date_to_epoch: int | None,
80+
) -> list[tuple[int, int]]:
81+
"""Yield (epoch_start, epoch_end) per calendar year between bounds (inclusive).
82+
83+
- If ``date_to_epoch`` is unset, defaults to "now".
84+
- If ``date_from_epoch`` is unset, defaults to ``DEFAULT_LOOKBACK_YEARS``
85+
before ``date_to``.
86+
- Each window is clamped to its calendar year boundary, but the first
87+
and last windows are clamped to the actual ``date_from``/``date_to``.
88+
"""
89+
end_dt = (
90+
datetime.fromtimestamp(date_to_epoch, tz=UTC)
91+
if date_to_epoch is not None
92+
else datetime.now(UTC)
93+
)
94+
start_dt = (
95+
datetime.fromtimestamp(date_from_epoch, tz=UTC)
96+
if date_from_epoch is not None
97+
else datetime(end_dt.year - DEFAULT_LOOKBACK_YEARS, 1, 1, tzinfo=UTC)
98+
)
99+
100+
windows: list[tuple[int, int]] = []
101+
year = start_dt.year
102+
while year <= end_dt.year:
103+
year_start = datetime(year, 1, 1, tzinfo=UTC)
104+
year_end = datetime(year, 12, 31, 23, 59, 59, tzinfo=UTC)
105+
win_start = max(start_dt, year_start)
106+
win_end = min(end_dt, year_end)
107+
if win_start <= win_end:
108+
windows.append((int(win_start.timestamp()), int(win_end.timestamp())))
109+
year += 1
110+
return windows
36111

37112

38113
class HNAlgoliaSource:
39-
"""[Source] HN Algolia REST client, paginated by ``nbPages``."""
114+
"""[Source] Phrase-driven HN obituary discovery via /search_by_date.
115+
116+
Sliced into one query per calendar year per phrase.
117+
"""
40118

41119
def __init__(
42120
self,
43121
*,
44-
query: str,
45-
since_epoch: int | None = None,
122+
queries_yaml_path: Path,
46123
user_agent: str = USER_AGENT,
47-
rps: float = 1.0,
124+
rps: float = 5.0,
48125
) -> None:
49-
self.query = query
50-
self.since_epoch = since_epoch
126+
cfg_obj = cast(
127+
"object",
128+
yaml.safe_load(queries_yaml_path.read_text(encoding="utf-8")),
129+
)
130+
if not isinstance(cfg_obj, dict):
131+
msg = f"hn_queries.yaml: expected mapping, got {type(cfg_obj).__name__}"
132+
raise TypeError(msg)
133+
cfg = cast("dict[str, object]", cfg_obj)
134+
defaults_obj: object = cfg.get("defaults") or {}
135+
if not isinstance(defaults_obj, dict):
136+
msg = "hn_queries.yaml: 'defaults' must be a mapping"
137+
raise TypeError(msg)
138+
defaults = cast("dict[str, object]", defaults_obj)
139+
phrases_obj: object = cfg.get("phrases") or []
140+
if not isinstance(phrases_obj, list):
141+
msg = "hn_queries.yaml: 'phrases' must be a list"
142+
raise TypeError(msg)
143+
phrases_list = cast("list[object]", phrases_obj)
144+
self.phrases: list[str] = [p for p in phrases_list if isinstance(p, str) and p.strip()]
145+
if not self.phrases:
146+
msg = "hn_queries.yaml: 'phrases' must contain at least one non-empty entry"
147+
raise ValueError(msg)
148+
149+
self.date_from_epoch: int | None = _epoch(str(defaults.get("date_from") or ""))
150+
self.date_to_epoch: int | None = _epoch(str(defaults.get("date_to") or ""), end_of_day=True)
151+
self.pages_per_window: int = _coerce_int(
152+
"pages_per_window",
153+
defaults.get("pages_per_window"),
154+
DEFAULT_PAGES_PER_WINDOW,
155+
)
156+
self.hits_per_page: int = _coerce_int(
157+
"hits_per_page",
158+
defaults.get("hits_per_page"),
159+
DEFAULT_HITS_PER_PAGE,
160+
)
51161
self.user_agent = user_agent
52162
self.rps = rps
53163

54-
def build_url(self, *, page: int) -> str:
55-
"""*page* is zero-based."""
56-
params = [
57-
f"query={quote_plus(self.query)}",
58-
"tags=story",
59-
f"page={page}",
60-
]
61-
if self.since_epoch is not None:
62-
# numericFilters=created_at_i>=<epoch>; the ``>=`` must be URL-encoded.
63-
params.append(f"numericFilters={quote_plus(f'created_at_i>={self.since_epoch}')}")
64-
return f"{ENDPOINT}?{'&'.join(params)}"
164+
self._windows: list[tuple[int, int]] = _year_windows(
165+
self.date_from_epoch, self.date_to_epoch
166+
)
167+
168+
def _build_url(self, phrase: str, page: int, win_start: int, win_end: int) -> str:
169+
# Literal double-quotes turn this into a phrase match. Bare tokens
170+
# AND-search across title/comments/body and explode recall.
171+
quoted_phrase = f'"{phrase}"'
172+
# ``>=`` keeps stories posted exactly at the year boundary.
173+
numeric = f"created_at_i>={win_start},created_at_i<{win_end}"
174+
return (
175+
f"{ENDPOINT}?query={quote_plus(quoted_phrase)}&tags=story"
176+
f"&page={page}&hitsPerPage={self.hits_per_page}"
177+
f"&numericFilters={quote_plus(numeric)}"
178+
)
65179

66180
@staticmethod
67-
def _hit_to_entry(
68-
hit: dict[str, Any], # pyright: ignore[reportExplicitAny]; Algolia payload
69-
) -> RawEntry | None:
70-
object_id: object = hit.get("objectID")
181+
def _hit_to_entry(hit: dict[str, object]) -> RawEntry | None:
182+
object_id = hit.get("objectID")
183+
url = hit.get("url")
184+
title = hit.get("title") or ""
185+
created_at = hit.get("created_at") or ""
186+
points = hit.get("points")
187+
num_comments = hit.get("num_comments")
71188
if not isinstance(object_id, str) or not object_id:
72189
return None
73-
url_field: object = hit.get("url")
74-
url = url_field if isinstance(url_field, str) and url_field else None
75-
title: object = hit.get("title") or ""
76-
body: object = hit.get("story_text") or hit.get("comment_text") or ""
77-
markdown_text = f"# {title}\n\n{body}".strip()
190+
if not isinstance(url, str) or not url:
191+
# Ask-HN / Show-HN self-posts have no external URL; the Tavily
192+
# enricher would have nothing to fetch.
193+
return None
194+
title_str = title if isinstance(title, str) else str(title)
195+
markdown = (
196+
f"# {title_str}\n\n"
197+
f"hn_object_id: {object_id}\n"
198+
f"created_at: {created_at}\n"
199+
f"points: {points}\n"
200+
f"num_comments: {num_comments}\n"
201+
).strip()
78202
return RawEntry(
79203
source=SOURCE_HN_ALGOLIA,
80204
source_id=object_id,
81205
url=url,
82206
raw_html=None,
83-
markdown_text=markdown_text or None,
207+
markdown_text=markdown,
84208
fetched_at=datetime.now(UTC),
85209
)
86210

87-
async def fetch(self) -> AsyncIterator[RawEntry]:
88-
page = 0
89-
while True:
90-
url = self.build_url(page=page)
91-
if not await respect_robots(url, user_agent=self.user_agent):
92-
logger.info("hn_algolia: robots blocked %s", url)
93-
return
94-
await throttle_for(url, rps=self.rps)
95-
resp = await safe_get(url)
96-
if resp.status_code >= HTTP_BAD_REQUEST:
97-
logger.warning("hn_algolia: HTTP %s for %s", resp.status_code, url)
98-
return
99-
payload = cast(
100-
"dict[str, Any]", # pyright: ignore[reportExplicitAny]
101-
resp.json(),
211+
async def _fetch_page(
212+
self, phrase: str, page: int, win_start: int, win_end: int
213+
) -> list[dict[str, object]] | None:
214+
url = self._build_url(phrase, page, win_start, win_end)
215+
# ``fetch`` already cleared robots once per host; skip the recheck.
216+
await throttle_for(url, rps=self.rps)
217+
resp = await safe_get(url)
218+
if resp.status_code >= HTTP_BAD_REQUEST:
219+
logger.warning(
220+
"hn_algolia: HTTP %s for phrase=%r window=(%d,%d) page=%d",
221+
resp.status_code,
222+
phrase,
223+
win_start,
224+
win_end,
225+
page,
102226
)
103-
hits_field: object = payload.get("hits") or []
104-
if not isinstance(hits_field, list):
105-
logger.warning("hn_algolia: unexpected hits type for %s", url)
106-
return
107-
hits_list = cast("list[object]", hits_field)
108-
logger.info("hn_algolia: page %d, %d hits", page, len(hits_list))
109-
for hit in hits_list:
110-
if not isinstance(hit, dict):
111-
continue
112-
entry = self._hit_to_entry(cast("dict[str, Any]", hit)) # pyright: ignore[reportExplicitAny]
113-
if entry is not None:
114-
yield entry
115-
nb_pages_field: object = payload.get("nbPages")
116-
nb_pages = nb_pages_field if isinstance(nb_pages_field, int) else 0
117-
page += 1
118-
if page >= nb_pages:
119-
return
227+
return None
228+
payload = cast("object", resp.json())
229+
if not isinstance(payload, dict):
230+
return None
231+
payload_dict = cast("dict[str, object]", payload)
232+
hits_obj: object = payload_dict.get("hits") or []
233+
if not isinstance(hits_obj, list):
234+
return None
235+
hits_list = cast("list[object]", hits_obj)
236+
return [cast("dict[str, object]", h) for h in hits_list if isinstance(h, dict)]
237+
238+
async def fetch(self) -> AsyncIterator[RawEntry]:
239+
# Robots is checked per-host. One check up front skips ~288 redundant
240+
# rechecks across every (phrase, year, page) and makes a blocked
241+
# endpoint short-circuit the whole run.
242+
if not await respect_robots(ENDPOINT, user_agent=self.user_agent):
243+
logger.info("hn_algolia: robots blocked %s; skipping source", ENDPOINT)
244+
return
245+
seen: set[str] = set()
246+
for phrase in self.phrases:
247+
for win_start, win_end in self._windows:
248+
for page in range(self.pages_per_window):
249+
hits = await self._fetch_page(phrase, page, win_start, win_end)
250+
if hits is None or not hits:
251+
# None: HTTP error or bad shape (logged upstream).
252+
# Empty: window exhausted. Either way, next window.
253+
break
254+
for hit in hits:
255+
entry = self._hit_to_entry(hit)
256+
if entry is None or entry.source_id in seen:
257+
continue
258+
seen.add(entry.source_id)
259+
yield entry
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# HN Algolia phrase-driven discovery config. Phrases are matched against
2+
# story titles + bodies via /search_by_date (chronological, not relevance-
3+
# ranked). Each (phrase, calendar-year) window paginates up to
4+
# ``pages_per_window`` × ``hits_per_page`` hits. Year-window slicing is
5+
# what makes the long tail reachable — flat pagination from "most recent"
6+
# only covers ~2.5 years before exhausting a 20-page budget.
7+
# Dedup is by HN ``objectID`` across phrases and windows.
8+
#
9+
# To add a phrase: append a string to ``phrases``. Re-record the HN cassette
10+
# (see ``docs/cassettes.md``) before committing.
11+
defaults:
12+
date_from: "2015-01-01"
13+
date_to: "" # empty = open-ended (today)
14+
pages_per_window: 3 # max pages per (phrase, year) window — 3 × 30 = 90 hits/window
15+
hits_per_page: 30
16+
17+
phrases:
18+
- "shutting down"
19+
- "winding down"
20+
- "winds down"
21+
- "wound down"
22+
- "is closing"
23+
- "we're closing"
24+
- "post-mortem"
25+
- "sunsetting"

0 commit comments

Comments
 (0)