# A week of NVIDIA news is 5,718 articles. My filter kept 161

I wanted a small script that pings me when something real happens to NVIDIA — an earnings surprise, an export-control change, a leadership move. Not a feed. A handful of lines a day I can actually read.

The first version was fifteen lines and it was wrong in a way I couldn't see from the output. So I pulled a full week of coverage three different ways and counted.

**A company news watcher is a filter chain, not a query.** Over seven days (2026-08-30 to 2026-09-05) there were **5,718 articles mentioning NVIDIA** — about 817 a day. Five filtering stages got that to **161 alerts, or 23 a day**. This post is the measurements behind each stage, and the finished script.

This is for developers who want a company news feed they can trust, and analysts who need to know what their alerts are silently dropping.

**What the week showed, in four lines:**

- Searching headlines for the company name finds **29.8%** of the coverage that mentions it.
- Entity resolution alone isn't a fix — it missed **180 articles** that had "nvidia" in the headline.
- Only **31.6%** of the coverage is in English.
- **12.5%** of what survives filtering is the same story republished under a near-identical headline.

*Measured 2026-09-06 against the APITube news API, over articles published 2026-08-30 to 2026-09-05. The pull script (`pull.py`) and the resulting counts (`data.csv`) sit alongside this post.*

## The fifteen-line version

Start with the obvious thing: search headlines for the company name.

```bash
curl -s -G "https://api.apitube.io/v1/news/everything" \
  -H "X-API-Key: $APITUBE_API_KEY" \
  --data-urlencode "title=nvidia" \
  --data-urlencode "per_page=2" \
  --data-urlencode "published_at.start=2026-08-30" \
  --data-urlencode "published_at.end=2026-09-05"
```

Trimmed to the fields that matter, one article comes back like this:

```json
{
  "id": 3078309425,
  "title": "Nvidia earnings highlight remarkable growth",
  "published_at": "2026-09-02T17:56:16.000Z",
  "language": "en",
  "source": {
    "domain": "proactiveadvisormagazine.com",
    "type": "news"
  },
  "entities": [
    {
      "id": 1280220,
      "name": "Nvidia",
      "type": "organization",
      "frequency": 3,
      "sentiment": { "score": 0.46, "polarity": "positive" },
      "metadata": {
        "aliases": ["NVIDIA", "nVidia", "NVDA"],
        "is_public_entity": true
      }
    }
  ],
  "sentiment": {
    "overall": { "score": 0.64, "polarity": "positive" }
  },
  "is_duplicate": false,
  "story": { "id": 3078309425 }
}
```

Two fields decide everything later: `entities[].frequency` (how many times the company is actually named in the piece) and `entities[].id` (the canonical company, independent of spelling).

## The headline query finds 30% of the coverage

Here is the part that made me throw away version one. I ran the same week twice — once filtering on the headline, once on the canonical entity id.

| Strategy | Unique articles, 7 days | Share of entity-matched coverage |
|---|---|---|
| `title=nvidia` | 1,831 | 29.8% |
| `entity.id=1280220` | 5,538 | 100% |
| union of both | 5,718 | — |

The headline query misses **3,890 articles** — a supplier's export licence, a hyperscaler's capex call, a competitor's benchmark. Those are the stories that move a position, and they rarely put "NVIDIA" in the headline.

The obvious fix is to drop the headline query and use entity resolution alone. That is also wrong. **180 articles had "nvidia" in the headline and no NVIDIA entity extracted at all** — 9.8% of the headline set. Named-entity recognition misses things too, especially on smaller and non-English outlets.

Neither filter is a superset of the other, so the watcher queries both and unions the results. Unlike a single-strategy filter, the union costs one extra request per window, which means you stop choosing between the two failure modes and just pay for both.

## Two thirds of it isn't in English

![Only 31.6% of NVIDIA coverage is English; German 9.4%, Spanish 7.4%, Chinese 6.4%, French 5.7%](https://cdn.hashnode.com/uploads/covers/6a9fa4994ac02a30c9ee5810/9fbf285d-029a-4f5d-bb84-1de57d951627.png)

*Language of all 5,718 articles mentioning NVIDIA over the seven days, from the `language` field on each article.*

Of those 5,718 articles, **1,806 were in English — 31.6%**. German was 9.4%, Spanish 7.4%, Chinese 6.4%, French 5.7%, Italian 4.9%, Japanese 4.2%, Portuguese 4.1%.

If you never set a language filter, you don't get a broader watcher — you get a mostly-unreadable one, and your theme keywords (written in English) silently fail against it. If you do set `language=en`, be honest that you just dropped two thirds of the world's coverage. I set it, because I can't read the rest. That's a choice, not a default.

## The five stages, with the numbers

![Five filter stages cut 5,718 articles a week to 161: English 1,806, headline plus 2+ mentions 679, dedup 594, theme match 161](https://cdn.hashnode.com/uploads/covers/6a9fa4994ac02a30c9ee5810/bb960f3b-8c10-4468-ba99-d42ef3cc2e99.png)

*Each stage applied in order to the same 7-day pull. Counts are articles, not alerts sent.*

Each stage below is one rule with one threshold, applied in order.

| Stage | Rule | Articles / 7 days | Per day |
|---|---|---|---|
| 1 | Everything mentioning NVIDIA (union of both queries) | 5,718 | 817 |
| 2 | `language == "en"` | 1,806 | 258 |
| 3 | NVIDIA in the headline **and** `frequency >= 2` | 679 | 97 |
| 4 | Near-duplicates removed | 594 | 85 |
| 5 | Matched a watch theme | 161 | 23 |

Stage 3 is the one worth explaining. Only **32.2% of entity-matched articles have NVIDIA in the headline**; the other two thirds mention it in passing — a monitor review, a fund's holdings list, an unrelated AI story name-checking the chip. Requiring the headline *and* at least two mentions in the body is a cheap proxy for "this article is about NVIDIA", and it cut 258/day to 97/day.

Stage 4 removed 12.5% of what survived stage 3. Those 924 source domains republish each other constantly — one acquisition story appeared eight times under near-identical headlines. Normalising the title to a sorted set of its long words catches these without any similarity library.

Stage 5 splits the remainder into themes, which is also the alert budget: earnings 11.4/day, leadership 10.0/day, legal 2.6/day, export controls 2.1/day. Those sum to more than 23 because one article can match two themes — an earnings call that announces a CFO change lands in both.

## The watcher

```python
import json, os, re, urllib.parse, urllib.request

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

THEMES = {
    "earnings": (
        r"earnings|revenue|quarter|guidance|\beps\b"
        r"|results|forecast"
    ),
    "leadership": (
        r"\bceo\b|jensen huang|\bcfo\b|resign"
        r"|steps down|appoint"
    ),
    "export": (
        r"export|sanction|china|\bban\b|restrict"
        r"|licen[cs]e|blackwell|tariff"
    ),
    "legal": (
        r"lawsuit|sue[sd]?\b|antitrust|probe"
        r"|investigat|settle|court"
    ),
}
THEME_RE = re.compile("|".join(THEMES.values()), re.I)

def fetch(**params):
    params.setdefault("per_page", 200)
    url = f"{API}?{urllib.parse.urlencode(params)}"
    req = urllib.request.Request(url, headers={
        "X-API-Key": KEY,
        # urllib's default UA gets a 403
        "User-Agent": "nvidia-watch/1.0",
    })
    resp = urllib.request.urlopen(req, timeout=60)
    payload = json.load(resp)
    # a typo'd filter is ignored, not rejected
    for w in payload.get("meta", {}).get("warnings", []):
        print("WARNING", w["code"], w["message"])
    return payload

def mentions(article, entity_id):
    for e in article.get("entities", []):
        if e["id"] == entity_id:
            return e.get("frequency", 0)
    return 0

def normalise(title):
    text = re.sub(r"[^a-z0-9 ]", " ", (title or "").lower())
    words = {w for w in text.split() if len(w) > 3}
    return " ".join(sorted(words))

def watch(entity_id, keyword, start, end, language="en"):
    seen, out = set(), []
    # stage 1: union of both queries
    for field, value in (("entity.id", entity_id),
                         ("title", keyword)):
        page = 1
        while True:
            q = {"published_at.start": start,
                 "published_at.end": end,
                 "page": page, field: value}
            data = fetch(**q)
            rows = data.get("results", [])
            for a in rows:
                # stage 2: language
                if a.get("language") != language:
                    continue
                # stage 3: headline and 2+ mentions
                title = a.get("title") or ""
                if not re.search(keyword, title, re.I):
                    continue
                if mentions(a, entity_id) < 2:
                    continue
                # stage 4: near-duplicate headline
                key = normalise(title)
                if key in seen:
                    continue
                # stage 5: matches a watch theme
                blob = f"{title} {a.get('description', '')}"
                if not THEME_RE.search(blob):
                    continue
                seen.add(key)
                out.append(a)
            if not rows or not data.get("has_next_pages"):
                break
            page += 1
    return out

if __name__ == "__main__":
    hits = watch(NVIDIA, "nvidia", "2026-08-30", "2026-09-05")
    n = len(hits)
    print(f"\n{n} alerts over 7 days ({n / 7:.1f}/day)\n")
    for a in sorted(hits, key=lambda x: x["published_at"]):
        blob = f"{a['title']} {a.get('description', '')}"
        theme = next(t for t, p in THEMES.items()
                     if re.search(p, blob, re.I))
        day = a["published_at"][:10]
        print(f"[{theme:10s}] {day}  {a['title'][:70]}")
```

Standard library only. Running it just now returned 153 rather than the 161 in my archived pull — the index keeps ingesting, so a re-run of a past window is close but not byte-identical. Worth knowing before you write a test that asserts an exact count.

Piping it into Telegram is four more lines and a bot token; the filtering is the part that was hard.

## Five things that cost me time

**There is no ticker filter.** `ticker=NVDA` returns HTTP 200 and quietly ignores you. NVDA lives in `entities[].metadata.aliases` alongside `is_public_entity: true`, so resolve ticker → entity id once, store the map, and filter on the id.

**`organization.name` is case-sensitive.** `organization.name=Nvidia` works. `organization.name=NVIDIA` returns HTTP 400, `ER0220`, "entity organization name 'NVIDIA' not found." The all-caps spelling most people type is the one that fails.

**Unknown parameters are warnings, not errors.** `entity.name` and `ticker` both come back 200 with `meta.warnings[].code == "ER0368"` and *unfiltered* results. A typo doesn't crash your watcher, it floods it. Print `meta.warnings` — that's the two lines in `fetch()` above.

**Headline search defaults to a 31-day window** (`ER0366`) if you don't pass `published_at.start` and `published_at.end`. Your "last 24 hours" alert is quietly a month.

**Python's default User-Agent gets a 403.** `urllib` sends `Python-urllib/3.x` and the edge rejects it. Set any real User-Agent and it works — this cost me twenty minutes of blaming my API key.

## Frequently asked questions

### How do I get news alerts for a specific company?

The reliable way to get news alerts for a specific company is a five-stage filter chain rather than a single query, because no single filter is both precise and complete. Query the company's canonical entity id, union it with a headline keyword search, then filter by language, require the company in the headline with two or more body mentions, drop near-duplicate titles, and match against theme keywords. In this measurement that chain reduced 817 articles a day to 23.

### Why do my company news alerts return irrelevant articles?

Company news alerts return irrelevant articles because a mention is not a subject. Only 32.2% of articles that mention NVIDIA put it in the headline; the rest are passing references in unrelated stories. Requiring a headline match plus `frequency >= 2` removes most of them.

### Can I filter news by ticker symbol?

No — this API has no `ticker` parameter, and passing one is silently ignored rather than rejected. The ticker appears inside `entities[].metadata.aliases`, so build your own ticker-to-entity-id table once and filter on `entity.id`.

### How do you deduplicate news articles from multiple sources?

The cheapest way to deduplicate news articles across sources is normalised-headline matching, because syndicated copies keep the same words while rewording punctuation and framing. Normalise each headline to a sorted set of its words longer than three characters and treat a repeat as a duplicate. Across 924 source domains that removed 12.5% of the surviving articles, with no similarity library.

### Is entity resolution enough on its own?

No — entity resolution alone is not enough, because entity extraction has its own recall gap. It missed 180 articles that had the company name in the headline — 9.8% of the headline-matched set. Query both ways and union the results.

## Making it yours

To point this at another company:

1. **Find the entity id.** Pull any article that mentions the company and read `entities[]` — the id, the aliases and `is_public_entity` are all in there. Store the mapping; it doesn't change.
2. **Rewrite `THEMES`.** The stage thresholds transfer between companies, the theme regexes don't — "export controls" matters for a chipmaker and means nothing for a bank.
3. **Re-measure the funnel.** Count what each stage drops for *your* company before you trust the output. A quiet mid-cap won't need stage 5 at all; a bank will need a different stage 3.

Mine only looked reasonable after I counted what it threw away.

Disclosure: I work on APITube, which is the API in the code above — free tier at [apitube.io](https://apitube.io). The measurement approach works against any news API that exposes entity ids and per-article mention counts.

## Resources

- [APITube news API documentation](https://docs.apitube.io) — endpoints, parameters, response schema
- [Google Alerts](https://www.google.com/alerts) — the no-code baseline: no API, no deduplication, no entity resolution
- [Perigon's NVIDIA monitoring guide](https://perigon.io/blog/monitor-nvidia-news) — good on entity resolution as a concept, no code or numbers

