Most people follow news the hard way: ten tabs, three apps and a feed that never ends. A daily digest fixes that. One message every morning with the headlines that matter to you and alerts for the topics you track, and nothing else.
In this guide you will build that digest bot in about 150 lines of Python. It will:
- Pull the latest world, tech and markets headlines.
- Search the last 24 hours of news for your own keywords (a competitor, a product, a technology).
- Remove anything it already sent you before.
- Send one tidy message to Telegram, Slack or email.
- Run on a schedule with cron or GitHub Actions, for free.
Want to skip the copy and paste? The finished bot is a GitHub template: nestdaddy/news-digest-bot. Click Use this template, add your secrets and it runs every morning.
The news comes from the NestDaddy API, which collects news from 45+ countries and returns plain JSON. You only need Python 3.9+ and the requests library.
What you need
- A NestDaddy API key. The free 7-day trial gives you 5,000 calls, and this bot uses about 6 calls a day. Sign up on the MCP and plans page.
- Somewhere to send the digest: a Telegram bot, a Slack incoming webhook, or an SMTP account.
pip install requests
Put your secrets in environment variables, never in the code:
export NESTDADDY_KEY="your_key"
export TELEGRAM_TOKEN="123456:ABC..." # optional
export TELEGRAM_CHAT_ID="123456789" # optional
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." # optional
Step 1: fetch the headlines
NestDaddy has one endpoint per news section. Each returns an articles list with title, url, description, source_domain and published_timestamp:
| Section | Endpoint |
|---|---|
| World | GET /api/v1/news/global |
| Tech | GET /api/v1/news/tech |
| Markets, crypto and economy | GET /api/v1/news/financial |
import os
import requests
API = "https://nestdaddy.com/api/v1"
HEADERS = {"X-API-Key": os.environ["NESTDADDY_KEY"]}
def get_json(path, **params):
r = requests.get(f"{API}{path}", headers=HEADERS, params=params, timeout=30)
r.raise_for_status()
return r.json()
def headlines(section, limit=5):
data = get_json(f"/news/{section}", limit=limit)
return data.get("articles", [])
limit goes up to 50. Five per section keeps the digest short enough to read over coffee.
Step 2: add your own keyword alerts
Headlines tell you what everyone is talking about. Keyword alerts tell you what you care about. The news search endpoint takes key words, an optional two-letter country code and how many days back to look:
WATCHLIST = [
{"q": "OpenAI"},
{"q": "electric vehicles", "country": "US"},
{"q": "interest rates", "country": "GB"},
]
def keyword_alerts(watch, limit=5):
params = {"q": watch["q"], "days": 1, "limit": limit}
if watch.get("country"):
params["country"] = watch["country"]
data = get_json("/news/search", **params)
return data.get("results", [])
days=1 keeps only the last 24 hours. Leave country out to search every country at once, or set it to focus on one market (for example US, GB or SG).
Step 3: never send the same story twice
The same article can show up in the world section and in a keyword alert, and yesterday's story can still be near the top today. A tiny SQLite table of URLs you already sent solves both:
import sqlite3
from urllib.parse import urlsplit, urlunsplit
db = sqlite3.connect("digest_seen.db")
db.execute("CREATE TABLE IF NOT EXISTS seen (url TEXT PRIMARY KEY, sent_at TEXT)")
def clean_url(url):
# Drop query strings and fragments so tracking tags do not create duplicates
p = urlsplit(url)
return urlunsplit((p.scheme, p.netloc.lower(), p.path.rstrip("/"), "", ""))
def only_new(articles):
fresh = []
for a in articles:
key = clean_url(a["url"])
if db.execute("SELECT 1 FROM seen WHERE url = ?", (key,)).fetchone():
continue
db.execute("INSERT INTO seen VALUES (?, datetime('now'))", (key,))
fresh.append(a)
return fresh
Call db.commit() only after the message was sent, so a failed send does not mark stories as seen.
Step 4: build the message
Keep it plain: a section title, then one line per story with the source. Telegram and Slack both handle simple Markdown-style links, so we build one text body and adapt it per channel:
def build_digest():
sections = []
for title, section in [("World", "global"), ("Tech", "tech"), ("Markets", "financial")]:
items = only_new(headlines(section))
if items:
sections.append((title, items))
for watch in WATCHLIST:
items = only_new(keyword_alerts(watch))
if items:
label = watch["q"] + (f" ({watch['country']})" if watch.get("country") else "")
sections.append((f"Alert: {label}", items))
return sections
def as_text(sections):
lines = []
for title, items in sections:
lines.append(f"\n{title.upper()}")
for a in items:
lines.append(f"- {a['title']} ({a.get('source_domain', '')})\n {a['url']}")
return "\n".join(lines).strip()
Step 5: send it
Telegram
Create a bot with @BotFather, send it one message, then open https://api.telegram.org/bot<TOKEN>/getUpdates to find your chat id. Telegram messages are limited to 4,096 characters, so long digests are split:
def send_telegram(text):
token, chat = os.environ["TELEGRAM_TOKEN"], os.environ["TELEGRAM_CHAT_ID"]
for i in range(0, len(text), 4000):
r = requests.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": chat, "text": text[i:i + 4000],
"disable_web_page_preview": True},
timeout=30,
)
r.raise_for_status()
Slack
Create an incoming webhook for your channel in your Slack app settings, then:
def send_slack(text):
r = requests.post(os.environ["SLACK_WEBHOOK_URL"], json={"text": text}, timeout=30)
r.raise_for_status()
import smtplib
from email.message import EmailMessage
def send_email(text):
msg = EmailMessage()
msg["Subject"] = "Your daily news digest"
msg["From"] = os.environ["SMTP_FROM"]
msg["To"] = os.environ["SMTP_TO"]
msg.set_content(text)
with smtplib.SMTP_SSL(os.environ["SMTP_HOST"], 465) as s:
s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
s.send_message(msg)
Step 6: put it together
import sys
def main():
sections = build_digest()
if not sections:
print("Nothing new today")
return
text = as_text(sections)
if os.environ.get("TELEGRAM_TOKEN"):
send_telegram(text)
if os.environ.get("SLACK_WEBHOOK_URL"):
send_slack(text)
if os.environ.get("SMTP_HOST"):
send_email(text)
db.commit() # mark stories as sent only after delivery worked
print(f"Sent {sum(len(i) for _, i in sections)} stories")
if __name__ == "__main__":
try:
main()
except requests.HTTPError as e:
print("Request failed:", e.response.status_code, e.response.text[:200], file=sys.stderr)
sys.exit(1)
Run it once with python digest.py and check your phone.
Step 7: run it every morning
With cron (any Linux server or Mac)
# 07:00 every day
0 7 * * * cd /home/you/digest && /usr/bin/python3 digest.py >> digest.log 2>&1
With GitHub Actions (no server at all)
Add your keys as repository secrets, then create .github/workflows/digest.yml:
name: daily-digest
on:
schedule:
- cron: "0 7 * * *" # 07:00 UTC
workflow_dispatch:
jobs:
send:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install requests
- run: python digest.py
env:
NESTDADDY_KEY: ${{ secrets.NESTDADDY_KEY }}
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
GitHub Actions starts with a clean machine each run, so the SQLite file is lost between runs. Either commit digest_seen.db back to the repo at the end of the job, or skip the database and use days=1 alone, which already keeps most repeats out.
Ideas to take it further
-
Summaries instead of headlines. Pass an article URL to
GET /api/v1/extract?url=...to get the readable text of the page, then let any language model write a two-line summary before you send it. -
One digest per team. Keep a
WATCHLISTper Slack channel: product names for marketing, competitor names for sales, library names for engineering. - Use it from an AI agent. If you would rather ask than schedule, connect the NestDaddy MCP server to Claude, Cursor or n8n and ask "what happened with electric vehicles in the US today?". See How to give Claude live web search and news with one MCP server.
- Coming from Bing News Search? The field mapping is in Bing Search API is retired: how to get raw web and news results as JSON again.
How many calls does it use?
Three headline calls plus one call per watchlist entry. With three keywords that is 6 calls a day, about 180 a month. The free trial covers that many times over, and every response includes a quota object (used, limit, remaining) so the bot can warn you before you run low.
Full API reference: nestdaddy.com/apis. More examples in Python, JavaScript and curl: nestdaddy/nestdaddy-examples.
Originally published on the NestDaddy blog. NestDaddy is an independent search engine with its own index: nestdaddy.com.
Top comments (0)