Google News results are useful when you want to monitor what is changing on the web.
You can track:
brand mentions
competitor news
industry trends
product launches
market signals
public opinion changes
new articles about a topic
You can do that manually.
You can also manually refresh Google News every morning like the internet is a punishment ritual.
A better approach is to collect Google News results programmatically, save snapshots, and compare what changed over time.
In this tutorial, we will build a Python workflow that uses Talordata SERP API to track Google News results.
The workflow looks like this:
topics.csv
→ Talordata SERP API
→ Google News results
→ clean article data
→ save daily snapshot
→ compare snapshots
→ detect new articles
This is useful for:
brand monitoring
competitor monitoring
PR tracking
market research
news intelligence
AI research assistants
trend detection
What we are building
We will build two scripts.
The first script collects Google News results and saves them to CSV.
The second script compares the latest snapshot with the previous one and detects new articles.
The final output will look like this:
snapshot_date,keyword,location,title,source,article_url,domain,published_date,snippet
2026-01-01,AI search tools,United States,Example article title,Example News,https://example.com/article,example.com,2 hours ago,Example snippet...
Then the comparison file will show:
keyword,location,title,source,article_url,change_type
AI search tools,United States,Example article title,Example News,https://example.com/article,new_article
Simple.
Useful.
Suspiciously rare.
Why use a SERP API for Google News?
Google News results are dynamic.
They change by:
query
location
language
freshness
publisher
time
search settings
A SERP API lets you request structured search data instead of building and maintaining your own scraper.
For a Google News tracking workflow, you usually want fields like:
title
URL
source
published date
snippet
thumbnail
position
Different API providers may return slightly different field names, so the parser in this tutorial is defensive.
The goal is to keep the workflow adaptable.
Install dependencies
Create a new project folder and install the packages:
pip install requests python-dotenv pandas
We will use:
requests → call Talordata SERP API
python-dotenv → load API keys
pandas → read and write CSV files
Create a .env file
Create a .env file:
TALORDATA_API_KEY=your_api_key
TALORDATA_SERP_ENDPOINT=https://your-talordata-serp-api-endpoint.example.com/search
Use the endpoint and parameter names from your Talordata dashboard or API docs.
The code below keeps the API request logic in one function, so if your endpoint or parameters are slightly different, you only need to edit one place.
That is not laziness. That is maintenance trying to survive.
Create the topics file
Create a file called topics.csv.
keyword,location,language
AI search tools,United States,en
SERP API,United States,en
OpenAI competitors,United States,en
Google AI search,United States,en
local SEO tools,United States,en
Each row is one Google News search.
Start small.
Five topics are enough to test the workflow.
Once the output looks clean, you can add more keywords, locations, or languages.
Step 1: Create the news tracker script
Create a file:
track_google_news.py
Add imports and settings:
import os
import re
import time
import hashlib
import requests
import pandas as pd
from datetime import date
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
from dotenv import load_dotenv
load_dotenv()
TALORDATA_API_KEY = os.getenv("TALORDATA_API_KEY")
TALORDATA_SERP_ENDPOINT = os.getenv("TALORDATA_SERP_ENDPOINT")
def validate_settings():
if not TALORDATA_API_KEY:
raise ValueError("Missing TALORDATA_API_KEY")
if not TALORDATA_SERP_ENDPOINT:
raise ValueError("Missing TALORDATA_SERP_ENDPOINT")
This keeps secrets out of the code.
Tiny security victory. Take them where you can.
Step 2: Load topics from CSV
Add a function to read topics.csv.
def load_topics(filename="topics.csv"):
df = pd.read_csv(filename)
required_columns = {"keyword", "location", "language"}
missing_columns = required_columns - set(df.columns)
if missing_columns:
raise ValueError(f"Missing columns in {filename}: {missing_columns}")
return df.to_dict("records")
This gives us input like:
[
{
"keyword": "AI search tools",
"location": "United States",
"language": "en"
}
]
Step 3: Call Talordata SERP API
Now create the request function.
Depending on your Talordata setup, the exact parameter names may differ.
This example uses a common SERP API pattern:
def fetch_google_news(keyword, location="United States", language="en"):
params = {
"api_key": TALORDATA_API_KEY,
"engine": "google",
"q": keyword,
"type": "news",
"location": location,
"language": language,
"output": "json",
}
response = requests.get(
TALORDATA_SERP_ENDPOINT,
params=params,
timeout=30,
)
response.raise_for_status()
return response.json()
Some APIs may use:
search_type=news
tbm=nws
engine=google_news
type=news
If your Talordata docs use a different option, update this function.
Do not change the whole script.
Future you already has enough problems.
Step 4: Extract news results
SERP APIs may return news results under different keys.
Common examples include:
news_results
news
top_stories
articles
results
Add a defensive extractor:
def get_news_items(data):
possible_keys = [
"news_results",
"news",
"top_stories",
"articles",
"results",
]
for key in possible_keys:
value = data.get(key)
if isinstance(value, list):
return value
serp = data.get("serp", {})
if isinstance(serp, dict):
for key in possible_keys:
value = serp.get(key)
if isinstance(value, list):
return value
return []
This makes the script easier to adapt if the response shape changes.
APIs love naming the same thing five different ways. A grand tradition of tiny suffering.
Step 5: Clean text and URLs
News URLs often include tracking parameters.
We want clean URLs for deduplication and reporting.
TRACKING_PARAMS = {
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid",
"mc_cid",
"mc_eid",
}
def clean_text(value):
if value is None:
return ""
value = str(value)
value = re.sub(r"<[^>]*>", " ", value)
value = re.sub(r"\s+", " ", value)
return value.strip()
def clean_url(url):
if not url:
return ""
url = str(url).strip()
try:
parsed = urlparse(url)
query_pairs = parse_qsl(
parsed.query,
keep_blank_values=True,
)
filtered_pairs = [
(key, value)
for key, value in query_pairs
if key.lower() not in TRACKING_PARAMS
]
cleaned_query = urlencode(filtered_pairs)
cleaned = parsed._replace(
query=cleaned_query,
fragment="",
)
return urlunparse(cleaned)
except Exception:
return url
def extract_domain(url):
if not url:
return ""
try:
parsed = urlparse(url)
domain = parsed.netloc.lower()
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return ""
Clean URLs make comparison easier.
Messy URLs make duplicates breed in your CSV like spreadsheet mold.
Step 6: Normalize one news article
Now convert one raw news result into a consistent schema.
def normalize_news_item(item, keyword, location, language, index):
raw_url = (
item.get("link")
or item.get("url")
or item.get("article_url")
or item.get("href")
or ""
)
article_url = clean_url(raw_url)
title = clean_text(
item.get("title")
or item.get("headline")
or ""
)
source = clean_text(
item.get("source")
or item.get("publisher")
or item.get("site")
or ""
)
snippet = clean_text(
item.get("snippet")
or item.get("description")
or item.get("summary")
or ""
)
published_date = clean_text(
item.get("date")
or item.get("published_date")
or item.get("published_at")
or item.get("time")
or ""
)
thumbnail = clean_text(
item.get("thumbnail")
or item.get("image")
or ""
)
position = (
item.get("position")
or item.get("rank")
or index
)
article_id = create_article_id(
title=title,
source=source,
article_url=article_url,
)
return {
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"location": location,
"language": language,
"article_id": article_id,
"position": position,
"title": title,
"source": source,
"article_url": article_url,
"domain": extract_domain(article_url),
"published_date": published_date,
"snippet": snippet,
"thumbnail": thumbnail,
}
This uses create_article_id(), which we will add next.
Step 7: Create a stable article ID
We need a way to detect whether an article is new or already seen.
Use a fingerprint based on:
title
source
URL
def normalize_for_id(value):
value = clean_text(value).lower()
value = re.sub(r"[^a-z0-9\s]", " ", value)
value = re.sub(r"\s+", " ", value)
return value.strip()
def create_article_id(title, source, article_url):
raw_key = "|".join([
normalize_for_id(title),
normalize_for_id(source),
clean_url(article_url),
])
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
This is not perfect.
Publishers can update headlines. URLs can change. Syndicated articles can appear across multiple domains.
But it works well for a first version.
Perfect deduplication is where simple scripts go to become unpaid research projects.
Step 8: Collect news for one topic
Now combine request, extraction, and normalization.
def collect_news_for_topic(topic):
keyword = topic["keyword"]
location = topic["location"]
language = topic["language"]
data = fetch_google_news(
keyword=keyword,
location=location,
language=language,
)
raw_items = get_news_items(data)
rows = [
normalize_news_item(
item=item,
keyword=keyword,
location=location,
language=language,
index=index,
)
for index, item in enumerate(raw_items, start=1)
]
return rows
This turns one topic into clean article rows.
Step 9: Collect all topics
Now loop through every topic in the CSV.
def collect_all_news(topics, delay=1):
all_rows = []
for topic in topics:
keyword = topic["keyword"]
location = topic["location"]
print(f"Collecting news: {keyword} | {location}")
try:
rows = collect_news_for_topic(topic)
all_rows.extend(rows)
print(f"Found {len(rows)} articles")
except Exception as exc:
print(f"Failed: {keyword} | {location}")
print(f"Error: {exc}")
all_rows.append({
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"location": location,
"language": topic.get("language", ""),
"article_id": "",
"position": "",
"title": "",
"source": "",
"article_url": "",
"domain": "",
"published_date": "",
"snippet": "",
"thumbnail": "",
"error": str(exc),
})
time.sleep(delay)
return all_rows
The delay keeps requests controlled.
Automation should be steady, not a caffeine-powered swarm of HTTP regret.
Step 10: Save a daily snapshot
Now save results to a dated CSV file.
def save_snapshot(rows):
today = date.today().isoformat()
filename = f"google_news_snapshot_{today}.csv"
df = pd.DataFrame(rows)
if not df.empty and "article_id" in df.columns:
df = df.drop_duplicates(subset=["article_id"])
df.to_csv(filename, index=False)
print(f"Saved snapshot: {filename}")
print(f"Rows saved: {len(df)}")
return filename
Each run creates a file like:
google_news_snapshot_2026-01-01.csv
Full tracking script
Here is the complete track_google_news.py file.
import os
import re
import time
import hashlib
import requests
import pandas as pd
from datetime import date
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
from dotenv import load_dotenv
load_dotenv()
TALORDATA_API_KEY = os.getenv("TALORDATA_API_KEY")
TALORDATA_SERP_ENDPOINT = os.getenv("TALORDATA_SERP_ENDPOINT")
TRACKING_PARAMS = {
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid",
"mc_cid",
"mc_eid",
}
def validate_settings():
if not TALORDATA_API_KEY:
raise ValueError("Missing TALORDATA_API_KEY")
if not TALORDATA_SERP_ENDPOINT:
raise ValueError("Missing TALORDATA_SERP_ENDPOINT")
def load_topics(filename="topics.csv"):
df = pd.read_csv(filename)
required_columns = {"keyword", "location", "language"}
missing_columns = required_columns - set(df.columns)
if missing_columns:
raise ValueError(f"Missing columns in {filename}: {missing_columns}")
return df.to_dict("records")
def fetch_google_news(keyword, location="United States", language="en"):
params = {
"api_key": TALORDATA_API_KEY,
"engine": "google",
"q": keyword,
"type": "news",
"location": location,
"language": language,
"output": "json",
}
response = requests.get(
TALORDATA_SERP_ENDPOINT,
params=params,
timeout=30,
)
response.raise_for_status()
return response.json()
def get_news_items(data):
possible_keys = [
"news_results",
"news",
"top_stories",
"articles",
"results",
]
for key in possible_keys:
value = data.get(key)
if isinstance(value, list):
return value
serp = data.get("serp", {})
if isinstance(serp, dict):
for key in possible_keys:
value = serp.get(key)
if isinstance(value, list):
return value
return []
def clean_text(value):
if value is None:
return ""
value = str(value)
value = re.sub(r"<[^>]*>", " ", value)
value = re.sub(r"\s+", " ", value)
return value.strip()
def clean_url(url):
if not url:
return ""
url = str(url).strip()
try:
parsed = urlparse(url)
query_pairs = parse_qsl(
parsed.query,
keep_blank_values=True,
)
filtered_pairs = [
(key, value)
for key, value in query_pairs
if key.lower() not in TRACKING_PARAMS
]
cleaned_query = urlencode(filtered_pairs)
cleaned = parsed._replace(
query=cleaned_query,
fragment="",
)
return urlunparse(cleaned)
except Exception:
return url
def extract_domain(url):
if not url:
return ""
try:
parsed = urlparse(url)
domain = parsed.netloc.lower()
if domain.startswith("www."):
domain = domain[4:]
return domain
except Exception:
return ""
def normalize_for_id(value):
value = clean_text(value).lower()
value = re.sub(r"[^a-z0-9\s]", " ", value)
value = re.sub(r"\s+", " ", value)
return value.strip()
def create_article_id(title, source, article_url):
raw_key = "|".join([
normalize_for_id(title),
normalize_for_id(source),
clean_url(article_url),
])
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
def normalize_news_item(item, keyword, location, language, index):
raw_url = (
item.get("link")
or item.get("url")
or item.get("article_url")
or item.get("href")
or ""
)
article_url = clean_url(raw_url)
title = clean_text(
item.get("title")
or item.get("headline")
or ""
)
source = clean_text(
item.get("source")
or item.get("publisher")
or item.get("site")
or ""
)
snippet = clean_text(
item.get("snippet")
or item.get("description")
or item.get("summary")
or ""
)
published_date = clean_text(
item.get("date")
or item.get("published_date")
or item.get("published_at")
or item.get("time")
or ""
)
thumbnail = clean_text(
item.get("thumbnail")
or item.get("image")
or ""
)
position = (
item.get("position")
or item.get("rank")
or index
)
article_id = create_article_id(
title=title,
source=source,
article_url=article_url,
)
return {
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"location": location,
"language": language,
"article_id": article_id,
"position": position,
"title": title,
"source": source,
"article_url": article_url,
"domain": extract_domain(article_url),
"published_date": published_date,
"snippet": snippet,
"thumbnail": thumbnail,
}
def collect_news_for_topic(topic):
keyword = topic["keyword"]
location = topic["location"]
language = topic["language"]
data = fetch_google_news(
keyword=keyword,
location=location,
language=language,
)
raw_items = get_news_items(data)
rows = [
normalize_news_item(
item=item,
keyword=keyword,
location=location,
language=language,
index=index,
)
for index, item in enumerate(raw_items, start=1)
]
return rows
def collect_all_news(topics, delay=1):
all_rows = []
for topic in topics:
keyword = topic["keyword"]
location = topic["location"]
print(f"Collecting news: {keyword} | {location}")
try:
rows = collect_news_for_topic(topic)
all_rows.extend(rows)
print(f"Found {len(rows)} articles")
except Exception as exc:
print(f"Failed: {keyword} | {location}")
print(f"Error: {exc}")
all_rows.append({
"snapshot_date": date.today().isoformat(),
"keyword": keyword,
"location": location,
"language": topic.get("language", ""),
"article_id": "",
"position": "",
"title": "",
"source": "",
"article_url": "",
"domain": "",
"published_date": "",
"snippet": "",
"thumbnail": "",
"error": str(exc),
})
time.sleep(delay)
return all_rows
def save_snapshot(rows):
today = date.today().isoformat()
filename = f"google_news_snapshot_{today}.csv"
df = pd.DataFrame(rows)
if not df.empty and "article_id" in df.columns:
df = df.drop_duplicates(subset=["article_id"])
df.to_csv(filename, index=False)
print(f"Saved snapshot: {filename}")
print(f"Rows saved: {len(df)}")
return filename
def main():
validate_settings()
topics = load_topics("topics.csv")
rows = collect_all_news(
topics=topics,
delay=1,
)
save_snapshot(rows)
if __name__ == "__main__":
main()
Run it:
python track_google_news.py
You should get a CSV file like:
google_news_snapshot_2026-01-01.csv
Step 11: Compare snapshots and detect new articles
A single snapshot is useful.
But news tracking becomes much more useful when you compare snapshots.
Create another file:
compare_google_news.py
import glob
import pandas as pd
def load_latest_snapshots():
files = sorted(glob.glob("google_news_snapshot_*.csv"))
if len(files) < 2:
raise ValueError("Need at least two snapshot files to compare")
previous_file = files[-2]
current_file = files[-1]
previous_df = pd.read_csv(previous_file)
current_df = pd.read_csv(current_file)
return previous_file, current_file, previous_df, current_df
def detect_new_articles(previous_df, current_df):
previous_ids = set(
previous_df["article_id"]
.dropna()
.astype(str)
.tolist()
)
current_df["change_type"] = current_df["article_id"].apply(
lambda article_id: "existing_article"
if str(article_id) in previous_ids
else "new_article"
)
new_articles = current_df[
current_df["change_type"] == "new_article"
].copy()
return current_df, new_articles
def main():
previous_file, current_file, previous_df, current_df = load_latest_snapshots()
compared_df, new_articles_df = detect_new_articles(
previous_df=previous_df,
current_df=current_df,
)
compared_file = "google_news_compared.csv"
new_articles_file = "google_news_new_articles.csv"
compared_df.to_csv(compared_file, index=False)
new_articles_df.to_csv(new_articles_file, index=False)
print(f"Previous snapshot: {previous_file}")
print(f"Current snapshot: {current_file}")
print(f"Saved comparison: {compared_file}")
print(f"Saved new articles: {new_articles_file}")
print("\nSummary:")
print(compared_df["change_type"].value_counts())
if __name__ == "__main__":
main()
Run it after you have at least two snapshot files:
python compare_google_news.py
This creates:
google_news_compared.csv
google_news_new_articles.csv
Now you can see what appeared since the previous run.
That is the actual monitoring part.
Without comparison, you are just collecting files with dates on them and calling it strategy.
What you can analyze next
Once you have Google News snapshots, you can analyze:
which publishers appear most often
which topics are getting more coverage
which companies are mentioned repeatedly
which articles are new today
which sources dominate a keyword
how coverage differs by country or language
For example, load the latest snapshot:
import pandas as pd
df = pd.read_csv("google_news_snapshot_2026-01-01.csv")
top_sources = (
df.groupby("source")
.size()
.reset_index(name="article_count")
.sort_values("article_count", ascending=False)
)
print(top_sources.head(20))
Analyze by keyword:
keyword_counts = (
df.groupby("keyword")
.size()
.reset_index(name="article_count")
.sort_values("article_count", ascending=False)
)
print(keyword_counts)
Filter articles from a specific domain:
domain_articles = df[df["domain"] == "example.com"]
print(domain_articles[["keyword", "title", "article_url"]])
Add brand monitoring
To monitor brand mentions, use keywords like:
YourBrand
YourBrand pricing
YourBrand reviews
YourBrand alternative
YourBrand competitor
For competitor monitoring, use:
CompetitorName
CompetitorName funding
CompetitorName launch
CompetitorName partnership
CompetitorName reviews
For industry tracking, use:
AI search
SERP API
RAG tools
local SEO software
search data API
This turns the script into a lightweight news intelligence workflow.
Not a full media monitoring platform.
But enough to know what changed without staring into Google News like it owes you money.
Add Slack alerts
If you want alerts, send google_news_new_articles.csv to Slack.
A simple message format:
New Google News articles detected
Keyword: AI search tools
Source: Example News
Title: Example article title
URL: https://example.com/article
You can add Slack later using:
Slack webhook
n8n
Zapier
Make
GitHub Actions
Keep the first version simple.
Collect first. Alert later.
Otherwise you build an alert system that loudly reports broken data. Inspiring, in the worst way.
Add LLM summaries
You can also summarize new articles with an LLM.
A simple prompt:
You are a news analyst.
Summarize the following new Google News results.
Rules:
- Group articles by topic.
- Mention important sources.
- Do not invent facts beyond the title and snippet.
- Keep the summary concise.
- Include article URLs.
News results:
{news_results}
This is useful for:
daily briefings
competitor updates
market research
PR monitoring
executive summaries
But keep the raw CSV.
LLM summaries are convenient.
Raw data is evidence.
Do not replace evidence with a paragraph that sounds confident and owns a blazer.
Schedule the tracker
You can run the tracker manually.
For monitoring, schedule it.
On macOS or Linux, use cron:
crontab -e
Run every day at 8 AM:
0 8 * * * /usr/bin/python3 /path/to/track_google_news.py
Run comparison at 8:10 AM:
10 8 * * * /usr/bin/python3 /path/to/compare_google_news.py
For GitHub Actions, create:
.github/workflows/google-news-tracker.yml
name: Google News Tracker
on:
schedule:
- cron: "0 8 * * *"
workflow_dispatch:
jobs:
track-news:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install requests python-dotenv pandas
- run: python track_google_news.py
env:
TALORDATA_API_KEY: ${{ secrets.TALORDATA_API_KEY }}
TALORDATA_SERP_ENDPOINT: ${{ secrets.TALORDATA_SERP_ENDPOINT }}
For production, use a database instead of CSV.
Good options:
SQLite
PostgreSQL
BigQuery
Supabase
S3
Google Sheets
CSV is fine for the first version.
CSV is not a destiny.
Common mistakes
Not saving snapshots
If you only keep the latest results, you cannot detect change.
Save every run.
Deduplicating only by title
News titles can change.
Use a combination of title, source, and URL.
Ignoring location and language
Google News results can change by country and language.
Always store location and language with each row.
Treating snippets as full articles
A snippet is not the full article.
Use it for discovery and lightweight summaries.
For deeper analysis, fetch the article page and extract full text.
Tracking too many keywords too soon
Start with five to ten topics.
Make sure the output is clean before scaling.
Forgetting source quality
Not all news sources are equal.
For serious monitoring, add source categories or trusted source lists.
Provider note
This tutorial uses Talordata SERP API as the search data layer. Start a 7-day free trial now>>
The important requirement is structured Google News data that your workflow can parse into fields like:
title
URL
source
published date
snippet
position
When testing any SERP API, inspect the response body first.
Check:
Are titles complete?
Are URLs clean?
Are sources included?
Are dates available?
Are snippets useful?
Does location targeting work?
Are empty responses common?
Is the JSON easy to normalize?
The homepage is not where your tracker runs.
Your tracker runs on the response body.
Terrible news for marketing adjectives, but excellent news for developers.
Final thoughts
A Google News tracker does not need to start as a large media monitoring product.
Start with:
topics.csv
Talordata SERP API request
news result parser
daily CSV snapshot
snapshot comparison
new article detection
Then improve it with:
Slack alerts
database storage
LLM summaries
source scoring
brand monitoring
competitor monitoring
multi-location tracking
full article extraction
The core idea is simple:
query + location + language
→ Google News results
→ clean article data
→ compare over time
Once you have snapshots, you can stop manually refreshing Google News and start working with actual change data.
Which is a small but meaningful upgrade from pretending browser tabs are a monitoring system.
Top comments (0)