diff --git a/core/seo.py b/core/seo.py new file mode 100644 index 0000000..abd2183 --- /dev/null +++ b/core/seo.py @@ -0,0 +1,18 @@ +from typing import Any +from typing import TypedDict + + +class SeoMeta(TypedDict, total=False): + """Shared typed optional SEO metadata for template context generation.""" + + page_url: str | None + 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/core/urls.py b/core/urls.py index 6966557..51bcf42 100644 --- a/core/urls.py +++ b/core/urls.py @@ -2,9 +2,27 @@ from typing import TYPE_CHECKING from django.urls import path -from core import views +from core.views import dashboard +from core.views import dataset_backup_download_view +from core.views import dataset_backups_view +from core.views import debug_view +from core.views import docs_rss_view +from core.views import search_view +from twitch.feeds import DropCampaignAtomFeed +from twitch.feeds import DropCampaignDiscordFeed from twitch.feeds import DropCampaignFeed +from twitch.feeds import GameAtomFeed +from twitch.feeds import GameCampaignAtomFeed +from twitch.feeds import GameCampaignDiscordFeed +from twitch.feeds import GameCampaignFeed +from twitch.feeds import GameDiscordFeed from twitch.feeds import GameFeed +from twitch.feeds import OrganizationAtomFeed +from twitch.feeds import OrganizationDiscordFeed +from twitch.feeds import OrganizationRSSFeed +from twitch.feeds import RewardCampaignAtomFeed +from twitch.feeds import RewardCampaignDiscordFeed +from twitch.feeds import RewardCampaignFeed if TYPE_CHECKING: from django.urls.resolvers import URLPattern @@ -15,21 +33,21 @@ app_name = "core" urlpatterns: list[URLPattern | URLResolver] = [ # / - path("", views.dashboard, name="dashboard"), + path("", dashboard, name="dashboard"), # /search/ - path("search/", views.search_view, name="search"), + path("search/", search_view, name="search"), # /debug/ - path("debug/", views.debug_view, name="debug"), + path("debug/", debug_view, name="debug"), # /datasets/ - path("datasets/", views.dataset_backups_view, name="dataset_backups"), + path("datasets/", dataset_backups_view, name="dataset_backups"), # /datasets/download// path( "datasets/download//", - views.dataset_backup_download_view, + dataset_backup_download_view, name="dataset_backup_download", ), # /docs/rss/ - path("docs/rss/", views.docs_rss_view, name="docs_rss"), + path("docs/rss/", docs_rss_view, name="docs_rss"), # RSS feeds # /rss/campaigns/ - all active campaigns path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"), @@ -38,59 +56,59 @@ urlpatterns: list[URLPattern | URLResolver] = [ # /rss/games//campaigns/ - active campaigns for a specific game path( "rss/games//campaigns/", - views.GameCampaignFeed(), + GameCampaignFeed(), name="game_campaign_feed", ), # /rss/organizations/ - newly added organizations path( "rss/organizations/", - views.OrganizationRSSFeed(), + OrganizationRSSFeed(), name="organization_feed", ), # /rss/reward-campaigns/ - all active reward campaigns path( "rss/reward-campaigns/", - views.RewardCampaignFeed(), + RewardCampaignFeed(), name="reward_campaign_feed", ), # Atom feeds (added alongside RSS to preserve backward compatibility) - path("atom/campaigns/", views.DropCampaignAtomFeed(), name="campaign_feed_atom"), - path("atom/games/", views.GameAtomFeed(), name="game_feed_atom"), + path("atom/campaigns/", DropCampaignAtomFeed(), name="campaign_feed_atom"), + path("atom/games/", GameAtomFeed(), name="game_feed_atom"), path( "atom/games//campaigns/", - views.GameCampaignAtomFeed(), + view=GameCampaignAtomFeed(), name="game_campaign_feed_atom", ), path( "atom/organizations/", - views.OrganizationAtomFeed(), + OrganizationAtomFeed(), name="organization_feed_atom", ), path( "atom/reward-campaigns/", - views.RewardCampaignAtomFeed(), + RewardCampaignAtomFeed(), name="reward_campaign_feed_atom", ), # Discord feeds (Atom feeds with Discord relative timestamps) path( "discord/campaigns/", - views.DropCampaignDiscordFeed(), + DropCampaignDiscordFeed(), name="campaign_feed_discord", ), - path("discord/games/", views.GameDiscordFeed(), name="game_feed_discord"), + path("discord/games/", GameDiscordFeed(), name="game_feed_discord"), path( "discord/games//campaigns/", - views.GameCampaignDiscordFeed(), + GameCampaignDiscordFeed(), name="game_campaign_feed_discord", ), path( "discord/organizations/", - views.OrganizationDiscordFeed(), + OrganizationDiscordFeed(), name="organization_feed_discord", ), path( "discord/reward-campaigns/", - views.RewardCampaignDiscordFeed(), + RewardCampaignDiscordFeed(), name="reward_campaign_feed_discord", ), ] diff --git a/core/views.py b/core/views.py index 601ce11..2b97f8f 100644 --- a/core/views.py +++ b/core/views.py @@ -3,7 +3,6 @@ import json import logging import operator from collections import OrderedDict -from copy import copy from typing import TYPE_CHECKING from typing import Any @@ -27,21 +26,6 @@ from django.utils import timezone from kick.models import KickChannel from kick.models import KickDropCampaign -from twitch.feeds import DropCampaignAtomFeed -from twitch.feeds import DropCampaignDiscordFeed -from twitch.feeds import DropCampaignFeed -from twitch.feeds import GameAtomFeed -from twitch.feeds import GameCampaignAtomFeed -from twitch.feeds import GameCampaignDiscordFeed -from twitch.feeds import GameCampaignFeed -from twitch.feeds import GameDiscordFeed -from twitch.feeds import GameFeed -from twitch.feeds import OrganizationAtomFeed -from twitch.feeds import OrganizationDiscordFeed -from twitch.feeds import OrganizationRSSFeed -from twitch.feeds import RewardCampaignAtomFeed -from twitch.feeds import RewardCampaignDiscordFeed -from twitch.feeds import RewardCampaignFeed from twitch.models import Channel from twitch.models import ChatBadge from twitch.models import ChatBadgeSet @@ -53,13 +37,11 @@ from twitch.models import RewardCampaign from twitch.models import TimeBasedDrop if TYPE_CHECKING: - from collections.abc import Callable from os import stat_result from pathlib import Path from django.db.models import QuerySet from django.http import HttpRequest - from django.http.request import QueryDict logger: logging.Logger = logging.getLogger("ttvdrops.views") @@ -73,6 +55,7 @@ DEFAULT_SITE_DESCRIPTION = "Archive of Twitch drops, campaigns, rewards, and mor def _build_seo_context( # noqa: PLR0913, PLR0917 page_title: str = "ttvdrops", page_description: str | None = None, + page_url: str | None = None, page_image: str | None = None, page_image_width: int | None = None, page_image_height: int | None = None, @@ -89,6 +72,7 @@ def _build_seo_context( # noqa: PLR0913, PLR0917 Args: page_title: Page title (shown in browser tab, og:title). page_description: Page description (meta description, og:description). + page_url: Canonical absolute URL for the current page. 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. @@ -115,6 +99,8 @@ def _build_seo_context( # noqa: PLR0913, PLR0917 "og_type": og_type, "robots_directive": robots_directive, } + if page_url: + context["page_url"] = page_url if page_image: context["page_image"] = page_image if page_image_width and page_image_height: @@ -275,177 +261,33 @@ def sitemap_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0915 # MARK: /docs/rss/ def docs_rss_view(request: HttpRequest) -> HttpResponse: - """View for /docs/rss that lists all available RSS feeds. + """View for /docs/rss that lists all available feeds and explains how to use them. Args: request: The HTTP request object. Returns: - Rendered HTML response with list of RSS feeds. + HttpResponse: The rendered documentation page. """ - - def absolute(path: str) -> str: - try: - return request.build_absolute_uri(path) - except Exception: - logger.exception("Failed to build absolute URL for %s", path) - return path - - def _pretty_example(xml_str: str, max_items: int = 1) -> str: - try: - trimmed: str = xml_str.strip() - first_item: int = trimmed.find("", second_item) - if end_channel != -1: - trimmed = trimmed[:second_item] + trimmed[end_channel:] - formatted: str = trimmed.replace("><", ">\n<") - return "\n".join(line for line in formatted.splitlines() if line.strip()) - except Exception: - logger.exception("Failed to pretty-print RSS example") - return xml_str - - def render_feed(feed_view: Callable[..., HttpResponse], *args: object) -> str: - try: - limited_request: HttpRequest = copy(request) - # Add limit=1 to GET parameters - get_data: QueryDict = request.GET.copy() - get_data["limit"] = "1" - limited_request.GET = get_data - - response: HttpResponse = feed_view(limited_request, *args) - return _pretty_example(response.content.decode("utf-8")) - except Exception: - logger.exception( - "Failed to render %s for RSS docs", - feed_view.__class__.__name__, - ) - return "" - - show_atom: bool = bool(request.GET.get("show_atom")) - - feeds: list[dict[str, str]] = [ - { - "title": "All Organizations", - "description": "Latest organizations added to TTVDrops", - "url": absolute(reverse("core:organization_feed")), - "atom_url": absolute(reverse("core:organization_feed_atom")), - "discord_url": absolute(reverse("core:organization_feed_discord")), - "example_xml": render_feed(OrganizationRSSFeed()), - "example_xml_atom": render_feed(OrganizationAtomFeed()) - if show_atom - else "", - "example_xml_discord": render_feed(OrganizationDiscordFeed()) - if show_atom - else "", - }, - { - "title": "All Games", - "description": "Latest games added to TTVDrops", - "url": absolute(reverse("core:game_feed")), - "atom_url": absolute(reverse("core:game_feed_atom")), - "discord_url": absolute(reverse("core:game_feed_discord")), - "example_xml": render_feed(GameFeed()), - "example_xml_atom": render_feed(GameAtomFeed()) if show_atom else "", - "example_xml_discord": render_feed(GameDiscordFeed()) if show_atom else "", - }, - { - "title": "All Drop Campaigns", - "description": "Latest drop campaigns across all games", - "url": absolute(reverse("core:campaign_feed")), - "atom_url": absolute(reverse("core:campaign_feed_atom")), - "discord_url": absolute(reverse("core:campaign_feed_discord")), - "example_xml": render_feed(DropCampaignFeed()), - "example_xml_atom": render_feed(DropCampaignAtomFeed()) - if show_atom - else "", - "example_xml_discord": render_feed(DropCampaignDiscordFeed()) - if show_atom - else "", - }, - { - "title": "All Reward Campaigns", - "description": "Latest reward campaigns (Quest rewards) on Twitch", - "url": absolute(reverse("core:reward_campaign_feed")), - "atom_url": absolute(reverse("core:reward_campaign_feed_atom")), - "discord_url": absolute(reverse("core:reward_campaign_feed_discord")), - "example_xml": render_feed(RewardCampaignFeed()), - "example_xml_atom": render_feed(RewardCampaignAtomFeed()) - if show_atom - else "", - "example_xml_discord": render_feed(RewardCampaignDiscordFeed()) - if show_atom - else "", - }, - ] - - sample_game: Game | None = Game.objects.order_by("-added_at").first() - sample_org: Organization | None = Organization.objects.order_by("-added_at").first() - if sample_org is None and sample_game is not None: - sample_org = sample_game.owners.order_by("-pk").first() - - filtered_feeds: list[dict[str, str | bool]] = [ - { - "title": "Campaigns for a Single Game", - "description": "Latest drop campaigns for one game.", - "url": ( - absolute( - reverse("core:game_campaign_feed", args=[sample_game.twitch_id]), - ) - if sample_game - else absolute("/rss/games//campaigns/") - ), - "atom_url": ( - absolute( - reverse( - "core:game_campaign_feed_atom", - args=[sample_game.twitch_id], - ), - ) - if sample_game - else absolute("/atom/games//campaigns/") - ), - "discord_url": ( - absolute( - reverse( - "core:game_campaign_feed_discord", - args=[sample_game.twitch_id], - ), - ) - if sample_game - else absolute("/discord/games//campaigns/") - ), - "has_sample": bool(sample_game), - "example_xml": render_feed(GameCampaignFeed(), sample_game.twitch_id) - if sample_game - else "", - "example_xml_atom": ( - render_feed(GameCampaignAtomFeed(), sample_game.twitch_id) - if sample_game and show_atom - else "" - ), - "example_xml_discord": ( - render_feed(GameCampaignDiscordFeed(), sample_game.twitch_id) - if sample_game and show_atom - else "" - ), - }, - ] + now: datetime.datetime = timezone.now() + sample_game: Game | None = ( + Game.objects + .filter(drop_campaigns__start_at__lte=now, drop_campaigns__end_at__gte=now) + .distinct() + .first() + ) seo_context: dict[str, Any] = _build_seo_context( - page_title="Twitch RSS Feeds", - page_description="RSS feeds for Twitch drops.", + page_title="Feed Documentation", + page_description="Documentation for the RSS feeds available on ttvdrops.lovinator.space, including how to use them and what data they contain.", + page_url=request.build_absolute_uri(reverse("core:docs_rss")), ) + return render( request, - "twitch/docs_rss.html", + "core/docs_rss.html", { - "feeds": feeds, - "filtered_feeds": filtered_feeds, - "sample_game": sample_game, - "sample_org": sample_org, + "game": sample_game, **seo_context, }, ) @@ -590,7 +432,7 @@ def debug_view(request: HttpRequest) -> HttpResponse: ) context.update(seo_context) - return render(request, "twitch/debug.html", context) + return render(request, "core/debug.html", context) # MARK: /datasets/ @@ -701,7 +543,7 @@ def dataset_backups_view(request: HttpRequest) -> HttpResponse: "dataset_count": len(datasets), **seo_context, } - return render(request, "twitch/dataset_backups.html", context) + return render(request, "core/dataset_backups.html", context) def dataset_backup_download_view( @@ -836,10 +678,11 @@ def search_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title=page_title, page_description=page_description, + page_url=request.build_absolute_uri(reverse("core:search")), ) return render( request, - "twitch/search_results.html", + "core/search_results.html", {"query": query, "results": results, **seo_context}, ) @@ -951,7 +794,9 @@ def dashboard(request: HttpRequest) -> HttpResponse: seo_context: dict[str, Any] = _build_seo_context( page_title="Twitch/Kick Drops", - page_description=("Twitch and Kick drops."), + page_description=( + "RSS feeds, historical data, and information about Twitch and Kick drops, campaigns, rewards, and more." + ), og_type="website", schema_data=website_schema, ) diff --git a/kick/views.py b/kick/views.py index d803524..f87fb08 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,38 @@ 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("page_url"): + context["page_url"] = seo_meta["page_url"] + 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: @@ -211,6 +227,7 @@ def campaign_list_view(request: HttpRequest) -> HttpResponse: seo_context: dict[str, str] = _build_seo_context( page_title=title, page_description="Browse Kick drop campaigns.", + seo_meta={"page_url": request.build_absolute_uri(base_url)}, ) return render( request, @@ -278,9 +295,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 +438,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 +543,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/core/dashboard.html b/templates/core/dashboard.html index d6b1bdf..44155b8 100644 --- a/templates/core/dashboard.html +++ b/templates/core/dashboard.html @@ -48,6 +48,7 @@ title="Atom feed for Twitch campaigns">[atom]</a> <a href="{% url 'core:campaign_feed_discord' %}" title="Discord feed for Twitch campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> </header> {% if campaigns_by_game %} @@ -223,6 +224,7 @@ title="Atom feed for all Kick campaigns">[atom]</a> <a href="{% url 'kick:campaign_feed_discord' %}" title="Discord feed for all Kick campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> </header> {% if kick_campaigns_by_game %} diff --git a/templates/twitch/dataset_backups.html b/templates/core/dataset_backups.html similarity index 100% rename from templates/twitch/dataset_backups.html rename to templates/core/dataset_backups.html diff --git a/templates/twitch/debug.html b/templates/core/debug.html similarity index 100% rename from templates/twitch/debug.html rename to templates/core/debug.html diff --git a/templates/core/docs_rss.html b/templates/core/docs_rss.html new file mode 100644 index 0000000..41011a1 --- /dev/null +++ b/templates/core/docs_rss.html @@ -0,0 +1,155 @@ +{% extends "base.html" %} +{% load static %} +{% block title %} + RSS Feeds Documentation +{% endblock title %} +{% block content %} + <main> + <h1>RSS Feeds Documentation</h1> + <p> + You have three types of feeds available for Twitch drops data: RSS, Atom, and Discord. + RSS and Atom feeds are similar and can be used in any RSS reader application. + The main difference is that Atom feeds include additional metadata and support for more complex content, while RSS feeds are more widely supported by older applications. + </p> + <p> + Discord feeds are available under the <code>/discord/</code> endpoints. These are Atom feeds + that include Discord relative timestamps (e.g., <code><t:1773450272:R></code>) for dates, + making them ideal for Discord bots and integrations. Future enhancements may include Discord-specific formatting or content. + </p> + <section> + <h2>Global RSS Feeds</h2> + <table> + <thead> + <tr> + <th>Description</th> + <th>RSS</th> + <th>Atom</th> + <th>Discord</th> + </tr> + </thead> + <tbody> + <tr> + <td>New Twitch games</td> + <td> + <a href="https://ttvdrops.lovinator.space/rss/games/">https://ttvdrops.lovinator.space/rss/games/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/atom/games/">https://ttvdrops.lovinator.space/atom/games/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/discord/games/">https://ttvdrops.lovinator.space/discord/games/</a> + </td> + </tr> + <tr> + <td>Latest Twitch drop campaigns</td> + <td> + <a href="https://ttvdrops.lovinator.space/rss/campaigns/">https://ttvdrops.lovinator.space/rss/campaigns/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/atom/campaigns/">https://ttvdrops.lovinator.space/atom/campaigns/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/discord/campaigns/">https://ttvdrops.lovinator.space/discord/campaigns/</a> + </td> + </tr> + <tr> + <td>Latest Twitch organizations</td> + <td> + <a href="https://ttvdrops.lovinator.space/rss/organizations/">https://ttvdrops.lovinator.space/rss/organizations/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/atom/organizations/">https://ttvdrops.lovinator.space/atom/organizations/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/discord/organizations/">https://ttvdrops.lovinator.space/discord/organizations/</a> + </td> + </tr> + <tr> + <td>Latest Twitch reward campaigns</td> + <td> + <a href="https://ttvdrops.lovinator.space/rss/reward-campaigns/">https://ttvdrops.lovinator.space/rss/reward-campaigns/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/atom/reward-campaigns/">https://ttvdrops.lovinator.space/atom/reward-campaigns/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/discord/reward-campaigns/">https://ttvdrops.lovinator.space/discord/reward-campaigns/</a> + </td> + </tr> + <tr> + <td>Latest Kick campaigns</td> + <td> + <a href="https://ttvdrops.lovinator.space/kick/rss/campaigns/">https://ttvdrops.lovinator.space/kick/rss/campaigns/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/kick/atom/campaigns/">https://ttvdrops.lovinator.space/kick/atom/campaigns/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/discord/campaigns/">https://ttvdrops.lovinator.space/discord/campaigns/</a> + </td> + </tr> + <tr> + <td>Latest Kick games</td> + <td> + <a href="https://ttvdrops.lovinator.space/kick/rss/games/">https://ttvdrops.lovinator.space/kick/rss/games/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/kick/atom/games/">https://ttvdrops.lovinator.space/kick/atom/games/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/discord/games/">https://ttvdrops.lovinator.space/discord/games/</a> + </td> + </tr> + <tr> + <td>Latest Kick organizations</td> + <td> + <a href="https://ttvdrops.lovinator.space/kick/rss/organizations/">https://ttvdrops.lovinator.space/kick/rss/organizations/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/kick/atom/organizations/">https://ttvdrops.lovinator.space/kick/atom/organizations/</a> + </td> + <td> + <a href="https://ttvdrops.lovinator.space/discord/organizations/">https://ttvdrops.lovinator.space/discord/organizations/</a> + </td> + </tr> + </tbody> + </table> + </section> + {% if game %} + <section> + <h2>Filtered RSS Feeds</h2> + <p>You can subscribe to RSS feeds scoped to a specific game.</p> + <table> + <thead> + <tr> + <th>Game</th> + <th>RSS</th> + <th>Atom</th> + <th>Discord</th> + </tr> + </thead> + <tbody> + <tr> + <td>{{ game.display_name }}</td> + <td> + <a href="{% url 'core:game_campaign_feed' game.twitch_id %}"> + https://ttvdrops.lovinator.space/rss/games/{{ game.twitch_id }}/ + </a> + </td> + <td> + <a href="{% url 'core:game_campaign_feed_atom' game.twitch_id %}"> + https://ttvdrops.lovinator.space/atom/games/{{ game.twitch_id }}/ + </a> + </td> + <td> + <a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}"> + https://ttvdrops.lovinator.space/discord/games/{{ game.twitch_id }}/ + </a> + </td> + </tr> + </tbody> + </table> + </section> + {% endif %} + </main> +{% endblock content %} diff --git a/templates/twitch/search_results.html b/templates/core/search_results.html similarity index 100% rename from templates/twitch/search_results.html rename to templates/core/search_results.html diff --git a/templates/includes/meta_tags.html b/templates/includes/meta_tags.html index 32a0b5f..a8e1797 100644 --- a/templates/includes/meta_tags.html +++ b/templates/includes/meta_tags.html @@ -16,9 +16,6 @@ {# - robots_directive: str - robots meta content (default: "index, follow") #} {# #} {% load static %} -{# Preconnect to external resources for performance #} -<link rel="preconnect" href="https://static-cdn.jtvnw.net" /> -<link rel="dns-prefetch" href="https://static-cdn.jtvnw.net" /> {# Description meta tag #} <meta name="description" content="{% firstof page_description 'ttvdrops - Track Twitch drops.' %}" /> @@ -26,7 +23,7 @@ <meta name="robots" content="{% firstof robots_directive 'index, follow' %}" /> {# Author and Copyright #} -<meta name="author" content="TheLovinator1" /> +<meta name="author" content="TheLovinator" /> <meta name="copyright" content="This work is dedicated to the public domain under CC0 1.0 Universal." /> {# Open Graph tags for social sharing #} @@ -45,10 +42,14 @@ {% endif %} {# Twitter Card tags for rich previews #} <meta name="twitter:card" - content="{% if page_image %}summary_large_image{% else %}summary{% endif %}" /> + content="{% if page_image %} + summary_large_image + {% else %} + summary + {% endif %}" /> <meta name="twitter:title" content="{% firstof page_title 'ttvdrops' %}" /> <meta name="twitter:description" - content="{% firstof page_description 'ttvdrops - Track Twitch drops.' %}" /> + content="{% firstof page_description 'ttvdrops - Twitch and Kick drops.' %}" /> {% if page_image %}<meta name="twitter:image" content="{{ page_image }}" />{% endif %} {# Article dates for content pages #} {% if published_date %}<meta property="article:published_time" content="{{ published_date }}" />{% endif %} @@ -61,6 +62,22 @@ {% for link in pagination_info %}<link rel="{{ link.rel }}" href="{{ link.url }}" />{% endfor %} {% endif %} {# Schema.org JSON-LD structured data #} -{% if schema_data %}<script type="application/ld+json">{{ schema_data|safe }}</script>{% endif %} +{% if schema_data %} + <script type="application/ld+json"> + { + { + schema_data | safe + } + } + </script> +{% endif %} {# Breadcrumb schema #} -{% if breadcrumb_schema %}<script type="application/ld+json">{{ breadcrumb_schema|safe }}</script>{% endif %} +{% if breadcrumb_schema %} + <script type="application/ld+json"> + { + { + breadcrumb_schema | safe + } + } + </script> +{% endif %} diff --git a/templates/kick/campaign_detail.html b/templates/kick/campaign_detail.html index 30b03dc..376d25f 100644 --- a/templates/kick/campaign_detail.html +++ b/templates/kick/campaign_detail.html @@ -59,6 +59,7 @@ title="Atom feed for {{ campaign.category.name }} campaigns">[atom]</a> <a href="{% url 'kick:game_campaign_feed_discord' campaign.category.kick_id %}" title="Discord feed for {{ campaign.category.name }} campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> {% endif %} <p style="margin: 0.25rem 0; color: #666;"> diff --git a/templates/kick/campaign_list.html b/templates/kick/campaign_list.html index 7e6d149..8b58cc8 100644 --- a/templates/kick/campaign_list.html +++ b/templates/kick/campaign_list.html @@ -26,6 +26,7 @@ title="Atom feed for all campaigns">[atom]</a> <a href="{% url 'kick:campaign_feed_discord' %}" title="Discord feed for all campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <form method="get" action="{% url 'kick:campaign_list' %}"> <div style="display: flex; diff --git a/templates/kick/category_detail.html b/templates/kick/category_detail.html index ddcb7b6..113479b 100644 --- a/templates/kick/category_detail.html +++ b/templates/kick/category_detail.html @@ -56,6 +56,7 @@ title="Atom feed for {{ category.name }} campaigns">[atom]</a> <a href="{% url 'kick:game_campaign_feed_discord' category.kick_id %}" title="Discord feed for {{ category.name }} campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> {% if category.kick_url %} <p style="margin: 0.25rem 0;"> diff --git a/templates/kick/category_list.html b/templates/kick/category_list.html index 9efd2a8..b09c3bf 100644 --- a/templates/kick/category_list.html +++ b/templates/kick/category_list.html @@ -25,6 +25,7 @@ title="Atom feed for all games">[atom]</a> <a href="{% url 'kick:game_feed_discord' %}" title="Discord feed for all games">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> {% if categories %} <ul> diff --git a/templates/kick/dashboard.html b/templates/kick/dashboard.html index 0f568d0..886a75c 100644 --- a/templates/kick/dashboard.html +++ b/templates/kick/dashboard.html @@ -27,6 +27,7 @@ title="Atom feed for all campaigns">[atom]</a> <a href="{% url 'kick:campaign_feed_discord' %}" title="Discord feed for all campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <hr /> {% if active_campaigns %} diff --git a/templates/kick/org_list.html b/templates/kick/org_list.html index c3aca0b..9e92e52 100644 --- a/templates/kick/org_list.html +++ b/templates/kick/org_list.html @@ -26,6 +26,7 @@ title="Atom feed for all organizations">[atom]</a> <a href="{% url 'kick:organization_feed_discord' %}" title="Discord feed for all organizations">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> {% if orgs %} <ul> diff --git a/templates/twitch/badge_list.html b/templates/twitch/badge_list.html index 49e4016..0a9e41c 100644 --- a/templates/twitch/badge_list.html +++ b/templates/twitch/badge_list.html @@ -7,13 +7,11 @@ <h1>{{ badge_sets.count }} Twitch Chat Badges</h1> {% if badge_sets %} {% for data in badge_data %} - <!-- {{ data.set.set_id }} - {{ data.badges|length }} version{% if data.badges|length > 1 %}s{% endif %} --> <h2> <a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">{{ data.set.set_id }}</a> </h2> <table> {% for badge in data.badges %} - <!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} --> <tr> <td style="width: 40px;"> <a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}"> diff --git a/templates/twitch/badge_set_detail.html b/templates/twitch/badge_set_detail.html index 3d203e6..c14bc1d 100644 --- a/templates/twitch/badge_set_detail.html +++ b/templates/twitch/badge_set_detail.html @@ -9,7 +9,6 @@ <table> <tbody> {% for badge in badges %} - <!-- {{ badge.title }} {% if badge.description != badge.title %}- {{ badge.description }}{% else %}{% endif %} --> <tr> <td> <code>{{ badge.badge_id }}</code> @@ -48,5 +47,4 @@ {% else %} <p>No badges found in this set.</p> {% endif %} - {{ set_data|safe }} {% endblock content %} diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index 61cfccc..6eda59b 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 %} @@ -96,6 +104,7 @@ title="Atom feed for {{ campaign.game.display_name }} campaigns">[atom]</a> <a href="{% url 'core:game_campaign_feed_discord' campaign.game.twitch_id %}" title="Discord feed for {{ campaign.game.display_name }} campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> {% endif %} </div> </div> @@ -106,7 +115,6 @@ <table style="border-collapse: collapse; width: 100%;"> <tbody> {% for drop in drops %} - <!-- {{ drop.drop.name }} - {{ drop.drop.benefits.all|join:", " }} --> <tr> <td> {% for benefit in drop.drop.benefits.all %} @@ -169,7 +177,6 @@ <h5>Allowed Channels</h5> <div> {% for channel in allowed_channels %} - <!-- {{ channel.display_name }} https://www.twitch.tv/{{ channel.display_name }} --> <a href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a> {% endfor %} </div> @@ -181,6 +188,4 @@ Go to a participating live channel </a> {% endif %} - <!-- Campaign JSON --> - {{ campaign_data|safe }} {% endblock content %} diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 9e904c0..9e17073 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% load static %} {% load image_tags %} -{% load image_tags %} {% block title %} Drop Campaigns {% endblock title %} @@ -31,6 +30,7 @@ title="Atom feed for all campaigns">[atom]</a> <a href="{% url 'core:campaign_feed_discord' %}" title="Discord feed for all campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'twitch:export_campaigns_csv' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" title="Export campaigns as CSV">[csv]</a> <a href="{% url 'twitch:export_campaigns_json' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" @@ -47,7 +47,6 @@ <select name="game"> <option value="">All Games</option> {% for game in games %} - <!-- Game option with Twitch ID {{ game.twitch_id }} and display name "{{ game.display_name }}" --> <option value="{{ game.twitch_id }}" {% if selected_game == game.twitch_id %}selected{% endif %}> {{ game.display_name|default:game.name|default:game.slug|default:game.twitch_id }} @@ -58,7 +57,6 @@ <select id="status" name="status"> <option value="">All Statuses</option> {% for status in status_options %} - <!-- Status option "{{ status }}" --> <option value="{{ status }}" {% if selected_status == status %}selected{% endif %}>{{ status|title }}</option> {% endfor %} @@ -71,7 +69,6 @@ {% if campaigns %} {% regroup campaigns by game as campaigns_by_game %} {% for game_group in campaigns_by_game %} - <!-- Game group for game "{{ game_group.grouper.display_name }}" with {{ game_group.list|length }} campaigns --> <section> <div style="display: flex; gap: 1rem;"> <div> @@ -108,9 +105,6 @@ <div style="overflow-x: auto;"> <div style="display: flex; gap: 1rem; min-width: max-content;"> {% for campaign in game_group.list %} - <!-- Campaign "{{ campaign.clean_name }}" with Twitch ID {{ campaign.twitch_id }} --> - <!-- https://ttvdrops.lovinator.space{% url 'twitch:campaign_detail' campaign.twitch_id %} --> - <!-- https://ttvdrops.lovinator.space{{ campaign.image_best_url }} --> <article style="display: flex; flex-direction: column; align-items: flex-start; @@ -165,7 +159,6 @@ {% endif %} <!-- Pagination --> {% if is_paginated %} - <!-- {{ page_obj.paginator.count }} total campaigns, showing {{ page_obj.start_index }} to {{ page_obj.end_index }} on page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} --> <nav style="text-align: center;"> <div> {% if page_obj.has_previous %} diff --git a/templates/twitch/channel_detail.html b/templates/twitch/channel_detail.html index be5d02a..49a7487 100644 --- a/templates/twitch/channel_detail.html +++ b/templates/twitch/channel_detail.html @@ -20,22 +20,26 @@ </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> <tbody> {% for campaign in active_campaigns %} - <!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) --> <tr> <td> <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a> {% if campaign.time_based_drops.all %} <!-- If the campaign has time-based drops, show the benefits in a nested div --> <div> - <!-- swag swag swag {{campaign.sorted_benefits}} --> {% for benefit in campaign.sorted_benefits %} - <!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) --> - <!-- {{ benefit.image_best_url }} --> <span title="{{ benefit.name }}"> {% if benefit.image_best_url or benefit.image_asset_url %} <!-- Show the benefit image if available --> @@ -77,7 +81,6 @@ <table> <tbody> {% for campaign in upcoming_campaigns %} - <!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) --> <tr> <td> <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a> @@ -85,7 +88,6 @@ <!-- If the campaign has time-based drops, show the benefits in a nested div --> <div> {% for benefit in campaign.sorted_benefits %} - <!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) --> <span title="{{ benefit.name }}"> {% if benefit.image_best_url or benefit.image_asset_url %} <!-- Show the benefit image if available --> @@ -127,7 +129,6 @@ <table> <tbody> {% for campaign in expired_campaigns %} - <!-- Campaign {{ campaign.name }} ({{ campaign.twitch_id }}) --> <tr> <td> <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}">{{ campaign.clean_name }}</a> @@ -135,7 +136,6 @@ <!-- If the campaign has time-based drops, show the benefits in a nested div --> <div> {% for benefit in campaign.sorted_benefits %} - <!-- Benefit {{ benefit.name }} ({{ benefit.twitch_id }}) --> <span title="{{ benefit.name }}"> {% if benefit.image_best_url or benefit.image_asset_url %} <img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}" @@ -173,5 +173,4 @@ {% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %} <p>No campaigns found for this channel.</p> {% endif %} - {{ channel_data|safe }} {% endblock content %} diff --git a/templates/twitch/dashboard.html b/templates/twitch/dashboard.html index 134e050..590950f 100644 --- a/templates/twitch/dashboard.html +++ b/templates/twitch/dashboard.html @@ -39,6 +39,7 @@ title="Atom feed for campaigns">[atom]</a> <a href="{% url 'core:campaign_feed_discord' %}" title="Discord feed for campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <hr /> {% if campaigns_by_game %} @@ -63,7 +64,6 @@ <div style="flex: 1; overflow-x: auto;"> <div style="display: flex; gap: 1rem; min-width: max-content;"> {% for campaign_data in game_data.campaigns %} - <!-- {{ campaign_data.campaign.name }} --> <article style="display: flex; flex-direction: column; align-items: center; @@ -106,7 +106,6 @@ {% if campaign_data.campaign.allow_is_enabled %} {% if campaign_data.allowed_channels %} {% for channel in campaign_data.allowed_channels|slice:":5" %} - <!-- {{ channel.name }} --> <li style="margin-bottom: 0.1rem;"> <a href="https://twitch.tv/{{ channel.name }}" rel="nofollow ugc" @@ -132,14 +131,12 @@ {% endif %} {% endif %} {% if campaign_data.allowed_channels|length > 5 %} - <!-- {{ campaign_data.allowed_channels|length }} allowed channels --> <li style="margin-bottom: 0.1rem; color: #666; font-style: italic;"> ... and {{ campaign_data.allowed_channels|length|add:"-5" }} more </li> {% endif %} {% else %} {% if campaign_data.campaign.game.twitch_directory_url %} - <!--{{ campaign_data.campaign.game.display_name }} Twitch directory URL: {{ campaign_data.campaign.game.twitch_directory_url }} --> <li> <a href="{{ campaign_data.campaign.game.twitch_directory_url }}" rel="nofollow ugc" @@ -148,7 +145,6 @@ </a> </li> {% else %} - <!-- {{ campaign_data.campaign.game.display_name }} Twitch directory URL not available --> <li>Failed to get Twitch directory URL :(</li> {% endif %} {% endif %} diff --git a/templates/twitch/docs_rss.html b/templates/twitch/docs_rss.html deleted file mode 100644 index b43f9b1..0000000 --- a/templates/twitch/docs_rss.html +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block title %} - RSS Feeds Documentation -{% endblock title %} -{% block content %} - <main> - <h1>RSS Feeds Documentation</h1> - <p>This page lists all available RSS feeds for TTVDrops.</p> - <p> - Atom feeds are also available for the same resources under the - <code>/atom/</code> endpoints. - </p> - <p> - Discord feeds are available under the <code>/discord/</code> endpoints. These are Atom feeds - that include Discord relative timestamps (e.g., <code><t:1773450272:R></code>) for dates, - making them ideal for Discord bots and integrations. - </p> - <section> - <h2>Global RSS Feeds</h2> - <p>These feeds contain all items across the entire site:</p> - <ul> - {% for feed in feeds %} - <li> - <h3>{{ feed.title }}</h3> - <p>{{ feed.description }}</p> - <p> - <a href="{{ feed.url }}">Subscribe to {{ feed.title }} RSS Feed</a> - {% if feed.atom_url %} -  |  - <a href="{{ feed.atom_url }}">Subscribe to {{ feed.title }} Atom Feed</a> - {% endif %} - {% if feed.discord_url %} -  |  - <a href="{{ feed.discord_url }}">Subscribe to {{ feed.title }} Discord Feed</a> - {% endif %} - </p> - <pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre> - {% if feed.example_xml_atom %} - <h4>Atom example</h4> - <pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre> - {% endif %} - {% if feed.example_xml_discord %} - <h4>Discord example</h4> - <pre><code class="language-xml">{{ feed.example_xml_discord|escape }}</code></pre> - {% endif %} - </li> - {% endfor %} - </ul> - </section> - <section> - <h2>Filtered RSS Feeds</h2> - <p> - You can subscribe to RSS feeds scoped to a specific game or organization. When available, links below point to live examples; otherwise use the endpoint template. - </p> - <ul> - {% for feed in filtered_feeds %} - <li> - <h3>{{ feed.title }}</h3> - <p>{{ feed.description }}</p> - <p> - Endpoint: <code>{{ feed.url }}</code> - {% if feed.atom_url %} |  Atom: <code>{{ feed.atom_url }}</code>{% endif %} - {% if feed.discord_url %} |  Discord: <code>{{ feed.discord_url }}</code>{% endif %} - </p> - {% if feed.has_sample %} - <p> - <a href="{{ feed.url }}">View a live example</a> - {% if feed.atom_url %} -  |  - <a href="{{ feed.atom_url }}">View Atom example</a> - {% endif %} - {% if feed.discord_url %} -  |  - <a href="{{ feed.discord_url }}">View Discord example</a> - {% endif %} - </p> - {% endif %} - <pre><code class="language-xml">{% if feed.example_xml %}{{ feed.example_xml|escape }}{% else %}No example XML available yet.{% endif %}</code></pre> - {% if feed.example_xml_atom %} - <h4>Atom example</h4> - <pre><code class="language-xml">{{ feed.example_xml_atom|escape }}</code></pre> - {% endif %} - {% if feed.example_xml_discord %} - <h4>Discord example</h4> - <pre><code class="language-xml">{{ feed.example_xml_discord|escape }}</code></pre> - {% endif %} - </li> - {% endfor %} - </ul> - </section> - <section> - <h2>How to Use RSS Feeds</h2> - <p> - RSS feeds allow you to stay updated with new content. You can use any RSS reader application to subscribe to these feeds. - </p> - <ul> - <li>Copy the feed URL</li> - <li>Paste it into your favorite RSS reader (Feedly, Inoreader, NetNewsWire, etc.)</li> - <li>Get automatic updates when new content is added</li> - </ul> - </section> - </main> -{% endblock content %} diff --git a/templates/twitch/emote_gallery.html b/templates/twitch/emote_gallery.html index b42b6d6..83178a1 100644 --- a/templates/twitch/emote_gallery.html +++ b/templates/twitch/emote_gallery.html @@ -6,8 +6,6 @@ {% block content %} <h1>Emotes</h1> {% for emote in emotes %} - <!-- Emote from campaign {{ emote.campaign.name }} --> - <!-- https://ttvdrops.lovinator.space{{ emote.image_url }} --> <a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}" title="{{ emote.campaign.name }}" style="display: inline-block"> diff --git a/templates/twitch/game_detail.html b/templates/twitch/game_detail.html index 6e68f7e..940b883 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 %}" @@ -55,6 +63,7 @@ title="Atom feed for {{ game.display_name }} campaigns">[atom]</a> <a href="{% url 'core:game_campaign_feed_discord' game.twitch_id %}" title="Discord feed for {{ game.display_name }} campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> </div> </div> @@ -150,5 +159,4 @@ {% if not active_campaigns and not upcoming_campaigns and not expired_campaigns %} <p id="no-campaigns-message">No campaigns found for this game.</p> {% endif %} - {{ game_data|safe }} {% endblock content %} diff --git a/templates/twitch/games_grid.html b/templates/twitch/games_grid.html index cc9fa22..a02b843 100644 --- a/templates/twitch/games_grid.html +++ b/templates/twitch/games_grid.html @@ -28,6 +28,7 @@ title="Atom feed for all games">[atom]</a> <a href="{% url 'core:game_feed_discord' %}" title="Discord feed for all games">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'twitch:export_games_csv' %}" title="Export all games as CSV">[csv]</a> <a href="{% url 'twitch:export_games_json' %}" diff --git a/templates/twitch/games_list.html b/templates/twitch/games_list.html index c2c1406..a2ac080 100644 --- a/templates/twitch/games_list.html +++ b/templates/twitch/games_list.html @@ -26,6 +26,7 @@ title="Atom feed for all games">[atom]</a> <a href="{% url 'core:game_feed_discord' %}" title="Discord feed for all games">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'twitch:export_games_csv' %}" title="Export all games as CSV">[csv]</a> <a href="{% url 'twitch:export_games_json' %}" diff --git a/templates/twitch/org_list.html b/templates/twitch/org_list.html index 838acae..7b2b465 100644 --- a/templates/twitch/org_list.html +++ b/templates/twitch/org_list.html @@ -11,6 +11,7 @@ title="Atom feed for all organizations">[atom]</a> <a href="{% url 'core:organization_feed_discord' %}" title="Discord feed for all organizations">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> <a href="{% url 'twitch:export_organizations_csv' %}" title="Export all organizations as CSV">[csv]</a> <a href="{% url 'twitch:export_organizations_json' %}" @@ -27,5 +28,4 @@ {% else %} <p>No organizations found.</p> {% endif %} - {{ orgs_data|safe }} {% endblock content %} diff --git a/templates/twitch/organization_detail.html b/templates/twitch/organization_detail.html index 25326b0..f460edc 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> @@ -36,6 +44,4 @@ {% endfor %} </tbody> </table> - <hr /> - {{ org_data|safe }} {% endblock content %} diff --git a/templates/twitch/reward_campaign_detail.html b/templates/twitch/reward_campaign_detail.html index 234c6b5..f5c937c 100644 --- a/templates/twitch/reward_campaign_detail.html +++ b/templates/twitch/reward_campaign_detail.html @@ -37,11 +37,12 @@ <div style="margin-bottom: 1rem;"> <a href="{% url 'core:reward_campaign_feed' %}" style="margin-right: 1rem" - title="RSS feed for all reward campaigns">RSS feed for all reward campaigns</a> + title="RSS feed for all reward campaigns">[rss]</a> <a href="{% url 'core:reward_campaign_feed_atom' %}" title="Atom feed for all reward campaigns">[atom]</a> <a href="{% url 'core:reward_campaign_feed_discord' %}" title="Discord feed for all reward campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <!-- Campaign Summary --> {% if reward_campaign.summary %}<p id="campaign-summary">{{ reward_campaign.summary|linebreaksbr }}</p>{% endif %} @@ -164,6 +165,4 @@ </tr> </tbody> </table> - <hr /> - {{ campaign_data|safe }} {% endblock content %} diff --git a/templates/twitch/reward_campaign_list.html b/templates/twitch/reward_campaign_list.html index 97affdf..1704dd2 100644 --- a/templates/twitch/reward_campaign_list.html +++ b/templates/twitch/reward_campaign_list.html @@ -27,6 +27,7 @@ title="Atom feed for all reward campaigns">[atom]</a> <a href="{% url 'core:reward_campaign_feed_discord' %}" title="Discord feed for all reward campaigns">[discord]</a> + <a href="{% url 'core:docs_rss' %}" title="RSS feed documentation">[explain]</a> </div> <p>This is an archive of old Twitch reward campaigns because we do not monitor them.</p> <p> diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index a125cd8..5a9bec4 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -958,7 +958,7 @@ class TestChannelListView: client: Client, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Game detail JSON payload should use `owners` (M2M), not stale `owner`.""" + """Game detail view should no longer expose debug JSON payload in context.""" org: Organization = Organization.objects.create( twitch_id="org-game-detail", name="Org Game Detail", @@ -970,17 +970,10 @@ class TestChannelListView: ) game.owners.add(org) - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) - url: str = reverse("twitch:game_detail", args=[game.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) assert response.status_code == 200 - - game_data: dict[str, Any] = response.context["game_data"] - fields: dict[str, Any] = game_data["fields"] - assert "owners" in fields - assert fields["owners"] == [org.pk] - assert "owner" not in fields + assert "game_data" not in response.context @pytest.mark.django_db def test_org_list_view(self, client: Client) -> None: @@ -1013,14 +1006,29 @@ class TestChannelListView: @pytest.mark.django_db def test_docs_rss_view(self, client: Client) -> None: - """Test docs RSS view returns 200 and has feeds in context.""" + """Test docs RSS view returns 200.""" response: _MonkeyPatchedWSGIResponse = client.get(reverse("core:docs_rss")) assert response.status_code == 200 - assert "feeds" in response.context - assert "filtered_feeds" in response.context - assert response.context["feeds"][0]["example_xml"] - html: str = response.content.decode() - assert '<code class="language-xml">' in html + + # Add Game with running campaign to ensure it's included in the RSS feed + game: Game = Game.objects.create( + twitch_id="g-rss", + name="Game RSS", + display_name="Game RSS", + ) + + DropCampaign.objects.create( + twitch_id="c-rss", + name="Campaign RSS", + game=game, + start_at=timezone.now() - timedelta(days=1), + end_at=timezone.now() + timedelta(days=1), + operation_names=["DropCampaignDetails"], + ) + + response = client.get(reverse("core:docs_rss")) + assert response.status_code == 200 + assert "g-rss" in response.content.decode("utf-8") @pytest.mark.django_db @@ -1051,9 +1059,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 +1093,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 @@ -1699,7 +1711,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """VideoGame schema image should be an ImageObject, not a plain URL.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) url: str = reverse("twitch:game_detail", args=[game.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) assert response.status_code == 200 @@ -1720,7 +1731,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """VideoGame ImageObject should carry creditText and copyrightNotice.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) url: str = reverse("twitch:game_detail", args=[game.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1735,7 +1745,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """VideoGame schema should omit image key when box_art is empty.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) game_no_art: Game = Game.objects.create( twitch_id="game-no-art", name="no_art_game", @@ -1756,7 +1765,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """VideoGame schema publisher name should match the owning organization.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) url: str = reverse("twitch:game_detail", args=[game.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1771,7 +1779,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """publisher.name and image.creditText should be the same value.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) url: str = reverse("twitch:game_detail", args=[game.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1784,7 +1791,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """When owner.name is empty, twitch_id is used as credit fallback.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) nameless_org: Organization = Organization.objects.create( twitch_id="org-nameless", name="", @@ -1812,7 +1818,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """Event schema image should be an ImageObject, not a plain URL string.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) assert response.status_code == 200 @@ -1833,7 +1838,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """Event ImageObject should carry creditText and copyrightNotice.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1849,7 +1853,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """Event schema should omit image key when campaign has no image.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) campaign_no_img: DropCampaign = DropCampaign.objects.create( twitch_id="camp-no-img", name="No Image Campaign", @@ -1871,7 +1874,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """Event schema organizer name should match the owning organization.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1885,7 +1887,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """organizer.name and image.creditText should be the same value.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) response: _MonkeyPatchedWSGIResponse = client.get(url) @@ -1898,7 +1899,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """When campaign has no owning org, creditText falls back to 'Twitch'.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) game_no_owner: Game = Game.objects.create( twitch_id="game-no-owner", name="no_owner_game", @@ -1926,7 +1926,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """When one owner is 'Twitch Gaming' and another is not, the non-generic one is used.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) twitch_gaming: Organization = Organization.objects.create( twitch_id="twitch-gaming", name="Twitch Gaming", @@ -1956,7 +1955,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """When the only owner is 'Twitch Gaming', it is still used (no other choice).""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) twitch_gaming: Organization = Organization.objects.create( twitch_id="twitch-gaming-solo", name="Twitch Gaming", @@ -1981,7 +1979,6 @@ class TestImageObjectStructuredData: monkeypatch: pytest.MonkeyPatch, ) -> None: """Campaign schema prefers a non-generic publisher over 'Twitch Gaming'.""" - monkeypatch.setattr("twitch.views.format_and_color_json", lambda data: data) twitch_gaming: Organization = Organization.objects.create( twitch_id="twitch-gaming-camp", name="Twitch Gaming", diff --git a/twitch/views.py b/twitch/views.py index 4c5420f..78ccc9a 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import csv import datetime import json @@ -12,7 +14,6 @@ from django.core.paginator import EmptyPage from django.core.paginator import Page from django.core.paginator import PageNotAnInteger from django.core.paginator import Paginator -from django.core.serializers import serialize from django.db.models import Case from django.db.models import Count from django.db.models import Prefetch @@ -26,9 +27,6 @@ from django.urls import reverse from django.utils import timezone from django.views.generic import DetailView from django.views.generic import ListView -from pygments import highlight -from pygments.formatters import HtmlFormatter -from pygments.lexers.data import JsonLexer from twitch.models import Channel from twitch.models import ChatBadge @@ -44,6 +42,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 +89,50 @@ 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("page_url"): + context["page_url"] = seo_meta["page_url"] + 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 @@ -276,14 +260,6 @@ def org_list_view(request: HttpRequest) -> HttpResponse: """ orgs: QuerySet[Organization] = Organization.objects.all().order_by("name") - # Serialize all organizations - serialized_orgs: str = serialize( - "json", - orgs, - fields=("twitch_id", "name", "added_at", "updated_at"), - ) - orgs_data: list[dict] = json.loads(serialized_orgs) - # CollectionPage schema for organizations list collection_schema: dict[str, str] = { "@context": "https://schema.org", @@ -296,11 +272,10 @@ 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, - "orgs_data": format_and_color_json(orgs_data), **seo_context, } @@ -308,7 +283,7 @@ def org_list_view(request: HttpRequest) -> HttpResponse: # MARK: /organizations/<twitch_id>/ -def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914 +def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: """Function-based view for organization detail. Args: @@ -329,30 +304,6 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon games: QuerySet[Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue] - serialized_org: str = serialize( - "json", - [organization], - fields=("twitch_id", "name", "added_at", "updated_at"), - ) - org_data: list[dict] = json.loads(serialized_org) - - if games.exists(): - serialized_games: str = serialize( - "json", - games, - fields=( - "twitch_id", - "slug", - "name", - "display_name", - "box_art", - "added_at", - "updated_at", - ), - ) - games_data: list[dict] = json.loads(serialized_games) - org_data[0]["fields"]["games"] = games_data - org_name: str = organization.name or organization.twitch_id games_count: int = games.count() s: Literal["", "s"] = "" if games_count == 1 else "s" @@ -361,13 +312,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,14 +347,16 @@ 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, "games": games, - "org_data": format_and_color_json(org_data[0]), **seo_context, } @@ -481,8 +446,11 @@ 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={ + "page_url": request.build_absolute_uri(base_url), + "pagination_info": pagination_info, + "schema_data": collection_schema, + }, ) context: dict[str, Any] = { "campaigns": campaigns, @@ -499,22 +467,6 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0 return render(request, "twitch/campaign_list.html", context) -def format_and_color_json(data: dict[str, Any] | list[dict] | str) -> str: - """Format and color a JSON string for HTML display. - - Args: - data: Either a dictionary, list of dictionaries, or a JSON string to format. - - Returns: - str: The formatted code with HTML styles. - """ - if isinstance(data, (dict, list)): - formatted_code: str = json.dumps(data, indent=4) - else: - formatted_code = data - return highlight(formatted_code, JsonLexer(), HtmlFormatter()) - - def _enhance_drops_with_context( drops: QuerySet[TimeBasedDrop], now: datetime.datetime, @@ -558,7 +510,7 @@ def _enhance_drops_with_context( # MARK: /campaigns/<twitch_id>/ -def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914, PLR0915 +def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpResponse: # noqa: PLR0914 """Function-based view for a drop campaign detail. Args: @@ -592,96 +544,6 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo .order_by("required_minutes_watched") ) - serialized_campaign: str = serialize( - "json", - [campaign], - fields=( - "twitch_id", - "name", - "description", - "details_url", - "account_link_url", - "image_url", - "start_at", - "end_at", - "allow_is_enabled", - "operation_names", - "game", - "created_at", - "updated_at", - ), - ) - campaign_data: list[dict[str, Any]] = json.loads(serialized_campaign) - - if drops.exists(): - badge_benefit_names: set[str] = { - benefit.name - for drop in drops - for benefit in drop.benefits.all() - if benefit.distribution_type == "BADGE" and benefit.name - } - badge_descriptions_by_title: dict[str, str] = dict( - ChatBadge.objects.filter(title__in=badge_benefit_names).values_list( - "title", - "description", - ), - ) - - serialized_drops = serialize( - "json", - drops, - fields=( - "twitch_id", - "name", - "required_minutes_watched", - "required_subs", - "start_at", - "end_at", - "added_at", - "updated_at", - ), - ) - drops_data: list[dict[str, Any]] = json.loads(serialized_drops) - - for i, drop in enumerate(drops): - drop_benefits: list[DropBenefit] = list(drop.benefits.all()) - if drop_benefits: - serialized_benefits: str = serialize( - "json", - drop_benefits, - fields=( - "twitch_id", - "name", - "image_asset_url", - "added_at", - "updated_at", - "created_at", - "entitlement_limit", - "is_ios_available", - "distribution_type", - ), - ) - benefits_data: list[dict[str, Any]] = json.loads(serialized_benefits) - - for benefit_data in benefits_data: - fields: dict[str, Any] = benefit_data.get("fields", {}) - if fields.get("distribution_type") != "BADGE": - continue - - # DropBenefit doesn't have a description field; fetch it from ChatBadge when possible. - if fields.get("description"): - continue - - badge_description: str | None = badge_descriptions_by_title.get( - fields.get("name", ""), - ) - if badge_description: - fields["description"] = badge_description - - drops_data[i]["fields"]["benefits"] = benefits_data - - campaign_data[0]["fields"]["drops"] = drops_data - now: datetime.datetime = timezone.now() enhanced_drops: list[dict[str, Any]] = _enhance_drops_with_context(drops, now) # Attach awarded_badge to each drop in enhanced_drops @@ -700,7 +562,6 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo "campaign": campaign, "now": now, "drops": enhanced_drops, - "campaign_data": format_and_color_json(campaign_data[0]), "owners": list(campaign.game.owners.all()), "allowed_channels": getattr(campaign, "channels_ordered", []), } @@ -724,7 +585,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 +599,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 +611,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 +656,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 +763,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) @@ -1022,45 +898,6 @@ class GameDetailView(DetailView): if campaign.end_at is not None and campaign.end_at < now ] - serialized_game: str = serialize( - "json", - [game], - fields=( - "twitch_id", - "slug", - "name", - "display_name", - "box_art", - "owners", - "added_at", - "updated_at", - ), - ) - game_data: list[dict[str, Any]] = json.loads(serialized_game) - - if campaigns_list: - serialized_campaigns = serialize( - "json", - campaigns_list, - fields=( - "twitch_id", - "name", - "description", - "details_url", - "account_link_url", - "image_url", - "start_at", - "end_at", - "allow_is_enabled", - "game", - "operation_names", - "added_at", - "updated_at", - ), - ) - campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns) - game_data[0]["fields"]["campaigns"] = campaigns_data - owners: list[Organization] = list(game.owners.all()) game_name: str = game.display_name or game.name or game.twitch_id @@ -1080,6 +917,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 +955,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, @@ -1129,7 +975,6 @@ class GameDetailView(DetailView): "owners": owners, "drop_awarded_badges": drop_awarded_badges, "now": now, - "game_data": format_and_color_json(game_data[0]), **seo_context, }) @@ -1213,8 +1058,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 +1153,11 @@ 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={ + "page_url": request.build_absolute_uri(base_url), + "pagination_info": pagination_info, + "schema_data": collection_schema, + }, ) context: dict[str, Any] = { "reward_campaigns": reward_campaigns, @@ -1344,28 +1194,6 @@ def reward_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRes msg = "No reward campaign found matching the query" raise Http404(msg) from exc - serialized_campaign: str = serialize( - "json", - [reward_campaign], - fields=( - "twitch_id", - "name", - "brand", - "summary", - "instructions", - "external_url", - "about_url", - "reward_value_url_param", - "starts_at", - "ends_at", - "is_sitewide", - "game", - "added_at", - "updated_at", - ), - ) - campaign_data: list[dict[str, Any]] = json.loads(serialized_campaign) - now: datetime.datetime = timezone.now() campaign_name: str = reward_campaign.name or reward_campaign.twitch_id @@ -1375,29 +1203,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,14 +1263,16 @@ 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, "now": now, - "campaign_data": format_and_color_json(campaign_data[0]), "is_active": reward_campaign.is_active, **seo_context, } @@ -1506,8 +1352,11 @@ 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={ + "page_url": self.request.build_absolute_uri(base_url), + "pagination_info": pagination_info, + "schema_data": collection_schema, + }, ) context.update(seo_context) context["search_query"] = search_query @@ -1615,50 +1464,36 @@ class ChannelDetailView(DetailView): if campaign.end_at is not None and campaign.end_at < now ] - serialized_channel: str = serialize( - "json", - [channel], - fields=("twitch_id", "name", "display_name", "added_at", "updated_at"), - ) - channel_data: list[dict[str, Any]] = json.loads(serialized_channel) - - if campaigns_list: - serialized_campaigns: str = serialize( - "json", - campaigns_list, - fields=( - "twitch_id", - "name", - "description", - "details_url", - "account_link_url", - "image_url", - "start_at", - "end_at", - "added_at", - "updated_at", - ), - ) - campaigns_data: list[dict[str, Any]] = json.loads(serialized_campaigns) - channel_data[0]["fields"]["campaigns"] = campaigns_data - name: str = channel.display_name or channel.name or channel.twitch_id total_campaigns: int = len(campaigns_list) description: str = f"{name} participates in {total_campaigns} drop campaign" 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,18 +1510,22 @@ 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, "upcoming_campaigns": upcoming_campaigns, "expired_campaigns": expired_campaigns, "now": now, - "channel_data": format_and_color_json(channel_data[0]), **seo_context, }) @@ -1733,7 +1572,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, @@ -1803,34 +1642,6 @@ def badge_set_detail_view(request: HttpRequest, set_id: str) -> HttpResponse: ).distinct() badge.award_campaigns = list(campaigns) # pyright: ignore[reportAttributeAccessIssue] - # Serialize for JSON display - serialized_set: str = serialize( - "json", - [badge_set], - fields=("set_id", "added_at", "updated_at"), - ) - set_data: list[dict[str, Any]] = json.loads(serialized_set) - - if badges: - serialized_badges: str = serialize( - "json", - badges, - fields=( - "badge_id", - "image_url_1x", - "image_url_2x", - "image_url_4x", - "title", - "description", - "click_action", - "click_url", - "added_at", - "updated_at", - ), - ) - badges_data: list[dict[str, Any]] = json.loads(serialized_badges) - set_data[0]["fields"]["badges"] = badges_data - badge_set_name: str = badge_set.set_id badge_set_description: str = f"Twitch chat badge set {badge_set_name} with {len(badges)} badge{'s' if len(badges) != 1 else ''} awarded through drop campaigns." @@ -1847,12 +1658,11 @@ 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, "badges": badges, - "set_data": format_and_color_json(set_data[0]), **seo_context, }