diff --git a/core/seo.py b/core/seo.py new file mode 100644 index 0000000..9f1061f --- /dev/null +++ b/core/seo.py @@ -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 diff --git a/kick/views.py b/kick/views.py index d803524..33066f4 100644 --- a/kick/views.py +++ b/kick/views.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import json import logging from typing import TYPE_CHECKING +from typing import Any from typing import Literal from django.core.paginator import EmptyPage @@ -24,6 +27,7 @@ if TYPE_CHECKING: from django.http import HttpRequest from django.http import HttpResponse + from core.seo import SeoMeta from kick.models import KickChannel from kick.models import KickReward @@ -33,26 +37,36 @@ logger: logging.Logger = logging.getLogger("ttvdrops.kick.views") def _build_seo_context( page_title: str = "Kick Drops", page_description: str | None = None, - og_type: str = "website", - robots_directive: str = "index, follow", -) -> dict[str, str]: - """Build minimal SEO context for template rendering. + seo_meta: SeoMeta | None = None, +) -> dict[str, Any]: + """Build SEO context for template rendering. Args: page_title: The title of the page for and OG tags. page_description: Optional description for meta and OG tags. - og_type: Open Graph type (default "website"). - robots_directive: Value for meta robots tag (default "index, follow"). + seo_meta: Optional typed SEO metadata. Returns: A dictionary with SEO-related context variables. """ - return { + context: dict[str, Any] = { "page_title": page_title, "page_description": page_description or "Archive of Kick drops.", - "og_type": og_type, - "robots_directive": robots_directive, + "og_type": "website", + "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: @@ -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_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( 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_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( 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_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( request, diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index 61cfccc..dc36335 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -45,6 +45,14 @@ </div> <!-- Campaign description --> <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 --> <div> {% if campaign.end_at < now %} diff --git a/templates/twitch/channel_detail.html b/templates/twitch/channel_detail.html index be5d02a..c5ad5d7 100644 --- a/templates/twitch/channel_detail.html +++ b/templates/twitch/channel_detail.html @@ -20,6 +20,14 @@ </iframe> <!-- Channel Info --> <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 %} <h5>Active Campaigns</h5> <table> diff --git a/templates/twitch/game_detail.html b/templates/twitch/game_detail.html index 6e68f7e..1c871f1 100644 --- a/templates/twitch/game_detail.html +++ b/templates/twitch/game_detail.html @@ -47,6 +47,14 @@ Twitch ID: <a href="https://www.twitch.tv/directory/category/{{ game.slug|urlencode }}">{{ game.twitch_id }}</a> </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 --> <div> <a href="{% url 'core:game_campaign_feed' game.twitch_id %}" diff --git a/templates/twitch/organization_detail.html b/templates/twitch/organization_detail.html index 25326b0..0464252 100644 --- a/templates/twitch/organization_detail.html +++ b/templates/twitch/organization_detail.html @@ -21,14 +21,22 @@ {% endif %} {% endblock extra_head %} {% block content %} - <h1 id="org-name">{{ organization.name }}</h1> - <theader> - <h2 id="games-header">Games by {{ organization.name }}</h2> - </theader> - <table id="games-table"> + <h1>{{ organization.name }}</h1> + <p> + Published: + <time datetime="{{ organization.added_at|date:'c' }}" + 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> {% for game in games %} - <tr id="game-row-{{ game.twitch_id }}"> + <tr> <td> <a href="{% url 'twitch:game_detail' game.twitch_id %}">{{ game }}</a> </td> diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index a125cd8..29b89fb 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -1051,9 +1051,11 @@ class TestSEOHelperFunctions: context: dict[str, Any] = _build_seo_context( page_title="Test Title", page_description="Test Description", - page_image="https://example.com/image.jpg", - og_type="article", - schema_data={"@context": "https://schema.org"}, + seo_meta={ + "page_image": "https://example.com/image.jpg", + "og_type": "article", + "schema_data": {"@context": "https://schema.org"}, + }, ) assert context["page_title"] == "Test Title" @@ -1083,14 +1085,16 @@ class TestSEOHelperFunctions: context: dict[str, Any] = _build_seo_context( page_title="Test", page_description="Desc", - page_image="https://example.com/img.jpg", - og_type="article", - schema_data={}, - breadcrumb_schema=breadcrumb, - pagination_info=[{"rel": "next", "url": "/page/2/"}], - published_date=now.isoformat(), - modified_date=now.isoformat(), - robots_directive="noindex, follow", + seo_meta={ + "page_image": "https://example.com/img.jpg", + "og_type": "article", + "schema_data": {}, + "breadcrumb_schema": breadcrumb, + "pagination_info": [{"rel": "next", "url": "/page/2/"}], + "published_date": now.isoformat(), + "modified_date": now.isoformat(), + "robots_directive": "noindex, follow", + }, ) # breadcrumb_schema is JSON-dumped, so parse it back diff --git a/twitch/views.py b/twitch/views.py index 4c5420f..e797cb3 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import csv import datetime import json @@ -44,6 +46,8 @@ if TYPE_CHECKING: from django.db.models import QuerySet from django.http import HttpRequest + from core.seo import SeoMeta + logger: logging.Logger = logging.getLogger("ttvdrops.views") 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] + "…" -def _build_seo_context( # noqa: PLR0913, PLR0917 +def _build_seo_context( page_title: str = "ttvdrops", page_description: str | None = None, - page_image: str | 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", + seo_meta: SeoMeta | None = None, ) -> dict[str, Any]: """Build SEO context for template rendering. Args: page_title: Page title (shown in browser tab, og:title). page_description: Page description (meta description, og:description). - page_image: Image URL for og:image meta tag. - page_image_width: Width of the image in pixels. - 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"). + seo_meta: Optional typed SEO metadata with image, schema, breadcrumb, + pagination, OpenGraph, and date fields. Returns: 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] = { "page_title": page_title, "page_description": page_description or DEFAULT_SITE_DESCRIPTION, - "og_type": og_type, - "robots_directive": robots_directive, + "og_type": "website", + "robots_directive": "index, follow", } - if page_image: - context["page_image"] = page_image - if page_image_width and page_image_height: - context["page_image_width"] = page_image_width - context["page_image_height"] = page_image_height - if schema_data: - context["schema_data"] = json.dumps(schema_data) - if breadcrumb_schema: - context["breadcrumb_schema"] = json.dumps(breadcrumb_schema) - if pagination_info: - context["pagination_info"] = pagination_info - if published_date: - context["published_date"] = published_date - if modified_date: - context["modified_date"] = modified_date + 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("page_image"): + context["page_image"] = seo_meta["page_image"] + if seo_meta.get("page_image_width") and seo_meta.get("page_image_height"): + context["page_image_width"] = seo_meta["page_image_width"] + context["page_image_height"] = seo_meta["page_image_height"] + if seo_meta.get("schema_data"): + context["schema_data"] = json.dumps(seo_meta["schema_data"]) + if seo_meta.get("breadcrumb_schema"): + context["breadcrumb_schema"] = json.dumps(seo_meta["breadcrumb_schema"]) + 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 @@ -296,7 +282,7 @@ def org_list_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Organizations", page_description="List of Twitch organizations.", - schema_data=collection_schema, + seo_meta={"schema_data": collection_schema}, ) context: dict[str, Any] = { "orgs": orgs, @@ -361,13 +347,25 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon url: str = request.build_absolute_uri( reverse("twitch:organization_detail", args=[organization.twitch_id]), ) - org_schema: dict[str, str | dict[str, str]] = { - "@context": "https://schema.org", + organization_node: dict[str, Any] = { "@type": "Organization", "name": org_name, "url": url, "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: 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( page_title=org_name, page_description=org_description, - schema_data=org_schema, - breadcrumb_schema=breadcrumb_schema, - modified_date=organization.updated_at.isoformat(), + seo_meta={ + "schema_data": org_schema, + "breadcrumb_schema": breadcrumb_schema, + "published_date": organization.added_at.isoformat(), + "modified_date": organization.updated_at.isoformat(), + }, ) context: dict[str, Any] = { "organization": organization, @@ -481,8 +482,10 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0 seo_context: dict[str, Any] = _build_seo_context( page_title=title, page_description=description, - pagination_info=pagination_info, - schema_data=collection_schema, + seo_meta={ + "pagination_info": pagination_info, + "schema_data": collection_schema, + }, ) context: dict[str, Any] = { "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 - campaign_schema: dict[str, str | dict[str, str]] = { + campaign_event: dict[str, Any] = { "@context": "https://schema.org", "@type": "Event", "name": campaign_name, @@ -738,9 +741,9 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo }, } if campaign.start_at: - campaign_schema["startDate"] = campaign.start_at.isoformat() + campaign_event["startDate"] = campaign.start_at.isoformat() if campaign.end_at: - campaign_schema["endDate"] = campaign.end_at.isoformat() + campaign_event["endDate"] = campaign.end_at.isoformat() campaign_owner: Organization | 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" ) if campaign_image: - campaign_schema["image"] = { + campaign_event["image"] = { "@type": "ImageObject", "contentUrl": request.build_absolute_uri(campaign_image), "creditText": campaign_owner_name, "copyrightNotice": campaign_owner_name, } if campaign_owner: - campaign_schema["organizer"] = { + campaign_event["organizer"] = { "@type": "Organization", "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 # 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( page_title=campaign_name, page_description=campaign_description, - page_image=campaign_image, - page_image_width=campaign_image_width, - page_image_height=campaign_image_height, - schema_data=campaign_schema, - breadcrumb_schema=breadcrumb_schema, - modified_date=campaign.updated_at.isoformat() if campaign.updated_at else None, + seo_meta={ + "page_image": campaign_image, + "page_image_width": campaign_image_width, + "page_image_height": campaign_image_height, + "schema_data": campaign_schema, + "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) @@ -887,7 +905,7 @@ class GamesGridView(ListView): seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Games", page_description="Twitch games that had or have Twitch drops.", - schema_data=collection_schema, + seo_meta={"schema_data": collection_schema}, ) context.update(seo_context) @@ -1080,6 +1098,10 @@ class GameDetailView(DetailView): 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) owner_name: str = ( (preferred_owner.name or preferred_owner.twitch_id) @@ -1114,12 +1136,17 @@ class GameDetailView(DetailView): seo_context: dict[str, Any] = _build_seo_context( page_title=game_name, page_description=game_description, - page_image=game_image, - page_image_width=game_image_width, - page_image_height=game_image_height, - schema_data=game_schema, - breadcrumb_schema=breadcrumb_schema, - modified_date=game.updated_at.isoformat() if game.updated_at else None, + seo_meta={ + "page_image": game_image, + "page_image_width": game_image_width, + "page_image_height": game_image_height, + "schema_data": game_schema, + "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({ "active_campaigns": active_campaigns, @@ -1213,8 +1240,10 @@ def dashboard(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Drops", page_description="Overview of active Twitch drop campaigns and rewards.", - og_type="website", - schema_data=website_schema, + seo_meta={ + "og_type": "website", + "schema_data": website_schema, + }, ) return render( request, @@ -1306,8 +1335,10 @@ def reward_campaign_list_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title=title, page_description=description, - pagination_info=pagination_info, - schema_data=collection_schema, + seo_meta={ + "pagination_info": pagination_info, + "schema_data": collection_schema, + }, ) context: dict[str, Any] = { "reward_campaigns": reward_campaigns, @@ -1375,29 +1406,45 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes else f"{campaign_name}" ) - campaign_schema: dict[str, str | dict[str, str]] = { - "@context": "https://schema.org", + reward_url: str = request.build_absolute_uri( + reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]), + ) + + campaign_event: dict[str, Any] = { "@type": "Event", "name": campaign_name, "description": campaign_description, - "url": request.build_absolute_uri( - reverse("twitch:reward_campaign_detail", args=[reward_campaign.twitch_id]), - ), + "url": reward_url, "eventStatus": "https://schema.org/EventScheduled", "eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode", "location": {"@type": "VirtualLocation", "url": "https://www.twitch.tv"}, } 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: - 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(): owner = reward_campaign.game.owners.first() - campaign_schema["organizer"] = { + campaign_event["organizer"] = { "@type": "Organization", "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: dict[str, Any] = _build_breadcrumb_schema([ {"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( page_title=campaign_name, page_description=campaign_description, - schema_data=campaign_schema, - breadcrumb_schema=breadcrumb_schema, - modified_date=reward_campaign.updated_at.isoformat(), + seo_meta={ + "schema_data": campaign_schema, + "breadcrumb_schema": breadcrumb_schema, + "published_date": reward_campaign.added_at.isoformat(), + "modified_date": reward_campaign.updated_at.isoformat(), + }, ) context: dict[str, Any] = { "reward_campaign": reward_campaign, @@ -1506,8 +1556,10 @@ class ChannelListView(ListView): seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Channels", page_description="List of Twitch channels participating in drop campaigns.", - pagination_info=pagination_info, - schema_data=collection_schema, + seo_meta={ + "pagination_info": pagination_info, + "schema_data": collection_schema, + }, ) context.update(seo_context) context["search_query"] = search_query @@ -1648,17 +1700,30 @@ class ChannelDetailView(DetailView): if total_campaigns > 1: description += "s" - channel_schema: dict[str, Any] = { - "@context": "https://schema.org", + channel_url: str = self.request.build_absolute_uri( + reverse("twitch:channel_detail", args=[channel.twitch_id]), + ) + channel_node: dict[str, Any] = { "@type": "BroadcastChannel", "name": name, "description": description, - "url": self.request.build_absolute_uri( - reverse("twitch:channel_detail", args=[channel.twitch_id]), - ), + "url": channel_url, "broadcastChannelId": channel.twitch_id, "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: dict[str, Any] = _build_breadcrumb_schema([ @@ -1675,11 +1740,16 @@ class ChannelDetailView(DetailView): seo_context: dict[str, Any] = _build_seo_context( page_title=name, page_description=description, - schema_data=channel_schema, - breadcrumb_schema=breadcrumb_schema, - modified_date=channel.updated_at.isoformat() - if channel.updated_at - else None, + seo_meta={ + "schema_data": channel_schema, + "breadcrumb_schema": breadcrumb_schema, + "published_date": channel.added_at.isoformat() + if channel.added_at + else None, + "modified_date": channel.updated_at.isoformat() + if channel.updated_at + else None, + }, ) context.update({ "active_campaigns": active_campaigns, @@ -1733,7 +1803,7 @@ def badge_list_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch Chat Badges", 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] = { "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( page_title=f"Badge Set: {badge_set_name}", page_description=badge_set_description, - schema_data=badge_schema, + seo_meta={"schema_data": badge_schema}, ) context: dict[str, Any] = { "badge_set": badge_set,