rss-feeds

Read RSS, Atom, JSON feeds; discover feeds behind a page.

  • RSS
  • Atom
  • Feeds
  • Monitoring
  • Research
  • Blogs
  • Releases

Declared platforms: linux · macos · windows

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/optional-skills/research/rss-feeds'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

scripts/feed.py

scripts/feed.pyBrowse 2 files
View on GitHub
← Back to SKILL.md
#!/usr/bin/env python3"""Read RSS / Atom / JSON Feed sources and discover feeds behind a page URL. Standard library only, so it runs in any Hermes environment without an installstep. Output is JSON (``--json``) or a compact text listing.     python3 feed.py read https://example.com/feed.xml [--limit N] [--since 2026-09-01]    python3 feed.py discover https://example.com/    python3 feed.py read https://example.com/  ->  discovers, then reads the first feed""" from __future__ import annotations import argparseimport htmlimport jsonimport reimport sysimport urllib.errorimport urllib.parseimport urllib.requestimport xml.etree.ElementTree as ETfrom datetime import datetime, timezonefrom email.utils import parsedate_to_datetime USER_AGENT = "hermes-agent/1.0 (rss-feeds skill; +https://github.com/NousResearch/hermes-agent)"TIMEOUT = 20NS = {    "atom": "http://www.w3.org/2005/Atom",    "dc": "http://purl.org/dc/elements/1.1/",    "content": "http://purl.org/rss/1.0/modules/content/",    "media": "http://search.yahoo.com/mrss/",}FEED_TYPES = ("application/rss+xml", "application/atom+xml", "application/feed+json", "application/json")COMMON_FEED_PATHS = ("/feed", "/feed.xml", "/rss", "/rss.xml", "/atom.xml", "/index.xml", "/feed.json", "/blog/feed", "/blog/rss.xml")_TAG_RE = re.compile(r"<[^>]+>")_WS_RE = re.compile(r"\s+")  def fetch(url: str) -> tuple[bytes, str]:    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"})    with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:        return resp.read(), resp.headers.get("Content-Type", "")  def strip_html(text: str | None) -> str:    if not text:        return ""    return _WS_RE.sub(" ", html.unescape(_TAG_RE.sub(" ", text))).strip()  def parse_date(value: str | None) -> str | None:    """Normalise RFC 822 (RSS) and ISO 8601 (Atom/JSON Feed) dates to UTC ISO."""    if not value:        return None    value = value.strip()    try:        dt = parsedate_to_datetime(value)    except (TypeError, ValueError):        try:            dt = datetime.fromisoformat(value.replace("Z", "+00:00"))        except ValueError:            return value    if dt.tzinfo is None:        dt = dt.replace(tzinfo=timezone.utc)    return dt.astimezone(timezone.utc).isoformat()  def _text(el, *paths) -> str | None:    for p in paths:        found = el.find(p, NS)        if found is not None and (found.text or "").strip():            return found.text    return None  def _atom_link(entry) -> str | None:    alternate = None    for link in entry.findall("atom:link", NS) + entry.findall("link"):        href = link.get("href")        if not href:            continue        rel = link.get("rel", "alternate")        if rel == "alternate":            return href        alternate = alternate or href    return alternate  def parse_xml(data: bytes) -> dict:    root = ET.fromstring(data)    tag = root.tag.rsplit("}", 1)[-1].lower()    if tag == "feed":  # Atom        title = strip_html(_text(root, "atom:title"))        entries = []        for e in root.findall("atom:entry", NS):            entries.append({                "title": strip_html(_text(e, "atom:title")),                "link": _atom_link(e),                "published": parse_date(_text(e, "atom:published", "atom:updated")),                "author": strip_html(_text(e, "atom:author/atom:name", "dc:creator")),                "summary": strip_html(_text(e, "atom:summary", "atom:content"))[:2000],            })        return {"format": "atom", "title": title, "entries": entries}    channel = root.find("channel") if tag == "rss" else root  # RSS 2.0 vs RDF/RSS 1.0    if channel is None:        raise ValueError(f"unrecognised XML root <{tag}>")    entries = []    for item in channel.iter("item") if tag == "rss" else root.iter("{http://purl.org/rss/1.0/}item"):        entries.append({            "title": strip_html(_text(item, "title", "{http://purl.org/rss/1.0/}title")),            "link": (_text(item, "link", "{http://purl.org/rss/1.0/}link") or "").strip() or None,            "published": parse_date(_text(item, "pubDate", "dc:date")),            "author": strip_html(_text(item, "dc:creator", "author")),            "summary": strip_html(_text(item, "content:encoded", "description", "{http://purl.org/rss/1.0/}description"))[:2000],        })    return {"format": "rss", "title": strip_html(_text(channel, "title", "{http://purl.org/rss/1.0/}title")), "entries": entries}  def parse_json_feed(data: bytes) -> dict:    doc = json.loads(data)    entries = []    for item in doc.get("items", []):        authors = item.get("authors") or ([item["author"]] if item.get("author") else [])        entries.append({            "title": strip_html(item.get("title")),            "link": item.get("url") or item.get("external_url"),            "published": parse_date(item.get("date_published") or item.get("date_modified")),            "author": ", ".join(a.get("name", "") for a in authors if isinstance(a, dict)) or None,            "summary": strip_html(item.get("summary") or item.get("content_text") or item.get("content_html"))[:2000],        })    return {"format": "jsonfeed", "title": strip_html(doc.get("title")), "entries": entries}  def parse_feed(data: bytes, content_type: str = "") -> dict:    head = data.lstrip()[:1]    if head == b"{" or "json" in content_type:        return parse_json_feed(data)    return parse_xml(data)  def discover(page_url: str, page_html: bytes | None = None) -> list[str]:    """Return candidate feed URLs for a page: <link rel=alternate> first, then well-known paths."""    if page_html is None:        page_html, _ = fetch(page_url)    text = page_html.decode("utf-8", "replace")    found: list[str] = []    for m in re.finditer(r"<link\b[^>]*>", text, re.I):        tag = m.group(0)        type_m = re.search(r"""type\s*=\s*["']([^"']+)""", tag, re.I)        href_m = re.search(r"""href\s*=\s*["']([^"']+)""", tag, re.I)        rel_m = re.search(r"""rel\s*=\s*["']([^"']+)""", tag, re.I)        if not href_m or not type_m or type_m.group(1).lower() not in FEED_TYPES:            continue        if rel_m and "alternate" not in rel_m.group(1).lower():            continue        url = urllib.parse.urljoin(page_url, html.unescape(href_m.group(1)))        if url not in found:            found.append(url)    if found:        return found    parsed = urllib.parse.urlsplit(page_url)    base = f"{parsed.scheme}://{parsed.netloc}"    return [base + p for p in COMMON_FEED_PATHS]  def looks_like_feed(data: bytes, content_type: str) -> bool:    head = data.lstrip()[:300].lower()    return head.startswith(b"{") and b"items" in data[:2000] or b"<rss" in head or b"<feed" in head or b"<rdf" in head  def read(url: str) -> dict:    data, ctype = fetch(url)    if looks_like_feed(data, ctype):        feed = parse_feed(data, ctype)        feed["url"] = url        return feed    candidates = discover(url, data)    errors = []    for cand in candidates:        try:            cdata, cctype = fetch(cand)        except (urllib.error.URLError, OSError) as exc:            errors.append(f"{cand}: {exc}")            continue        if looks_like_feed(cdata, cctype):            feed = parse_feed(cdata, cctype)            feed["url"] = cand            feed["discovered_from"] = url            return feed    raise SystemExit(f"no feed found at {url}; tried {len(candidates)} candidates\n" + "\n".join(errors))  def filter_entries(entries: list[dict], limit: int, since: str | None) -> list[dict]:    if since:        cutoff = datetime.fromisoformat(since).replace(tzinfo=timezone.utc) if "T" not in since else datetime.fromisoformat(since.replace("Z", "+00:00"))        if cutoff.tzinfo is None:            cutoff = cutoff.replace(tzinfo=timezone.utc)        entries = [e for e in entries if e["published"] and datetime.fromisoformat(e["published"]) >= cutoff]    entries.sort(key=lambda e: e["published"] or "", reverse=True)    return entries[:limit]  def render_text(feed: dict) -> str:    lines = [f"{feed.get('title') or '(untitled feed)'}  [{feed['format']}]  {feed['url']}"]    for e in feed["entries"]:        when = (e["published"] or "")[:10]        by = f"  — {e['author']}" if e.get("author") else ""        lines.append(f"- {when}  {e['title'] or '(no title)'}{by}\n  {e['link'] or ''}")        if e.get("summary"):            lines.append(f"  {e['summary'][:300]}")    return "\n".join(lines)  def main(argv: list[str] | None = None) -> int:    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)    sub = ap.add_subparsers(dest="cmd", required=True)    r = sub.add_parser("read", help="read a feed (or discover one behind a page URL)")    r.add_argument("url")    r.add_argument("--limit", type=int, default=20)    r.add_argument("--since", help="ISO date/datetime; drop older entries")    r.add_argument("--json", action="store_true")    d = sub.add_parser("discover", help="list feed URLs advertised by a page")    d.add_argument("url")    d.add_argument("--json", action="store_true")    args = ap.parse_args(argv)     try:        if args.cmd == "discover":            urls = discover(args.url)            print(json.dumps(urls, indent=2) if args.json else "\n".join(urls))            return 0        feed = read(args.url)        feed["entries"] = filter_entries(feed["entries"], args.limit, args.since)        print(json.dumps(feed, indent=2, ensure_ascii=False) if args.json else render_text(feed))        return 0    except urllib.error.HTTPError as exc:        print(f"HTTP {exc.code} for {exc.url}", file=sys.stderr)        return 2    except (urllib.error.URLError, ET.ParseError, ValueError, json.JSONDecodeError) as exc:        print(f"error: {exc}", file=sys.stderr)        return 2  if __name__ == "__main__":    sys.exit(main()) 
Referenced from SKILL.md