Chapter 8 of 8 — the final chapter. Prerequisites: Chapter 6 and Chapter 7 — you should have working GSC and GA4 subcommands in the CLI from chapter 5. This chapter wraps them into a daily digest, schedules it with launchd, and surfaces the output through Telegram.
What the digest does
The pieces from chapter 5, 6, and 7 give you a CLI you can run by hand. That CLI is more useful than the UI for ad-hoc questions, but the UI still wins on rhythm — most people open the GSC and GA4 UIs at least weekly because they are there in the browser. A CLI is not. A CLI you have to remember to run is a CLI you forget.
The digest is what fixes that. Every morning at 19:00 BST (a deliberate choice — see Two bots, one token: infinite Telegram 409 Conflict errors for why slot timing matters), a launchd job runs:
- Pull today's state from GSC + GA4 using the chapter 6 and chapter 7 subcommands.
- Diff today's pull against yesterday's stored snapshot.
- Format the diff as a short Telegram message — buckets that changed, top pages that moved, queries that flipped from one URL to another.
- Send the message to the Telegram chat you have configured, with inline-keyboard buttons for follow-up actions (request re-index, dismiss, snooze).
No new code in the API layer — every subcommand is reused as-is. The work in this chapter is the wrapper, the scheduling, the diff, and the bot.
The digest module
A new module, telemetry/digest.py, owns the orchestration. It imports the GSC and GA4 modules directly and calls into them.
# telemetry/digest.py
import json
import os
from datetime import date
from pathlib import Path
from . import auth, ga4, gsc, telegram
SNAPSHOT_DIR = Path.home() / ".telemetry" / "snapshots"
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
def run_daily_digest(args) -> int:
"""Pull → diff → send. Idempotent against same-day reruns."""
creds = auth.build_credentials()
today = date.today().isoformat()
# Pull today's state
today_state = {
"gsc": gsc.pull_state(creds, args.gsc_property),
"ga4": ga4.pull_state(creds, args.ga4_property),
}
# Read yesterday's snapshot (None on first run)
yesterday_file = SNAPSHOT_DIR / f"{_yesterday()}.json"
yesterday_state = (
json.loads(yesterday_file.read_text())
if yesterday_file.exists()
else None
)
# Compute diff (first run sends a baseline, not a diff)
if yesterday_state:
diff = _compute_diff(yesterday_state, today_state)
message = _format_diff(diff)
else:
message = _format_baseline(today_state)
# Persist today's snapshot for tomorrow's diff
(SNAPSHOT_DIR / f"{today}.json").write_text(json.dumps(today_state))
# Send to Telegram
telegram.send(
text=message,
chat_id=os.environ["TELEGRAM_CHAT_ID"],
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
)
return 0
gsc.pull_state() and ga4.pull_state() are thin wrappers around the chapter 6 and chapter 7 subcommand bodies that return structured data instead of printing — give yourself one of those per domain, then the subcommand bodies become two-liners that call the same function and format the result for the terminal. Same data, different presentation.
The Telegram bot module
telemetry/telegram.py is one function: send a message. The Telegram Bot API is documented thoroughly enough that you do not need a library for this — requests.post against sendMessage is enough.
# telemetry/telegram.py
import requests
TELEGRAM_API = "https://api.telegram.org"
def send(text: str, chat_id: str, bot_token: str, **kwargs) -> None:
"""Post a message to a Telegram chat.
Per the Bot API docs, parse_mode=MarkdownV2 supports inline
formatting that survives in mobile clients. The bot must already
have a chat with the recipient — Telegram does not let bots
initiate conversations."""
url = f"{TELEGRAM_API}/bot{bot_token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": "MarkdownV2",
"disable_web_page_preview": True,
**kwargs,
}
response = requests.post(url, json=payload, timeout=10)
response.raise_for_status()
Two things the Bot API docs make clear that catch people out:
- The bot must initiate-by-being-talked-to. Telegram does not let bots DM a chat that has not first messaged the bot. Before scheduling the digest, open Telegram on your phone, find the bot by its username, and send it any message. The
chat_idreturned fromgetUpdatesis the one you put inTELEGRAM_CHAT_ID. - MarkdownV2 is strict. Periods, hyphens, and parentheses need backslash-escaping. The simplest pattern is a
_escape_markdown_v2()helper that runs every dynamic string throughre.subbefore it goes into the message body.
Scheduling with launchd
On macOS, launchd is the supported scheduler. It is what you should use, not cron — cron is still present on macOS but Apple has not maintained it for a decade and launchd handles wake-from-sleep correctly while cron does not.
A launchd job is a plist file. Save it as ~/Library/LaunchAgents/com.yourname.telemetry-digest.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.yourname.telemetry-digest</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/env</string>
<string>python3</string>
<string>-m</string>
<string>telemetry</string>
<string>digest</string>
<string>--gsc-property</string>
<string>https://example.com/</string>
<string>--ga4-property</string>
<string>123456789</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/yourname/path/to/telemetry-project</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>19</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/tmp/telemetry-digest.out</string>
<key>StandardErrorPath</key>
<string>/tmp/telemetry-digest.err</string>
</dict>
</plist>
Load it with launchctl, per the launchctl reference:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yourname.telemetry-digest.plist
launchctl kickstart -k gui/$(id -u)/com.yourname.telemetry-digest
The bootstrap registers the job; the kickstart -k runs it once immediately so you can verify the wiring without waiting until 19:00. Check /tmp/telemetry-digest.err for stderr after that first run — most setup mistakes (missing env vars, bad property ID, Keychain permission prompt) show up there.
Inline keyboard for triage
For actions you want to take on the digest — re-request indexation on a URL Google failed to index, snooze an alert, dismiss a false positive — the inline keyboard markup on the Telegram side lets you put buttons under the message. Each button carries a callback_data string that the bot receives when tapped.
keyboard = {
"inline_keyboard": [[
{"text": "🔄 Request re-index", "callback_data": f"reindex:{url}"},
{"text": "💤 Snooze 7d", "callback_data": f"snooze:{url}:7"},
{"text": "❌ Dismiss", "callback_data": f"dismiss:{url}"},
]]
}
telegram.send(
text=message,
chat_id=chat_id,
bot_token=bot_token,
reply_markup=json.dumps(keyboard),
)
Handling the button taps requires a webhook or a long-polling getUpdates loop — out of scope for this chapter, but the bot-registry article linked above sketches the pattern in production.
What you have built
You started at chapter 1 with the case for automation. You now have:
- A Google Cloud project with two APIs enabled and an OAuth client.
- A refresh token in Keychain, not in
.env. - A CLI shape that scales beyond two scripts.
- Subcommands that pull GSC indexation buckets, GSC queries, GA4 sessions, GA4 top pages, and GA4 custom events.
- A daily digest that diffs against yesterday and DMs you only the parts that changed.
- A launchd job that runs the digest unattended.
- A Telegram surface you can act on without opening the laptop.
Each piece is independently useful. Together they replace the GSC and GA4 UIs as your daily glance — the UIs stay as the deeper exploration surface, but the rhythm of what changed since yesterday lives in the digest.
That is the end of this guide. If you build the pieces and they help you catch something the UI would have missed, that is the win — and I would be interested to hear about it.
Ship a daily digest for your own stack
Wire your CLI into an unattended daily report: a digest subcommand that pulls today's state, diffs it against yesterday's JSON snapshot, formats the changes, and DMs them to you through your own Telegram bot, scheduled by a launchd agent you can kickstart on demand.
Expected behaviour
- A digest run that writes a dated snapshot file, sending a baseline message on first run and a genuine diff on the second
- A Telegram send that reaches a chat you opened with the bot first, using the chat_id recovered from getUpdates
- A launchd plist under ~/Library/LaunchAgents with a StartCalendarInterval plus stdout and stderr paths you can inspect
- Environment loading that survives launchd's stripped-down environment, via dotenv with an absolute path or plist EnvironmentVariables
- A kickstart invocation that runs the job immediately so the wiring is verified without waiting for the scheduled hour
PROVE IT Bootstrap the agent, kickstart it twice, either a day apart or with a hand-edited snapshot date, and show the second Telegram message contains a diff rather than a baseline.
Why does the chapter schedule with launchd instead of cron on macOS?
After bootstrapping the agent, which launchctl subcommand runs the job once immediately so you can verify the wiring?
Why must you message your bot from your own Telegram account before the digest can ever reach you?
Show answer
Telegram does not let bots initiate conversations, so a bot can only DM a chat that has already messaged it first. Sending the bot any message creates that chat, and the chat_id returned from getUpdates is the value you then configure as TELEGRAM_CHAT_ID.
↺ re-read: “The Telegram bot module”