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] = [ urlpatterns: list[URLPattern | URLResolver] = [
path(route="sitemap.xml", view=core_views.sitemap_view, name="sitemap"), 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 # Core app
path(route="", view=include("core.urls", namespace="core")), path(route="", view=include("core.urls", namespace="core")),
# Twitch app # Twitch app

View file

@ -11,7 +11,6 @@ from django.db import connection
from django.db.models import Count from django.db.models import Count
from django.db.models import Exists from django.db.models import Exists
from django.db.models import F from django.db.models import F
from django.db.models import Max
from django.db.models import OuterRef from django.db.models import OuterRef
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models import Q from django.db.models import Q
@ -120,357 +119,143 @@ def _build_seo_context( # noqa: PLR0913, PLR0917
return context 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 # MARK: /sitemap.xml
def sitemap_view(request: HttpRequest) -> HttpResponse: def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915
"""Sitemap index pointing to per-section sitemap files. """Generate a dynamic XML sitemap for search engines.
Args: Args:
request: The HTTP request. request: The HTTP request.
Returns: 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. # Start building sitemap XML
# 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)
sitemap_urls: list[dict[str, str | dict[str, str]]] = [] sitemap_urls: list[dict[str, str | dict[str, str]]] = []
channels: QuerySet[Channel] = Channel.objects.all() # Static pages
for channel in channels: sitemap_urls.extend([
resource_url: str = reverse("twitch:channel_detail", args=[channel.twitch_id]) {"url": f"{base_url}/", "priority": "1.0", "changefreq": "daily"},
full_url: str = f"{base_url}{resource_url}" {"url": f"{base_url}/campaigns/", "priority": "0.9", "changefreq": "daily"},
entry: dict[str, str | dict[str, str]] = {"url": full_url} {
if channel.updated_at: "url": f"{base_url}/reward-campaigns/",
entry["lastmod"] = channel.updated_at.isoformat() "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) sitemap_urls.append(entry)
xml_content: str = _render_urlset_xml(sitemap_urls) # Dynamic detail pages - Campaigns
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]]] = []
campaigns: QuerySet[DropCampaign] = DropCampaign.objects.all() campaigns: QuerySet[DropCampaign] = DropCampaign.objects.all()
for campaign in campaigns: for campaign in campaigns:
resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) resource_url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
full_url: str = f"{base_url}{resource_url}" 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: if campaign.updated_at:
entry["lastmod"] = campaign.updated_at.isoformat() entry["lastmod"] = campaign.updated_at.isoformat()
sitemap_urls.append(entry) 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() reward_campaigns: QuerySet[RewardCampaign] = RewardCampaign.objects.all()
for reward_campaign in reward_campaigns: for reward_campaign in reward_campaigns:
resource_url = reverse( resource_url = reverse(
"twitch:reward_campaign_detail", "twitch:reward_campaign_detail",
args=[reward_campaign.twitch_id], args=[
reward_campaign.twitch_id,
],
) )
full_url: str = f"{base_url}{resource_url}" 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: if reward_campaign.updated_at:
entry["lastmod"] = reward_campaign.updated_at.isoformat() entry["lastmod"] = reward_campaign.updated_at.isoformat()
sitemap_urls.append(entry) sitemap_urls.append(entry)
xml_content: str = _render_urlset_xml(sitemap_urls) # Build XML
return HttpResponse(xml_content, content_type="application/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 xml_content += "</urlset>"
def sitemap_twitch_others_view(request: HttpRequest) -> HttpResponse:
"""Sitemap containing other Twitch pages (games, organizations, badges, emotes).
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") 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.urls import reverse
from django.utils import timezone 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 Channel
from twitch.models import ChatBadge from twitch.models import ChatBadge
from twitch.models import ChatBadgeSet from twitch.models import ChatBadgeSet
@ -1324,61 +1321,19 @@ class TestSitemapView:
name="ch1", name="ch1",
display_name="Channel 1", display_name="Channel 1",
) )
now: datetime = timezone.now()
campaign: DropCampaign = DropCampaign.objects.create( campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="camp1", twitch_id="camp1",
name="Test Campaign", name="Test Campaign",
description="Desc", description="Desc",
game=game, game=game,
operation_names=["DropCampaignDetails"], 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") badge: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="badge1")
return { return {
"org": org, "org": org,
"game": game, "game": game,
"channel": channel, "channel": channel,
"campaign": campaign, "campaign": campaign,
"inactive_campaign": inactive_campaign,
"kick_active": kick_active,
"kick_inactive": kick_inactive,
"badge": badge, "badge": badge,
} }
@ -1402,24 +1357,16 @@ class TestSitemapView:
content = response.content.decode() content = response.content.decode()
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>') assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
def test_sitemap_contains_sitemap_index( def test_sitemap_contains_urlset(
self, self,
client: Client, client: Client,
sample_entities: dict[str, Any], sample_entities: dict[str, Any],
) -> None: ) -> None:
"""Test sitemap index contains sitemap locations.""" """Test sitemap contains urlset element."""
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode() content: str = response.content.decode()
assert "<sitemapindex" in content assert "<urlset" in content
assert "</sitemapindex>" in content assert "</urlset>" 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
def test_sitemap_contains_static_pages( def test_sitemap_contains_static_pages(
self, self,
@ -1427,19 +1374,15 @@ class TestSitemapView:
sample_entities: dict[str, Any], sample_entities: dict[str, Any],
) -> None: ) -> None:
"""Test sitemap includes static pages.""" """Test sitemap includes static pages."""
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-static.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode() content: str = response.content.decode()
# Check for some static pages
# Check for the homepage and a few key list views across apps.
assert ( assert (
"<loc>http://testserver/</loc>" in content "<loc>http://testserver/</loc>" in content
or "<loc>http://localhost:8000/</loc>" in content or "<loc>http://localhost:8000/</loc>" in content
) )
assert "http://testserver/twitch/" in content assert "/campaigns/" in content
assert "http://testserver/kick/" in content assert "/games/" in content
assert "http://testserver/youtube/" in content
assert "http://testserver/twitch/campaigns/" in content
assert "http://testserver/twitch/games/" in content
def test_sitemap_contains_game_detail_pages( def test_sitemap_contains_game_detail_pages(
self, self,
@ -1448,7 +1391,7 @@ class TestSitemapView:
) -> None: ) -> None:
"""Test sitemap includes game detail pages.""" """Test sitemap includes game detail pages."""
game: Game = sample_entities["game"] game: Game = sample_entities["game"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode() content: str = response.content.decode()
assert f"/games/{game.twitch_id}/" in content assert f"/games/{game.twitch_id}/" in content
@ -1459,62 +1402,10 @@ class TestSitemapView:
) -> None: ) -> None:
"""Test sitemap includes campaign detail pages.""" """Test sitemap includes campaign detail pages."""
campaign: DropCampaign = sample_entities["campaign"] campaign: DropCampaign = sample_entities["campaign"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-drops.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode() content: str = response.content.decode()
assert f"/campaigns/{campaign.twitch_id}/" in content 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( def test_sitemap_contains_organization_detail_pages(
self, self,
client: Client, client: Client,
@ -1522,7 +1413,7 @@ class TestSitemapView:
) -> None: ) -> None:
"""Test sitemap includes organization detail pages.""" """Test sitemap includes organization detail pages."""
org: Organization = sample_entities["org"] org: Organization = sample_entities["org"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode() content: str = response.content.decode()
assert f"/organizations/{org.twitch_id}/" in content assert f"/organizations/{org.twitch_id}/" in content
@ -1533,9 +1424,7 @@ class TestSitemapView:
) -> None: ) -> None:
"""Test sitemap includes channel detail pages.""" """Test sitemap includes channel detail pages."""
channel: Channel = sample_entities["channel"] channel: Channel = sample_entities["channel"]
response: _MonkeyPatchedWSGIResponse = client.get( response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
"/sitemap-twitch-channels.xml",
)
content: str = response.content.decode() content: str = response.content.decode()
assert f"/twitch/channels/{channel.twitch_id}/" in content assert f"/twitch/channels/{channel.twitch_id}/" in content
@ -1546,19 +1435,31 @@ class TestSitemapView:
) -> None: ) -> None:
"""Test sitemap includes badge detail pages.""" """Test sitemap includes badge detail pages."""
badge: ChatBadge = sample_entities["badge"] badge: ChatBadge = sample_entities["badge"]
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-twitch-others.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode() content: str = response.content.decode()
assert f"/badges/{badge.set_id}/" in content # pyright: ignore[reportAttributeAccessIssue] assert f"/badges/{badge.set_id}/" in content # pyright: ignore[reportAttributeAccessIssue]
def test_sitemap_contains_youtube_pages( def test_sitemap_includes_priority(
self, self,
client: Client, client: Client,
sample_entities: dict[str, Any], sample_entities: dict[str, Any],
) -> None: ) -> None:
"""Test sitemap includes YouTube landing page.""" """Test sitemap includes priority values."""
response: _MonkeyPatchedWSGIResponse = client.get("/sitemap-youtube.xml") response: _MonkeyPatchedWSGIResponse = client.get("/sitemap.xml")
content: str = response.content.decode() 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( def test_sitemap_includes_lastmod(
self, self,
@ -1566,7 +1467,7 @@ class TestSitemapView:
sample_entities: dict[str, Any], sample_entities: dict[str, Any],
) -> None: ) -> None:
"""Test sitemap includes lastmod for detail pages.""" """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() content: str = response.content.decode()
# Check for lastmod in game or campaign entries # Check for lastmod in game or campaign entries
assert "<lastmod>" in content assert "<lastmod>" in content
@ -1829,7 +1730,7 @@ class TestImageObjectStructuredData:
org: Organization, org: Organization,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> 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]) url: str = reverse("twitch:game_detail", args=[game.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1842,6 +1743,14 @@ class TestImageObjectStructuredData:
"name": org.name, "name": org.name,
"url": f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}", "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( def test_game_schema_no_image_when_no_box_art(
self, self,
@ -1941,7 +1850,7 @@ class TestImageObjectStructuredData:
org: Organization, org: Organization,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> 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]) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id])
response: _MonkeyPatchedWSGIResponse = client.get(url) response: _MonkeyPatchedWSGIResponse = client.get(url)
@ -1954,6 +1863,14 @@ class TestImageObjectStructuredData:
"name": org.name, "name": org.name,
"url": f"http://testserver{reverse('twitch:organization_detail', args=[org.twitch_id])}", "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( def test_campaign_schema_no_image_when_no_image_url(
self, self,
@ -2030,6 +1947,8 @@ class TestImageObjectStructuredData:
"name": "Twitch", "name": "Twitch",
"url": "https://www.twitch.tv/", "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 assert "organizer" not in schema
# --- _pick_owner / Twitch Gaming skipping --- # --- _pick_owner / Twitch Gaming skipping ---

View file

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