|
1 | | -"""HN Algolia source: chronological obituary coverage via the Algolia REST API. |
| 1 | +"""HN Algolia phrase-driven discovery source. |
2 | 2 |
|
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. |
6 | 10 |
|
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. |
11 | 15 | """ |
12 | 16 |
|
13 | 17 | from __future__ import annotations |
14 | 18 |
|
15 | 19 | import logging |
16 | 20 | from datetime import UTC, datetime |
17 | | -from typing import TYPE_CHECKING, Any, cast |
| 21 | +from typing import TYPE_CHECKING, Final, cast |
18 | 22 | from urllib.parse import quote_plus |
19 | 23 |
|
| 24 | +import yaml |
| 25 | + |
20 | 26 | from slopmortem.corpus.sources._names import SOURCE_HN_ALGOLIA |
21 | 27 | from slopmortem.corpus.sources._throttle import ( |
22 | 28 | HTTP_BAD_REQUEST, |
|
29 | 35 |
|
30 | 36 | if TYPE_CHECKING: |
31 | 37 | from collections.abc import AsyncIterator |
| 38 | + from pathlib import Path |
32 | 39 |
|
33 | 40 | logger = logging.getLogger(__name__) |
34 | 41 |
|
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 |
36 | 111 |
|
37 | 112 |
|
38 | 113 | 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 | + """ |
40 | 118 |
|
41 | 119 | def __init__( |
42 | 120 | self, |
43 | 121 | *, |
44 | | - query: str, |
45 | | - since_epoch: int | None = None, |
| 122 | + queries_yaml_path: Path, |
46 | 123 | user_agent: str = USER_AGENT, |
47 | | - rps: float = 1.0, |
| 124 | + rps: float = 5.0, |
48 | 125 | ) -> 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 | + ) |
51 | 161 | self.user_agent = user_agent |
52 | 162 | self.rps = rps |
53 | 163 |
|
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 | + ) |
65 | 179 |
|
66 | 180 | @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") |
71 | 188 | if not isinstance(object_id, str) or not object_id: |
72 | 189 | 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() |
78 | 202 | return RawEntry( |
79 | 203 | source=SOURCE_HN_ALGOLIA, |
80 | 204 | source_id=object_id, |
81 | 205 | url=url, |
82 | 206 | raw_html=None, |
83 | | - markdown_text=markdown_text or None, |
| 207 | + markdown_text=markdown, |
84 | 208 | fetched_at=datetime.now(UTC), |
85 | 209 | ) |
86 | 210 |
|
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, |
102 | 226 | ) |
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 |
0 commit comments