diff --git a/apps/blogs/migrations/0004_normalize_blogentry_urls.py b/apps/blogs/migrations/0004_normalize_blogentry_urls.py new file mode 100644 index 000000000..39ddda703 --- /dev/null +++ b/apps/blogs/migrations/0004_normalize_blogentry_urls.py @@ -0,0 +1,32 @@ +"""Rewrite legacy pythoninsider.blogspot.com URLs to blog.python.org. + +Blogger's RSS feed historically served entry links under the old +pythoninsider.blogspot.com domain. This one-time migration normalizes +existing BlogEntry rows so that the parser's runtime normalization +(added in the same changeset) doesn't create duplicates via +update_or_create. +""" + +from django.db import migrations +from django.db.models import Value +from django.db.models.functions import Replace + + +def normalize_blogentry_urls(apps, schema_editor): + BlogEntry = apps.get_model("blogs", "BlogEntry") + BlogEntry.objects.filter(url__contains="pythoninsider.blogspot.com").update( + url=Replace("url", Value("pythoninsider.blogspot.com"), Value("blog.python.org")), + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("blogs", "0003_alter_relatedblog_creator_and_more"), + ] + + operations = [ + migrations.RunPython( + normalize_blogentry_urls, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apps/blogs/parser.py b/apps/blogs/parser.py index 8f557dd9a..5efd2b99f 100644 --- a/apps/blogs/parser.py +++ b/apps/blogs/parser.py @@ -1,6 +1,7 @@ """RSS feed parsing and blog supernav rendering utilities.""" import datetime +from urllib.parse import urlparse, urlunparse import feedparser from django.conf import settings @@ -9,6 +10,19 @@ from apps.blogs.models import BlogEntry, Feed from apps.boxes.models import Box +# Blogger serves RSS entry links with this legacy domain instead of +# the canonical blog.python.org hostname. +_BLOGGER_LEGACY_HOST = "pythoninsider.blogspot.com" + + +def _normalize_blog_url(url): + """Rewrite legacy Blogger URLs to the canonical blog.python.org domain.""" + parsed = urlparse(url) + if parsed.hostname == _BLOGGER_LEGACY_HOST: + canonical = urlparse(settings.PYTHON_BLOG_URL) + return urlunparse(parsed._replace(scheme=canonical.scheme, netloc=canonical.netloc)) + return url + def get_all_entries(feed_url): """Retrieve all entries from a feed URL.""" @@ -22,7 +36,7 @@ def get_all_entries(feed_url): "title": e["title"], "summary": e.get("summary", ""), "pub_date": published, - "url": e["link"], + "url": _normalize_blog_url(e["link"]), } entries.append(entry) diff --git a/apps/blogs/tests/test_parser.py b/apps/blogs/tests/test_parser.py index 1b1a83ae8..2600d39d9 100644 --- a/apps/blogs/tests/test_parser.py +++ b/apps/blogs/tests/test_parser.py @@ -1,7 +1,7 @@ import datetime import unittest -from apps.blogs.parser import get_all_entries +from apps.blogs.parser import _normalize_blog_url, get_all_entries from apps.blogs.tests.utils import get_test_rss_path @@ -24,3 +24,27 @@ def test_entries(self): self.entries[0]["url"], "http://feedproxy.google.com/~r/PythonInsider/~3/tGNCqyOiun4/introducing-electronic-contributor.html", ) + + +class NormalizeBlogUrlTest(unittest.TestCase): + def test_rewrites_pythoninsider_blogspot(self): + url = "https://pythoninsider.blogspot.com/2026/02/join-the-python-security-response-team.html" + self.assertEqual( + _normalize_blog_url(url), + "https://blog.python.org/2026/02/join-the-python-security-response-team.html", + ) + + def test_rewrites_http_to_canonical_scheme(self): + url = "http://pythoninsider.blogspot.com/2026/01/some-post.html" + self.assertEqual( + _normalize_blog_url(url), + "https://blog.python.org/2026/01/some-post.html", + ) + + def test_preserves_non_blogspot_urls(self): + url = "http://feedproxy.google.com/~r/PythonInsider/~3/abc/some-post.html" + self.assertEqual(_normalize_blog_url(url), url) + + def test_preserves_blog_python_org_urls(self): + url = "https://blog.python.org/2026/02/some-post.html" + self.assertEqual(_normalize_blog_url(url), url)