We wanted a small script that pings us 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 we can actually read. The first version was fifteen lines and it was wrong in a way we couldn't see from the output. So we 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. curl -s -H "X-API-Key: $APITUBE_API_KEY" \ "https://api.apitube.io/v1/news/everything?title=nvidia&per_page=2&published_at.start=2026-08-30&published_at.end=2026-09-05" Trimmed to the fields that matter, one article comes back like this: { "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 us throw away version one. We 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 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. We set it, because we can't read the rest. That's a choice, not a default. The five stages, with the numbers 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 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|results|forecast", "leadership": r"\bceo\b|jensen huang|\bcfo\b|resign|steps down|appoint", "export": r"export|sanction|china|\bban\b|restrict|licen[cs]e|blackwell|tariff", "legal": r"lawsuit|sue[sd]?\b|antitrust|probe|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, "User-Agent": "nvidia-watch/1.0", # urllib's default UA gets a 403 }) payload = json.load(urllib.request.urlopen(req, timeout=60)) for w in payload.get("meta", {}).get("warnings", []): print("WARNING", w["code"], w["message"]) # a typo'd filter is ignored, not rejected 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): words = re.sub(r"[^a-z0-9 ]", " ", (title or "").lower()).split() return " ".join(sorted(set(w for w in words if len(w) > 3))) def watch(entity_id, keyword, start, end, language="en"): seen, out = set(), [] for field in ("entity.id", "title"): # stage 1: union page = 1 while True: q = {"published_at.start": start, "published_at.end": end, "page": page, field: entity_id if field == "entity.id" else keyword} data = fetch(**q) rows = data.get("results", []) for a in rows: if a.get("language") != language: # stage 2 continue if not re.search(keyword, a.get("title") or "", re.I): # stage 3a continue if mentions(a, entity_id) < 2: # stage 3b continue key = normalise(a["title"]) if key in seen: # stage 4 continue blob = f"{a.get('title','')} {a.get('description','')}" if not THEME_RE.search(blob): # stage 5 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") print(f"\n{len(hits)} alerts over 7 days ({len(hits)/7:.1f}/day)\n") for a in sorted(hits, key=lambda x: x["published_at"]): theme = next(t for t, p in THEMES.items() if re.search(p, f"{a['title']} {a.get('description','')}", re.I)) print(f"[{theme:10s}] {a['published_at'][:10]} {a['title'][:70]}") Standard library only. Running it just now returned 153 rather than the 161 in our 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 us 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 us twenty minutes of blaming our 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: 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. 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. 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 we counted what it threw away. Disclosure: we work on APITube, which is the API in the code above — free tier at apitube.io. The measurement approach works against any news API that exposes entity ids and per-article mention counts. Resources APITube news API documentation — endpoints, parameters, response schema Google Alerts — the no-code baseline: no API, no deduplication, no entity resolution Perigon's NVIDIA monitoring guide — good on entity resolution as a concept, no code or numbers