Skip to content

Commit c28d353

Browse files
Merge issue-162-podcast-canonicals: Restore .html podcast canonical routes (#162)
2 parents 3072230 + ebc2afc commit c28d353

6 files changed

Lines changed: 100 additions & 114 deletions

File tree

content/public_data.py

Lines changed: 11 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -124,15 +124,18 @@ class PodcastSeason:
124124

125125

126126
def podcast_public_path(record: dict[str, Any]) -> str:
127-
"""Return the canonical season/episode podcast URL without a file extension."""
127+
"""Return the checked canonical podcast detail path."""
128128

129-
season = _podcast_number(record, "season")
130-
episode = _podcast_number(record, "episode")
131-
title = record.get("title")
132-
if not isinstance(title, str) or not title.strip():
133-
raise ImproperlyConfigured("Public podcast title is required for its URL.")
134-
key = f"s{season:02d}e{episode:02d}"
135-
return f"/podcast/{key}/{event_title_slug(title)}"
129+
slug = record.get("slug")
130+
public_path = record.get("public_path")
131+
if (
132+
not isinstance(slug, str)
133+
or not slug
134+
or not isinstance(public_path, str)
135+
or public_path != f"/podcast/{slug}.html"
136+
):
137+
raise ImproperlyConfigured("Public podcast canonical path is invalid.")
138+
return public_path
136139

137140

138141
def _podcast_number(record: dict[str, Any], field: str) -> int:
@@ -320,61 +323,6 @@ def _apply_runtime_event_public_paths(
320323
)
321324

322325

323-
def _replace_exact_public_paths(value: Any, replacements: dict[str, str]) -> Any:
324-
if isinstance(value, str):
325-
return replacements.get(value, value)
326-
if isinstance(value, list):
327-
return [_replace_exact_public_paths(item, replacements) for item in value]
328-
if isinstance(value, dict):
329-
return {key: _replace_exact_public_paths(item, replacements) for key, item in value.items()}
330-
return value
331-
332-
333-
def _apply_runtime_podcast_public_paths(projection: dict[str, Any]) -> None:
334-
"""Expose season/episode podcast paths while retaining `.html` source routes as aliases."""
335-
336-
replacements: dict[str, str] = {}
337-
podcasts: list[dict[str, Any]] = []
338-
for raw_podcast in projection["podcasts"]:
339-
podcast = dict(raw_podcast)
340-
canonical = podcast_public_path(podcast)
341-
replacements[podcast["public_path"]] = canonical
342-
podcast["public_path"] = canonical
343-
podcasts.append(podcast)
344-
if len({podcast["public_path"] for podcast in podcasts}) != len(podcasts):
345-
raise ImproperlyConfigured("Public podcast canonical paths are not unique.")
346-
projection["podcasts"] = tuple(podcasts)
347-
projection["podcasts_by_path"] = {podcast["public_path"]: podcast for podcast in podcasts}
348-
projection["podcasts_by_slug"] = {
349-
slug: projection["podcasts_by_path"].get(podcast_public_path(podcast), podcast)
350-
for slug, podcast in projection["podcasts_by_slug"].items()
351-
}
352-
353-
for person in projection["people"]:
354-
relationships = person.get("relationships", ())
355-
person["relationships"] = tuple(
356-
{
357-
**relationship,
358-
"public_path": replacements.get(
359-
relationship.get("public_path"), relationship.get("public_path", "")
360-
),
361-
}
362-
for relationship in relationships
363-
)
364-
365-
projection["wiki_graph"] = _replace_exact_public_paths(projection["wiki_graph"], replacements)
366-
projection["wiki_search"] = _replace_exact_public_paths(projection["wiki_search"], replacements)
367-
368-
route_manifest = projection["editorial_route_migration"]
369-
projection["editorial_route_aliases_by_path"] = {
370-
item["source_path"]: {
371-
**item,
372-
"final_path": replacements.get(item["final_path"], item["final_path"]),
373-
}
374-
for item in route_manifest["aliases"]
375-
}
376-
377-
378326
def _expected_editorial_routes(
379327
projection: dict[str, Any],
380328
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
@@ -700,7 +648,6 @@ def _adapted_public_projection(
700648
}
701649
for person in source["people"]
702650
)
703-
_apply_runtime_podcast_public_paths(projection)
704651
_apply_runtime_event_public_paths(projection, runtime_identities)
705652
# The adapters mutate copied people records; refresh their lookup indexes as well so detail
706653
# pages and relationship tests do not retain references to the checked source records.

content/public_urls.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,18 @@
3636
path("podcast", public_views.podcast_hub, name="podcast"),
3737
path("podcast.html", public_views.permanent_public_redirect, {"target": "/podcast"}),
3838
path("podcast/", public_views.permanent_public_redirect, {"target": "/podcast"}),
39-
path(
40-
"podcast/<slug:episode_key>/<slug:title_slug>",
41-
public_views.podcast_detail_canonical,
42-
name="public-podcast-canonical",
43-
),
4439
path(
4540
"podcast/<path:slug>/",
4641
public_views.permanent_detail_redirect,
4742
{"collection": "podcast"},
4843
),
4944
path(
5045
"podcast/s24e06-how-to-build-ai-that-actually-ships-in-production.html",
51-
public_views.podcast_detail_legacy,
46+
public_views.podcast_detail,
5247
{"slug": "s24e06-how-to-build-ai-that-actually-ships-in-production"},
5348
name="podcast-ai-production",
5449
),
55-
path("podcast/<path:slug>.html", public_views.podcast_detail_legacy, name="public-podcast"),
50+
path("podcast/<path:slug>.html", public_views.podcast_detail, name="public-podcast"),
5651
path(
5752
"podcast/<path:slug>",
5853
public_views.permanent_detail_redirect,

content/public_views.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -622,24 +622,6 @@ def podcast_detail(request: HttpRequest, slug: str) -> HttpResponse:
622622
return _render_podcast_detail(request, episode)
623623

624624

625-
@require_safe
626-
def podcast_detail_canonical(
627-
request: HttpRequest, episode_key: str, title_slug: str
628-
) -> HttpResponse:
629-
episode = public_projection()["podcasts_by_path"].get(f"/podcast/{episode_key}/{title_slug}")
630-
if episode is None:
631-
raise Http404
632-
return _render_podcast_detail(request, episode)
633-
634-
635-
@require_safe
636-
def podcast_detail_legacy(request: HttpRequest, slug: str) -> HttpResponse:
637-
episode = public_projection()["podcasts_by_slug"].get(slug)
638-
if episode is None:
639-
raise Http404
640-
return permanent_public_redirect(request, target=episode["public_path"])
641-
642-
643625
@require_safe
644626
def book_detail(request: HttpRequest, slug: str) -> HttpResponse:
645627
book = public_projection()["books_by_slug"].get(slug)

content/tests/test_podcast_catalog.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,53 @@ def test_each_actual_season_contains_one_complete_season_and_all_details_once(se
185185
types = {item.get("@type") for item in json.loads(payload_match.group(1))["@graph"]}
186186
self.assertIn("PodcastEpisode", types)
187187

188+
def test_detail_routes_keep_html_finals_and_reject_competing_season_paths(self) -> None:
189+
projection = public_projection()
190+
podcasts = projection["podcasts"]
191+
migration = projection["editorial_route_migration"]
192+
podcast_finals = {
193+
item["final_path"] for item in migration["finals"] if item["collection"] == "podcasts"
194+
}
195+
podcast_aliases = [
196+
item for item in migration["aliases"] if item["collection"] == "podcasts"
197+
]
198+
199+
self.assertEqual(len(podcasts), 205)
200+
self.assertEqual({episode["public_path"] for episode in podcasts}, podcast_finals)
201+
self.assertEqual(len(podcast_finals), 205)
202+
self.assertTrue(
203+
all(path.startswith("/podcast/") and path.endswith(".html") for path in podcast_finals)
204+
)
205+
self.assertEqual(len(podcast_aliases), 410)
206+
self.assertEqual({item["final_path"] for item in podcast_aliases}, podcast_finals)
207+
208+
episode = podcasts[0]
209+
final_path = episode["public_path"]
210+
query = "utm_source=oncall%2Btest&x=a%2Fb&blank="
211+
for method in ("GET", "HEAD"):
212+
response = self.client.generic(method, f"{final_path}?{query}", follow=False)
213+
self.assertEqual(response.status_code, 200)
214+
self.assertNotIn("Location", response.headers)
215+
self.assertEqual(self.client.post(final_path).status_code, 405)
216+
217+
aliases = (final_path.removesuffix(".html"), f"{final_path.removesuffix('.html')}/")
218+
for alias_path in aliases:
219+
for method in ("GET", "HEAD"):
220+
response = self.client.generic(method, f"{alias_path}?{query}", follow=False)
221+
self.assertEqual(response.status_code, 301)
222+
self.assertEqual(response.headers["Location"], f"{final_path}?{query}")
223+
self.assertEqual(self.client.post(alias_path).status_code, 405)
224+
225+
competing_path = (
226+
f"/podcast/s{episode['season']:02d}e{episode['episode']:02d}/competing-title"
227+
)
228+
for method in ("GET", "HEAD"):
229+
response = self.client.generic(method, competing_path, follow=False)
230+
self.assertEqual(response.status_code, 404)
231+
self.assertNotIn("Location", response.headers)
232+
self.assertNotContains(response, 'rel="canonical"', status_code=404)
233+
self.assertEqual(self.client.post(competing_path).status_code, 405)
234+
188235
def test_latest_middle_and_oldest_emit_exact_seo_and_navigation(self) -> None:
189236
scenarios = (
190237
("/podcast", 24, "/podcast", "DataTalks.Club Podcast — DataTalks.Club", None, 23),

content/tests/test_public_routes_and_seo.py

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,6 @@ def test_editorial_detail_aliases_redirect_directly_to_html_canonicals(self) ->
8989
self.assertEqual(len(migration["aliases"]), 1_592)
9090
canonical_paths = {item["final_path"] for item in migration["finals"]}
9191
alias_map = {item["source_path"]: item["final_path"] for item in migration["aliases"]}
92-
runtime_canonical_paths = set()
93-
for item in migration["finals"]:
94-
runtime_path = item["final_path"]
95-
if item["collection"] == "podcasts":
96-
podcast = next(
97-
record
98-
for record in projection["podcasts"]
99-
if record["provenance"]["source_key"] == item["record_key"]
100-
)
101-
runtime_path = podcast["public_path"]
102-
runtime_canonical_paths.add(runtime_path)
10392
self.assertEqual(len(alias_map), 1_592)
10493
self.assertEqual(set(alias_map.values()), canonical_paths)
10594
self.assertTrue(set(alias_map).isdisjoint(canonical_paths))
@@ -118,31 +107,24 @@ def test_editorial_detail_aliases_redirect_directly_to_html_canonicals(self) ->
118107
with self.subTest(source=source):
119108
response = self.client.get(f"{source}?{query}", follow=False)
120109
self.assertEqual(response.status_code, 301)
121-
migration_entry = next(
122-
item for item in migration["aliases"] if item["source_path"] == source
123-
)
124-
expected_target = target
125-
if migration_entry["collection"] == "podcasts":
126-
podcast = next(
127-
record
128-
for record in projection["podcasts"]
129-
if record["provenance"]["source_key"] == migration_entry["record_key"]
130-
)
131-
expected_target = podcast["public_path"]
132-
self.assertEqual(response.headers["Location"], f"{expected_target}?{query}")
110+
self.assertEqual(response.headers["Location"], f"{target}?{query}")
133111
self.assertEqual(response.headers["X-Robots-Tag"], "noindex, nofollow")
134112
head = self.client.head(f"{source}?{query}", follow=False)
135113
self.assertEqual(head.status_code, 301)
136-
self.assertEqual(head.headers["Location"], f"{expected_target}?{query}")
114+
self.assertEqual(head.headers["Location"], f"{target}?{query}")
137115
self.assertEqual(self.client.post(source).status_code, 405)
138116

139-
for target in runtime_canonical_paths:
117+
for target in canonical_paths:
140118
with self.subTest(target=target):
141119
final = self.client.get(target, follow=False)
142120
self.assertEqual(final.status_code, 200)
143121
self.assertNotIn("Location", final.headers)
144122
self.assertEqual(final.headers["X-Robots-Tag"], "noindex, nofollow")
145123
self.assertEqual(self.client.post(target).status_code, 405)
124+
head = self.client.head(target, follow=False)
125+
self.assertEqual(head.status_code, 200)
126+
self.assertEqual(head.content, b"")
127+
self.assertNotIn("Location", head.headers)
146128
canonical_url = f"https://datatalks.club{target}"
147129
self.assertContains(
148130
final,

playwright_tests/test_podcast_seasons.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,39 @@ def test_alias_query_and_safe_denial_browser_matrix(page: Page, live_server) ->
331331
assert invalid_redirect.status == 301
332332
assert invalid_redirect.headers["location"] == "/podcast?page=2"
333333

334+
episode = ordered_podcasts()[0]
335+
final_path = episode["public_path"]
336+
detail_query = "utm_source=oncall%2Btest&x=a%2Fb&blank="
337+
for alias in (final_path.removesuffix(".html"), f"{final_path.removesuffix('.html')}/"):
338+
redirected = page.request.get(
339+
f"{origin}{alias}?{detail_query}",
340+
max_redirects=0,
341+
)
342+
assert redirected.status == 301
343+
assert redirected.headers["location"] == f"{final_path}?{detail_query}"
344+
head = page.request.head(f"{origin}{alias}?{detail_query}", max_redirects=0)
345+
assert head.status == 301
346+
assert head.headers["location"] == f"{final_path}?{detail_query}"
347+
348+
final = page.goto(f"{origin}{final_path}?{detail_query}", wait_until="networkidle")
349+
assert final is not None and final.status == 200
350+
expect(page).to_have_url(f"{origin}{final_path}?{detail_query}")
351+
expect(page.get_by_role("heading", name=episode["title"], exact=True)).to_be_visible()
352+
expect(page.locator('link[rel="canonical"]')).to_have_attribute(
353+
"href",
354+
f"https://datatalks.club{final_path}",
355+
)
356+
expect(page.locator('meta[property="og:url"]')).to_have_attribute(
357+
"content",
358+
f"https://datatalks.club{final_path}",
359+
)
360+
361+
competing_path = f"/podcast/s{episode['season']:02d}e{episode['episode']:02d}/competing-title"
362+
competing = page.request.get(f"{origin}{competing_path}", max_redirects=0)
363+
assert competing.status == 404
364+
assert "location" not in competing.headers
365+
assert "canonical" not in competing.text().casefold()
366+
334367
denials = (
335368
("GET", "/podcast?page=2", 400),
336369
("GET", "/podcast?season=01", 400),

0 commit comments

Comments
 (0)