Scrape Hacker News: Stories, Comments, Points, Users
Scrape Hacker News the right way: the official Firebase API, the Algolia search API, recursive comment trees, points, users, and a proxy-safe HTML fallback.

To scrape Hacker News you almost never need a scraper. Hacker News publishes every story, comment, point total, and user profile through two free, unauthenticated JSON APIs, so most of the HTML tutorials floating around solve a problem that does not exist. This guide covers the official Firebase API (the source of truth for items, users, and the live front page), the Algolia search API (full-text search, filters, and a whole comment thread in one request), how to walk the comment tree correctly, and the narrow case where falling back to HTML actually earns its place. Every example uses public data and stays inside Hacker News's rules.
API or HTML: The Fastest Way In
Hacker News gives you three ways to read its data, and two of them beat parsing HTML by a wide margin. Reach for the APIs first.
The Firebase API at hacker-news.firebaseio.com is the official one, run by Y Combinator. It is read-only JSON, needs no key, and returns stories, comments, jobs, polls, and user profiles by id. It also exposes the live front page, the newest submissions, and a running maximum item id you can walk for a full backfill. What it does not do is search.
The Algolia search API at hn.algolia.com fills that gap. It indexes every item and lets you search full text, filter by points or date or author, and, best of all, pull an entire nested comment thread in a single request. It is the query layer that sits on top of the same data.
HTML scraping of news.ycombinator.com is the last resort, useful only for the handful of fields the APIs do not model or when you want the page exactly as rendered. Since the content is already served as clean JSON, treating a site's own API as the primary source is the same principle covered in how to scrape hidden JSON API endpoints: find the data feed before you parse the markup.
| Route | Best for | Auth | Rate limit | Search |
|---|---|---|---|---|
| Firebase API (`hacker-news.firebaseio.com`) | Fetch stories, comments, users by id; live lists; full backfill | None | None published | No |
| Algolia API (`hn.algolia.com`) | Full-text search, filter by points/date/author, whole comment tree in one call | None | 10,000 requests/hour per IP | Yes |
| HTML (`news.ycombinator.com`) | Only fields the APIs omit, or the page as rendered | None | `Crawl-delay: 30` in robots.txt | Via URL params |
The Official Firebase API
The base URL is https://hacker-news.firebaseio.com/v0. The single most useful fact about it: everything is an item. A story, a comment, a job listing, a poll, and a poll option all come from the same endpoint, /item/, and share one schema. You write one parser and branch on the type field.
# Every Hacker News object is an "item", fetched by id
curl -s "https://hacker-news.firebaseio.com/v0/item/8863.json"
# -> a story item: title, by, score (points), time, kids (comment ids), descendants
Two Python helpers cover almost everything you will do against Firebase. Note that a missing id returns JSON null, and usernames are case-sensitive.
import requests
HN = "https://hacker-news.firebaseio.com/v0"
def hn_item(item_id):
r = requests.get(f"{HN}/item/{item_id}.json", timeout=15)
r.raise_for_status()
return r.json() # None if the id does not exist
def hn_user(username):
r = requests.get(f"{HN}/user/{username}.json", timeout=15)
r.raise_for_status()
return r.json() # usernames are case-sensitive: "pg" != "Pg"
Beyond fetching by id, Firebase exposes the live site as flat arrays of ids. These are the lists that power the front page and the section tabs:
| Endpoint | Returns | Cap |
|---|---|---|
| `/topstories.json` | Front-page ranking as ids (includes jobs) | up to 500 |
| `/newstories.json` | Newest submissions | up to 500 |
| `/beststories.json` | Highest-rated recent stories | up to 500 |
| `/askstories.json` | Ask HN | up to 200 |
| `/showstories.json` | Show HN | up to 200 |
| `/jobstories.json` | Jobs | up to 200 |
| `/maxitem.json` | Current largest item id | one integer |
| `/updates.json` | Recently changed items and profiles | `{items, profiles}` |
The official docs state plainly: "There is currently no rate limit." They also advise clients to "gracefully handle additional fields they don't expect, and simply ignore them," so read the fields you need with .get() and do not assume every optional field is present.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Hacker News Item and User Fields
An item carries only the fields that apply to its type. A comment has no title; a story has no parent. Here are the ones you will actually read:
| Field | Type | Meaning |
|---|---|---|
| `id` | int | Item id (always present) |
| `type` | string | `story`, `comment`, `job`, `poll`, or `pollopt` |
| `by` | string | Author username |
| `time` | int | Creation time, Unix epoch (UTC) |
| `title` | string | Title (stories and polls) |
| `url` | string | Outbound link (absent on text posts such as Ask HN) |
| `score` | int | Points (stories and polls only) |
| `descendants` | int | Total comment count (stories and polls) |
| `text` | string | Body as HTML (comments, Ask/Show text, polls) |
| `kids` | int[] | Child comment ids, in ranked display order |
| `parent` | int | Parent item (comments and poll options) |
| `dead` / `deleted` | bool | Flagged or removed markers |
Two field details save hours of confusion. First, score is the story's points, and it exists only on stories and polls. Hacker News does not expose comment scores through any public API, so if your goal is per-comment points, stop now: that data is not available. Second, time is a Unix timestamp, so convert before you compare against anything human-readable:
from datetime import datetime, timezone
posted_at = datetime.fromtimestamp(item["time"], tz=timezone.utc)
The user object is smaller and just as clean. It is genuinely public data with no email or private field anywhere in it:
| Field | Type | Meaning |
|---|---|---|
| `id` | string | Username, case-sensitive (always present) |
| `created` | int | Account creation, Unix epoch (always present) |
| `karma` | int | Public karma (always present) |
| `about` | string | Self-description as HTML (optional) |
| `submitted` | int[] | Ids of the user's stories, comments, and polls (optional) |
Fetch Stories, Points, and Users
Getting the current front page with points is a two-step job: read topstories.json for the ordered ids, then fetch each item. The order of that array mirrors the site's front-page ranking, so the index is the rank. Fetch the items concurrently, because you are making a lot of small independent requests.
from concurrent.futures import ThreadPoolExecutor
def top_stories(n=30):
ids = requests.get(f"{HN}/topstories.json", timeout=15).json()[:n]
with ThreadPoolExecutor(max_workers=20) as pool:
items = list(pool.map(hn_item, ids))
return [
{
"rank": i + 1, # topstories order = front-page rank
"id": it["id"],
"title": it.get("title"),
"points": it.get("score"), # 'score' holds the story's points
"by": it.get("by"),
"comments": it.get("descendants", 0),
"url": it.get("url"), # missing on Ask HN text posts
}
for i, it in enumerate(items) if it
]
The descendants field already holds the total comment count, so you can size a job without walking the thread. Users are a single call:
u = hn_user("pg")
print(u["id"], u["karma"], u["created"]) # created is a Unix timestamp
# u["submitted"] is a list of item ids; u["about"] is HTML; there is no email
For a full backfill you do not need search at all. maxitem.json gives you the newest id in the whole system, and every id below it is a real item you can walk downward:
# Firehose backfill: walk ids down from the newest item
newest = requests.get(f"{HN}/maxitem.json", timeout=15).json()
for item_id in range(newest, newest - 500, -1):
item = hn_item(item_id)
if item and item.get("type") == "story":
handle(item)
Walk the Comment Tree Recursively
Comments form a tree. Each item's kids array lists its direct replies by id, and each of those replies has its own kids. The array is ordered the way Hacker News displays it, so a depth-first walk over kids reproduces the thread exactly as it reads on the page. No HTML, no fragile CSS selectors.
def comment_tree(item_id, depth=0):
item = hn_item(item_id)
if not item or item.get("deleted") or item.get("dead"):
return [] # skip removed and flagged nodes
rows = []
if item.get("type") == "comment":
rows.append({
"id": item["id"],
"by": item.get("by"),
"depth": depth,
"text": item.get("text", ""), # body is HTML, not plain text
})
for kid_id in item.get("kids", []): # kids: ranked display order
rows.extend(comment_tree(kid_id, depth + 1))
return rows
story = hn_item(8863)
print(story.get("descendants"), "comments to fetch")
threads = comment_tree(8863) # story is depth 0, replies follow
The catch is request count. A busy thread with a thousand comments is a thousand Firebase calls, one per node. That is fine at low volume, but if you are pulling whole threads in bulk, the Algolia API returns the entire nested tree in a single request. That trade-off is the next section.
One thing to remember about text: it is HTML with entities and tags, not clean prose. Unescape the entities and strip the tags before you store it, or keep the raw HTML if you plan to render it later.
Search and Filter with the Algolia API
Firebase can fetch anything by id but cannot answer "every story about Postgres over 100 points." That is what the hacker news algolia api at https://hn.algolia.com/api/v1 is for. Two search endpoints matter: /search ranks results by popularity (a blend of points and comments), and /search_by_date returns them newest first.
# Ranked by popularity
curl -s "https://hn.algolia.com/api/v1/search?query=web+scraping&tags=story"
# Same query, newest first
curl -s "https://hn.algolia.com/api/v1/search_by_date?query=web+scraping&tags=story"
The real power is in tags and numericFilters. Tags restrict by object type or scope, and numeric filters slice by points, comment count, or Unix creation time. Combine them to build precise feeds:
| Parameter | Example | Effect |
|---|---|---|
| `tags` | `story`, `comment`, `poll`, `show_hn`, `ask_hn`, `front_page` | Restrict by object type |
| `tags` (author) | `author_pg` | Only items by user `pg` |
| `tags` (story) | `story_8863` | Only comments on story `8863` |
| `numericFilters` | `points>100` | Stories above 100 points |
| `numericFilters` | `created_at_i>1704067200` | Created after a Unix timestamp |
| `page` / `hitsPerPage` | `page=2`, `hitsPerPage=100` | Paging (max 1000 hits per page) |
Paging is zero-indexed, and the response tells you nbPages so you know when to stop:
def algolia_search(query, tags="story", min_points=0, pages=5):
hits = []
for page in range(pages):
params = {
"query": query,
"tags": tags,
"numericFilters": f"points>{min_points}",
"hitsPerPage": 100,
"page": page,
}
data = requests.get(
"https://hn.algolia.com/api/v1/search_by_date",
params=params, timeout=15,
).json()
hits.extend(data["hits"])
if page >= data["nbPages"] - 1:
break
return hits
# Every story about "postgres" with more than 100 points
popular = algolia_search("postgres", min_points=100)
Now the feature that makes Algolia worth using even when you are not searching. /items/ returns a story with its entire comment tree already nested under a children key. One request replaces the thousand recursive Firebase calls from the last section.
# Firebase needs one request per comment. Algolia returns the whole
# nested thread in a single call via /items/<id>
def algolia_thread(story_id):
data = requests.get(
f"https://hn.algolia.com/api/v1/items/{story_id}", timeout=20,
).json()
def walk(node, depth=0):
out = [{
"id": node["id"],
"by": node.get("author"),
"depth": depth,
"text": node.get("text"),
}]
for child in node.get("children", []):
out.extend(walk(child, depth + 1))
return out
return walk(data)
So the split is simple. Firebase is for fetch-by-id, live lists, and backfills. Algolia is for search, filters, and pulling a full scrape hacker news comments thread in one shot. Most real pipelines use both. Remember the ceiling: the Algolia API is rate limited to 10,000 requests per hour per IP, which is generous but real.
When to Fall Back to HTML
With two JSON APIs covering stories, points, comments, and users, the honest answer is that you rarely need to touch the HTML at all. There are a few exceptions:
- You want the page precisely as rendered, including the exact ordering and any moderation state the APIs smooth over.
- You need something the item schema does not carry, such as the visual flag/vouch controls or a page the APIs do not model.
- Both APIs are unavailable and you need a stopgap.
If you do hit the HTML, respect the rules. The news.ycombinator.com/robots.txt sets Crawl-delay: 30 for all bots and disallows the action endpoints (/login, /vote?, /reply?, /flag?, /hide?, /x?, and similar). The listing pages themselves are server-rendered plain HTML, so no JavaScript execution is required. The markup is old-school table rows: each story is a tr.athing, and its points sit in the following row.
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
for row in soup.select("tr.athing"):
rank = row.select_one(".rank").get_text(strip=True).rstrip(".")
link = row.select_one(".titleline > a")
subtext = row.find_next_sibling("tr").select_one(".subtext")
score = subtext.select_one(".score") # job posts have no .score
print(rank, link.get_text(), score.get_text() if score else "0 points")
That Crawl-delay: 30 is the whole reason HTML is the slow path: 30 seconds between page loads makes any volume painful. The APIs have no such delay, which is one more reason to keep HTML as the exception, not the plan.
Scrape Hacker News with the SparkProxy Scraping API
Here is the honest version most vendor guides skip: for the Firebase and Algolia APIs, you do not need a proxy at all. They are public, keyless, and un-throttled (or generously throttled), and they hand back clean JSON. Route them straight from your own code.
Proxies and a managed scraper earn their keep in two situations. The first is the HTML fallback above, where you are hitting news.ycombinator.com and would rather not manage the Crawl-delay, retries, and the occasional block from a single busy IP. The second is a large Algolia backfill where you want to distribute requests across IPs so no single address trips the 10,000-per-hour ceiling.
For the HTML case, the SparkProxy Scraping API fetches the page through a clean IP and returns the HTML. Hacker News is server-rendered, so keep render_js off and the call stays at 1 credit instead of 5.
import requests
from bs4 import BeautifulSoup
r = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"}, # key format: sk-...
params={
"url": "https://news.ycombinator.com/news",
"render_js": "false", # HN serves plain HTML -> 1 credit, no browser
"country_code": "us", # geo-target the exit IP
},
timeout=60,
)
soup = BeautifulSoup(r.text, "html.parser") # same parsing as before
rows = soup.select("tr.athing")
Authentication is the X-API-Key header carrying a key of the form sk-... from your dashboard. If you would rather have the API extract fields server-side instead of returning raw HTML, the extract_rules parameter takes CSS selectors and returns structured JSON. Deciding whether to build and babysit this yourself or route through a managed endpoint is the same trade covered in web scraping API vs self-managed proxies. For a target this light, the calculus usually favors the plain APIs, with the managed path reserved for the HTML edge cases where blocks are covered in how to avoid getting your proxy blocked.
Rate Limits and Being a Good Citizen
Hacker News is one of the friendliest scraping targets on the web, which is exactly why you should not abuse it. A few rules keep a collection job defensible and unblocked:
- Cache aggressively. The Firebase docs say there is no rate limit, but that is an invitation to be efficient, not a dare. Store items by id and refetch only what changes. The
updates.jsonendpoint lists recently changed items and profiles so you can poll deltas instead of re-crawling. - Stay under the Algolia ceiling. 10,000 requests per hour per IP is plenty for most jobs. Back off on a
429, and distribute a big backfill rather than hammering from one address. - Honor the HTML
Crawl-delay: 30. If you scrape the site directly, pace yourself and do not touch the disallowed action endpoints. - Collect public data only. Everything the APIs return is already public, including usernames and karma. That does not make it ethical to build a dossier on an individual. Aggregating one person's entire comment history to profile or target them crosses from research into surveillance, so do not.
- Honor deletions. Items marked
deletedordeadare gone for a reason. Skip them, and do not resurrect removed content from a cache.
A polite client backs off when told to. This tiny wrapper handles the only push-back you are likely to see, an Algolia 429:
import time
def polite_get(url, params=None, tries=4):
for attempt in range(tries):
r = requests.get(url, params=params, timeout=20)
if r.status_code == 429: # Algolia: 10,000/hour per IP
time.sleep(2 ** attempt)
continue
r.raise_for_status()
return r
raise RuntimeError("rate limited after retries")
Clean engineering and ethical scraping point the same way. A job that caches, paces itself, takes only public data, and respects deletions is both harder to block and easier to defend. The same discipline shows up in using proxies for market research and data collection.
Common Errors and Fixes
| Symptom | Cause | Fix |
|---|---|---|
| `hn_item()` returns `None` | Item id does not exist or was purged | Check for `None` before parsing, then skip |
| No `score` on a comment | Hacker News does not expose comment points | Only stories and polls carry `score` |
| `KeyError` on `url` | Text post (Ask HN) has no outbound link | Use `.get("url")`, fall back to the HN item page |
| `kids` empty but `descendants` > 0 | Replies are dead/collapsed, or a stale read | Re-fetch, walk the `kids` you do get, treat as best-effort |
| `429` from Algolia | Over 10,000 requests/hour from one IP | Back off, cache, or distribute across IPs |
| Garbled comment body | `text` is HTML, not plain text | Unescape entities and strip tags before storing |
| Blocked scraping the HTML front page | Ignoring `Crawl-delay`, too many hits from one IP | Honor the 30s delay, or route through the Scraping API |
Frequently asked questions
FAQ
Hacker News data is public, and both official APIs are provided for programmatic access, so reading public stories, comments, points, and profiles is generally permissible. Legality still depends on jurisdiction and use: aggregating public content for research is very different from building profiles of individuals or ignoring the stated rate rules. For commercial projects, review Y Combinator's terms with counsel.
Yes. The official hacker news api is a Firebase-backed JSON service at hacker-news.firebaseio.com/v0, run by Y Combinator. It serves items (stories, comments, jobs, polls), user profiles, and live lists such as topstories and newstories, with no key and no published rate limit.
Fetch the story with /item/ and read score for the points and descendants for the total comment count. Both fields exist only on stories and polls, and the story's points also appear as points on Algolia search hits.
No. Hacker News hides comment scores from the public, so neither the Firebase API nor the Algolia hacker news algolia api returns a per-comment point total. You can get the comment's author, text, timestamp, and position in the tree, but not its score.
For the Firebase and Algolia APIs, no. They are public, keyless, and return JSON, so you can call them directly. A proxy or a managed hacker news scraper only helps when you fall back to scraping the site's HTML or when you distribute a very large Algolia backfill across IPs.
The fastest way is the Algolia endpoint /items/, which returns the whole thread already nested under children, so one request captures the full tree. The alternative is a recursive walk of each item's kids array on the Firebase API, which reproduces the same ranked order but costs one request per comment.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

Proxy Acceptable Use Policies: What Providers Ban and Why
Proxy acceptable use policy explained: the targets, ports and account behaviours providers restrict, how violations are detected, and how to stay unsuspended.

Monthly vs Annual Proxy Plans: When Committing Pays Off
Is an annual proxy plan worth it? Break-even months for 5% to 30% term discounts, the resizing and vendor risks that erase them, and what to ask first.

Scraping API Pricing: How Credit Multipliers Set Real Cost
Scraping API pricing explained: how JS rendering, premium proxies, domain surcharges and billed failures multiply credit costs, with a worked estimate.
