Disclosure: I'm Ben, and I built the Apify Actor mentioned later in this post. I charge per result for it. Everything in the tutorial part works with free tools too.
If you track mentions of a brand, a competitor, a supplier or a topic, you've probably tried Google Alerts and found it hit-and-miss. There's a free, open alternative that a lot of developers haven't met: the GDELT Project.
GDELT monitors online news from sites in over 100 countries, in 65+ languages, and updates every 15 minutes. Its DOC 2.0 API lets you search roughly the last three months of articles by keyword, and filter by things Google News doesn't expose: the outlet's country, the article's language, specific domains, GDELT's themes (like CYBER_ATTACK or ECON_BANKRUPTCY) and tone. No key, no signup.
Step 1: A query
https://api.gdeltproject.org/api/v2/doc/doc?query="data breach" sourcelang:english&mode=artlist&format=json&maxrecords=250×pan=24h&sort=datedesc Operators go inside query: sourcelang:, sourcecountry:, domain:, theme:, tone<-5, near20:"a b" and more. Each article comes back with url, title, seendate, domain, language, sourcecountry and socialimage.
Step 2: Respect the rate limit (seriously)
GDELT asks clients to send at most one request every 5 seconds, and when you don't, it doesn't always send a polite 429. While I was writing this, a quick burst of test calls got this back as plain text instead of JSON:
Please limit requests to one every 5 seconds or contact kalev.leetaru5@gmail.com for larger queries. ... So a naive r.json() crashes. Here's a version that treats anything non-JSON as "busy" and backs off. It ran as-is on 25 Oct 2026:
import time, requests def gdelt(query, timespan="24h", max_records=250, tries=8): params = {"query": query, "mode": "artlist", "format": "json", "maxrecords": max_records, "timespan": timespan, "sort": "datedesc"} for attempt in range(tries): r = requests.get("https://api.gdeltproject.org/api/v2/doc/doc", params=params, timeout=60) if r.status_code == 200 and r.text.lstrip().startswith("{"): return r.json().get("articles", []) print("busy:", r.status_code, r.text[:60].replace("\n"," ")) # throttled: GDELT answers 429 or a plain-text "Please limit requests..." message time.sleep(10 * (attempt + 1)) raise RuntimeError("GDELT kept saying busy") arts = gdelt('"data breach" sourcelang:english') seen, unique = set(), [] for a in arts: key = a["title"].lower().strip() if key not in seen: # syndicated copies share a headline seen.add(key) unique.append(a) print(len(arts), "articles,", len(unique), "unique headlines") for a in unique[:3]: print(a["seendate"], a["domain"], a["sourcecountry"], "|", a["title"][:60]) Output:
9 articles, 8 unique headlines 20261025T111500Z thehindubusinessline.com India | Think . Over the week - The HinduBusinessLine 20261025T110000Z themarketsdaily.com United States | CocaCola Company ( The ) $KO Shares Purchased by Signaturefd 20261025T103000Z koreatimes.co.kr South Korea | Korean Air breach exposes blind spot in airline cybersecurit Step 3: The pitfalls (several are visible in that output)
- Matches aren't headline matches. The Coca-Cola share-purchase headline above says nothing about a breach. GDELT matched the phrase somewhere in the page. Add must-have terms, exclude terms, or a tone filter if you want tighter results.
- Low counts can be real. Only 9 English articles matched
"data breach"in that 24-hour window. GDELT is broad, but it isn't every site on the web, so don't treat one query as "all the news". - Syndication. The same wire story appears on dozens of sites with the same headline. Dedupe on a normalised title (and canonical URL), or you'll count one story many times.
- Titles are tokenised. Note
Think . Over the weekand( The ): punctuation comes back space-padded. Clean it for display. - 250 per request. For busy topics or long ranges, split the time range into windows and merge.
- Dates are
YYYYMMDDTHHMMSSZin UTC, andseendateis when GDELT saw the article, not necessarily when it was published. - Three months back. The DOC API covers roughly the last 3 months.
Step 4: Or use the packaged version
I packaged all of this as an Apify Actor, GDELT News Scraper – Global News Monitor & Search API. It waits the 5 seconds between requests, retries patiently when GDELT is busy (you aren't charged for waiting), splits large ranges into time windows to go past 250 per query, canonicalises URLs and removes syndicated duplicates before charging.
Run-ready link: open it in Apify Console with an example input already filled in ({"keywords": ["artificial intelligence"], "languages": ["english"], "maxItems": 50}). Click Start, or change the input first.
{ "keywords": ["data breach", "ransomware"], "languages": ["english"], "countries": ["US", "UK"], "timespan": "24h", "sentiment": true, "maxItems": 500 } Inputs: keywords (any), must-contain (all), exclude terms, only/exclude domains, source countries, languages, GDELT themes, a raw query box for any operator, sentiment labels (negative/neutral/positive from GDELT's tone) or min/max tone, and a timespan or exact dates. Optionally it visits each article for its meta description, author, published time and image, and up to 5,000 characters of opening text.
The default test (keyword artificial intelligence, English, last 24 hours, 50 articles) ran on 18 Oct 2026. It saved 50 articles and skipped 74 duplicate headlines, which weren't charged. Most of its 3.7 minutes were spent waiting for GDELT. A few rows:
| Seen (UTC) | Title | Outlet | Outlet country |
|---|---|---|---|
| 2026-10-18 16:45 | S. Korea growth momentum to strengthen amid chip boom: Finance minister | prokerala.com | India |
| 2026-10-18 16:45 | Aboitiz Group builds future - ready talent as AI transforms business | bworldonline.com | Philippines |
| 2026-10-18 16:45 | Bangkok Meetings Highlight Overlapping Shocks to Global Economy | english.aawsat.com | Syria |
74 duplicates for 50 unique articles gives you an idea how much syndication there is.
As a Google Alerts replacement
Schedule a run with timespan matching the interval (24h daily, 1h hourly) and send new rows to Slack, email or a sheet. Use sentiment or a max tone if you only care about negative coverage, which is the common setup for adverse-media screening.
Limits, honestly
- It's slow by design. Runs take from about 30 seconds to a few minutes, longer when GDELT is busy. Sentiment labelling runs 3 queries per window, so it's about 3× slower.
- GDELT's coverage, not Google's. It's strong on global and non-English outlets. If you want "what Google News shows for this query", a Google News tool fits better.
- About 3 months of history.
- Relevance is GDELT's. Full-text matching produces some off-topic hits (see the Coca-Cola example above).
- Not affiliated with the GDELT Project. Respect publishers' copyright if you enable text snippets.
What it costs
$0.002 per unique article ($2 per 1,000), no start fee, duplicates free. Runs stop at your "maximum cost per run". The 50-article test was 10 cents. The script above is free, and it's what I'd use for a single daily query.
What do you use for news monitoring today, and what does it miss? I'm curious whether GDELT's country and language filters matter to anyone but me, so tell me in the comments.