Back to blog

August 11, 2026 · GNews Team

AI Sorted News Channels: Build Topic Routing with the GNews API (Python, JS, cURL)

AI Sorted News Channels: Build Topic Routing with the GNews API (Python, JS, cURL)

AI sorted news channels are topic feeds where every incoming article is routed automatically to the right channel instead of being filed by hand. You build them in two layers: a news API supplies one normalized article stream, and a classification step on top decides which channel each article belongs in. This tutorial builds both with the GNews API in Python, JavaScript, and cURL, using top-headlines for the channels GNews sorts server-side and search for the ones you define. Be clear about the split first: GNews returns articles, not topic labels, so the routing logic is code you own.

Two-layer pipeline for AI sorted news channels: the GNews API streams normalized article cards into a routing node that fans them out into three separate topic channel columns

What AI sorted news channels are (and which half GNews handles)

A channel is a bucket with a rule attached. A dashboard with tabs for Technology, Science and Everything Else is three channels. The shape never changes: one stream in, N topic buckets out, a routing decision in the middle. Here is where the line falls:

Building AI sorted news channels means running two layers, not one. The retrieval layer is a news API: it crawls publishers, deduplicates and normalizes their output, and hands you article objects with title, description, content, url, image, publishedAt and source. GNews is that layer, and it also does cheap pre-sorting for you through category on top-headlines and through query operators on search. The sorting layer is yours. It reads the text the API returned and assigns a channel, using keyword rules, embedding similarity, or a single LLM call, depending on how much ambiguity you actually have. No news API returns a predicted topic label per article, GNews included. What the API buys you is that the classification layer stays small: you are scoring clean text instead of writing crawlers, and you can push a large share of the filtering into the request itself so your model never sees the articles you already know you do not want.

Every request below was executed against the live API on 2026-08-04, and every count, headline and source name quoted comes from those stored responses.

What you'll build

A channel router in four parts: pull a raw unsorted stream, take the channels GNews sorts server-side, express a channel no category covers as a query, then route each article into technology, science or other.

Prerequisites

  • A free GNews API key: create an account at gnews.io and copy it from your dashboard.
  • Python 3 with requests, or Node.js 18+. The cURL calls need no runtime.
  • No ML dependency. Routing runs on keyword rules; Step 4 shows where a model plugs in.

The search versus top-headlines basics are assumed here; both are documented at docs.gnews.io.

Step 1: Pull the raw article stream

Start from the unsorted feed, because that is what your router has to survive. top-headlines with category=general applies no topic filtering.

Python

import requests

API_KEY = "YOUR_API_KEY"
params = {
    "category": "general",
    "lang": "en",
    "country": "us",
    "max": 10,
    "apikey": API_KEY,
}
data = requests.get("https://gnews.io/api/v4/top-headlines", params=params).json()
for a in data["articles"]:
    print(f'{a["publishedAt"]}  {a["source"]["name"]}: {a["title"]}')

JavaScript

const API_KEY = "YOUR_API_KEY";
const params = new URLSearchParams({
  category: "general",
  lang: "en",
  country: "us",
  max: "10",
  apikey: API_KEY,
});
const data = await fetch(`https://gnews.io/api/v4/top-headlines?${params}`).then((r) => r.json());
for (const a of data.articles) {
  console.log(`${a.publishedAt}  ${a.source.name}: ${a.title}`);
}

cURL

curl "https://gnews.io/api/v4/top-headlines?category=general&lang=en&country=us&max=10&apikey=YOUR_API_KEY"

That request reported totalArticles: 462992 and returned 10 of them. The printed output:

2026-08-04T03:36:50Z  nbcsports.com: Phillies land Luis Arraez in five-player trade
2026-08-04T03:26:00Z  NBC Sports: Report: Red Sox acquire Adley Rutschman from O’s
2026-08-04T03:00:00Z  The Independent: Ariana Grande to take break from public life amid ‘endless’ scrutiny about her health
2026-08-04T02:19:51Z  The Times of Israel: Board of Peace appears to shift terms for IDF pullback after meeting PM; Gaza airstrike reported
2026-08-04T01:57:44Z  AP News: Judge blocks New York law banning federal agents wearing masks
2026-08-04T01:55:27Z  The New York Times: Drone Explodes on Russian Beach, Killing 7, Officials Say
2026-08-04T01:01:00Z  Yahoo: Powerball jackpot hits $748M. Check Monday's winning numbers
2026-08-04T00:56:00Z  kcra.com: Stockton-based In-N-Out manager identified as Idaho mass shooting victim
2026-08-04T00:50:00Z  CNN: Cases of rare tick-borne ‘rabbit fever’ suspected in New York
2026-08-04T00:12:33Z  NPR: House Ethics panel recommends censure for Rep. Chuck Edwards : NPR

Ten articles, ten unrelated subjects: baseball trades, a music story, Gaza, a court ruling, a drone strike, a lottery draw, a shooting, an infection, an ethics report. Everything downstream is about narrowing that raw feed.

Step 2: Use the channels GNews already sorts for you

Spend the free sorting first. category on top-headlines is a pre-sorted channel for one parameter, accepting general, world, nation, business, technology, entertainment, sports, science and health (docs.gnews.io). Filtering the news API by category costs no classifier work.

curl "https://gnews.io/api/v4/top-headlines?category=technology&lang=en&country=us&max=10&apikey=YOUR_API_KEY"

Same shape in Python, one key changed:

params = {
    "category": "technology",
    "lang": "en",
    "country": "us",
    "max": 10,
    "apikey": API_KEY,
}
data = requests.get("https://gnews.io/api/v4/top-headlines", params=params).json()

That call returned totalArticles: 197604 and 10 articles. Check where they came from before trusting the label: five came from games outlets (nintendolife.com, Game Informer, Kotaku twice, Polygon.com) and one from the car site Jalopnik.

That breadth is where the taxonomy stops. Nine values cover the whole world of news, so anything narrower than "technology" (an AI channel, a semiconductors channel, one open source project) is either a query (Step 3) or a classifier decision (Step 4).

One request per channel, not one feed filtered client-side

Issue one request per channel rather than filtering a large general feed in your app. Filtering discards work GNews already did and reintroduces classification error. Each channel also gets its own max.

Step 3: Define a custom channel as a query

Here the channel definition is the query. Quote multi-word terms so they match as phrases, widen with OR, pin the match with in=title,description, and add sortby=publishedAt so the channel reads as realtime news, newest first, instead of a relevance ranking.

Python

params = {
    "q": '"artificial intelligence" OR "machine learning"',
    "in": "title,description",
    "lang": "en",
    "country": "us",
    "sortby": "publishedAt",
    "max": 10,
    "apikey": API_KEY,
}
data = requests.get("https://gnews.io/api/v4/search", params=params).json()
print(data["totalArticles"], len(data["articles"]))

JavaScript

const params = new URLSearchParams({
  q: '"artificial intelligence" OR "machine learning"',
  in: "title,description",
  lang: "en",
  country: "us",
  sortby: "publishedAt",
  max: "10",
  apikey: API_KEY,
});
const data = await fetch(`https://gnews.io/api/v4/search?${params}`).then((r) => r.json());

cURL

curl "https://gnews.io/api/v4/search?q=%22artificial%20intelligence%22%20OR%20%22machine%20learning%22&in=title,description&lang=en&country=us&sortby=publishedAt&max=10&apikey=YOUR_API_KEY"

This returned totalArticles: 24108 and the 10 newest, from 2026-08-04T03:00:00Z down to 2026-08-03T18:39:07Z: a working AI channel with no classifier attached. The batch included "Anthropic, OpenAI Face New EU AI Crackdown as Regulators Gain Enforcement Power" (Benzinga).

Trim a channel with in, from and NOT

Three parameters bound a noisy channel. Same window, same query, three variants, each with max=25 so the narrow ones return their entire result set rather than a first page:

# 1. default field scope (title and description), 7-day window
curl "https://gnews.io/api/v4/search?q=%22electric%20vehicle%22&lang=en&from=2026-07-28T00:00:00Z&sortby=publishedAt&max=25&apikey=YOUR_API_KEY"

# 2. same, restricted to the headline
curl "https://gnews.io/api/v4/search?q=%22electric%20vehicle%22&in=title&lang=en&from=2026-07-28T00:00:00Z&sortby=publishedAt&max=25&apikey=YOUR_API_KEY"

# 3. same, plus an exclusion
curl "https://gnews.io/api/v4/search?q=%22electric%20vehicle%22%20NOT%20sports&in=title&lang=en&from=2026-07-28T00:00:00Z&sortby=publishedAt&max=25&apikey=YOUR_API_KEY"

Variant 1 reported totalArticles: 119, variant 2 reported 20, variant 3 also 20. So in=title did the cutting, taking the channel from 119 candidates to 20 by demanding the topic be the headline rather than a passing mention in the summary. Variants 2 and 3 each returned all 20 of their articles, and the two sets are identical, so NOT sports excluded nothing: not one of those 20 headlines contains the word "sports". Keep the operator for channels that genuinely collide with another meaning, and check it against a control run instead of assuming it fired. Every article the request excludes is one your classifier never has to score.

Step 4: Add the sorting layer on top

Now the part GNews does not do. The router reads title plus description from each Step 1 article, scores it against per-channel keyword rules, and assigns the best-scoring channel, or other.

import re

CHANNELS = {
    "technology": ["ai", "artificial intelligence", "software", "chip", "app",
                   "cyberattack", "hackers", "microsoft", "google", "android",
                   "windows", "startup", "gadget", "robot", "drone"],
    "science": ["study", "research", "researchers", "nasa", "climate", "space",
                "disease", "virus", "vaccine", "infection", "bacteria", "cdc"],
}

def score(text, keywords):
    return sum(1 for kw in keywords
               if re.search(rf"\b{re.escape(kw)}\b", text))

def route(article):
    text = f"{article['title']} {article.get('description') or ''}".lower()
    scores = {name: score(text, kws) for name, kws in CHANNELS.items()}
    best = max(scores, key=scores.get)
    return best if scores[best] > 0 else "other"

channels = {name: [] for name in list(CHANNELS) + ["other"]}
for a in data["articles"]:
    channels[route(a)].append(a)

Note the \b word boundaries. Substring matching is the first bug everyone ships: a rule like ai matches inside "Gair" and "airstrike". One regex removes that error class.

Run it over the ten Step 1 articles: technology gets 1, science gets 0, other gets 9. The single technology article is "Drone Explodes on Russian Beach, Killing 7, Officials Say" (The New York Times), matched on drone: a war casualty story in the wrong channel. Both results are the lesson. A raw general feed is mostly not your channels, and a rule that reads well in a config file still misfires on real headlines.

Rules first, model second

The escalation pattern keeps classification cheap without leaving the ambiguous cases unhandled. Run deterministic keyword rules first: they are free, instant, auditable, and on a pre-filtered channel feed they resolve the obvious majority. Then define what "unsure" means numerically, for example no channel scored above zero, or the top two channels tied, or the winning score sits below a threshold you picked. Only that remainder escalates. The cheaper escalation is embedding similarity: embed each channel description once, embed the article's title plus description, take the nearest channel above a cosine floor. The more capable one is a single LLM call that gets the title, the description, the fixed list of channel names, and an instruction to return exactly one of them or other. Two rules make this safe in production: constrain the model to your enumerated labels so it cannot invent a channel, and cache the decision keyed on the article id so a retry or a second channel poll never pays for the same classification twice.

def classify(article):
    channel = route(article)          # cheap deterministic pass
    if channel != "other":
        return channel
    # escalate only the leftovers:
    #   return embed_nearest_channel(article) or
    #   return llm_label(article, labels=list(CHANNELS) + ["other"])
    return "other"

Dedup and ordering inside a channel

The same story reaches you more than once, from syndication and overlapping channels. Key on url, then sort each channel by publishedAt.

def dedupe(articles):
    seen, out = set(), []
    for a in sorted(articles, key=lambda x: x["publishedAt"], reverse=True):
        if a["url"] in seen:
            continue
        seen.add(a["url"])
        out.append(a)
    return out

Be honest about what url deduplication catches: the same article arriving through two of your channels. It does not catch syndication. All 10 articles in the Step 3 response had distinct url and distinct id values, so keying on url removed nothing. Yet The Atlanta Journal-Constitution, SFGATE and WDIV ClickOnDetroit carried the identical headline "Japan says combat drones key to adapting to new warfare as tension rises in the region". A second key on the full normalized title (lowercased, punctuation stripped) takes that batch from 10 to 8, dropping the SFGATE and WDIV copies.

What survives shows the limit. SFGATE returned the same Sam Altman story twice, as "OpenAI CEO goes viral for more strange ChatGPT parenting advice" and "OpenAI CEO goes viral for strange ChatGPT parenting advice". One word apart, so a full-title key calls them distinct and keeps both. A five-word prefix catches that pair, giving 7, at the risk of merging stories that only share an opening. Title keys reduce, they do not solve.

Example response

The real JSON from the Step 1 call, the shape your router consumes:

{
  "totalArticles": 462992,
  "articles": [
    {
      "id": "c500fb4a401347b0c96e97781d159097",
      "title": "Phillies land Luis Arraez in five-player trade",
      "description": "Phillies acquired 2B Luis Arraez and RHP Caleb Kilian from the Giants for Ramon Marquez and RHP Marty Gair.",
      "url": "https://www.nbcsports.com/fantasy/baseball/player-news/2026-08-03/phillies-land-luis-arraez-in-five-player-trade",
      "publishedAt": "2026-08-04T03:36:50Z",
      "lang": "en",
      "source": {
        "id": "dc8f73f077072c35626eba314ee483bd",
        "name": "nbcsports.com",
        "url": "https://www.nbcsports.com"
      }
    }
  ]
}

Trim note: this is the first of the ten objects in articles. Two fields were removed from it, content and image, and the nine other article objects were removed. Nothing else was altered.

From the router's point of view: title and description are the text you classify and render, publishedAt the sort key inside a channel, url the deduplication key and outbound link, source.name the attribution, id the cache key for a classification you do not want to pay for twice. No topic, label, score or channel field exists in the schema: the two-layer split, restated in JSON.

FAQ

Does the GNews API sort news with AI?

No, and the distinction matters when you size the work. GNews is the retrieval layer: it discovers, deduplicates and normalizes articles from publishers worldwide and returns them as JSON with title, description, content, url, image, publishedAt and source. It offers server-side pre-sorting through the category parameter on top-headlines and through query operators on search, so a large part of your filtering can happen before any article reaches your code. What it does not return is a predicted topic label per article. Semantic classification, ranking articles into your own channel taxonomy, and any personalization on top are the sorting layer, and that layer is yours to write. The practical upside is that the layer stays small, because you are scoring clean normalized text rather than maintaining crawlers and publisher parsers.

How do I build a news channel for a topic that has no category?

Express it as a search query: quote multi-word terms, widen with OR, add in=title,description. Step 3 shows the executed request, q="artificial intelligence" OR "machine learning" with sortby=publishedAt.

Which news categories does the API sort automatically?

top-headlines accepts general, world, nation, business, technology, entertainment, sports, science and health. Those nine are the whole built-in taxonomy; anything narrower has to be a query or a classifier decision. See docs.gnews.io.

Do I need an LLM to classify articles into channels?

No. Keyword rules over title plus description handle routing on a feed already pre-filtered with category or a query, and cost nothing per article. Escalate to embeddings or a single LLM call only for the ambiguous remainder, as in Step 4.

How do I keep the same story from showing up in several channels?

Deduplicate on url before rendering, since the same article commonly matches two channel queries. Whether a channel is exclusive or overlapping decides if you dedupe globally or per channel. Syndicated copies carry different url values and need a second key, such as the normalized title (Step 4).

Next steps

You now have the whole pattern: a raw ingestion stream, free pre-sorted channels from category, custom channels expressed as queries, and a routing layer with deduplication and an escalation path to a model. Extend it one channel at a time and watch what lands in other, the cheapest signal about where your taxonomy is wrong. Ready to build? Start building for free.