diff --git a/requirements.txt b/requirements.txt
index 2c361251a3..cb3874124d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,6 +12,7 @@ Flask-OpenID==1.3.0
Flask-WTF==1.2.1
bleach==6.1.0
emoji==2.9.0
+feedgen==1.0.0
humanize==4.9.0
mistune==2.0.5
pybadges==3.0.1
diff --git a/tests/tests_feeds.py b/tests/tests_feeds.py
new file mode 100644
index 0000000000..628bf06422
--- /dev/null
+++ b/tests/tests_feeds.py
@@ -0,0 +1,72 @@
+import unittest
+import xml.etree.ElementTree as ET
+from unittest.mock import patch, Mock
+from webapp.app import create_app
+
+
+class TestFeeds(unittest.TestCase):
+ def setUp(self):
+ self.app = create_app(testing=True)
+ self.client = self.app.test_client()
+
+ @patch("webapp.feeds.feeds.session.get")
+ def test_feeds_updates_success(self, mock_get):
+ """Test successful RSS feed generation using feedgen"""
+ mock_response = Mock()
+ mock_response.json.return_value = {
+ "page": 1,
+ "size": 2,
+ "snaps": [
+ {
+ "name": "test-snap",
+ "title": "Test Snap",
+ "summary": "A test snap",
+ "publisher": "Test Publisher",
+ "license": "MIT",
+ "version": "1.0.0",
+ "last_updated": "Thu, 02 Oct 2025 22:07:58 GMT",
+ "icon": "https://example.com/icon.png",
+ "snap_id": "test-snap-id-123",
+ "media": [
+ {
+ "type": "screenshot",
+ "url": "https://example.com/screenshot.png",
+ }
+ ],
+ }
+ ],
+ }
+ mock_response.raise_for_status.return_value = None
+ mock_get.return_value = mock_response
+
+ response = self.client.get("/feeds/updates")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.content_type, "application/rss+xml; charset=utf-8"
+ )
+ content = response.get_data(as_text=True)
+ self.assertIn("", content)
+ self.assertIn(
+ "
Snapcraft - recently updated snaps", content
+ )
+
+ try:
+ root = ET.fromstring(content)
+ except ET.ParseError as e:
+ self.fail(f"Returned content is not valid XML: {e}")
+
+ self.assertEqual(root.tag, "rss")
+ channel = root.find("channel")
+ self.assertIsNotNone(channel, "No element found in RSS feed")
+
+ items = channel.findall("item")
+ self.assertTrue(
+ any(item.findtext("title") == "Test Snap" for item in items)
+ )
+ self.assertIn("", content)
+ self.assertIn("Test Publisher", content)
+ self.assertIn("https://snapcraft.io/test-snap", content)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/webapp/app.py b/webapp/app.py
index bf8f4c59a1..00082e7764 100644
--- a/webapp/app.py
+++ b/webapp/app.py
@@ -34,6 +34,7 @@
from webapp.endpoints.validation_sets import validation_sets
from webapp.endpoints.invites import invites
from webapp.endpoints.settings import settings
+from webapp.feeds.feeds import feeds
from webapp.config import SENTRY_DSN
@@ -81,6 +82,7 @@ def inject_csrf_token():
app.register_blueprint(validation_sets)
app.register_blueprint(invites)
app.register_blueprint(settings)
+ app.register_blueprint(feeds)
init_docs(app, "/docs")
init_blog(app, "/blog")
init_tutorials(app, "/tutorials")
diff --git a/webapp/feeds/__init__.py b/webapp/feeds/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/webapp/feeds/feeds.py b/webapp/feeds/feeds.py
new file mode 100644
index 0000000000..cbe6c7bcc7
--- /dev/null
+++ b/webapp/feeds/feeds.py
@@ -0,0 +1,139 @@
+import flask
+import requests
+from datetime import datetime, timezone
+from flask import Response
+from feedgen.feed import FeedGenerator
+from requests import Session
+from html import escape
+from urllib.parse import urlparse
+
+feeds = flask.Blueprint(
+ "feeds",
+ __name__,
+)
+
+session = Session()
+
+
+def is_safe_url(url):
+ """Check if URL is safe (http/https only)."""
+ if not url:
+ return False
+ try:
+ parsed = urlparse(url)
+ return parsed.scheme in ("http", "https") and parsed.netloc
+ except Exception:
+ return False
+
+
+def parse_snap_date(date_string):
+ """Parse date string from API to datetime object."""
+ try:
+ dt = datetime.strptime(date_string, "%a, %d %b %Y %H:%M:%S %Z")
+ return dt.replace(tzinfo=timezone.utc)
+ except ValueError:
+ return datetime.now(timezone.utc)
+
+
+def create_snap_description(snap):
+ """Create HTML description for RSS item."""
+ description_parts = []
+
+ if snap.get("icon") and is_safe_url(snap["icon"]):
+ icon_url = escape(snap["icon"])
+ description_parts.append(
+ f'
'
+ )
+
+ if snap.get("summary"):
+ summary = escape(snap["summary"])
+ description_parts.append(f"{summary}
")
+
+ additional_info = []
+
+ if snap.get("publisher"):
+ publisher = escape(snap["publisher"])
+ additional_info.append(f"Developer: {publisher}")
+
+ if snap.get("version"):
+ version = escape(snap["version"])
+ additional_info.append(f"Version: {version}")
+
+ if additional_info:
+ description_parts.append("" + "".join(additional_info) + "
")
+
+ if snap.get("media"):
+ for media in snap["media"]:
+ if (
+ media.get("type") == "screenshot"
+ and media.get("url")
+ and is_safe_url(media["url"])
+ ):
+ media_url = escape(media["url"])
+ description_parts.append(f'
')
+
+ return "".join(description_parts)
+
+
+@feeds.route("/feeds/updates")
+def recently_updated_feed():
+ """Generate RSS feed for recently updated snaps."""
+
+ fg = FeedGenerator()
+ fg.title("Snapcraft - recently updated snaps")
+ fg.link(href="https://snapcraft.io/store", rel="alternate")
+ fg.description("Recently updated snaps published on Snapcraft")
+ fg.language("en")
+ fg.docs("http://www.rssboard.org/rss-specification")
+ fg.generator("python-feedgen")
+
+ size = int(flask.request.args.get("size", "50"))
+
+ page = int(flask.request.args.get("page", "1"))
+
+ try:
+ api_url = flask.current_app.config.get(
+ "RECOMMENDATION_API_URL",
+ "https://recommendations.snapcraft.io/api/recently-updated",
+ )
+ params = {"size": size, "page": page}
+ response = session.get(api_url, params=params, timeout=10)
+ response.raise_for_status()
+
+ data = response.json()
+ snaps = data.get("snaps", [])
+
+ except (requests.RequestException, ValueError) as e:
+ flask.current_app.logger.error(f"Failed to fetch recommendations: {e}")
+ snaps = []
+
+ for snap in snaps:
+ try:
+ fe = fg.add_entry()
+
+ title = escape(snap.get("title"))
+
+ fe.title(title)
+
+ snap_name = snap.get("name")
+ snap_url = f"https://snapcraft.io/{snap_name}"
+ fe.link(href=snap_url)
+
+ description = create_snap_description(snap)
+ fe.description(description)
+
+ pub_date = parse_snap_date(snap["last_updated"])
+ fe.pubDate(pub_date)
+
+ except Exception as e:
+ flask.current_app.logger.error(
+ f"Failed to add snap to RSS feed: {e}"
+ )
+ continue
+
+ rss_str = fg.rss_str(pretty=True)
+
+ response = Response(rss_str, mimetype="application/rss+xml")
+ response.headers["Cache-Control"] = "public, max-age=86400"
+
+ return response