Publish a valid RSS 2.0 or Atom 1.0 feed an agent can maintain (required elements, guid strategy, content:encoded, autodiscovery), and poll feeds reliably with conditional GET (ETag/Last-Modified), guid-based dedupe, polite intervals, and malformed-feed handling. Use when an agent needs to syndicate its own updates or watch someone else's feed for new items without spamming or re-notifying.
./install.sh rss-feedsname: "rss_feeds" description: "Publish a valid RSS 2.0 or Atom 1.0 feed an agent can maintain (required elements, guid strategy, content:encoded, autodiscovery), and poll feeds reliably with conditional GET (ETag/Last-Modified), guid-based dedupe, polite intervals, and malformed-feed handling. Use when an agent needs to syndicate its own updates or watch someone else's feed for new items without spamming or re-notifying." metadata: includeInPrompt: true
Two directions, one file. Publish: emit a minimal valid RSS 2.0 (or Atom 1.0) feed with stable guids, full HTML bodies, and an autodiscovery tag. Consume: poll feeds with conditional GET, dedupe on guid (never title), and report only genuinely new items. A poller that re-notifies on re-seen items is a bug, not a feature.
A. Publish
make_feed.py, edit CHANNEL and ITEMS (newest first), run python3 make_feed.py > feed.xml. Requires stdlib only.python3 -c "import xml.etree.ElementTree as ET; ET.parse('feed.xml')" must succeed, and feedparser.parse('feed.xml').bozo must be False with the expected entry count (pip install feedparser).feed.xml over HTTPS with Content-Type: application/rss+xml (Atom: application/atom+xml) and a stable URL — moving the URL breaks every subscriber.<head>: <link rel="alternate" type="application/rss+xml" title="My Agent Feed" href="https://example.com/feed.xml">python
#!/usr/bin/env python3
"""Minimal valid RSS 2.0 generator. Edit CHANNEL/ITEMS, run: python3 make_feed.py > feed.xml"""
import xml.etree.ElementTree as ET
from email.utils import format_datetime
from datetime import datetime, timezone
CHANNEL = {"title": "My Agent Feed", "link": "https://example.com/",
"description": "Updates from my agent", "feed_url": "https://example.com/feed.xml"}
ITEMS = [ # newest first; guid = stable, unique, never reused (permalink URL or tag: URI)
{"title": "First post", "link": "https://example.com/posts/1",
"guid": "https://example.com/posts/1",
"pub": datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc),
"summary": "Short summary.", "html": "<p>Full <b>HTML</b> body.</p>"},
]
rss = ET.Element("rss", version="2.0")
rss.set("xmlns:atom", "http://www.w3.org/2005/Atom")
rss.set("xmlns:content", "http://purl.org/rss/1.0/modules/content/")
ch = ET.SubElement(rss, "channel")
ET.SubElement(ch, "title").text = CHANNEL["title"]
ET.SubElement(ch, "link").text = CHANNEL["link"]
ET.SubElement(ch, "description").text = CHANNEL["description"]
ET.SubElement(ch, "language").text = "en-us"
ET.SubElement(ch, "{http://www.w3.org/2005/Atom}link",
href=CHANNEL["feed_url"], rel="self", type="application/rss+xml")
for it in ITEMS:
i = ET.SubElement(ch, "item")
ET.SubElement(i, "title").text = it["title"]
ET.SubElement(i, "link").text = it["link"]
ET.SubElement(i, "guid", isPermaLink="true").text = it["guid"]
ET.SubElement(i, "pubDate").text = format_datetime(it["pub"], usegmt=True)
ET.SubElement(i, "description").text = it["summary"]
ET.SubElement(i, "{http://purl.org/rss/1.0/modules/content/}encoded").text = it["html"]
print('<?xml version="1.0" encoding="UTF-8"?>')
print(ET.tostring(rss, encoding="unicode"))
Atom 1.0 element map (same data, different tags): channel→feed, item→entry, guid→id, pubDate (RFC 822, e.g. Mon, 21 Sep 2026 12:00:00 GMT)→updated (RFC 3339, e.g. 2026-09-21T12:00:00Z), content:encoded→<content type="html">, self-link→<link href="..." rel="self"/>. Absolute URLs everywhere.
B. Consume / poll
poll.py (pip install feedparser first). Run: python3 poll.py FEED_URL. State lives in ~/.rss_poller_state.json.ETag and Last-Modified response headers plus every seen guid. Later runs pass them back as If-None-Match / If-Modified-Since.304 Not Modified means zero new items — exit quietly, do not re-scan, do not re-report.200, compute new items as entry.id not in seen_guids, report those, then add their guids to the seen set. entry.id is the guid; never dedupe on title or link.feedparser sets bozo and yields no entries, log the exception and skip the run — never crash, never invent missing fields.python
#!/usr/bin/env python3
"""RSS/Atom poller: conditional GET + guid dedupe. Run: python3 poll.py FEED_URL"""
import json, os, sys
import feedparser
URL = sys.argv[1]
STATE = os.path.expanduser("~/.rss_poller_state.json")
def load():
try: return json.load(open(STATE))
except (FileNotFoundError, json.JSONDecodeError): return {}
def main():
st = load().get(URL, {})
d = feedparser.parse(URL, etag=st.get("etag"), modified=st.get("modified"),
agent="rss_feeds-skill-poller/1.0")
if getattr(d, "status", 200) == 304:
print("304 not modified — nothing new"); return
if d.bozo and not d.entries:
print("WARN: unparseable feed:", repr(d.bozo_exception)[:120]); return
seen = set(st.get("seen", []))
new = [e for e in d.entries if e.get("id") not in seen]
for e in new:
print("NEW", e.get("id"), "|", e.get("title"), "|", e.get("link"))
seen.add(e.get("id"))
if d.get("etag"): st["etag"] = d.etag
if d.get("modified"): st["modified"] = d.modified
st["seen"] = sorted(seen)[-5000:]
all_st = load(); all_st[URL] = st
json.dump(all_st, open(STATE, "w"))
if not new: print("no new items")
main()
xml.etree parse OK and feedparser bozo=False with entry count; (3) the autodiscovery <link> tag placed in the site <head> (or a report of where it must go).guid | title | link | published/updated; (3) on 304 or unparseable feed, return a one-line status and emit nothing else. A run that re-emits an already-seen guid fails the contract.entry.id), never title or link. Titles get corrected, links get redirected; guids are the identity.If-None-Match / If-Modified-Since) and honor 304 as "nothing changed". A feed may send only one of ETag/Last-Modified; store and send whichever you got.ttl, sy:updatePeriod/sy:updateFrequency, Cache-Control) and check its robots.txt/terms before going faster; default to hourly unless the source clearly updates faster and permits it. Back off on 429 and honor Retry-After; one request per feed per interval, no parallel fetches of the same feed.isPermaLink="true" only when the guid is the item's real URL.pubDate/lastBuildDate = RFC 822 (Mon, 21 Sep 2026 12:00:00 GMT); Atom updated/published = RFC 3339 (2026-09-21T12:00:00Z). email.utils.format_datetime(dt, usegmt=True) produces valid RFC 822.link and guid. Relative URLs silently break subscribers and dedupe.User-Agent (the script's agent= kwarg) so publishers can contact you instead of blocking you.bozo with entries, use what parsed and flag the warning; on total failure, skip the run. Try the feed's declared encoding before assuming UTF-8; chardet is a fallback, not a first resort.feedparser bozo=False. A feed that only parses in your head is not a feed.publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.