Improve SEO?

This commit is contained in:
Joakim Hellsén 2026-03-17 04:34:09 +01:00
commit 725df27b47
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
8 changed files with 353 additions and 128 deletions

17
core/seo.py Normal file
View file

@ -0,0 +1,17 @@
from typing import Any
from typing import TypedDict
class SeoMeta(TypedDict, total=False):
"""Shared typed optional SEO metadata for template context generation."""
page_image: str | None
page_image_width: int | None
page_image_height: int | None
og_type: str
schema_data: dict[str, Any] | None
breadcrumb_schema: dict[str, Any] | None
pagination_info: list[dict[str, str]] | None
published_date: str | None
modified_date: str | None
robots_directive: str

View file

@ -1,6 +1,9 @@
from __future__ import annotations
import json import json
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any
from typing import Literal from typing import Literal
from django.core.paginator import EmptyPage from django.core.paginator import EmptyPage
@ -24,6 +27,7 @@ if TYPE_CHECKING:
from django.http import HttpRequest from django.http import HttpRequest
from django.http import HttpResponse from django.http import HttpResponse
from core.seo import SeoMeta
from kick.models import KickChannel from kick.models import KickChannel
from kick.models import KickReward from kick.models import KickReward
@ -33,26 +37,36 @@ logger: logging.Logger = logging.getLogger("ttvdrops.kick.views")
def _build_seo_context( def _build_seo_context(
page_title: str = "Kick Drops", page_title: str = "Kick Drops",
page_description: str | None = None, page_description: str | None = None,
og_type: str = "website", seo_meta: SeoMeta | None = None,
robots_directive: str = "index, follow", ) -> dict[str, Any]:
) -> dict[str, str]: """Build SEO context for template rendering.
"""Build minimal SEO context for template rendering.
Args: Args:
page_title: The title of the page for <title> and OG tags. page_title: The title of the page for <title> and OG tags.
page_description: Optional description for meta and OG tags. page_description: Optional description for meta and OG tags.
og_type: Open Graph type (default "website"). seo_meta: Optional typed SEO metadata.
robots_directive: Value for meta robots tag (default "index, follow").
Returns: Returns:
A dictionary with SEO-related context variables. A dictionary with SEO-related context variables.
""" """
return { context: dict[str, Any] = {
"page_title": page_title, "page_title": page_title,
"page_description": page_description or "Archive of Kick drops.", "page_description": page_description or "Archive of Kick drops.",
"og_type": og_type, "og_type": "website",
"robots_directive": robots_directive, "robots_directive": "index, follow",
} }
if seo_meta:
if seo_meta.get("og_type"):
context["og_type"] = seo_meta["og_type"]
if seo_meta.get("robots_directive"):
context["robots_directive"] = seo_meta["robots_directive"]
if seo_meta.get("schema_data"):
context["schema_data"] = json.dumps(seo_meta["schema_data"])
if seo_meta.get("published_date"):
context["published_date"] = seo_meta["published_date"]
if seo_meta.get("modified_date"):
context["modified_date"] = seo_meta["modified_date"]
return context
def _build_breadcrumb_schema(items: list[dict[str, str]]) -> str: def _build_breadcrumb_schema(items: list[dict[str, str]]) -> str:
@ -278,9 +292,51 @@ def campaign_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse:
}, },
]) ])
seo_context: dict[str, str] = _build_seo_context( campaign_url: str = request.build_absolute_uri(
reverse("kick:campaign_detail", args=[campaign.kick_id]),
)
campaign_event: dict[str, Any] = {
"@type": "Event",
"name": campaign.name,
"description": f"Kick drop campaign: {campaign.name}.",
"url": campaign_url,
"eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
"location": {"@type": "VirtualLocation", "url": "https://kick.com"},
}
if campaign.starts_at:
campaign_event["startDate"] = campaign.starts_at.isoformat()
if campaign.ends_at:
campaign_event["endDate"] = campaign.ends_at.isoformat()
if campaign.organization:
campaign_event["organizer"] = {
"@type": "Organization",
"name": campaign.organization.name,
}
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": campaign_url,
"datePublished": campaign.added_at.isoformat(),
"dateModified": campaign.updated_at.isoformat(),
}
campaign_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@graph": [
campaign_event,
webpage_node,
],
}
seo_context: dict[str, Any] = _build_seo_context(
page_title=campaign.name, page_title=campaign.name,
page_description=f"Kick drop campaign: {campaign.name}.", page_description=f"Kick drop campaign: {campaign.name}.",
seo_meta={
"schema_data": campaign_schema,
"published_date": campaign.added_at.isoformat(),
"modified_date": campaign.updated_at.isoformat(),
},
) )
return render( return render(
request, request,
@ -379,9 +435,27 @@ def category_detail_view(request: HttpRequest, kick_id: int) -> HttpResponse:
}, },
]) ])
seo_context: dict[str, str] = _build_seo_context( category_url: str = request.build_absolute_uri(
reverse("kick:game_detail", args=[category.kick_id]),
)
category_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": category.name,
"description": f"Kick drop campaigns for {category.name}.",
"url": category_url,
"datePublished": category.added_at.isoformat(),
"dateModified": category.updated_at.isoformat(),
}
seo_context: dict[str, Any] = _build_seo_context(
page_title=category.name, page_title=category.name,
page_description=f"Kick drop campaigns for {category.name}.", page_description=f"Kick drop campaigns for {category.name}.",
seo_meta={
"schema_data": category_schema,
"published_date": category.added_at.isoformat(),
"modified_date": category.updated_at.isoformat(),
},
) )
return render( return render(
request, request,
@ -466,9 +540,37 @@ def organization_detail_view(request: HttpRequest, kick_id: str) -> HttpResponse
}, },
]) ])
seo_context: dict[str, str] = _build_seo_context( org_url: str = request.build_absolute_uri(
reverse("kick:organization_detail", args=[org.kick_id]),
)
organization_node: dict[str, Any] = {
"@type": "Organization",
"name": org.name,
"url": org_url,
"description": f"Kick drop campaigns by {org.name}.",
}
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": org_url,
"datePublished": org.added_at.isoformat(),
"dateModified": org.updated_at.isoformat(),
}
org_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@graph": [
organization_node,
webpage_node,
],
}
seo_context: dict[str, Any] = _build_seo_context(
page_title=org.name, page_title=org.name,
page_description=f"Kick drop campaigns by {org.name}.", page_description=f"Kick drop campaigns by {org.name}.",
seo_meta={
"schema_data": org_schema,
"published_date": org.added_at.isoformat(),
"modified_date": org.updated_at.isoformat(),
},
) )
return render( return render(
request, request,

View file

@ -45,6 +45,14 @@
</div> </div>
<!-- Campaign description --> <!-- Campaign description -->
<p>{{ campaign.description|linebreaksbr }}</p> <p>{{ campaign.description|linebreaksbr }}</p>
<small>
Published:
<time datetime="{{ campaign.added_at|date:'c' }}"
title="{{ campaign.added_at|date:'DATETIME_FORMAT' }}">{{ campaign.added_at|date:"M d, Y H:i" }}</time>
· Last updated:
<time datetime="{{ campaign.updated_at|date:'c' }}"
title="{{ campaign.updated_at|date:'DATETIME_FORMAT' }}">{{ campaign.updated_at|date:"M d, Y H:i" }}</time>
</small>
<!-- Campaign end times --> <!-- Campaign end times -->
<div> <div>
{% if campaign.end_at < now %} {% if campaign.end_at < now %}

View file

@ -20,6 +20,14 @@
</iframe> </iframe>
<!-- Channel Info --> <!-- Channel Info -->
<p>Channel ID: {{ channel.twitch_id }}</p> <p>Channel ID: {{ channel.twitch_id }}</p>
<p>
Published:
<time datetime="{{ channel.added_at|date:'c' }}"
title="{{ channel.added_at|date:'DATETIME_FORMAT' }}">{{ channel.added_at|date:"M d, Y H:i" }}</time>
· Last updated:
<time datetime="{{ channel.updated_at|date:'c' }}"
title="{{ channel.updated_at|date:'DATETIME_FORMAT' }}">{{ channel.updated_at|date:"M d, Y H:i" }}</time>
</p>
{% if active_campaigns %} {% if active_campaigns %}
<h5>Active Campaigns</h5> <h5>Active Campaigns</h5>
<table> <table>

View file

@ -47,6 +47,14 @@
Twitch ID: <a href="https://www.twitch.tv/directory/category/{{ game.slug|urlencode }}">{{ game.twitch_id }}</a> Twitch ID: <a href="https://www.twitch.tv/directory/category/{{ game.slug|urlencode }}">{{ game.twitch_id }}</a>
</div> </div>
<div>Twitch slug: {{ game.slug }}</div> <div>Twitch slug: {{ game.slug }}</div>
<small>
Published:
<time datetime="{{ game.added_at|date:'c' }}"
title="{{ game.added_at|date:'DATETIME_FORMAT' }}">{{ game.added_at|date:"M d, Y H:i" }}</time>
· Last updated:
<time datetime="{{ game.updated_at|date:'c' }}"
title="{{ game.updated_at|date:'DATETIME_FORMAT' }}">{{ game.updated_at|date:"M d, Y H:i" }}</time>
</small>
<!-- RSS Feeds --> <!-- RSS Feeds -->
<div> <div>
<a href="{% url 'core:game_campaign_feed' game.twitch_id %}" <a href="{% url 'core:game_campaign_feed' game.twitch_id %}"

View file

@ -21,14 +21,22 @@
{% endif %} {% endif %}
{% endblock extra_head %} {% endblock extra_head %}
{% block content %} {% block content %}
<h1 id="org-name">{{ organization.name }}</h1> <h1>{{ organization.name }}</h1>
<theader> <p>
<h2 id="games-header">Games by {{ organization.name }}</h2> Published:
</theader> <time datetime="{{ organization.added_at|date:'c' }}"
<table id="games-table"> title="{{ organization.added_at|date:'DATETIME_FORMAT' }}">{{ organization.added_at|date:"M d, Y H:i" }}</time>
· Last updated:
<time datetime="{{ organization.updated_at|date:'c' }}"
title="{{ organization.updated_at|date:'DATETIME_FORMAT' }}">{{ organization.updated_at|date:"M d, Y H:i" }}</time>
</p>
<header>
<h2>Games by {{ organization.name }}</h2>
</header>
<table>
<tbody> <tbody>
{% for game in games %} {% for game in games %}
<tr id="game-row-{{ game.twitch_id }}"> <tr>
<td> <td>
<a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game }}</a> <a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game }}</a>
</td> </td>

View file

@ -1051,9 +1051,11 @@ class TestSEOHelperFunctions:
context: dict[str, Any] = _build_seo_context( context: dict[str, Any] = _build_seo_context(
page_title="Test Title", page_title="Test Title",
page_description="Test Description", page_description="Test Description",
page_image="https://example.com/image.jpg", seo_meta={
og_type="article", "page_image": "https://example.com/image.jpg",
schema_data={"@context": "https://schema.org"}, "og_type": "article",
"schema_data": {"@context": "https://schema.org"},
},
) )
assert context["page_title"] == "Test Title" assert context["page_title"] == "Test Title"
@ -1083,14 +1085,16 @@ class TestSEOHelperFunctions:
context: dict[str, Any] = _build_seo_context( context: dict[str, Any] = _build_seo_context(
page_title="Test", page_title="Test",
page_description="Desc", page_description="Desc",
page_image="https://example.com/img.jpg", seo_meta={
og_type="article", "page_image": "https://example.com/img.jpg",
schema_data={}, "og_type": "article",
breadcrumb_schema=breadcrumb, "schema_data": {},
pagination_info=[{"rel": "next", "url": "/page/2/"}], "breadcrumb_schema": breadcrumb,
published_date=now.isoformat(), "pagination_info": [{"rel": "next", "url": "/page/2/"}],
modified_date=now.isoformat(), "published_date": now.isoformat(),
robots_directive="noindex, follow", "modified_date": now.isoformat(),
"robots_directive": "noindex, follow",
},
) )
# breadcrumb_schema is JSON-dumped, so parse it back # breadcrumb_schema is JSON-dumped, so parse it back

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import csv import csv
import datetime import datetime
import json import json
@ -44,6 +46,8 @@ if TYPE_CHECKING:
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from core.seo import SeoMeta
logger: logging.Logger = logging.getLogger("ttvdrops.views") logger: logging.Logger = logging.getLogger("ttvdrops.views")
MIN_QUERY_LENGTH_FOR_FTS = 3 MIN_QUERY_LENGTH_FOR_FTS = 3
@ -89,66 +93,48 @@ def _truncate_description(text: str, max_length: int = 160) -> str:
return text[:max_length].rsplit(" ", 1)[0] + "" return text[:max_length].rsplit(" ", 1)[0] + ""
def _build_seo_context( # noqa: PLR0913, PLR0917 def _build_seo_context(
page_title: str = "ttvdrops", page_title: str = "ttvdrops",
page_description: str | None = None, page_description: str | None = None,
page_image: str | None = None, seo_meta: SeoMeta | None = None,
page_image_width: int | None = None,
page_image_height: int | None = None,
og_type: str = "website",
schema_data: dict[str, Any] | None = None,
breadcrumb_schema: dict[str, Any] | None = None,
pagination_info: list[dict[str, str]] | None = None,
published_date: str | None = None,
modified_date: str | None = None,
robots_directive: str = "index, follow",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build SEO context for template rendering. """Build SEO context for template rendering.
Args: Args:
page_title: Page title (shown in browser tab, og:title). page_title: Page title (shown in browser tab, og:title).
page_description: Page description (meta description, og:description). page_description: Page description (meta description, og:description).
page_image: Image URL for og:image meta tag. seo_meta: Optional typed SEO metadata with image, schema, breadcrumb,
page_image_width: Width of the image in pixels. pagination, OpenGraph, and date fields.
page_image_height: Height of the image in pixels.
og_type: OpenGraph type (e.g., "website", "article").
schema_data: Dict representation of Schema.org JSON-LD data.
breadcrumb_schema: Breadcrumb schema dict for navigation hierarchy.
pagination_info: List of dicts with "rel" (prev|next|first|last) and "url".
published_date: ISO 8601 published date (e.g., "2025-01-01T00:00:00Z").
modified_date: ISO 8601 modified date.
robots_directive: Robots meta content (e.g., "index, follow" or "noindex").
Returns: Returns:
Dict with SEO context variables to pass to render(). Dict with SEO context variables to pass to render().
""" """
# TODO(TheLovinator): Instead of having so many parameters, # noqa: TD003
# consider having a single "seo_info" parameter that
# can contain all of these optional fields. This would make
# it easier to extend in the future without changing the
# function signature.
context: dict[str, Any] = { context: dict[str, Any] = {
"page_title": page_title, "page_title": page_title,
"page_description": page_description or DEFAULT_SITE_DESCRIPTION, "page_description": page_description or DEFAULT_SITE_DESCRIPTION,
"og_type": og_type, "og_type": "website",
"robots_directive": robots_directive, "robots_directive": "index, follow",
} }
if page_image: if seo_meta:
context["page_image"] = page_image if seo_meta.get("og_type"):
if page_image_width and page_image_height: context["og_type"] = seo_meta["og_type"]
context["page_image_width"] = page_image_width if seo_meta.get("robots_directive"):
context["page_image_height"] = page_image_height context["robots_directive"] = seo_meta["robots_directive"]
if schema_data: if seo_meta.get("page_image"):
context["schema_data"] = json.dumps(schema_data) context["page_image"] = seo_meta["page_image"]
if breadcrumb_schema: if seo_meta.get("page_image_width") and seo_meta.get("page_image_height"):
context["breadcrumb_schema"] = json.dumps(breadcrumb_schema) context["page_image_width"] = seo_meta["page_image_width"]
if pagination_info: context["page_image_height"] = seo_meta["page_image_height"]
context["pagination_info"] = pagination_info if seo_meta.get("schema_data"):
if published_date: context["schema_data"] = json.dumps(seo_meta["schema_data"])
context["published_date"] = published_date if seo_meta.get("breadcrumb_schema"):
if modified_date: context["breadcrumb_schema"] = json.dumps(seo_meta["breadcrumb_schema"])
context["modified_date"] = modified_date if seo_meta.get("pagination_info"):
context["pagination_info"] = seo_meta["pagination_info"]
if seo_meta.get("published_date"):
context["published_date"] = seo_meta["published_date"]
if seo_meta.get("modified_date"):
context["modified_date"] = seo_meta["modified_date"]
return context return context
@ -296,7 +282,7 @@ def org_list_view(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Organizations", page_title="Twitch Organizations",
page_description="List of Twitch organizations.", page_description="List of Twitch organizations.",
schema_data=collection_schema, seo_meta={"schema_data": collection_schema},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"orgs": orgs, "orgs": orgs,
@ -361,13 +347,25 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
url: str = request.build_absolute_uri( url: str = request.build_absolute_uri(
reverse("twitch:organization_detail", args=[organization.twitch_id]), reverse("twitch:organization_detail", args=[organization.twitch_id]),
) )
org_schema: dict[str, str | dict[str, str]] = { organization_node: dict[str, Any] = {
"@context": "https://schema.org",
"@type": "Organization", "@type": "Organization",
"name": org_name, "name": org_name,
"url": url, "url": url,
"description": org_description, "description": org_description,
} }
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": url,
"datePublished": organization.added_at.isoformat(),
"dateModified": organization.updated_at.isoformat(),
}
org_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@graph": [
organization_node,
webpage_node,
],
}
# Breadcrumb schema # Breadcrumb schema
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([ breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
@ -384,9 +382,12 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=org_name, page_title=org_name,
page_description=org_description, page_description=org_description,
schema_data=org_schema, seo_meta={
breadcrumb_schema=breadcrumb_schema, "schema_data": org_schema,
modified_date=organization.updated_at.isoformat(), "breadcrumb_schema": breadcrumb_schema,
"published_date": organization.added_at.isoformat(),
"modified_date": organization.updated_at.isoformat(),
},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"organization": organization, "organization": organization,
@ -481,8 +482,10 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=title, page_title=title,
page_description=description, page_description=description,
pagination_info=pagination_info, seo_meta={
schema_data=collection_schema, "pagination_info": pagination_info,
"schema_data": collection_schema,
},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"campaigns": campaigns, "campaigns": campaigns,
@ -724,7 +727,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
) )
# TODO(TheLovinator): If the campaign has specific allowed channels, we could list those as potential locations instead of just linking to Twitch homepage. # noqa: TD003 # TODO(TheLovinator): If the campaign has specific allowed channels, we could list those as potential locations instead of just linking to Twitch homepage. # noqa: TD003
campaign_schema: dict[str, str | dict[str, str]] = { campaign_event: dict[str, Any] = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Event", "@type": "Event",
"name": campaign_name, "name": campaign_name,
@ -738,9 +741,9 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
}, },
} }
if campaign.start_at: if campaign.start_at:
campaign_schema["startDate"] = campaign.start_at.isoformat() campaign_event["startDate"] = campaign.start_at.isoformat()
if campaign.end_at: if campaign.end_at:
campaign_schema["endDate"] = campaign.end_at.isoformat() campaign_event["endDate"] = campaign.end_at.isoformat()
campaign_owner: Organization | None = ( campaign_owner: Organization | None = (
_pick_owner(list(campaign.game.owners.all())) if campaign.game else None _pick_owner(list(campaign.game.owners.all())) if campaign.game else None
) )
@ -750,17 +753,25 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
else "Twitch" else "Twitch"
) )
if campaign_image: if campaign_image:
campaign_schema["image"] = { campaign_event["image"] = {
"@type": "ImageObject", "@type": "ImageObject",
"contentUrl": request.build_absolute_uri(campaign_image), "contentUrl": request.build_absolute_uri(campaign_image),
"creditText": campaign_owner_name, "creditText": campaign_owner_name,
"copyrightNotice": campaign_owner_name, "copyrightNotice": campaign_owner_name,
} }
if campaign_owner: if campaign_owner:
campaign_schema["organizer"] = { campaign_event["organizer"] = {
"@type": "Organization", "@type": "Organization",
"name": campaign_owner_name, "name": campaign_owner_name,
} }
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": url,
"datePublished": campaign.added_at.isoformat(),
"dateModified": campaign.updated_at.isoformat(),
}
campaign_event["mainEntityOfPage"] = webpage_node
campaign_schema: dict[str, Any] = campaign_event
# Breadcrumb schema for navigation # Breadcrumb schema for navigation
# TODO(TheLovinator): We should have a game.get_display_name() method that encapsulates the logic of choosing between display_name, name, and twitch_id. # noqa: TD003 # TODO(TheLovinator): We should have a game.get_display_name() method that encapsulates the logic of choosing between display_name, name, and twitch_id. # noqa: TD003
@ -787,12 +798,19 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=campaign_name, page_title=campaign_name,
page_description=campaign_description, page_description=campaign_description,
page_image=campaign_image, seo_meta={
page_image_width=campaign_image_width, "page_image": campaign_image,
page_image_height=campaign_image_height, "page_image_width": campaign_image_width,
schema_data=campaign_schema, "page_image_height": campaign_image_height,
breadcrumb_schema=breadcrumb_schema, "schema_data": campaign_schema,
modified_date=campaign.updated_at.isoformat() if campaign.updated_at else None, "breadcrumb_schema": breadcrumb_schema,
"published_date": campaign.added_at.isoformat()
if campaign.added_at
else None,
"modified_date": campaign.updated_at.isoformat()
if campaign.updated_at
else None,
},
) )
context.update(seo_context) context.update(seo_context)
@ -887,7 +905,7 @@ class GamesGridView(ListView):
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Games", page_title="Twitch Games",
page_description="Twitch games that had or have Twitch drops.", page_description="Twitch games that had or have Twitch drops.",
schema_data=collection_schema, seo_meta={"schema_data": collection_schema},
) )
context.update(seo_context) context.update(seo_context)
@ -1080,6 +1098,10 @@ class GameDetailView(DetailView):
reverse("twitch:game_detail", args=[game.twitch_id]), reverse("twitch:game_detail", args=[game.twitch_id]),
), ),
} }
if game.added_at:
game_schema["datePublished"] = game.added_at.isoformat()
if game.updated_at:
game_schema["dateModified"] = game.updated_at.isoformat()
preferred_owner: Organization | None = _pick_owner(owners) preferred_owner: Organization | None = _pick_owner(owners)
owner_name: str = ( owner_name: str = (
(preferred_owner.name or preferred_owner.twitch_id) (preferred_owner.name or preferred_owner.twitch_id)
@ -1114,12 +1136,17 @@ class GameDetailView(DetailView):
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=game_name, page_title=game_name,
page_description=game_description, page_description=game_description,
page_image=game_image, seo_meta={
page_image_width=game_image_width, "page_image": game_image,
page_image_height=game_image_height, "page_image_width": game_image_width,
schema_data=game_schema, "page_image_height": game_image_height,
breadcrumb_schema=breadcrumb_schema, "schema_data": game_schema,
modified_date=game.updated_at.isoformat() if game.updated_at else None, "breadcrumb_schema": breadcrumb_schema,
"published_date": game.added_at.isoformat() if game.added_at else None,
"modified_date": game.updated_at.isoformat()
if game.updated_at
else None,
},
) )
context.update({ context.update({
"active_campaigns": active_campaigns, "active_campaigns": active_campaigns,
@ -1213,8 +1240,10 @@ def dashboard(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Drops", page_title="Twitch Drops",
page_description="Overview of active Twitch drop campaigns and rewards.", page_description="Overview of active Twitch drop campaigns and rewards.",
og_type="website", seo_meta={
schema_data=website_schema, "og_type": "website",
"schema_data": website_schema,
},
) )
return render( return render(
request, request,
@ -1306,8 +1335,10 @@ def reward_campaign_list_view(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=title, page_title=title,
page_description=description, page_description=description,
pagination_info=pagination_info, seo_meta={
schema_data=collection_schema, "pagination_info": pagination_info,
"schema_data": collection_schema,
},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"reward_campaigns": reward_campaigns, "reward_campaigns": reward_campaigns,
@ -1375,29 +1406,45 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
else f"{campaign_name}" else f"{campaign_name}"
) )
campaign_schema: dict[str, str | dict[str, str]] = { reward_url: str = request.build_absolute_uri(
"@context": "https://schema.org", reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]),
)
campaign_event: dict[str, Any] = {
"@type": "Event", "@type": "Event",
"name": campaign_name, "name": campaign_name,
"description": campaign_description, "description": campaign_description,
"url": request.build_absolute_uri( "url": reward_url,
reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]),
),
"eventStatus": "https://schema.org/EventScheduled", "eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode", "eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
"location": {"@type": "VirtualLocation", "url": "https://www.twitch.tv"}, "location": {"@type": "VirtualLocation", "url": "https://www.twitch.tv"},
} }
if reward_campaign.starts_at: if reward_campaign.starts_at:
campaign_schema["startDate"] = reward_campaign.starts_at.isoformat() campaign_event["startDate"] = reward_campaign.starts_at.isoformat()
if reward_campaign.ends_at: if reward_campaign.ends_at:
campaign_schema["endDate"] = reward_campaign.ends_at.isoformat() campaign_event["endDate"] = reward_campaign.ends_at.isoformat()
if reward_campaign.game and reward_campaign.game.owners.exists(): if reward_campaign.game and reward_campaign.game.owners.exists():
owner = reward_campaign.game.owners.first() owner = reward_campaign.game.owners.first()
campaign_schema["organizer"] = { campaign_event["organizer"] = {
"@type": "Organization", "@type": "Organization",
"name": owner.name or owner.twitch_id, "name": owner.name or owner.twitch_id,
} }
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": reward_url,
"datePublished": reward_campaign.added_at.isoformat(),
"dateModified": reward_campaign.updated_at.isoformat(),
}
campaign_schema = {
"@context": "https://schema.org",
"@graph": [
campaign_event,
webpage_node,
],
}
# Breadcrumb schema # Breadcrumb schema
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([ breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
{"name": "Home", "url": request.build_absolute_uri("/")}, {"name": "Home", "url": request.build_absolute_uri("/")},
@ -1419,9 +1466,12 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=campaign_name, page_title=campaign_name,
page_description=campaign_description, page_description=campaign_description,
schema_data=campaign_schema, seo_meta={
breadcrumb_schema=breadcrumb_schema, "schema_data": campaign_schema,
modified_date=reward_campaign.updated_at.isoformat(), "breadcrumb_schema": breadcrumb_schema,
"published_date": reward_campaign.added_at.isoformat(),
"modified_date": reward_campaign.updated_at.isoformat(),
},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"reward_campaign": reward_campaign, "reward_campaign": reward_campaign,
@ -1506,8 +1556,10 @@ class ChannelListView(ListView):
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Channels", page_title="Twitch Channels",
page_description="List of Twitch channels participating in drop campaigns.", page_description="List of Twitch channels participating in drop campaigns.",
pagination_info=pagination_info, seo_meta={
schema_data=collection_schema, "pagination_info": pagination_info,
"schema_data": collection_schema,
},
) )
context.update(seo_context) context.update(seo_context)
context["search_query"] = search_query context["search_query"] = search_query
@ -1648,17 +1700,30 @@ class ChannelDetailView(DetailView):
if total_campaigns > 1: if total_campaigns > 1:
description += "s" description += "s"
channel_schema: dict[str, Any] = { channel_url: str = self.request.build_absolute_uri(
"@context": "https://schema.org", reverse("twitch:channel_detail", args=[channel.twitch_id]),
)
channel_node: dict[str, Any] = {
"@type": "BroadcastChannel", "@type": "BroadcastChannel",
"name": name, "name": name,
"description": description, "description": description,
"url": self.request.build_absolute_uri( "url": channel_url,
reverse("twitch:channel_detail", args=[channel.twitch_id]),
),
"broadcastChannelId": channel.twitch_id, "broadcastChannelId": channel.twitch_id,
"providerName": "Twitch", "providerName": "Twitch",
} }
webpage_node: dict[str, Any] = {
"@type": "WebPage",
"url": channel_url,
"datePublished": channel.added_at.isoformat(),
"dateModified": channel.updated_at.isoformat(),
}
channel_schema: dict[str, Any] = {
"@context": "https://schema.org",
"@graph": [
channel_node,
webpage_node,
],
}
# Breadcrumb schema # Breadcrumb schema
breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([ breadcrumb_schema: dict[str, Any] = _build_breadcrumb_schema([
@ -1675,11 +1740,16 @@ class ChannelDetailView(DetailView):
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=name, page_title=name,
page_description=description, page_description=description,
schema_data=channel_schema, seo_meta={
breadcrumb_schema=breadcrumb_schema, "schema_data": channel_schema,
modified_date=channel.updated_at.isoformat() "breadcrumb_schema": breadcrumb_schema,
if channel.updated_at "published_date": channel.added_at.isoformat()
else None, if channel.added_at
else None,
"modified_date": channel.updated_at.isoformat()
if channel.updated_at
else None,
},
) )
context.update({ context.update({
"active_campaigns": active_campaigns, "active_campaigns": active_campaigns,
@ -1733,7 +1803,7 @@ def badge_list_view(request: HttpRequest) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title="Twitch Chat Badges", page_title="Twitch Chat Badges",
page_description="List of Twitch chat badges awarded through drop campaigns.", page_description="List of Twitch chat badges awarded through drop campaigns.",
schema_data=collection_schema, seo_meta={"schema_data": collection_schema},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"badge_sets": badge_sets, "badge_sets": badge_sets,
@ -1847,7 +1917,7 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse:
seo_context: dict[str, Any] = _build_seo_context( seo_context: dict[str, Any] = _build_seo_context(
page_title=f"Badge Set: {badge_set_name}", page_title=f"Badge Set: {badge_set_name}",
page_description=badge_set_description, page_description=badge_set_description,
schema_data=badge_schema, seo_meta={"schema_data": badge_schema},
) )
context: dict[str, Any] = { context: dict[str, Any] = {
"badge_set": badge_set, "badge_set": badge_set,