Skip to main content

Command Palette

Search for a command to run...

Monitoring OpenAI news in Python, where entity filters fail

Updated
13 min readView as Markdown
Monitoring OpenAI news in Python, where entity filters fail
A
We build APITube, a news API that returns articles from 300,000+ sources across 177 countries and 59 languages as structured JSON, with sentiment, entities and topics already attached. Posts here are measured experiments, numbers included.

We wanted to know when OpenAI ships a model, loses an executive, or gets sued, without refreshing anyone's timeline. The script that does this is about eighty lines. Getting to those eighty lines took a week of measurement, and most of what we measured contradicted the advice we started with.

A news watcher is a scheduled query against a news index plus the filtering you write yourself: deduplication, theme routing, and a noise threshold. The query is the easy part. Every vendor blog stops there. This one doesn't, because the query returned 820 articles in a week and we only wanted about fifteen a day.

The watcher runs four steps in this order, and every one of them turned out to matter:

  1. Query one day of coverage from a news index, paging until the index says there's no next page.

  2. Deduplicate by title token overlap, because the same filing reaches you from a dozen newsrooms.

  3. Route each article into a theme — launch, people, legal, outage, money — with regex over title and description.

  4. Threshold on source authority, then deliver what's left.

All numbers below come from one collection run against the APITube news API: English-language articles with "OpenAI" in the title, 30 August to 6 September 2026, 820 articles across 372 sources. Disclosure: we work on APITube. Where it fell over, we say so — most of this article is about it falling over.

The obvious approach, and why it failed for OpenAI

Every guide to company monitoring says the same thing: don't search for a keyword, filter by the company entity. Keyword search catches "Apple" the fruit; entity resolution catches Apple Inc. It's good advice. It also assumes the entity exists in the vendor's graph.

APITube has an organization.name filter. Here is what a week of coverage looks like through it:

Filter value Articles, 30 Aug – 6 Sep What came back
organization.name=Microsoft 4,123 Microsoft coverage
organization.name=Anthropic 2,080 Anthropic coverage
organization.name=Nvidia 1,770 Nvidia coverage
organization.name=Apple 4,123 includes Gooey Apple Cinnamon Rolls
organization.name=Tesla 11 Tesla coverage, almost none of it
organization.name=Meta 0 nothing
organization.name=OpenAI entity organization name 'OpenAI' not found
organization.name=Alphabet entity organization name 'Alphabet' not found
title=OpenAI 828 OpenAI coverage

OpenAI isn't in the entity graph at all. Neither is Alphabet. Meta resolves to something with zero articles, Tesla to something with eleven, and Apple returns the same count as Microsoft while serving us a cinnamon roll recipe. We also pulled every organization entity out of 195 OpenAI articles: Hugging Face, SES S.A., Microsoft, Anthropic, Gruppo API, and a person called Joseph Altman. Not one mention of OpenAI.

So the rule isn't "use entity filters" or "use keywords". It's check whether your entity exists before you design around it, which is one request:

curl -s "https://api.apitube.io/v1/companies?name=OpenAI" \
  -H "X-API-Key: $APITUBE_API_KEY"
# {"status":"ok","limit":100,"page":1,"has_next_pages":false,"results":[]}

Empty array. The autocomplete endpoint agrees — GET /v1/suggest/entities?prefix=OpenAI returns Openair Frauenfeld, a Swiss music festival. Unlike a keyword search, an entity filter fails silently when the entity is missing: you get zero rows and no error telling you the concept doesn't exist, which means a watcher built on it stays quiet forever and looks like it's working.

For OpenAI, the watcher runs on title=OpenAI and does its own filtering downstream.

What the watcher queries

curl -s -G "https://api.apitube.io/v1/news/everything" \
  -H "X-API-Key: $APITUBE_API_KEY" \
  -d title=OpenAI \
  -d language.code=en \
  -d published_at.start=2026-09-05 \
  -d published_at.end=2026-09-06 \
  -d sort.by=published_at -d sort.order=desc \
  -d per_page=100

One result, trimmed to the fields the watcher touches:

{
  "id": 3079204730,
  "title": "Seattle Times, Newsday Take OpenAI And Microsoft To Court Over Copyright Claims",
  "href": "https://www.timesnownews.com/technology-science/seattle-times-newsday-take-openai-and-microsoft-to-court-over-copyright-claims-article-156045463",
  "published_at": "2026-09-05T04:23:43.000Z",
  "source": { "domain": "timesnownews.com", "rankings": { "opr": 6 } },
  "sentiment": { "overall": { "polarity": "neutral" } },
  "story": { "id": 3079204730 },
  "is_duplicate": false
}

Two things bit us here, both worth knowing before you schedule anything.

published_at.end is exclusive. Asking for start=2026-09-06&end=2026-09-06 returns zero rows; end=2026-09-07 returns the day. A daily watcher written the obvious way skips every day it runs and never errors.

Unknown parameters are sometimes ignored in silence. entity.name, body and keyword at least come back flagged in meta.warnings, so log that array. search doesn't: search=zzqqxwv returned three cheerfully unrelated articles. Before trusting any filter, send it a nonsense value and check you get nothing back.

Deduplication is the part nobody writes about

820 articles from 372 sources over seven days, and the same story arrives four or five times. We tried four ways of collapsing them on the same dataset:

Method Duplicates caught Share of 820
The API's is_duplicate flag 0 0.0%
Grouping by story.id 8 1.0%
Exact match on normalised titles 106 12.9%
Token-set overlap ≥ 0.6 179 21.8%
Of 820 articles, the API's own is_duplicate flag caught 0 duplicates and story.id clustering caught 8, while exact title matching caught 106 and token overlap caught 179

The vendor's own duplicate flag was false on all 820 rows. Story clustering caught eight. Whatever your provider promises about deduplication, measure it on your own query before you rely on it — ours cost us one line of Python to check and would have cost us a fifth of our alert volume to skip.

Token overlap is what the watcher uses. Strip punctuation and stopwords, compare titles as sets, drop anything that overlaps an already-kept title by 60% or more:

STOP = set("a an the of in on at to for and or with by from is are was were as "
           "its it this that new says said after over into be will has have "
           "how why what when who".split())

def tokens(title):
    return {w for w in re.sub(r"[^a-z0-9 ]", " ", title.lower()).split()
            if w not in STOP and len(w) > 2}

def dedup(articles):
    kept, seen = [], []
    for a in articles:
        t = tokens(a["title"])
        if any(len(t & s) / len(t | s) >= 0.6 for s in seen if t and s):
            continue
        seen.append(t)
        kept.append(a)
    return kept

It catches syndication, not rewriting. On 5 September the copyright suit reached us as four separate alerts — "Seattle Times, Newsday sue OpenAI, Microsoft", "Seattle Times, Newsday Take OpenAI And Microsoft To Court", "US newspapers sue OpenAI, Microsoft", and "Newsday sues OpenAI and Microsoft" — because four newsrooms wrote four genuinely different sentences. Dropping the threshold to catch those starts merging unrelated stories. We kept the duplicates and moved on; embeddings would fix it, and would also turn eighty lines into a service.

Routing into launches, departures and lawsuits

Five regexes over title plus the first 400 characters of the description:

THEMES = {
    "launch": r"(launch\w*|releas\w*|unveil\w*|introduc\w*|roll\w* out|debut\w*|announc\w*|general availability|preview)",
    "people": r"(hir\w+|join\w+|depart\w+|resign\w*|step\w* down|quit\w*|fired|ousted|appoint\w*|new (ceo|cto|cfo|president))",
    "legal":  r"(lawsuit\w*|sue[sd]?\b|suing|court|judge|settl\w+|copyright|antitrust|subpoena|alleg\w+|litigation|injunction|regulat\w+|probe|investigat\w+)",
    "outage": r"(outage\w*|downtime|degrad\w+|incident\w*|status page|disruption\w*|restored|went down)",
    "money":  r"(funding|fundrais\w*|valuation|\bround\b|invest\w+|acqui\w+|\bipo\b|billion (deal|agreement)|stake)",
}
THEMES = {k: re.compile(v, re.I) for k, v in THEMES.items()}

def route(a):
    text = a["title"] + " " + (a.get("description") or "")[:400]
    return [name for name, rx in THEMES.items() if rx.search(text)]

Across the week: launch 284 articles (34.6%), legal 191 (23.3%), money 68 (8.3%), outage 62 (7.6%), people 53 (6.5%). 259 articles (31.6%) matched nothing and 90 (11.0%) matched more than one theme.

Reading the description roughly doubles recall and imports someone else's summary into your routing. Of 62 outage matches, 38 matched only in the description — including "OpenAI Says It Wants to Create a Standard for Revealing AI Alignment Meltdowns", which is a policy story that says "incidents" in its second sentence. Match people and legal across title and description, because a departure is often buried in paragraph three. Match outage on the title alone.

That 31.6% residual is not a bug to tune away. It's GPT-6 benchmark scores, congressional candidates expensing subscriptions, and agents editing a German wiki — real coverage that no keyword set anticipated. Send it as a low-priority bucket rather than dropping it.

How loud should the watcher be

Every article carries source.rankings.opr, an authority score from 0 to 8. Pick your threshold by how many alerts a day you'll actually read:

Threshold Articles kept Share Per day
opr >= 3 783 95.5% 112
opr >= 4 696 84.9% 99
opr >= 5 531 64.8% 76
opr >= 6 246 30.0% 35
opr >= 7 78 9.5% 11

Below 5 you're subscribing to the whole internet — the top ten domains are only 17.2% of volume, so the long tail is where the count lives. At 7 you get eleven a day and you're reading TechCrunch, which you already do. We run at 6.

820 raw articles fall to 641 after dedup, 177 after the opr filter and 118 after theme routing — from 117 a day down to 17

The table above applies the threshold to raw articles. In the watcher it runs after dedup, so the numbers differ: 820 raw → 641 after dedup → 177 above opr >= 6 → 118 with a theme. Seventeen alerts a day, of which the ones we'd have wanted to know about within the hour — the GPT-6 Astra launch, four newsrooms filing suit, the wiki incident — all survived.

Cost, since nobody mentions it: a day of OpenAI coverage is one to two requests at per_page=100, so a daily digest is about 500 requests a year and a fifteen-minute loop is roughly 35,000. Both fit inside a free tier on most providers. The expensive version is the one that polls every source separately.

The whole script

import os, re, sqlite3, datetime as dt
import requests

API = "https://api.apitube.io/v1/news/everything"
KEY = os.environ["APITUBE_API_KEY"]
MIN_OPR = 6

def fetch(day, query="OpenAI"):
    """One day of coverage. published_at.end is exclusive — pass the NEXT day."""
    out, page = [], 1
    while True:
        r = requests.get(API, headers={"X-API-Key": KEY}, timeout=30, params={
            "title": query,
            "language.code": "en",
            "published_at.start": day.isoformat(),
            "published_at.end": (day + dt.timedelta(days=1)).isoformat(),
            "sort.by": "published_at", "sort.order": "desc",
            "per_page": 100, "page": page,
        })
        r.raise_for_status()
        body = r.json()
        for w in (body.get("meta") or {}).get("warnings", []):
            print("API warning:", w["message"])
        out += body.get("results", [])
        if not body.get("has_next_pages"):
            return out
        page += 1

def loud_enough(a):
    return (a["source"].get("rankings", {}).get("opr") or 0) >= MIN_OPR

def main():
    db = sqlite3.connect("seen.db")
    db.execute("CREATE TABLE IF NOT EXISTS seen (id INTEGER PRIMARY KEY)")
    yesterday = dt.date.today() - dt.timedelta(days=1)

    raw = fetch(yesterday)
    fresh = [a for a in raw
             if not db.execute("SELECT 1 FROM seen WHERE id=?", (a["id"],)).fetchone()]
    digest = [a for a in dedup(fresh) if loud_enough(a) and route(a)]

    for a in digest:
        print(f"[{'/'.join(route(a))}] {a['title']}  "
              f"({a['source']['domain']}, opr {a['source']['rankings']['opr']})")

    db.executemany("INSERT OR IGNORE INTO seen VALUES (?)", [(a["id"],) for a in raw])
    db.commit()

if __name__ == "__main__":
    main()

The SQLite table exists because reprints trickle in for days and the dedup pass only sees one run at a time. Point print at a Telegram sendMessage call or an SMTP digest and you're done. Run it on cron at 08:00 for a morning digest, or every fifteen minutes if you want legal and outage fast — the freshest article in our collection was 53 minutes old when we pulled it, so a quarter-hour loop is not obviously wasteful.

Real output, 5 September:

2026-09-05: 87 articles -> 78 after dedup -> 15 alerts
[outage] OpenAI confirms 'wiki incident,' says it's 'working on a framework'  (techcrunch.com, opr 8)
[launch] OpenAI Launches GPT-6 Astra With Computer Use Tools  (dev.to, opr 7)
[legal] Newsday sues OpenAI and Microsoft over copyright infringement  (newsday.com, opr 6)
[launch/money] Nvidia inks $13 billion deal to buy the AI startup hacked by OpenAI  (egyptindependent.com, opr 6)

Swap the query, keep the pipeline

Change title=OpenAI to organization.name=Anthropic and everything downstream still works — dedup, routing and thresholds don't care where the rows came from. That's the point of doing the filtering yourself. Just re-run the entity check first, and re-measure the duplicate rate: 21.8% is what OpenAI's coverage looked like, not a constant.

Questions we had while building this

How do I monitor OpenAI news automatically?

Monitoring OpenAI news automatically means scheduling a daily query against a news index for articles with OpenAI in the title, then deduplicating, routing into themes, and thresholding on source authority before anything is delivered. The query takes ten lines; the filtering is where the work is. On our dataset that pipeline reduced 820 articles a week to 17 alerts a day.

Does OpenAI have an RSS feed?

OpenAI publishes an RSS feed at https://openai.com/news/rss.xml, free and complete for anything the company says about itself. It will never carry a lawsuit filed against OpenAI, an executive leaving, or an outage the company hasn't acknowledged yet. Subscribe to it and run a third-party watcher; they cover different things.

How do I track a company's news with Python?

Tracking a company's news in Python starts with checking that the entity exists (/v1/companies?name=...), falling back to title search if it doesn't, paging through with per_page=100, remembering what you've already sent in SQLite, and deduplicating by token overlap. That's roughly eighty lines with requests and the standard library.

How do you filter duplicate news articles?

Duplicate news articles are best filtered by comparing normalised titles as token sets and dropping anything that overlaps an already-kept title by 60% or more. On our dataset that caught 179 of 820 articles, against 106 for exact title matching and 0 for the API's own duplicate flag. Rewritten headlines still slip through; embeddings are the next step.

What's the best API for AI news?

The best API for AI news is whichever one resolves the entities you actually care about, and that is a single request to find out — don't take a vendor's entity coverage on faith, including ours. We used APITube because it exposes source authority scores and a count endpoint, which is what made the thresholds in this article measurable. APITube is one of the APIs we mentioned — there's a free tier at apitube.io.

Resources

More from this blog

A

APITube News API

2 posts

Measured experiments with news data: what a filter actually drops, what a byline field actually contains, what an API actually returns. Code and numbers included, from the team behind the APITube news API.