RSS and Page Change Monitoring skill for Codex
One alert per new item, zero for timestamp churn. Codex builds feed and page watchers that poll with ETag and Last-Modified, key items on guid or link so republished posts do not re-alert, and diff only the page element you care about after stripping timestamps and ads. The first run seeds state silently, alerts arrive as digests at a destination you confirmed, and a broken feed or selector raises its own health alert.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- rss-feed-monitoring Installs to ~/.agents/skills/rss-feed-monitoring/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install rss-feed-monitoring The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project rss-feed-monitoring Installs to .agents/skills/rss-feed-monitoring/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/rss-feed-monitoring.zipunzip -o rss-feed-monitoring.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) rss-feed-monitoring PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $rss-feed-monitoring, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds monitors that watch RSS, Atom and JSON feeds or ordinary web pages for new items and changes - conditional GET with ETag and Last-Modified, stable item ids, SQLite state, CSS-selector page diffs that ignore noise, and digest or instant notifications without duplicates. Covers feedparser in Python and self-hosted changedetection.io. Use when asked to alert on new blog posts, releases, job listings or price changes, watch a competitor's page, build a feed digest, or fix a monitor that sends duplicate or missed alerts.
Use it when
- You want notifications for new posts, releases or job listings
- A specific value on a web page should be watched for changes
- An existing monitor sends duplicates or misses items
- Several feeds should become one daily digest
Use something else when
- You need the full content of many pages, not change alerts: use web-scraping
- The source is a JSON API with cursors or updated_since: use api-polling-workers
How to check the result
Run twice against a saved fixture and confirm zero alerts on the second run, then add one item and confirm exactly one alert; a deliberately broken selector must produce a health error.
The mistake it prevents
Hashing the whole page, so every rotating ad or relative timestamp triggers a false change alert.
A monitor is right when it alerts exactly once per genuinely new item, stays silent through timestamp churn and ad rotation, costs the publisher almost nothing per check (304 Not Modified most of the time), and tells you when it has itself stopped working.
Choose the source
| Signal you want | Watch |
|---|---|
| New posts, releases, episodes | The feed (<link rel="alternate" type="application/rss+xml"> in the page head) |
| GitHub releases or tags | https://github.com/OWNER/REPO/releases.atom or /tags.atom |
| A value on a page (price, status, stock) | That element via CSS selector, not the whole page |
| A list without a feed (jobs, changelogs) | The list container; diff the set of item links |
An API that exposes updated_since | Poll the API instead ($api-polling-workers) |
Ask the publisher’s terms and robots.txt before polling a non-feed page often. Feeds exist to be polled; pages generally are not.
Polling budget
- Feeds: every 15 to 60 minutes is normal. Honour
<ttl>(RSS, minutes),Cache-Control: max-age, andRetry-After. Never under 5 minutes without permission. - Pages: hourly or daily. Back off exponentially after errors; stop after a run of 403/410.
- Always send conditional requests; a well-behaved monitor mostly receives 304s.
- Fetching needs network; in a Codex sandbox with network off, build and test against saved fixture files and ask before live runs.
Feed monitor (Python)
import hashlib, sqlite3, time
import feedparser
UA = "acme-feedwatch/1.0 (+mailto:[email protected])"
db = sqlite3.connect("feedwatch.db")
db.executescript("""
CREATE TABLE IF NOT EXISTS feeds(url TEXT PRIMARY KEY, etag TEXT, modified TEXT,
last_ok REAL, failures INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS seen(feed TEXT, item_key TEXT, first_seen REAL,
PRIMARY KEY(feed, item_key));
""")
def item_key(e) -> str:
# guid/id first; fall back to link; last resort a hash of title+link
raw = e.get("id") or e.get("link") or (e.get("title", "") + e.get("link", ""))
return hashlib.sha256(raw.strip().encode()).hexdigest()
def check(url: str) -> list[dict]:
row = db.execute("SELECT etag, modified, last_ok FROM feeds WHERE url=?", (url,)).fetchone()
etag, modified, last_ok = row if row else (None, None, None)
d = feedparser.parse(url, etag=etag, modified=modified, agent=UA)
status = getattr(d, "status", None)
if status == 304:
return []
if status is None or status >= 400 or (d.bozo and not d.entries):
db.execute("INSERT INTO feeds(url, failures) VALUES(?,1) ON CONFLICT(url) "
"DO UPDATE SET failures = failures + 1", (url,))
db.commit()
raise RuntimeError(f"{url}: status={status} bozo={d.get('bozo_exception')}")
first_run = last_ok is None # never succeeded before
new = []
for e in d.entries:
key = item_key(e)
cur = db.execute("INSERT OR IGNORE INTO seen VALUES(?,?,?)", (url, key, time.time()))
if cur.rowcount and not first_run: # first run seeds state silently
new.append({"title": e.get("title"), "link": e.get("link"),
"published": e.get("published")})
db.execute("INSERT INTO feeds VALUES(?,?,?,?,0) ON CONFLICT(url) DO UPDATE SET "
"etag=excluded.etag, modified=excluded.modified, last_ok=excluded.last_ok, failures=0",
(url, d.get("etag"), d.get("modified"), time.time()))
db.commit()
return new
Design points: the first run seeds state instead of alerting on fifty old posts; state is committed only after the item is recorded; failures are counted so you can alert on a broken feed, not just on new items.
Page change monitor
import difflib, hashlib, re
import httpx
from bs4 import BeautifulSoup
def snapshot(url: str, selector: str) -> str:
r = httpx.get(url, headers={"User-Agent": UA}, timeout=30, follow_redirects=True)
r.raise_for_status()
node = BeautifulSoup(r.text, "lxml").select_one(selector)
if node is None:
raise RuntimeError(f"selector {selector!r} matched nothing on {url}; layout changed?")
for junk in node.select("script, style, time, .ad, [data-timestamp]"):
junk.decompose()
text = node.get_text("\n", strip=True)
text = re.sub(r"\b\d+ (minutes?|hours?|days?) ago\b", "", text) # relative times
return "\n".join(line for line in text.splitlines() if line.strip())
def changed(old: str, new: str) -> str | None:
if hashlib.sha256(old.encode()).digest() == hashlib.sha256(new.encode()).digest():
return None
return "\n".join(difflib.unified_diff(old.splitlines(), new.splitlines(),
"before", "after", lineterm="", n=1))
Store the last snapshot text per (url, selector) and send the diff, not just “page changed”.
For lists, compare sets of item links: new_links = current - previous gives clean alerts
even when the list reorders.
Noise filters that matter
| Noise | Filter |
|---|---|
| Timestamps, “x minutes ago”, view counters | Strip by selector or regex before hashing |
| Rotating ads, recommendations | Narrow the selector to the content container |
Feed items re-published with new updated dates | Key on id/link, not on date or content hash |
| Tracking parameters in links | Drop utm_*, fbclid, ref before comparing |
| Whitespace and markup changes | Compare extracted text, not HTML |
| A/B tests and geo variants | Pin Accept-Language; require the change to persist on two consecutive checks |
Notifications
- Batch: one digest per run with all new items beats one message per item.
- Include title, link and the diff excerpt; cap message length.
- Sending to email, Slack or chat is outward-facing: confirm the destination with the user at setup and send a test message to them first. Webhook URLs and SMTP passwords come from environment variables, never from code.
- Alert on the monitor’s own health: a feed failing for more than a day, or a selector that stopped matching, is a notification too.
Off-the-shelf option
For many pages with a UI, self-host changedetection.io (Docker image
ghcr.io/dgtlmoon/changedetection.io). It supports CSS/XPath filters, ignore-text rules,
browser-based fetching for JS pages and many notification targets via Apprise. Recommend it
when the user wants to manage watches by hand rather than in code. Running containers needs
Docker and network access; ask first.
Scheduling
Run the checker from cron, a systemd timer, launchd or a scheduled GitHub Actions workflow
($github-actions-automation). Keep the SQLite file on persistent storage; in Actions,
commit the state file or use a cache, otherwise every run is a first run.
Gotchas
feedparsersetsbozo = 1on many slightly malformed feeds that still parse; treat it as fatal only when there are no entries.- Some feeds change every
guidon each build; detect it (all items new on every run) and fall back to the link as key. - Servers that ignore conditional headers return 200 every time; the item-key table still prevents duplicates, which is why both layers exist.
- Feeds often contain only the last 10 to 20 items; polling too rarely misses items on busy feeds. Size the interval to the publish rate.
- Redirects to a new feed URL (301) should update the stored URL; feedparser exposes
d.hrefandd.status. - Time zones in
publishedvary; usepublished_parsed(UTC struct_time) when sorting.
Verification
- Run twice against a saved fixture: first run seeds, second run reports zero new
- Add one item to the fixture: exactly one alert
- Live run logs a 304 on the second request for servers that support it
- Break the selector deliberately: the monitor reports a health error, not a change
- Test notification reached the confirmed destination
Handoff
**Watching:** 14 feeds, 3 pages (list in watches.yaml)
**Interval:** feeds 30 min, pages hourly, via systemd timer feedwatch.timer
**State:** /var/lib/feedwatch/feedwatch.db (seen items, ETags, failure counts)
**Alerts:** hourly digest to #product-intel (tested), health alert after 6 failed checks
**Noise rules:** timestamps stripped; price page compares .price-box only What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "RSS and Page Change Monitoring"
short_description: "One alert per new item, zero for timestamp churn"
default_prompt: "$rss-feed-monitoring Alert me in Slack when any of these 12 engineering blogs publishes a new post" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.