Compare commits

..

No commits in common. "02ea6314c35a5ca3fef8240870619b9e893b1afc" and "28cd62b161e9f20836374f82f0c67fe1eb10776f" have entirely different histories.

4 changed files with 151 additions and 476 deletions

View file

@ -13,36 +13,6 @@ if TYPE_CHECKING:
urlpatterns: list[URLPattern | URLResolver] = [
path(route="sitemap.xml", view=core_views.sitemap_view, name="sitemap"),
path(
route="sitemap-static.xml",
view=core_views.sitemap_static_view,
name="sitemap-static",
),
path(
route="sitemap-twitch-channels.xml",
view=core_views.sitemap_twitch_channels_view,
name="sitemap-twitch-channels",
),
path(
route="sitemap-twitch-drops.xml",
view=core_views.sitemap_twitch_drops_view,
name="sitemap-twitch-drops",
),
path(
route="sitemap-twitch-others.xml",
view=core_views.sitemap_twitch_others_view,
name="sitemap-twitch-others",
),
path(
route="sitemap-kick.xml",
view=core_views.sitemap_kick_view,
name="sitemap-kick",
),
path(
route="sitemap-youtube.xml",
view=core_views.sitemap_youtube_view,
name="sitemap-youtube",
),
# Core app
path(route="", view=include("core.urls", namespace="core")),
# Twitch app

View file

@ -11,7 +11,6 @@ from django.db import connection
from django.db.models import Count
from django.db.models import Exists
from django.db.models import F
from django.db.models import Max
from django.db.models import OuterRef
from django.db.models import Prefetch
from django.db.models import Q
@ -120,357 +119,143 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
return context
def _render_urlset_xml(
url_entries: list[dict[str, str | dict[str, str]]],
) -> str:
"""Render a <urlset> sitemap XML string from URL entries.
Args:
url_entries: List of dictionaries containing URL entry data.
Returns:
A string containing the rendered XML.
"""
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for url_entry in url_entries:
xml += " <url>\n"
xml += f" <loc>{url_entry['url']}</loc>\n"
if url_entry.get("lastmod"):
xml += f" <lastmod>{url_entry['lastmod']}</lastmod>\n"
xml += " </url>\n"
xml += "</urlset>"
return xml
def _render_sitemap_index_xml(sitemap_entries: list[dict[str, str]]) -> str:
"""Render a <sitemapindex> XML string listing sitemap URLs.
Args:
sitemap_entries: List of dictionaries with "loc" and optional "lastmod".
Returns:
A string containing the rendered XML.
"""
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for entry in sitemap_entries:
xml += " <sitemap>\n"
xml += f" <loc>{entry['loc']}</loc>\n"
if entry.get("lastmod"):
xml += f" <lastmod>{entry['lastmod']}</lastmod>\n"
xml += " </sitemap>\n"
xml += "</sitemapindex>"
return xml
def _build_base_url(request: HttpRequest) -> str:
"""Return base url including scheme and host."""
return f"{request.scheme}://{request.get_host()}"
# MARK: /sitemap.xml
def sitemap_view(request: HttpRequest) -> HttpResponse:
"""Sitemap index pointing to per-section sitemap files.
def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915
"""Generate a dynamic XML sitemap for search engines.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered sitemap index XML.
HttpResponse: XML sitemap.
"""
base_url: str = _build_base_url(request)
base_url: str = f"{request.scheme}://{request.get_host()}"
# Compute last modified per-section so search engines can more intelligently crawl.
# Do not fabricate a lastmod date if the section has no data.
twitch_channels_lastmod = Channel.objects.aggregate(max=Max("updated_at"))["max"]
twitch_drops_lastmod = max(
[
dt
for dt in [
DropCampaign.objects.aggregate(max=Max("updated_at"))["max"],
RewardCampaign.objects.aggregate(max=Max("updated_at"))["max"],
]
if dt is not None
]
or [None],
)
twitch_others_lastmod = max(
[
dt
for dt in [
Game.objects.aggregate(max=Max("updated_at"))["max"],
Organization.objects.aggregate(max=Max("updated_at"))["max"],
ChatBadgeSet.objects.aggregate(max=Max("updated_at"))["max"],
]
if dt is not None
]
or [None],
)
kick_lastmod = KickDropCampaign.objects.aggregate(max=Max("updated_at"))["max"]
sitemap_entries: list[dict[str, str]] = [
{"loc": f"{base_url}/sitemap-static.xml"},
{"loc": f"{base_url}/sitemap-twitch-channels.xml"},
{"loc": f"{base_url}/sitemap-twitch-drops.xml"},
{"loc": f"{base_url}/sitemap-twitch-others.xml"},
{"loc": f"{base_url}/sitemap-kick.xml"},
{"loc": f"{base_url}/sitemap-youtube.xml"},
]
if twitch_channels_lastmod is not None:
sitemap_entries[1]["lastmod"] = twitch_channels_lastmod.isoformat()
if twitch_drops_lastmod is not None:
sitemap_entries[2]["lastmod"] = twitch_drops_lastmod.isoformat()
if twitch_others_lastmod is not None:
sitemap_entries[3]["lastmod"] = twitch_others_lastmod.isoformat()
if kick_lastmod is not None:
sitemap_entries[4]["lastmod"] = kick_lastmod.isoformat()
xml_content: str = _render_sitemap_index_xml(sitemap_entries)
return HttpResponse(xml_content, content_type="application/xml")
# MARK: /sitemap-static.xml
def sitemap_static_view(request: HttpRequest) -> HttpResponse:
"""Sitemap containing the main static pages.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered sitemap XML.
"""
base_url: str = _build_base_url(request)
# Include the canonical top-level pages, the main app dashboards, and key list views.
# Using reverse() keeps these URLs correct if route patterns change.
static_route_names: list[str] = [
"core:dashboard",
# "core:search",
"core:dataset_backups",
"core:docs_rss",
# Core RSS/Atom feeds
# "core:campaign_feed",
# "core:game_feed",
# "core:organization_feed",
# "core:reward_campaign_feed",
# "core:campaign_feed_atom",
# "core:game_feed_atom",
# "core:organization_feed_atom",
# "core:reward_campaign_feed_atom",
# "core:campaign_feed_discord",
# "core:game_feed_discord",
# "core:organization_feed_discord",
# "core:reward_campaign_feed_discord",
# Twitch pages
"twitch:dashboard",
"twitch:badge_list",
"twitch:campaign_list",
"twitch:channel_list",
"twitch:emote_gallery",
"twitch:games_grid",
"twitch:games_list",
"twitch:org_list",
"twitch:reward_campaign_list",
# Kick pages
"kick:dashboard",
"kick:campaign_list",
"kick:game_list",
"kick:category_list",
"kick:organization_list",
# Kick RSS/Atom feeds
# "kick:campaign_feed",
# "kick:game_feed",
# "kick:category_feed",
# "kick:organization_feed",
# "kick:campaign_feed_atom",
# "kick:game_feed_atom",
# "kick:category_feed_atom",
# "kick:organization_feed_atom",
# "kick:campaign_feed_discord",
# "kick:game_feed_discord",
# "kick:category_feed_discord",
# "kick:organization_feed_discord",
# YouTube
"youtube:index",
]
sitemap_urls: list[dict[str, str | dict[str, str]]] = []
for route_name in static_route_names:
url = reverse(route_name)
sitemap_urls.append({"url": f"{base_url}{url}"})
xml_content: str = _render_urlset_xml(sitemap_urls)
return HttpResponse(xml_content, content_type="application/xml")
# MARK: /sitemap-twitch-channels.xml
def sitemap_twitch_channels_view(request: HttpRequest) -> HttpResponse:
"""Sitemap containing Twitch channel pages.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered sitemap XML.
"""
base_url: str = _build_base_url(request)
# Start building sitemap XML
sitemap_urls: list[dict[str, str | dict[str, str]]] = []
channels: QuerySet[Channel] = Channel.objects.all()
for channel in channels:
resource_url: str = reverse("twitch:channel_detail", args=[channel.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {"url": full_url}
if channel.updated_at:
entry["lastmod"] = channel.updated_at.isoformat()
# Static pages
sitemap_urls.extend([
{"url": f"{base_url}/", "priority": "1.0", "changefreq": "daily"},
{"url": f"{base_url}/campaigns/", "priority": "0.9", "changefreq": "daily"},
{
"url": f"{base_url}/reward-campaigns/",
"priority": "0.9",
"changefreq": "daily",
},
{"url": f"{base_url}/games/", "priority": "0.9", "changefreq": "weekly"},
{
"url": f"{base_url}/organizations/",
"priority": "0.8",
"changefreq": "weekly",
},
{"url": f"{base_url}/channels/", "priority": "0.8", "changefreq": "weekly"},
{"url": f"{base_url}/badges/", "priority": "0.7", "changefreq": "monthly"},
{"url": f"{base_url}/emotes/", "priority": "0.7", "changefreq": "monthly"},
{"url": f"{base_url}/search/", "priority": "0.6", "changefreq": "monthly"},
])
# Dynamic detail pages - Games
games: QuerySet[Game] = Game.objects.all()
for game in games:
entry: dict[str, str | dict[str, str]] = {
"url": f"{base_url}{reverse('twitch:game_detail', args=[game.twitch_id])}",
"priority": "0.8",
"changefreq": "weekly",
}
if game.updated_at:
entry["lastmod"] = game.updated_at.isoformat()
sitemap_urls.append(entry)
xml_content: str = _render_urlset_xml(sitemap_urls)
return HttpResponse(xml_content, content_type="application/xml")
# MARK: /sitemap-twitch-drops.xml
def sitemap_twitch_drops_view(request: HttpRequest) -> HttpResponse:
"""Sitemap containing Twitch drop campaign pages.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered sitemap XML.
"""
base_url: str = _build_base_url(request)
sitemap_urls: list[dict[str, str | dict[str, str]]] = []
# Dynamic detail pages - Campaigns
campaigns: QuerySet[DropCampaign] = DropCampaign.objects.all()
for campaign in campaigns:
resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str] = {"url": full_url}
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.7",
"changefreq": "weekly",
}
if campaign.updated_at:
entry["lastmod"] = campaign.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Organizations
orgs: QuerySet[Organization] = Organization.objects.all()
for org in orgs:
resource_url = reverse("twitch:organization_detail", args=[org.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.7",
"changefreq": "weekly",
}
if org.updated_at:
entry["lastmod"] = org.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Channels
channels: QuerySet[Channel] = Channel.objects.all()
for channel in channels:
resource_url = reverse("twitch:channel_detail", args=[channel.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.6",
"changefreq": "weekly",
}
if channel.updated_at:
entry["lastmod"] = channel.updated_at.isoformat()
sitemap_urls.append(entry)
# Dynamic detail pages - Badges
badge_sets: QuerySet[ChatBadgeSet] = ChatBadgeSet.objects.all()
for badge_set in badge_sets:
resource_url = reverse("twitch:badge_set_detail", args=[badge_set.set_id])
full_url: str = f"{base_url}{resource_url}"
sitemap_urls.append({
"url": full_url,
"priority": "0.5",
"changefreq": "monthly",
})
# Dynamic detail pages - Reward Campaigns
reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
for reward_campaign in reward_campaigns:
resource_url = reverse(
"twitch:reward_campaign_detail",
args=[reward_campaign.twitch_id],
args=[
reward_campaign.twitch_id,
],
)
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {"url": full_url}
entry: dict[str, str | dict[str, str]] = {
"url": full_url,
"priority": "0.6",
"changefreq": "weekly",
}
if reward_campaign.updated_at:
entry["lastmod"] = reward_campaign.updated_at.isoformat()
sitemap_urls.append(entry)
xml_content: str = _render_urlset_xml(sitemap_urls)
return HttpResponse(xml_content, content_type="application/xml")
# Build XML
xml_content = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml_content += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for url_entry in sitemap_urls:
xml_content += " <url>\n"
xml_content += f" <loc>{url_entry['url']}</loc>\n"
if url_entry.get("lastmod"):
xml_content += f" <lastmod>{url_entry['lastmod']}</lastmod>\n"
xml_content += (
f" <changefreq>{url_entry.get('changefreq', 'monthly')}</changefreq>\n"
)
xml_content += f" <priority>{url_entry.get('priority', '0.5')}</priority>\n"
xml_content += " </url>\n"
# MARK: /sitemap-twitch-others.xml
def sitemap_twitch_others_view(request: HttpRequest) -> HttpResponse:
"""Sitemap containing other Twitch pages (games, organizations, badges, emotes).
xml_content += "</urlset>"
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered sitemap XML.
"""
base_url: str = _build_base_url(request)
sitemap_urls: list[dict[str, str | dict[str, str]]] = []
games: QuerySet[Game] = Game.objects.all()
for game in games:
resource_url: str = reverse("twitch:game_detail", args=[game.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {"url": full_url}
if game.updated_at:
entry["lastmod"] = game.updated_at.isoformat()
sitemap_urls.append(entry)
orgs: QuerySet[Organization] = Organization.objects.all()
for org in orgs:
resource_url: str = reverse("twitch:organization_detail", args=[org.twitch_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {"url": full_url}
if org.updated_at:
entry["lastmod"] = org.updated_at.isoformat()
sitemap_urls.append(entry)
badge_sets: QuerySet[ChatBadgeSet] = ChatBadgeSet.objects.all()
for badge_set in badge_sets:
resource_url = reverse("twitch:badge_set_detail", args=[badge_set.set_id])
full_url = f"{base_url}{resource_url}"
sitemap_urls.append({"url": full_url})
# Emotes currently don't have individual detail pages, but keep a listing here.
sitemap_urls.append({"url": f"{base_url}/emotes/"})
xml_content: str = _render_urlset_xml(sitemap_urls)
return HttpResponse(xml_content, content_type="application/xml")
# MARK: /sitemap-kick.xml
def sitemap_kick_view(request: HttpRequest) -> HttpResponse:
"""Sitemap containing Kick drops and related pages.
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered sitemap XML.
"""
base_url: str = _build_base_url(request)
sitemap_urls: list[dict[str, str | dict[str, str]]] = []
kick_campaigns: QuerySet[KickDropCampaign] = KickDropCampaign.objects.all()
for campaign in kick_campaigns:
resource_url: str = reverse("kick:campaign_detail", args=[campaign.kick_id])
full_url: str = f"{base_url}{resource_url}"
entry: dict[str, str | dict[str, str]] = {"url": full_url}
if campaign.updated_at:
entry["lastmod"] = campaign.updated_at.isoformat()
sitemap_urls.append(entry)
# Include Kick organizations and game list pages
sitemap_urls.extend((
{"url": f"{base_url}/kick/organizations/"},
{"url": f"{base_url}/kick/games/"},
))
xml_content: str = _render_urlset_xml(sitemap_urls)
return HttpResponse(xml_content, content_type="application/xml")
# MARK: /sitemap-youtube.xml
def sitemap_youtube_view(request: HttpRequest) -> HttpResponse:
"""Sitemap containing the YouTube page(s).
Args:
request: The HTTP request.
Returns:
HttpResponse: The rendered sitemap XML.
"""
base_url: str = _build_base_url(request)
sitemap_urls: list[dict[str, str | dict[str, str]]] = [
{"url": f"{base_url}{reverse('youtube:index')}"},
]
xml_content: str = _render_urlset_xml(sitemap_urls)
return HttpResponse(xml_content, content_type="application/xml")

View file

@ -12,9 +12,6 @@ from django.test import RequestFactory
from django.urls import reverse
from django.utils import timezone
from kick.models import KickCategory
from kick.models import KickDropCampaign
from kick.models import KickOrganization
from twitch.models import Channel
from twitch.models import ChatBadge
from twitch.models import ChatBadgeSet
@ -1324,61 +1321,19 @@ class TestSitemapView:
name="ch1",
display_name="Channel 1",
)
now: datetime = timezone.now()
campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="camp1",
name="Test Campaign",
description="Desc",
game=game,
operation_names=["DropCampaignDetails"],
start_at=now - datetime.timedelta(days=1),
end_at=now + datetime.timedelta(days=1),
)
inactive_campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="camp2",
name="Inactive Campaign",
description="Desc",
game=game,
operation_names=["DropCampaignDetails"],
start_at=now - datetime.timedelta(days=10),
end_at=now - datetime.timedelta(days=5),
)
kick_org: KickOrganization = KickOrganization.objects.create(
kick_id="org1",
name="Kick Org",
)
kick_cat: KickCategory = KickCategory.objects.create(
kick_id=1,
name="Kick Game",
slug="kick-game",
)
kick_active: KickDropCampaign = KickDropCampaign.objects.create(
kick_id="kcamp1",
name="Kick Active Campaign",
organization=kick_org,
category=kick_cat,
starts_at=now - datetime.timedelta(days=1),
ends_at=now + datetime.timedelta(days=1),
)
kick_inactive: KickDropCampaign = KickDropCampaign.objects.create(
kick_id="kcamp2",
name="Kick Inactive Campaign",
organization=kick_org,
category=kick_cat,
starts_at=now - datetime.timedelta(days=10),
ends_at=now - datetime.timedelta(days=5),
)
badge: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="badge1")
return {
"org": org,
"game": game,
"channel": channel,
"campaign": campaign,
"inactive_campaign": inactive_campaign,
"kick_active": kick_active,
"kick_inactive": kick_inactive,
"badge": badge,
}
@ -1402,24 +1357,16 @@ class TestSitemapView:
content = response.content.decode()
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
def test_sitemap_contains_sitemap_index(
def test_sitemap_contains_urlset(
self,
client: Client,
sample_entities: dict[str, Any],
) -> None:
"""Test sitemap index contains sitemap locations."""
"""Test sitemap contains urlset element."""
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
assert "<sitemapindex" in content
assert "</sitemapindex>" in content
assert "/sitemap-static.xml" in content
assert "/sitemap-twitch-channels.xml" in content
assert "/sitemap-twitch-drops.xml" in content
assert "/sitemap-kick.xml" in content
assert "/sitemap-youtube.xml" in content
# Ensure at least one entry includes a lastmod (there are entities created by the fixture)
assert "<lastmod>" in content
assert "<urlset" in content
assert "</urlset>" in content
def test_sitemap_contains_static_pages(
self,
@ -1427,19 +1374,15 @@ class TestSitemapView:
sample_entities: dict[str, Any],
) -> None:
"""Test sitemap includes static pages."""
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-static.xml")
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
# Check for the homepage and a few key list views across apps.
# Check for some static pages
assert (
"<loc>http://testserver/</loc>" in content
or "<loc>http://localhost:8000/</loc>" in content
)
assert "http://testserver/twitch/" in content
assert "http://testserver/kick/" in content
assert "http://testserver/youtube/" in content
assert "http://testserver/twitch/campaigns/" in content
assert "http://testserver/twitch/games/" in content
assert "/campaigns/" in content
assert "/games/" in content
def test_sitemap_contains_game_detail_pages(
self,
@ -1448,7 +1391,7 @@ class TestSitemapView:
) -> None:
"""Test sitemap includes game detail pages."""
game: Game = sample_entities["game"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml")
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
assert f"/games/{game.twitch_id}/" in content
@ -1459,62 +1402,10 @@ class TestSitemapView:
) -> None:
"""Test sitemap includes campaign detail pages."""
campaign: DropCampaign = sample_entities["campaign"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-drops.xml")
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
assert f"/campaigns/{campaign.twitch_id}/" in content
def test_sitemap_prioritizes_active_drops(
self,
client: Client,
sample_entities: dict[str, Any],
) -> None:
"""Test active drops are prioritised and crawled more frequently."""
active_campaign: DropCampaign = sample_entities["campaign"]
inactive_campaign: DropCampaign = sample_entities["inactive_campaign"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-drops.xml")
content: str = response.content.decode()
active_loc: str = f"<loc>http://testserver/twitch/campaigns/{active_campaign.twitch_id}/</loc>"
active_index: int = content.find(active_loc)
assert active_index != -1
active_end: int = content.find("</url>", active_index)
assert active_end != -1
inactive_loc: str = f"<loc>http://testserver/twitch/campaigns/{inactive_campaign.twitch_id}/</loc>"
inactive_index: int = content.find(inactive_loc)
assert inactive_index != -1
inactive_end: int = content.find("</url>", inactive_index)
assert inactive_end != -1
def test_sitemap_prioritizes_active_kick_campaigns(
self,
client: Client,
sample_entities: dict[str, Any],
) -> None:
"""Test active Kick campaigns are prioritised and crawled more frequently."""
active_campaign: KickDropCampaign = sample_entities["kick_active"]
inactive_campaign: KickDropCampaign = sample_entities["kick_inactive"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-kick.xml")
content: str = response.content.decode()
active_loc: str = (
f"<loc>http://testserver/kick/campaigns/{active_campaign.kick_id}/</loc>"
)
active_index: int = content.find(active_loc)
assert active_index != -1
active_end: int = content.find("</url>", active_index)
assert active_end != -1
inactive_loc: str = (
f"<loc>http://testserver/kick/campaigns/{inactive_campaign.kick_id}/</loc>"
)
inactive_index: int = content.find(inactive_loc)
assert inactive_index != -1
inactive_end: int = content.find("</url>", inactive_index)
assert inactive_end != -1
def test_sitemap_contains_organization_detail_pages(
self,
client: Client,
@ -1522,7 +1413,7 @@ class TestSitemapView:
) -> None:
"""Test sitemap includes organization detail pages."""
org: Organization = sample_entities["org"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml")
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
assert f"/organizations/{org.twitch_id}/" in content
@ -1533,9 +1424,7 @@ class TestSitemapView:
) -> None:
"""Test sitemap includes channel detail pages."""
channel: Channel = sample_entities["channel"]
response: _MonkeyPatchedWSGIResponse = client.get(
"/sitemap-twitch-channels.xml",
)
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
assert f"/twitch/channels/{channel.twitch_id}/" in content
@ -1546,19 +1435,31 @@ class TestSitemapView:
) -> None:
"""Test sitemap includes badge detail pages."""
badge: ChatBadge = sample_entities["badge"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml")
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
assert f"/badges/{badge.set_id}/" in content # pyright: ignore[reportAttributeAccessIssue]
def test_sitemap_contains_youtube_pages(
def test_sitemap_includes_priority(
self,
client: Client,
sample_entities: dict[str, Any],
) -> None:
"""Test sitemap includes YouTube landing page."""
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-youtube.xml")
"""Test sitemap includes priority values."""
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
assert "/youtube/" in content
assert "<priority>" in content
assert "</priority>" in content
def test_sitemap_includes_changefreq(
self,
client: Client,
sample_entities: dict[str, Any],
) -> None:
"""Test sitemap includes changefreq values."""
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
assert "<changefreq>" in content
assert "</changefreq>" in content
def test_sitemap_includes_lastmod(
self,
@ -1566,7 +1467,7 @@ class TestSitemapView:
sample_entities: dict[str, Any],
) -> None:
"""Test sitemap includes lastmod for detail pages."""
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml")
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode()
# Check for lastmod in game or campaign entries
assert "<lastmod>" in content
@ -1829,7 +1730,7 @@ class TestImageObjectStructuredData:
org: Organization,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""VideoGame ImageObject should carry attribution metadata."""
"""VideoGame ImageObject should carry attribution and license metadata."""
url: str = reverse("twitch:game_detail", args=[game.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1842,6 +1743,14 @@ class TestImageObjectStructuredData:
"name": org.name,
"url": f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}",
}
assert (
img["license"]
== f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}"
)
assert (
img["acquireLicensePage"]
== f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}"
)
def test_game_schema_no_image_when_no_box_art(
self,
@ -1941,7 +1850,7 @@ class TestImageObjectStructuredData:
org: Organization,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Event ImageObject should carry attribution metadata."""
"""Event ImageObject should carry attribution and license metadata."""
url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1954,6 +1863,14 @@ class TestImageObjectStructuredData:
"name": org.name,
"url": f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}",
}
assert (
img["license"]
== f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}"
)
assert (
img["acquireLicensePage"]
== f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}"
)
def test_campaign_schema_no_image_when_no_image_url(
self,
@ -2030,6 +1947,8 @@ class TestImageObjectStructuredData:
"name": "Twitch",
"url": "https://www.twitch.tv/",
}
assert schema["image"]["license"] == "https://www.twitch.tv/"
assert schema["image"]["acquireLicensePage"] == "https://www.twitch.tv/"
assert "organizer" not in schema
# --- _pick_owner / Twitch Gaming skipping ---

View file

@ -79,7 +79,7 @@ def _build_image_object(
*,
copyright_notice: str | None = None,
) -> dict[str, Any]:
"""Build a Schema.org ImageObject with attribution metadata.
"""Build a Schema.org ImageObject with attribution and license metadata.
Args:
request: The HTTP request used for absolute URL building.
@ -96,13 +96,14 @@ def _build_image_object(
"name": creator_name,
"url": creator_url,
}
return {
"@type": "ImageObject",
"contentUrl": request.build_absolute_uri(image_url),
"creditText": creator_name,
"copyrightNotice": copyright_notice or creator_name,
"creator": creator,
"license": creator_url,
"acquireLicensePage": creator_url,
}