Most brand monitoring tools charge for dashboards you open twice a month. If what you need is "tell me when a new article mentions us, or mentions us next to the word breach", a short script and a news search API cover it.
This post builds that script in about 60 lines of Python. It runs a few Google News queries, filters out noise, remembers what it has already shown you and prints only what is new. We ran every step on 3 November 2026 using the public Siftwright Google News Scraper Actor on Apify, which is the same engine behind our Google News API. We use "Notion" as the example brand because it is a common word, which makes it a good test of noise filtering.
What a budget monitor needs (and what it does not)
A useful press-coverage alert has four parts:
- Queries that match your brand, plus a second set for risk words such as outage, breach or lawsuit.
- Noise filtering, because brand names double as ordinary words and companies publish about themselves.
- De-duplication across runs, so you see each article once.
- A delivery step: stdout, email, Slack, whatever you already read.
What it does not do: sentiment scoring, social media, podcasts, paywalled archives or guaranteed completeness. Google News is an index of publishers it chooses to include. If you need legal-grade coverage tracking, a paid media-monitoring service is the right tool. For early warning on a startup or a side project, this is enough.
Step 1: Fetch results for a query
Google News search accepts operators such as quoted phrases, OR and when: for recency. Putting them in the query string works through the Actor too. Here is the fetch function:
import json, os, urllib.request ACTOR = "https://api.apify.com/v2/acts/siftwright~google-news-scraper/run-sync-get-dataset-items" def fetch(query, max_items=50): body = json.dumps({"query": query, "maxItems": max_items}).encode() req = urllib.request.Request(f"{ACTOR}?token={os.environ['APIFY_TOKEN']}", body, {"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=150) as r: return json.load(r) For the query "Notion" when:7d with maxItems 20, the call returned HTTP 201 and 20 records. Every record has the same seven fields:
{ "query": "\"Notion\" when:7d", "title": "ZoomInfo Launches MCP Integration With Notion Through GTM.AI", "link": "https://news.google.com/rss/articles/CBMi...?oc=5", "source": "Yahoo Finance", "publishedAt": "2026-11-03T13:30:00.000Z", "snippet": "ZoomInfo Launches MCP Integration With Notion Through GTM.AI Yahoo Finance", "status": "ok" } Two details matter for monitoring. First, link is a Google redirect URL, not the publisher's URL. It is still unique per article, which is all we need for de-duplication. Second, snippet is mostly the title plus the source name, so do not expect an article summary. If you need the full text, you fetch the publisher page yourself afterwards.
Step 2: Choose queries that find coverage, not noise
Two query sets work well together:
QUERIES = [ '"Notion" when:2d', # brand name, last 2 days '"Notion" (outage OR breach OR lawsuit) when:7d', # risk terms, last week ] Quote the brand name so Google matches the phrase. Use a longer when: window for rare risk terms, because a breach story from five days ago is still news to you if your script did not run last week.
If your brand name is a common word, add context words: "Acme" (software OR startup OR funding). If it is unique, the bare quoted name is fine. Expect to tune this list after the first run; the first results show you which kinds of noise your name attracts.
Step 3: Filter noise
Two filters removed obvious junk in our run, and you will probably add your own:
IGNORE_SOURCES = {"Notion"} # the brand's own posts are not coverage IGNORE_WORDS = ("horoscope", "giveaway") # recurring noise for your brand name The first one matters more than it looks. In our test, a result with source "Notion" was the company's own page, not press coverage. Dropping your own publications and your PR wire pages keeps the digest honest.
Also skip records where status is not ok. The Actor reports empty searches and failures as records with another status rather than raising an error, so a quiet day produces a record you can ignore instead of a crash.
Step 4: Remember what you have seen
Because the same article stays in results for days, you need state. A JSON file is enough for one machine:
import hashlib def item_id(r): # the link path is stable per article; drop the tracking query string return hashlib.sha1(r["link"].split("?")[0].encode()).hexdigest()[:16] The script stores each ID with a timestamp. On the first run it records everything as a baseline and prints no alerts, otherwise your first notification is a wall of old articles. If you move this to a server, replace the file with a small database table or a key-value store.
The complete script
import hashlib, json, os, sys, urllib.request from datetime import datetime, timezone ACTOR = "https://api.apify.com/v2/acts/siftwright~google-news-scraper/run-sync-get-dataset-items" STATE = "seen.json" QUERIES = [ '"Notion" when:2d', # brand name, last 2 days '"Notion" (outage OR breach OR lawsuit) when:7d', # risk terms ] IGNORE_SOURCES = {"Notion"} # the brand's own posts are not coverage IGNORE_WORDS = ("horoscope", "giveaway") # recurring noise for your brand name def fetch(query, max_items=50): body = json.dumps({"query": query, "maxItems": max_items}).encode() req = urllib.request.Request(f"{ACTOR}?token={os.environ['APIFY_TOKEN']}", body, {"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=150) as r: return json.load(r) def item_id(r): # links are Google redirect URLs; the path segment is stable per article return hashlib.sha1(r["link"].split("?")[0].encode()).hexdigest()[:16] def load_seen(): try: return json.load(open(STATE)) except FileNotFoundError: return {} def main(): seen = load_seen() fresh, first_run = {}, not seen for q in QUERIES: for r in fetch(q): if r.get("status") != "ok": continue if r["source"] in IGNORE_SOURCES or any(w in r["title"].lower() for w in IGNORE_WORDS): continue i = item_id(r) if i not in seen and i not in fresh: fresh[i] = r now = datetime.now(timezone.utc).isoformat(timespec="seconds") for i in fresh: seen[i] = now json.dump(seen, open(STATE, "w")) print(f"{len(fresh)} new mentions" + (" (first run: baseline)" if first_run else "")) if not first_run: for r in sorted(fresh.values(), key=lambda r: r["publishedAt"], reverse=True): print(f"- {r['publishedAt'][:16]} | {r['source']} | {r['title'][:80]}") if __name__ == "__main__": main() Run it by hand first, then schedule it.
Real output
We ran the script three times against the live Actor.
Run 1 built the baseline:
35 new mentions (first run: baseline) The two queries together produced 35 unique articles after filtering and de-duplication.
Run 2, immediately afterwards, printed:
0 new mentions Run 3 followed after we deleted two stored IDs from seen.json to simulate fresh articles. The script printed:
3 new mentions - 2026-11-03T13:33 | StreetInsider | ZoomInfo launches MCP integration with Notion for B2B data access - 2026-11-03T13:30 | Yahoo Finance | ZoomInfo Launches MCP Integration With Notion Through GTM.AI - 2026-10-29T07:00 | The Media Online | In the move-fast-and-break-things age of AI, some rules on reputation remain We removed two IDs and got three alerts. We did not trace the extra one; the most likely explanation is that the result set shifted slightly between runs, which is normal for a live news index. The simulation is crude: it proves the dedupe and the output, not how many fresh stories a real day brings. We did not run the script over several days, so we have no measured alert rate to quote. The risk-word query returned only 5 articles over seven days, all of which were already in the baseline. For a brand in that position, silence is the expected result, and that is what you want from an alarm.
Scheduling and delivery
Run the script from cron once or twice a day:
0 8,16 * * * cd /opt/brandwatch && APIFY_TOKEN=... python3 brandwatch.py >> brandwatch.log To deliver alerts somewhere you will see them, send the printed lines to a Slack incoming webhook, an email, or a ticket. Post one message per run, not one per article, so a busy day does not flood a channel. Keep the token in an environment variable or a secrets manager and never commit it.
Cost and limits
- Pay only for results. The Actor charges per result, so a narrow query with
when:2dis cheap and a broad one withmaxItems: 100is not. KeepmaxItemsnear the number of articles you expect per window. - Result cap. In our earlier RSS versus API comparison a single search topped out at 100 records, so split broad brands into several narrower queries instead of asking for more.
- Coverage. Google News does not index every publisher, and links are redirects. Verify any story that matters at the source.
- Language and region. Add
languageandcountryto the Actor input if you monitor a non-US market, one run per market. - Duplicates across outlets. The same wire story appears under many publishers. The example script treats each as separate. Group by normalised title if that bothers you.
If you run this in production, move from the Actor to the API subscription once your volume is steady: plans start at $29/month and cover the same Google News data as JSON. For a wider look at assembling feeds, see the news monitoring pipeline guide, and if you only want to try a query once, the free Google News tool needs no code.
Summary
A budget brand monitor is a loop: query, filter, de-duplicate, deliver. The script above does that with one HTTP call per query, tested against live data for the fetch, baseline and de-duplication paths. Start with two queries, read the first week of output, and tighten the noise filters before you add anything else.
