infini-news API Reference
Public API and web interface for searching 1.36 billion news articles (CC-News, Aug 2016 -- Apr 2026) with sub-second full-text search powered by an infini-gram-mini FM-index. The corpus spans 117 monthly shards across 11 calendar years.
Base URL: https://infini-news.uni-graz.at (currently running from a work computer)
The curl and Python examples below use https://infini-news.uni-graz.at; replace the host if you run your own gateway.
The OpenAPI schema is served at /openapi.json for tooling. Interactive Swagger UI is disabled by default because its assets would otherwise load from a third-party CDN that the gateway's Content-Security-Policy forbids; this reference and the schema cover the API surface.
Table of Contents
- Quick Start
- REST API
- Web Interface
- Full-Text Retrieval via HuggingFace
- Typical Workflow
- Rate Limits
- Error Handling
Quick Start
Count how many times a phrase appears in the corpus:
curl -X POST https://infini-news.uni-graz.at/api/v1/count \
-H 'Content-Type: application/json' \
-d '{"query": "climate change", "index": "ccnews"}'
{"count": 2345678, "latency_ms": 45.2}
REST API
All endpoints accept and return JSON (Content-Type: application/json).
CORS is enabled for all origins.
Authentication: none. The API is public and keyless — no API key, token, or login is required. (The HuggingFace dataset holding the full article text is separately gated; see Full-Text Retrieval via HuggingFace.)
Versioning: every endpoint lives under the /api/v1/ prefix. A
backward-incompatible change would ship under a new prefix (/api/v2/) rather
than altering v1 in place.
Index and year scoping
"index": "ccnews" selects the corpus (Aug 2016 -- Apr 2026, 117 monthly
shards). Behind the gateway it is one FM-index engine per year. A request may
carry an optional inclusive year range — year_min, year_max — that scopes
the search to those years' shards:
| Years searched | Cost |
|---|---|
one year (e.g. 2026) |
~4--12 shards, fast |
| omit the range | all 117 shards, slow on a cold cache (~5--15 s) |
Each shard is searched on its own thread, so a narrow range is proportionally
faster, and count/find reflect only the selected years. Results are cached
(the corpus is static), so a repeat is instant. find returns shard_years —
the year of each returned shard, index-aligned to segment_by_shard.
get_doc must repeat the same year_min/year_max it used for find, so
the shard index s resolves to the same shard. The web UI defaults the range to
the newest year (fast) and re-runs the search when you change it.
The authoritative index list is returned by GET /api/v1/info.
GET /api/v1/info
Returns corpus metadata, available indexes, and HuggingFace retrieval information.
Request:
curl https://infini-news.uni-graz.at/api/v1/info
Response:
{
"name": "infini-news",
"description": "1.36B-article news corpus (CC-News, Aug 2016-Apr 2026) with sub-second full-text search powered by the infini-gram-mini FM-index.",
"article_count": "1,357,027,742",
"date_range": "2016-08 to 2026-04",
"available_indexes": ["ccnews"],
"hf_repo_url": "https://huggingface.co/datasets/ruggsea/infini-news-corpus",
"api_docs": "/openapi.json",
"hf_retrieval": {
"repo": "ruggsea/infini-news-corpus",
"repo_url": "https://huggingface.co/datasets/ruggsea/infini-news-corpus",
"viewer_url_template": "https://huggingface.co/datasets/ruggsea/infini-news-corpus/viewer/year_{year}/train?p={page}&row={offset}",
"python_example": "...",
"note": "Dataset access may require approval. See repo page for details."
}
}
| Field | Type | Description |
|---|---|---|
name |
string | Project identifier |
description |
string | Human-readable description |
article_count |
string | Approximate article count |
date_range |
string | Temporal coverage |
available_indexes |
string[] | Valid index names for other endpoints |
hf_repo_url |
string | HuggingFace dataset URL |
api_docs |
string | Path to interactive docs |
hf_retrieval |
object | Info for fetching full articles from HuggingFace |
POST /api/v1/count
Count exact occurrences of a string in the index.
Request body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
query |
string | yes | max 10,000 chars | Search string. Empty string returns total token count. |
index |
string | yes | must match ^[a-z0-9_]+$ |
Index to search |
year_min |
int | no | 1900--2200 | Inclusive start year; omit both to search the whole corpus |
year_max |
int | no | 1900--2200 | Inclusive end year; omit both to search the whole corpus |
Example -- count phrase:
curl -X POST https://infini-news.uni-graz.at/api/v1/count \
-H 'Content-Type: application/json' \
-d '{"query": "neural network", "index": "ccnews"}'
{"count": 891234, "latency_ms": 12.5}
Example -- total tokens in index (empty query):
curl -X POST https://infini-news.uni-graz.at/api/v1/count \
-H 'Content-Type: application/json' \
-d '{"query": "", "index": "ccnews"}'
{"count": 98000000000, "latency_ms": 0.1}
Response:
| Field | Type | Description |
|---|---|---|
count |
int | Number of occurrences |
latency_ms |
float | Backend query time in milliseconds |
POST /api/v1/find
Find all occurrence segments across shards. Returns ranges that can be used to retrieve individual documents with get_doc.
Request body:
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
query |
string | yes | min 1, max 10,000 chars | Search string (non-empty) |
index |
string | yes | must match ^[a-z0-9_]+$ |
Index to search |
year_min |
int | no | 1900--2200 | Inclusive start year; omit both to search the whole corpus |
year_max |
int | no | 1900--2200 | Inclusive end year; omit both to search the whole corpus |
Example:
curl -X POST https://infini-news.uni-graz.at/api/v1/find \
-H 'Content-Type: application/json' \
-d '{"query": "climate change", "index": "ccnews"}'
{
"count": 2345678,
"segment_by_shard": [
[0, 50000],
[0, 48000],
[0, 62000]
],
"shard_years": ["2016", "2016", "2016"],
"latency_ms": 125.5
}
segment_by_shard and shard_years have one entry per shard (117 for the
shipped corpus; truncated to three above).
Response:
| Field | Type | Description |
|---|---|---|
count |
int | Total occurrences across the whole corpus |
segment_by_shard |
int[][] | Per-shard [start_rank, end_rank) ranges, one per shard. A shard with no matches has start == end; entries are never dropped or reordered, so the array index is a stable shard index s. |
shard_years |
string[] | Four-digit year of each shard, aligned to segment_by_shard. Filter on this to scope retrieval to a year range. |
latency_ms |
float | Backend query time in milliseconds |
Each entry in segment_by_shard is a two-element array [start, end). The array index is the shard index s, and shard_years[s] is that shard's year. Any rank in [start, end) can be passed to get_doc to retrieve a document containing the match.
POST /api/v1/get_doc
Retrieve a document snippet by shard index and rank. Returns the text around the matched occurrence, structured metadata, and highlighted spans.
Request body:
| Field | Type | Required | Default | Constraints | Description |
|---|---|---|---|---|---|
query |
string | yes | max 10,000 chars | Original search string | |
index |
string | yes | must match ^[a-z0-9_]+$ |
Index name | |
year_min |
int | no | 1900--2200 | Must match the find call's range so s resolves correctly |
|
year_max |
int | no | 1900--2200 | Must match the find call's range so s resolves correctly |
|
s |
int | yes | >= 0 | Shard index (position in segment_by_shard) |
|
rank |
int | yes | >= 0 | Position within the shard's range | |
max_ctx_len |
int | no | 1000 | 1--10,000 | Maximum context characters around the match |
Example:
curl -X POST https://infini-news.uni-graz.at/api/v1/get_doc \
-H 'Content-Type: application/json' \
-d '{
"query": "climate change",
"index": "ccnews",
"s": 0,
"rank": 100,
"max_ctx_len": 1000
}'
{
"doc_ix": 42,
"doc_len": 15000,
"disp_len": 1000,
"needle_offset": 500,
"text": "...extensive measures to combat climate change across the region...",
"spans": [
["...extensive measures to combat ", null],
["climate change", "0"],
[" across the region...", null]
],
"metadata": {
"url": "https://www.example.com/article/climate-2022",
"date": "2022-01-15",
"warc_source": "CC-NEWS-20220115...",
"language": "eng",
"country_tld": null,
"title": "Climate Measures Announced",
"author": "Jane Doe",
"sitename": "Example News",
"hostname": "www.example.com"
},
"latency_ms": 200.1
}
Response:
| Field | Type | Description |
|---|---|---|
doc_ix |
int | Row offset of the document within its crawl year's HuggingFace config (year_YYYY); with find's shard_years[s] it addresses the article on HuggingFace |
doc_len |
int | Full document length (bytes) |
disp_len |
int | Length of the returned text snippet |
needle_offset |
int | Byte offset of the match within the snippet |
text |
string | Document text snippet (up to max_ctx_len characters) |
spans |
array | Text split into [fragment, label] pairs for highlighting |
metadata |
object | Structured article metadata (see below) |
latency_ms |
float | Backend query time in milliseconds |
Spans format: Each element is [text, label]. If label is null, the text is plain context. If label is "0", the text is a highlighted match. Concatenating all text values reconstructs the full snippet.
Metadata fields (all nullable):
| Field | Type | Description |
|---|---|---|
url |
string | Original article URL |
date |
string | Publication date (YYYY-MM-DD), falling back to the crawl timestamp |
warc_source |
string | WARC archive filename |
language |
string | ISO 639-3 language code (e.g. eng, deu) |
country_tld |
string | Lowercase 2-letter country-code TLD (ccTLD), or null |
title |
string | Article title |
author |
string | Author name |
sitename |
string | Publication/site name |
hostname |
string | URL hostname |
Note on country_tld: This is the article URL's country-code top-level domain (ccTLD), lowercased, when it has one — e.g. .de → de, .at → at. Generic TLDs (.com, .org) and anything that is not exactly two ASCII letters yield null. There is no TLD-to-country mapping: .uk stays uk, it is not converted to gb.
Note on escaping: Metadata string values (title, url, sitename, …) are returned verbatim from the source document and are not sanitized. A client that renders them as HTML must escape them — and validate url against an http/https allowlist — to avoid injection. The bundled web UI does both.
Note on decoding errors: Some documents may fail to decode at certain max_ctx_len values. If you get a 502 with "Document decoding failed", try a different max_ctx_len.
Web Interface
The web UI is served at the root URL (/). It provides all search functionality in a single page with no dependencies or login required.
Search
- Type a query in the search field.
- Press Enter or click Search to find documents.
- Press Shift+Enter or click Count to count occurrences without loading documents.
Search runs across the whole corpus (all years) — there is no index to pick.
After searching, the interface shows:
- Occurrence count across all years.
- Years range selector — narrows the displayed and collected documents to a year range; the count line then also reports how many occurrences fall in that range.
- Document cards with highlighted text, metadata (title, date, language, site), and links.
- Load more documents button to fetch additional random samples.
Document Cards
Each card displays:
- Title (or URL if no title is available).
- Metadata: date, site name, language, country TLD.
- Text snippet with the search query highlighted in context.
- Source link to the original article URL.
- View on HF link that opens the HuggingFace dataset viewer at the article's exact row (its
doc_ixoffset within the year config).
Get the Full Articles Panel
After a search returns results, a collapsible "Get the full articles" panel opens in the count bar. It leads with a ready-to-run Python | curl snippet (pre-filled with the current query and year range) that fetches every matching article from the dataset by doc_ix offset — the Python version writes them to a CSV — plus a link to open the corpus on HuggingFace. A Copy button copies the active snippet.
Preview a sample (optional) -- expand this disclosure to sample matching articles in the browser:
- Set Sample size (default 50) -- how many evenly-spaced samples are taken per shard.
- Click Load preview. A progress bar tracks fetching; results are deduplicated by URL and shown in a table.
- Optionally filter the table by Language, then Download CSV of this sample.
Full-Text Retrieval via HuggingFace
The API returns document snippets (up to 10,000 characters of context). Full article texts are stored in the HuggingFace dataset ruggsea/infini-news-corpus.
The dataset may be gated (require access approval). See the repo page for details.
Finding a Single Article
get_doc returns doc_ix — the article's row offset within its crawl year's HuggingFace config (year_{year}, where year is find's shard_years[s]). That addresses the exact row, so you fetch it directly from the datasets-server with no parquet scan:
import os
import requests
# year from find()'s shard_years[s]; offset from get_doc()'s doc_ix
year, offset = 2022, 12345
row = requests.get(
"https://datasets-server.huggingface.co/rows",
params={"dataset": "ruggsea/infini-news-corpus", "config": f"year_{year}",
"split": "train", "offset": offset, "length": 1},
headers={"Authorization": f"Bearer {os.environ['HF_TOKEN']}"}, # the corpus is gated
).json()["rows"][0]["row"]
print(row["title"], "—", row["url"])
print(row["text"][:500])
Or open it directly in the dataset viewer in your browser (p is offset // 100):
https://huggingface.co/datasets/ruggsea/infini-news-corpus/viewer/year_2022/train?p=123&row=12345
Bulk Retrieval (Python)
import os
import sys
import time
import requests
API = "https://infini-news.uni-graz.at"
QUERY = "climate change"
INDEX = "ccnews" # the whole corpus, all years
ROWS = "https://datasets-server.huggingface.co/rows"
DATASET = "ruggsea/infini-news-corpus"
HF_TOKEN = os.environ.get("HF_TOKEN") # required: the corpus is gated
HF_HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
# 1. Find all occurrence segments; shard_years[s] is shard s's crawl year (its HF config).
resp = requests.post(f"{API}/api/v1/find", json={"query": QUERY, "index": INDEX})
resp.raise_for_status()
find = resp.json()
print(f"Found {find['count']:,} occurrences")
years = find.get("shard_years", [])
# 2. For each match, get_doc -> doc_ix (the row offset), then fetch the row directly by
# offset. No parquet scan. Rate limit: 120 get_doc requests/minute — back off on 429.
max_per_shard = 50
seen = set()
for s, seg in enumerate(find["segment_by_shard"]):
if len(seg) != 2 or seg[1] <= seg[0] or s >= len(years):
continue
year = years[s]
step = max(1, (seg[1] - seg[0]) // max_per_shard)
for rank in range(seg[0], seg[1], step):
try:
r = requests.post(f"{API}/api/v1/get_doc", json={
"query": QUERY, "index": INDEX, "s": s, "rank": rank, "max_ctx_len": 100,
})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 5)))
continue
r.raise_for_status()
offset = r.json()["doc_ix"]
except requests.RequestException as e:
print(f"skipping: {e}", file=sys.stderr)
continue
if (year, offset) in seen:
continue
seen.add((year, offset))
rr = requests.get(ROWS, params={
"dataset": DATASET, "config": f"year_{year}", "split": "train",
"offset": offset, "length": 1,
}, headers=HF_HEADERS)
rr.raise_for_status()
row = rr.json()["rows"][0]["row"]
print(f"\n--- {row.get('url')} ---")
print((row.get("text") or "")[:500])
print(f"\nFetched {len(seen)} unique articles")
Typical Workflow
1. Count (optional)
Check how common a phrase is before committing to a full search:
curl -s -X POST $API/api/v1/count \
-H 'Content-Type: application/json' \
-d '{"query": "Graz", "index": "ccnews"}' | python3 -m json.tool
2. Find
Get shard segments:
FIND=$(curl -s -X POST $API/api/v1/find \
-H 'Content-Type: application/json' \
-d '{"query": "Graz", "index": "ccnews"}')
echo "$FIND" | python3 -m json.tool
3. Retrieve Documents
Pick a shard and rank from the segment_by_shard ranges:
curl -s -X POST $API/api/v1/get_doc \
-H 'Content-Type: application/json' \
-d '{"query": "Graz", "index": "ccnews", "s": 0, "rank": 500, "max_ctx_len": 2000}' \
| python3 -m json.tool
4. Get Full Article from HuggingFace
Use the url and date from the metadata to fetch the complete article text from the HuggingFace dataset. See Full-Text Retrieval via HuggingFace.
Rate Limits
Rate limiting is per-IP (using the X-Real-IP header behind nginx, falling back to the connection IP).
| Endpoint | Limit |
|---|---|
POST /api/v1/count |
60 requests/minute |
POST /api/v1/find |
60 requests/minute |
POST /api/v1/get_doc |
120 requests/minute |
GET /api/v1/info |
No limit |
Concurrent requests per IP (count, find, get_doc combined) |
2 in flight |
Exceeding either limit returns HTTP 429 with a Retry-After header. The
concurrency cap exists because the per-minute limits do not bound parallelism:
a client with many workers can stay under them and still occupy every backend
slot, so everyone else waits and gets 503s. Serial clients never hit it.
Error Handling
All errors return a JSON object with a detail field (for HTTP errors) or an error field (for validation errors).
| Status | Meaning | Common Causes |
|---|---|---|
| 400 | Bad Request | Invalid index name, missing fields, validation failure |
| 422 | Unprocessable Entity | Pydantic validation error (wrong types, out of range) |
| 429 | Too Many Requests | Rate limit exceeded, or more than 2 requests in flight from one IP |
| 502 | Bad Gateway | Backend unavailable or document decoding failed |
| 504 | Gateway Timeout | Backend did not respond within 30 seconds |
Example error responses:
{"detail": "Invalid index. Available: [...full list returned at runtime...]"}
{"detail": "Backend unavailable"}
{"detail": "Document decoding failed. Try a different max_ctx_len value."}
For 422 errors, the response body contains a detail array with per-field validation messages:
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "query"],
"msg": "String should have at least 1 character",
"input": ""
}
]
}