Created
May 24, 2026 20:49
-
-
Save andysylvester/fa359c07cae9542aaf153f69209acc78 to your computer and use it in GitHub Desktop.
Python script to convert a news sitemap file to a RSS feed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| sitemap_to_rss.py | |
| ================= | |
| Convert a sitemap XML file (URL or local path) into an RSS 2.0 feed. | |
| Supported namespaces | |
| -------------------- | |
| • http://www.sitemaps.org/schemas/sitemap/0.9 (standard sitemap) | |
| • http://www.google.com/schemas/sitemap-news/0.9 (Google News extension) | |
| • http://www.google.com/schemas/sitemap-image/1.1 (Google Image extension) | |
| Sitemap index files (sitemapindex) are detected automatically; use | |
| --follow-index to recursively fetch and merge all child sitemaps. | |
| Images are emitted as both <enclosure> (first image, RSS 2.0 core) and | |
| <media:content> elements (Media RSS, all images). Keywords / genres from | |
| Google News appear in <category> elements. | |
| Usage examples | |
| -------------- | |
| python sitemap_to_rss.py https://www.usatoday.com/news-sitemap.xml | |
| python sitemap_to_rss.py sitemap.xml -o feed.rss | |
| python sitemap_to_rss.py https://example.com/sitemap.xml \\ | |
| --title "My Feed" --link https://example.com --max-items 50 | |
| python sitemap_to_rss.py https://example.com/sitemap-index.xml --follow-index | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import mimetypes | |
| import re | |
| import sys | |
| import urllib.request | |
| import urllib.error | |
| from datetime import datetime, timezone | |
| from email.utils import format_datetime | |
| from pathlib import Path | |
| from typing import Optional | |
| from urllib.parse import urlparse | |
| from xml.dom import minidom | |
| from xml.etree import ElementTree as ET | |
| # --------------------------------------------------------------------------- | |
| # Namespace constants | |
| # --------------------------------------------------------------------------- | |
| NS_SITEMAP = "http://www.sitemaps.org/schemas/sitemap/0.9" | |
| NS_NEWS = "http://www.google.com/schemas/sitemap-news/0.9" | |
| NS_IMAGE = "http://www.google.com/schemas/sitemap-image/1.1" | |
| NS_MEDIA = "http://search.yahoo.com/mrss/" | |
| NS_DC = "http://purl.org/dc/elements/1.1/" | |
| # Clark-notation helpers | |
| SM = f"{{{NS_SITEMAP}}}" | |
| N = f"{{{NS_NEWS}}}" | |
| IMG = f"{{{NS_IMAGE}}}" | |
| # --------------------------------------------------------------------------- | |
| # HTTP / file fetching | |
| # --------------------------------------------------------------------------- | |
| _USER_AGENT = ( | |
| "Mozilla/5.0 (compatible; SitemapToRSS/1.0; " | |
| "+https://github.com/)" | |
| ) | |
| def fetch_xml(source: str) -> bytes: | |
| """Return raw bytes from a URL or local file path.""" | |
| if source.startswith(("http://", "https://")): | |
| req = urllib.request.Request(source, headers={"User-Agent": _USER_AGENT}) | |
| try: | |
| with urllib.request.urlopen(req, timeout=30) as resp: | |
| return resp.read() | |
| except urllib.error.HTTPError as exc: | |
| raise SystemExit(f"HTTP {exc.code} fetching {source}: {exc.reason}") from exc | |
| except urllib.error.URLError as exc: | |
| raise SystemExit(f"Network error fetching {source}: {exc.reason}") from exc | |
| else: | |
| path = Path(source) | |
| if not path.is_file(): | |
| raise SystemExit(f"File not found: {source}") | |
| return path.read_bytes() | |
| def parse_root(source: str) -> ET.Element: | |
| """Fetch *source* and return the parsed XML root element.""" | |
| raw = fetch_xml(source) | |
| try: | |
| return ET.fromstring(raw) | |
| except ET.ParseError as exc: | |
| raise SystemExit(f"XML parse error in {source}: {exc}") from exc | |
| # --------------------------------------------------------------------------- | |
| # Date helpers | |
| # --------------------------------------------------------------------------- | |
| _ISO_PATTERNS = [ | |
| "%Y-%m-%dT%H:%M:%S%z", | |
| "%Y-%m-%dT%H:%M:%S.%f%z", | |
| "%Y-%m-%d", | |
| ] | |
| def parse_date(date_str: str) -> Optional[datetime]: | |
| """Parse an ISO 8601 / RFC 3339 string into a tz-aware datetime.""" | |
| if not date_str: | |
| return None | |
| # Normalise trailing Z → +00:00 for broad Python version support | |
| s = date_str.strip().replace("Z", "+00:00") | |
| for fmt in _ISO_PATTERNS: | |
| try: | |
| dt = datetime.strptime(s, fmt) | |
| if dt.tzinfo is None: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| return dt | |
| except ValueError: | |
| continue | |
| return None | |
| def to_rss_date(dt: Optional[datetime]) -> str: | |
| """Format *dt* as an RFC 2822 string suitable for RSS <pubDate>.""" | |
| if dt is None: | |
| dt = datetime.now(timezone.utc) | |
| if dt.tzinfo is None: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| return format_datetime(dt) | |
| # --------------------------------------------------------------------------- | |
| # XML helpers | |
| # --------------------------------------------------------------------------- | |
| def _text(el: Optional[ET.Element], *tags: str) -> str: | |
| """ | |
| Return stripped text of the first matching child tag, trying each tag in | |
| *tags* until one is found. Falls back to "" if none match. | |
| Supports Clark-notation tags, e.g. "{http://...}name". | |
| """ | |
| if el is None: | |
| return "" | |
| for tag in tags: | |
| child = el.find(tag) | |
| if child is not None and child.text: | |
| return child.text.strip() | |
| return "" | |
| # --------------------------------------------------------------------------- | |
| # Sitemap parsing | |
| # --------------------------------------------------------------------------- | |
| def _is_index(root: ET.Element) -> bool: | |
| """Return True when *root* is a <sitemapindex> element.""" | |
| local = root.tag.split("}")[-1] if "}" in root.tag else root.tag | |
| return local == "sitemapindex" | |
| def _child_sitemap_urls(root: ET.Element) -> list[str]: | |
| """Extract <loc> values from a sitemapindex root.""" | |
| urls = [] | |
| for sm_el in root.findall(f"{SM}sitemap") + root.findall("sitemap"): | |
| loc = _text(sm_el, f"{SM}loc", "loc") | |
| if loc: | |
| urls.append(loc) | |
| return urls | |
| def _extract_url_elements(root: ET.Element) -> list[ET.Element]: | |
| """Return all <url> child elements regardless of namespace prefix.""" | |
| els = root.findall(f"{SM}url") | |
| if not els: | |
| els = root.findall("url") | |
| return els | |
| _EXTRA_MIME: dict[str, str] = { | |
| # Types not always registered in the system mimetypes database | |
| ".webp": "image/webp", | |
| ".avif": "image/avif", | |
| ".heic": "image/heic", | |
| ".heif": "image/heif", | |
| ".jxl": "image/jxl", | |
| ".svg": "image/svg+xml", | |
| } | |
| def _image_mime(url: str) -> str: | |
| """Guess a MIME type from an image URL extension; fall back to image/jpeg.""" | |
| path = url.split("?")[0].lower() | |
| for ext, mime in _EXTRA_MIME.items(): | |
| if path.endswith(ext): | |
| return mime | |
| mime, _ = mimetypes.guess_type(path) | |
| return mime if mime and mime.startswith("image/") else "image/jpeg" | |
| def extract_items(root: ET.Element) -> list[dict]: | |
| """ | |
| Parse all <url> entries in *root* into a list of dicts with keys: | |
| Standard sitemap | |
| ---------------- | |
| loc, lastmod, changefreq, priority | |
| Google News (news:*) | |
| -------------------- | |
| news_title, news_pub_date, news_keywords, news_genres, | |
| news_pub_name, news_pub_language | |
| Google Image (image:*) | |
| ---------------------- | |
| images → list of {"loc", "caption", "title", "license"} | |
| """ | |
| items: list[dict] = [] | |
| for url_el in _extract_url_elements(root): | |
| item: dict = { | |
| # Standard sitemap | |
| "loc": _text(url_el, f"{SM}loc", "loc"), | |
| "lastmod": _text(url_el, f"{SM}lastmod", "lastmod"), | |
| "changefreq": _text(url_el, f"{SM}changefreq", "changefreq"), | |
| "priority": _text(url_el, f"{SM}priority", "priority"), | |
| # Google News (populated below) | |
| "news_title": "", | |
| "news_pub_date": "", | |
| "news_keywords": "", | |
| "news_genres": "", | |
| "news_pub_name": "", | |
| "news_pub_language": "", | |
| # Google Image (populated below) | |
| "images": [], | |
| } | |
| # ---- Google News extension ---- | |
| news_el = url_el.find(f"{N}news") | |
| if news_el is not None: | |
| pub_el = news_el.find(f"{N}publication") | |
| item["news_title"] = _text(news_el, f"{N}title") | |
| item["news_pub_date"] = _text(news_el, f"{N}publication_date") | |
| item["news_keywords"] = _text(news_el, f"{N}keywords") | |
| item["news_genres"] = _text(news_el, f"{N}genres") | |
| item["news_pub_name"] = _text(pub_el, f"{N}name") if pub_el is not None else "" | |
| item["news_pub_language"] = _text(pub_el, f"{N}language") if pub_el is not None else "" | |
| # ---- Google Image extension ---- | |
| for img_el in url_el.findall(f"{IMG}image"): | |
| item["images"].append({ | |
| "loc": _text(img_el, f"{IMG}loc"), | |
| "caption": _text(img_el, f"{IMG}caption"), | |
| "title": _text(img_el, f"{IMG}title"), | |
| "license": _text(img_el, f"{IMG}license"), | |
| }) | |
| if item["loc"]: | |
| items.append(item) | |
| return items | |
| def collect_items( | |
| source: str, | |
| follow_index: bool = False, | |
| visited: Optional[set] = None, | |
| ) -> list[dict]: | |
| """ | |
| Fetch *source*, parse it, and return all URL items. | |
| If *follow_index* is True and the sitemap is a sitemapindex, recursively | |
| fetches each child sitemap and merges their items. | |
| """ | |
| if visited is None: | |
| visited = set() | |
| if source in visited: | |
| return [] | |
| visited.add(source) | |
| print(f" Fetching: {source}", file=sys.stderr) | |
| root = parse_root(source) | |
| if _is_index(root): | |
| child_urls = _child_sitemap_urls(root) | |
| print( | |
| f" → Sitemap index with {len(child_urls)} child sitemaps.", | |
| file=sys.stderr, | |
| ) | |
| if not follow_index: | |
| print( | |
| " (Use --follow-index to fetch child sitemaps.)", | |
| file=sys.stderr, | |
| ) | |
| return [] | |
| all_items: list[dict] = [] | |
| for url in child_urls: | |
| all_items.extend(collect_items(url, follow_index, visited)) | |
| return all_items | |
| return extract_items(root) | |
| # --------------------------------------------------------------------------- | |
| # Channel metadata auto-detection | |
| # --------------------------------------------------------------------------- | |
| def _infer_channel_meta( | |
| source: str, | |
| items: list[dict], | |
| title_override: str, | |
| link_override: str, | |
| desc_override: str, | |
| lang_override: str, | |
| ) -> tuple[str, str, str, str]: | |
| """Return (title, link, description, language) from overrides + heuristics.""" | |
| title = title_override | |
| link = link_override | |
| lang = lang_override | |
| desc = desc_override | |
| # Infer from Google News publication fields | |
| for item in items: | |
| if not title and item.get("news_pub_name"): | |
| title = item["news_pub_name"] | |
| if lang == "en" and item.get("news_pub_language"): | |
| lang = item["news_pub_language"] | |
| if not link and item.get("loc"): | |
| parsed = urlparse(item["loc"]) | |
| link = f"{parsed.scheme}://{parsed.netloc}" | |
| if title and link: | |
| break | |
| # Final fallbacks | |
| if not title: | |
| parsed = urlparse(source) | |
| title = parsed.netloc or source | |
| if not link: | |
| parsed = urlparse(source) | |
| link = f"{parsed.scheme}://{parsed.netloc}" if parsed.netloc else source | |
| if not desc: | |
| desc = f"RSS feed generated from {source}" | |
| return title, link, desc, lang | |
| # --------------------------------------------------------------------------- | |
| # RSS 2.0 builder | |
| # --------------------------------------------------------------------------- | |
| def _sort_key(item: dict) -> datetime: | |
| """Return a tz-aware datetime used to sort items newest-first.""" | |
| ds = item.get("news_pub_date") or item.get("lastmod") or "" | |
| return parse_date(ds) or datetime.min.replace(tzinfo=timezone.utc) | |
| def build_rss( | |
| items: list[dict], | |
| channel_title: str, | |
| channel_link: str, | |
| channel_description: str, | |
| channel_language: str, | |
| max_items: Optional[int] = None, | |
| source: str = "", | |
| ) -> ET.Element: | |
| """ | |
| Build and return an <rss> ElementTree element (RSS 2.0). | |
| Mapping summary | |
| --------------- | |
| news:title → <title> + <description> fallback | |
| news:publication_date → <pubDate> | |
| news:publication/name → <dc:creator> + channel <title> heuristic | |
| news:keywords → <category> elements | |
| news:genres → <category> elements | |
| image:image/loc → first: <enclosure>; all: <media:content> | |
| image:image/caption → <media:description> | |
| image:image/title → <media:title> | |
| sitemap:lastmod → <pubDate> fallback | |
| sitemap:loc → <link> + <guid isPermaLink="true"> | |
| """ | |
| # Register namespaces so ElementTree emits them with clean prefixes. | |
| # Do NOT also set xmlns:* attributes manually — that would duplicate them. | |
| ET.register_namespace("media", NS_MEDIA) | |
| ET.register_namespace("dc", NS_DC) | |
| # Root element | |
| rss = ET.Element("rss") | |
| rss.set("version", "2.0") | |
| channel = ET.SubElement(rss, "channel") | |
| # ---- Channel metadata ---- | |
| ET.SubElement(channel, "title").text = channel_title | |
| ET.SubElement(channel, "link").text = channel_link | |
| ET.SubElement(channel, "description").text = channel_description | |
| ET.SubElement(channel, "language").text = channel_language | |
| ET.SubElement(channel, "generator").text = "sitemap_to_rss.py" | |
| ET.SubElement(channel, "lastBuildDate").text = to_rss_date(datetime.now(timezone.utc)) | |
| if source: | |
| ET.SubElement(channel, "docs").text = "https://www.rssboard.org/rss-specification" | |
| # ---- Sort and optionally truncate ---- | |
| sorted_items = sorted(items, key=_sort_key, reverse=True) | |
| if max_items and max_items > 0: | |
| sorted_items = sorted_items[:max_items] | |
| # ---- Items ---- | |
| for item in sorted_items: | |
| entry = ET.SubElement(channel, "item") | |
| # Title | |
| title = item.get("news_title") or item.get("loc", "Untitled") | |
| ET.SubElement(entry, "title").text = title | |
| # Link | |
| link = item.get("loc", "") | |
| ET.SubElement(entry, "link").text = link | |
| # GUID | |
| guid = ET.SubElement(entry, "guid") | |
| guid.set("isPermaLink", "true") | |
| guid.text = link | |
| # Publication date (news date preferred; lastmod as fallback) | |
| ds = item.get("news_pub_date") or item.get("lastmod") | |
| ET.SubElement(entry, "pubDate").text = to_rss_date(parse_date(ds)) | |
| # Description | |
| desc_parts: list[str] = [] | |
| if item.get("news_keywords"): | |
| desc_parts.append(f"Keywords: {item['news_keywords']}") | |
| if item.get("news_genres"): | |
| desc_parts.append(f"Genres: {item['news_genres']}") | |
| if item.get("changefreq"): | |
| desc_parts.append(f"Change frequency: {item['changefreq']}") | |
| ET.SubElement(entry, "description").text = ( | |
| " | ".join(desc_parts) if desc_parts else title | |
| ) | |
| # dc:creator (news publication name) | |
| if item.get("news_pub_name"): | |
| ET.SubElement(entry, f"{{{NS_DC}}}creator").text = item["news_pub_name"] | |
| # dc:language | |
| if item.get("news_pub_language"): | |
| ET.SubElement(entry, f"{{{NS_DC}}}language").text = item["news_pub_language"] | |
| # <category> — keywords | |
| if item.get("news_keywords"): | |
| for kw in re.split(r"[,;]\s*", item["news_keywords"]): | |
| kw = kw.strip() | |
| if kw: | |
| ET.SubElement(entry, "category").text = kw | |
| # <category> — genres | |
| if item.get("news_genres"): | |
| for genre in re.split(r"[,;]\s*", item["news_genres"]): | |
| genre = genre.strip() | |
| if genre: | |
| cat = ET.SubElement(entry, "category") | |
| cat.set("domain", "genre") | |
| cat.text = genre | |
| # Images | |
| images = item.get("images", []) | |
| for idx, img in enumerate(images): | |
| img_url = img.get("loc", "") | |
| if not img_url: | |
| continue | |
| mime = _image_mime(img_url) | |
| # First image → RSS 2.0 <enclosure> | |
| if idx == 0: | |
| enc = ET.SubElement(entry, "enclosure") | |
| enc.set("url", img_url) | |
| enc.set("type", mime) | |
| enc.set("length", "0") # length required by spec; 0 = unknown | |
| # All images → <media:content> (Media RSS) | |
| mc = ET.SubElement(entry, f"{{{NS_MEDIA}}}content") | |
| mc.set("url", img_url) | |
| mc.set("medium", "image") | |
| mc.set("type", mime) | |
| if img.get("title"): | |
| ET.SubElement(mc, f"{{{NS_MEDIA}}}title").text = img["title"] | |
| if img.get("caption"): | |
| ET.SubElement(mc, f"{{{NS_MEDIA}}}description").text = img["caption"] | |
| if img.get("license"): | |
| ET.SubElement(mc, f"{{{NS_MEDIA}}}license").text = img["license"] | |
| return rss | |
| # --------------------------------------------------------------------------- | |
| # Output formatting | |
| # --------------------------------------------------------------------------- | |
| def _pretty_xml(element: ET.Element) -> str: | |
| """Return a pretty-printed, UTF-8 XML string with a proper declaration.""" | |
| raw = ET.tostring(element, encoding="unicode") | |
| dom = minidom.parseString(raw) | |
| pretty = dom.toprettyxml(indent=" ", encoding=None) | |
| # toprettyxml adds its own <?xml?> declaration; keep it but normalise it | |
| # to an explicit UTF-8 declaration | |
| lines = pretty.splitlines() | |
| output_lines: list[str] = ['<?xml version="1.0" encoding="utf-8"?>'] | |
| for line in lines: | |
| stripped = line.rstrip() | |
| # Skip the declaration emitted by toprettyxml (first line) | |
| if stripped.startswith("<?xml") and len(output_lines) == 1: | |
| continue | |
| # Skip blank lines introduced by toprettyxml | |
| if stripped: | |
| output_lines.append(stripped) | |
| return "\n".join(output_lines) + "\n" | |
| def _compact_xml(element: ET.Element) -> str: | |
| """Return a compact XML string (no extra whitespace).""" | |
| raw = ET.tostring(element, encoding="unicode") | |
| return '<?xml version="1.0" encoding="utf-8"?>\n' + raw + "\n" | |
| # --------------------------------------------------------------------------- | |
| # CLI | |
| # --------------------------------------------------------------------------- | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| prog="sitemap_to_rss.py", | |
| description="Convert a sitemap XML (standard / Google News / Image) to RSS 2.0.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples | |
| -------- | |
| # Fetch a live news sitemap and print RSS to stdout | |
| python sitemap_to_rss.py https://www.usatoday.com/news-sitemap.xml | |
| # Save to a file, limit to 25 items | |
| python sitemap_to_rss.py https://www.bbc.com/sitemap.xml -o bbc.rss --max-items 25 | |
| # Process a local sitemap file with custom channel metadata | |
| python sitemap_to_rss.py sitemap.xml -o feed.rss \\ | |
| --title "My News Site" --link https://example.com | |
| # Follow a sitemap index and merge all child sitemaps | |
| python sitemap_to_rss.py https://example.com/sitemap-index.xml --follow-index -o all.rss | |
| """, | |
| ) | |
| parser.add_argument( | |
| "source", | |
| help="Sitemap URL (http/https) or local file path.", | |
| ) | |
| parser.add_argument( | |
| "-o", "--output", | |
| metavar="FILE", | |
| help="Write RSS output to FILE instead of stdout.", | |
| ) | |
| parser.add_argument( | |
| "--title", | |
| default="", | |
| metavar="TEXT", | |
| help="RSS channel <title> (auto-detected from news:publication if omitted).", | |
| ) | |
| parser.add_argument( | |
| "--link", | |
| default="", | |
| metavar="URL", | |
| help="RSS channel <link> (auto-detected from item URLs if omitted).", | |
| ) | |
| parser.add_argument( | |
| "--description", | |
| default="", | |
| metavar="TEXT", | |
| help="RSS channel <description>.", | |
| ) | |
| parser.add_argument( | |
| "--language", | |
| default="en", | |
| metavar="LANG", | |
| help="RSS channel <language> (default: en; auto-detected from news:language).", | |
| ) | |
| parser.add_argument( | |
| "--follow-index", | |
| action="store_true", | |
| help="If source is a sitemap index, fetch and merge all child sitemaps.", | |
| ) | |
| parser.add_argument( | |
| "--max-items", | |
| type=int, | |
| default=0, | |
| metavar="N", | |
| help="Keep only the N most-recent items (default: all).", | |
| ) | |
| parser.add_argument( | |
| "--no-pretty", | |
| action="store_true", | |
| help="Compact output (no indentation / extra whitespace).", | |
| ) | |
| return parser | |
| def main(argv: Optional[list[str]] = None) -> int: | |
| parser = build_parser() | |
| args = parser.parse_args(argv) | |
| # ---- Collect items ---- | |
| print(f"Processing sitemap: {args.source}", file=sys.stderr) | |
| items = collect_items(args.source, follow_index=args.follow_index) | |
| if not items: | |
| print( | |
| "Warning: No URL entries found. " | |
| "If this is a sitemap index, try --follow-index.", | |
| file=sys.stderr, | |
| ) | |
| print(f"Extracted {len(items)} URL entries.", file=sys.stderr) | |
| # ---- Infer channel metadata ---- | |
| title, link, desc, lang = _infer_channel_meta( | |
| source=args.source, | |
| items=items, | |
| title_override=args.title, | |
| link_override=args.link, | |
| desc_override=args.description, | |
| lang_override=args.language, | |
| ) | |
| # ---- Build RSS ---- | |
| rss = build_rss( | |
| items=items, | |
| channel_title=title, | |
| channel_link=link, | |
| channel_description=desc, | |
| channel_language=lang, | |
| max_items=args.max_items or None, | |
| source=args.source, | |
| ) | |
| # ---- Serialise ---- | |
| xml_text = _compact_xml(rss) if args.no_pretty else _pretty_xml(rss) | |
| if args.output: | |
| Path(args.output).write_text(xml_text, encoding="utf-8") | |
| print(f"RSS feed written to: {args.output}", file=sys.stderr) | |
| else: | |
| sys.stdout.write(xml_text) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment