Add sitemap, RSS feed, OG image, and pagination size controls

This commit is contained in:
Warren Chen 2026-06-04 15:29:06 +09:00
parent b16ee811e3
commit 8c4ce7b92e
13 changed files with 195 additions and 17 deletions

View File

@ -0,0 +1,30 @@
from django.contrib.syndication.views import Feed
from django.utils.feedgenerator import Rss201rev2Feed
from django.utils.html import strip_tags
from .models import ArticlePage
class LatestArticlesFeed(Feed):
feed_type = Rss201rev2Feed
title = "DeBuT AI 最新文章"
link = "/"
description = "DeBuT AI 最新文章 RSS feed"
def items(self):
return ArticlePage.objects.live().order_by("-date", "-id")[:20]
def item_title(self, item):
return item.title
def item_description(self, item):
return strip_tags(item.intro or item.search_description or "")
def item_link(self, item):
return item.url
def item_pubdate(self, item):
return item.date
def item_categories(self, item):
return list(item.tags.values_list("name", flat=True))

View File

@ -8,6 +8,7 @@ from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase from taggit.models import TaggedItemBase
from wagtail.search import index from wagtail.search import index
from .pagination import build_pagination_context, get_page_size
def _get_env_int(name, default): def _get_env_int(name, default):
value = os.environ.get(name) value = os.environ.get(name)
@ -62,7 +63,7 @@ class CategoryMixin:
ArticlePage.objects.child_of(self) ArticlePage.objects.child_of(self)
.live() .live()
.order_by("-date", "-id"), .order_by("-date", "-id"),
PAGE_SIZE, get_page_size(request, PAGE_SIZE),
) )
page_number = request.GET.get("page") if request else None page_number = request.GET.get("page") if request else None
@ -78,7 +79,7 @@ class CategoryMixin:
"title": self.title, "title": self.title,
"items": page_obj, "items": page_obj,
"url": self.url, "url": self.url,
"page_range": paginator.get_elided_page_range(page_obj.number), "pagination": build_pagination_context(request, page_obj, paginator),
} }
) )
return blocks return blocks
@ -98,7 +99,8 @@ class CategoryMixin:
else: else:
# Paginated view # Paginated view
paginator = Paginator( paginator = Paginator(
ArticlePage.objects.live().order_by("-date", "-id"), PAGE_SIZE ArticlePage.objects.live().order_by("-date", "-id"),
get_page_size(request, PAGE_SIZE),
) )
page_number = request.GET.get("page") page_number = request.GET.get("page")
@ -112,7 +114,7 @@ class CategoryMixin:
"title": self.title, "title": self.title,
"items": page_obj, "items": page_obj,
"url": self.url, "url": self.url,
"page_range": paginator.get_elided_page_range(page_obj.number), "pagination": build_pagination_context(request, page_obj, paginator),
} }
def get_trending_articles(self, request=None, exclude_ids=None): def get_trending_articles(self, request=None, exclude_ids=None):
@ -134,7 +136,7 @@ class CategoryMixin:
} }
else: else:
# Paginated view # Paginated view
paginator = Paginator(articles_qs, PAGE_SIZE) paginator = Paginator(articles_qs, get_page_size(request, PAGE_SIZE))
page_number = request.GET.get("page") page_number = request.GET.get("page")
try: try:
@ -147,7 +149,7 @@ class CategoryMixin:
"title": self.title, "title": self.title,
"items": page_obj, "items": page_obj,
"url": self.url, "url": self.url,
"page_range": paginator.get_elided_page_range(page_obj.number), "pagination": build_pagination_context(request, page_obj, paginator),
} }
@ -310,6 +312,15 @@ class ArticlePage(Page, BreadcrumbMixin):
def get_context(self, request): def get_context(self, request):
context = super().get_context(request) context = super().get_context(request)
if self.cover_image:
cover = self.cover_image.get_rendition("original")
context["og_image"] = {
"url": request.build_absolute_uri(cover.url),
"width": cover.width,
"height": cover.height,
"alt": self.title,
}
breadcrumbs, site_root = self.build_breadcrumbs() breadcrumbs, site_root = self.build_breadcrumbs()
# context["breadcrumbs"] = breadcrumbs # context["breadcrumbs"] = breadcrumbs
# context["breadcrumb_root"] = site_root # context["breadcrumb_root"] = site_root

View File

@ -0,0 +1,56 @@
PAGE_SIZE_OPTIONS = (10, 20, 30)
def get_page_size(request, default):
try:
page_size = int(request.GET.get("page_size", default))
except (TypeError, ValueError):
return default
return page_size if page_size in PAGE_SIZE_OPTIONS else default
def build_query_string(request, **updates):
query = request.GET.copy()
for key, value in updates.items():
if value is None:
query.pop(key, None)
else:
query[key] = value
return query.urlencode()
def build_pagination_context(request, page_obj, paginator):
page_range = paginator.get_elided_page_range(page_obj.number)
page_size = paginator.per_page
return {
"page_size": page_size,
"page_size_options": [
{
"value": option,
"url": f"?{build_query_string(request, page_size=option, page=None)}",
"is_current": option == page_size,
}
for option in PAGE_SIZE_OPTIONS
],
"pages": [
{
"number": page_num,
"url": f"?{build_query_string(request, page=page_num)}"
if page_num != ""
else "",
"is_current": page_num == page_obj.number,
"is_ellipsis": page_num == "",
}
for page_num in page_range
],
"previous_url": f"?{build_query_string(request, page=page_obj.previous_page_number())}"
if page_obj.has_previous()
else "",
"next_url": f"?{build_query_string(request, page=page_obj.next_page_number())}"
if page_obj.has_next()
else "",
}

View File

@ -136,6 +136,35 @@
color: #0e1b4266; color: #0e1b4266;
} }
.page-size-selector {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin: 40px 0 20px;
font-size: 14px;
}
.page-size-label {
color: #0e1b4266;
}
.page-size-option {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 32px;
border: 1px solid #0e1b42;
border-radius: 4px;
color: #0e1b42;
}
.page-size-option.is-current {
background: #0e1b42;
color: #ffffff;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@ -3,6 +3,14 @@
color: #ffffff; color: #ffffff;
} }
.template-darkbackground .category-title {
height: 60px;
}
.template-darkbackground .category-title span {
line-height: 60px;
}
.subcategory-title { .subcategory-title {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -63,6 +63,13 @@
<path class="icon-cutout" d="M6.321 6.016c-.27-.18-1.166-.802-1.166-.802.756-1.081 1.753-1.502 3.132-1.502.975 0 1.803.327 2.394.948s.928 1.509 1.005 2.644q.492.207.905.484c1.109.745 1.719 1.86 1.719 3.137 0 2.716-2.226 5.075-6.256 5.075C4.594 16 1 13.987 1 7.994 1 2.034 4.482 0 8.044 0 9.69 0 13.55.243 15 5.036l-1.36.353C12.516 1.974 10.163 1.43 8.006 1.43c-3.565 0-5.582 2.171-5.582 6.79 0 4.143 2.254 6.343 5.63 6.343 2.777 0 4.847-1.443 4.847-3.556 0-1.438-1.208-2.127-1.27-2.127-.236 1.234-.868 3.31-3.644 3.31-1.618 0-3.013-1.118-3.013-2.582 0-2.09 1.984-2.847 3.55-2.847.586 0 1.294.04 1.663.114 0-.637-.54-1.728-1.9-1.728-1.25 0-1.566.405-1.967.868ZM8.716 8.19c-2.04 0-2.304.87-2.304 1.416 0 .878 1.043 1.168 1.6 1.168 1.02 0 2.067-.282 2.232-2.423a6.2 6.2 0 0 0-1.528-.161" transform="translate(7.59 7.59) scale(1.125)"/> <path class="icon-cutout" d="M6.321 6.016c-.27-.18-1.166-.802-1.166-.802.756-1.081 1.753-1.502 3.132-1.502.975 0 1.803.327 2.394.948s.928 1.509 1.005 2.644q.492.207.905.484c1.109.745 1.719 1.86 1.719 3.137 0 2.716-2.226 5.075-6.256 5.075C4.594 16 1 13.987 1 7.994 1 2.034 4.482 0 8.044 0 9.69 0 13.55.243 15 5.036l-1.36.353C12.516 1.974 10.163 1.43 8.006 1.43c-3.565 0-5.582 2.171-5.582 6.79 0 4.143 2.254 6.343 5.63 6.343 2.777 0 4.847-1.443 4.847-3.556 0-1.438-1.208-2.127-1.27-2.127-.236 1.234-.868 3.31-3.644 3.31-1.618 0-3.013-1.118-3.013-2.582 0-2.09 1.984-2.847 3.55-2.847.586 0 1.294.04 1.663.114 0-.637-.54-1.728-1.9-1.728-1.25 0-1.566.405-1.967.868ZM8.716 8.19c-2.04 0-2.304.87-2.304 1.416 0 .878 1.043 1.168 1.6 1.168 1.02 0 2.067-.282 2.232-2.423a6.2 6.2 0 0 0-1.528-.161" transform="translate(7.59 7.59) scale(1.125)"/>
</svg> </svg>
</a> </a>
<a href="https://social-plugins.line.me/lineit/share?url={{ share_url }}" target="_blank" rel="noopener noreferrer" aria-label="分享到 LINE">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" role="img">
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
<path class="icon-cutout" d="M17.69 6.71L16.06 6.67L15.12 6.74L14.01 6.9L13.17 7.1L12.17 7.4L11.03 7.87L10.19 8.31L9.45 8.78L8.41 9.59L7.78 10.21L7.23 10.85L6.63 11.73L6.22 12.49L5.86 13.43L5.62 14.44L5.56 15.02L5.54 15.99L5.59 16.56L5.86 17.82L6.38 19.08L6.72 19.68L7.31 20.51L7.74 21.01L8.67 21.89L9.69 22.63L10.61 23.17L11.49 23.57L12.38 23.91L13.41 24.2L15.02 24.5L15.55 24.74L15.76 25L15.84 25.39L15.83 25.89L15.6 27.38L15.67 27.56L15.86 27.69L16.31 27.64L17.09 27.28L19.21 26L20.95 24.82L22.15 23.93L23.3 23.01L23.44 22.83L23.67 22.68L24.25 22.15L25.7 20.61L26.21 19.91L26.72 19.1L26.97 18.58L27.28 17.77L27.48 17.04L27.59 16.14L27.61 15.29L27.49 14.29L27.28 13.45L26.99 12.65L26.62 11.91L26.1 11.1L25.47 10.32L24.82 9.67L23.91 8.93L23.15 8.42L21.53 7.61L20.85 7.36L19.7 7.03L18.55 6.8Z" fill="var(--cutout, #fff)" />
<path d="M9.28 13.25L9.17 13.37L9.19 18.37L9.3 18.45L12.54 18.44L12.64 18.34L12.64 17.42L12.56 17.32L10.37 17.3L10.32 17.25L10.3 13.35L10.21 13.25Z M13.46 13.25L13.37 13.38L13.37 18.34L13.5 18.45L14.37 18.45L14.47 18.4L14.52 18.31L14.52 13.4L14.39 13.25Z M15.41 13.27L15.33 13.37L15.33 18.32L15.46 18.45L16.33 18.45L16.48 18.32L16.48 15.44L16.53 15.41L18.76 18.4L18.84 18.45L19.78 18.42L19.85 18.34L19.83 13.33L19.73 13.25L18.83 13.25L18.71 13.35L18.71 16.22L18.66 16.3L16.38 13.27Z M20.72 13.28L20.67 13.35L20.69 18.37L20.8 18.45L24.06 18.42L24.14 18.32L24.14 17.43L24.09 17.34L23.93 17.29L21.87 17.3L21.81 17.22L21.81 16.48L21.86 16.43L24.01 16.43L24.14 16.28L24.12 15.36L24.03 15.28L21.86 15.28L21.81 15.2L21.81 14.45L21.86 14.4L24.01 14.4L24.14 14.26L24.11 13.32L24.03 13.25Z" fill="var(--fill-0, #0E1B42)" />
</svg>
</a>
</div> </div>
{% endwith %} {% endwith %}
{% with tags=page.tags.all %} {% with tags=page.tags.all %}

View File

@ -6,10 +6,23 @@
<div class="page-article-list"> <div class="page-article-list">
{% include "home/includes/article_list.html" with items=category.items show_hero=show_hero empty_message=empty_message %} {% include "home/includes/article_list.html" with items=category.items show_hero=show_hero empty_message=empty_message %}
{% if category.pagination.page_size_options %}
<div class="page-size-selector" aria-label="每頁文章數">
<span class="page-size-label">每頁</span>
{% for option in category.pagination.page_size_options %}
{% if option.is_current %}
<span class="page-size-option is-current">{{ option.value }}</span>
{% else %}
<a class="page-size-option" href="{{ option.url }}">{{ option.value }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if category.items.paginator.num_pages > 1 %} {% if category.items.paginator.num_pages > 1 %}
<div class="pagination"> <div class="pagination">
{% if category.items.has_previous %} {% if category.items.has_previous %}
<a class="prev-page" href="?page={{ category.items.previous_page_number }}"> <a class="prev-page" href="{{ category.pagination.previous_url }}">
<button class="left-arrow" type="button" data-dir="left" aria-label="更多文章"> <button class="left-arrow" type="button" data-dir="left" aria-label="更多文章">
<svg class="left-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142"> <svg class="left-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/> <path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
@ -28,18 +41,18 @@
</a> </a>
{% endif %} {% endif %}
<div class="pagination-pages"> <div class="pagination-pages">
{% for page_num in category.page_range %} {% for page_item in category.pagination.pages %}
{% if page_num == category.items.number %} {% if page_item.is_current %}
<span class="pagination-current">{{ page_num }}</span> <span class="pagination-current">{{ page_item.number }}</span>
{% elif page_num == "…" %} {% elif page_item.is_ellipsis %}
<span class="pagination-ellipsis">{{ page_num }}</span> <span class="pagination-ellipsis">{{ page_item.number }}</span>
{% else %} {% else %}
<a href="?page={{ page_num }}">{{ page_num }}</a> <a href="{{ page_item.url }}">{{ page_item.number }}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% if category.items.has_next %} {% if category.items.has_next %}
<a class="next-page" href="?page={{ category.items.next_page_number }}"> <a class="next-page" href="{{ category.pagination.next_url }}">
<span>NEXT</span> <span>NEXT</span>
<button class="right-arrow" type="button" data-dir="right" aria-label="更多文章"> <button class="right-arrow" type="button" data-dir="right" aria-label="更多文章">
<svg class="right-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142"> <svg class="right-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">

View File

@ -5,6 +5,7 @@ from taggit.models import Tag
from wagtail.models import Site from wagtail.models import Site
from .models import ArticlePage, CATEGORY_HOT_SIZE, PAGE_SIZE from .models import ArticlePage, CATEGORY_HOT_SIZE, PAGE_SIZE
from .pagination import build_pagination_context, get_page_size
def hashtag_search(request, slug): def hashtag_search(request, slug):
@ -15,7 +16,7 @@ def hashtag_search(request, slug):
.order_by("-date", "-id") .order_by("-date", "-id")
) )
paginator = Paginator(articles, PAGE_SIZE) paginator = Paginator(articles, get_page_size(request, PAGE_SIZE))
page_number = request.GET.get("page") page_number = request.GET.get("page")
try: try:
@ -35,7 +36,7 @@ def hashtag_search(request, slug):
"title": f"#{tag.name}", "title": f"#{tag.name}",
"items": page_obj, "items": page_obj,
"url": request.path, "url": request.path,
"page_range": paginator.get_elided_page_range(page_obj.number), "pagination": build_pagination_context(request, page_obj, paginator),
} }
], ],
"category_trending": ( "category_trending": (

View File

@ -123,6 +123,7 @@ INSTALLED_APPS = [
"search", "search",
"wagtail.contrib.forms", "wagtail.contrib.forms",
"wagtail.contrib.redirects", "wagtail.contrib.redirects",
"wagtail.contrib.sitemaps",
"wagtail.contrib.settings", "wagtail.contrib.settings",
"wagtail.embeds", "wagtail.embeds",
"wagtail.sites", "wagtail.sites",
@ -142,6 +143,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.sitemaps",
"base", "base",
] ]

View File

@ -311,6 +311,14 @@ a {
color: #0e1b42; color: #0e1b42;
} }
.template-darkbackground .site-hero-band .news-title {
height: 60px;
}
.template-darkbackground .site-hero-band .news-title span {
line-height: 60px;
}
@media (max-width: 1023px) { @media (max-width: 1023px) {
.site-container { .site-container {
max-width: 640px; max-width: 640px;

View File

@ -16,7 +16,14 @@
{% if page.search_description %} {% if page.search_description %}
<meta name="description" content="{{ page.search_description }}" /> <meta name="description" content="{{ page.search_description }}" />
{% endif %} {% endif %}
{% if og_image %}
<meta property="og:image" content="{{ og_image.url }}" />
<meta property="og:image:width" content="{{ og_image.width }}" />
<meta property="og:image:height" content="{{ og_image.height }}" />
<meta property="og:image:alt" content="{{ og_image.alt }}" />
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="alternate" type="application/rss+xml" title="DeBuT AI 最新文章 RSS" href="{% url 'article_feed' %}">
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon"> <link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<link rel="shortcut icon" href="{% static 'favicon.ico' %}" type="image/x-icon"> <link rel="shortcut icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
{% if ga4_measurement_id %} {% if ga4_measurement_id %}

View File

@ -4,9 +4,11 @@ from django.contrib import admin
from wagtail.admin import urls as wagtailadmin_urls from wagtail.admin import urls as wagtailadmin_urls
from wagtail import urls as wagtail_urls from wagtail import urls as wagtail_urls
from wagtail.contrib.sitemaps.views import sitemap
from wagtail.documents import urls as wagtaildocs_urls from wagtail.documents import urls as wagtaildocs_urls
from search import views as search_views from search import views as search_views
from home.feeds import LatestArticlesFeed
from home import views as home_views from home import views as home_views
from base import views as base_views from base import views as base_views
@ -14,6 +16,8 @@ urlpatterns = [
path("django-admin/", admin.site.urls), path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)), path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)), path("documents/", include(wagtaildocs_urls)),
path("feed.xml", LatestArticlesFeed(), name="article_feed"),
path("sitemap.xml", sitemap, name="sitemap"),
path("health", base_views.health_check, name="health_check"), path("health", base_views.health_check, name="health_check"),
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve # use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"), path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),

View File

@ -7,6 +7,7 @@ from django.db.models import Q
from wagtail.models import Site from wagtail.models import Site
from home.models import ArticlePage, PAGE_SIZE from home.models import ArticlePage, PAGE_SIZE
from home.pagination import build_pagination_context, get_page_size
def search(request): def search(request):
@ -26,7 +27,7 @@ def search(request):
results_count = primary_qs.count() results_count = primary_qs.count()
if results_count: if results_count:
paginator = Paginator(primary_qs, PAGE_SIZE) paginator = Paginator(primary_qs, get_page_size(request, PAGE_SIZE))
results_page = paginator.get_page(page_number) results_page = paginator.get_page(page_number)
query_string = urlencode({"query": search_query}) query_string = urlencode({"query": search_query})
category_sections = [ category_sections = [
@ -34,6 +35,7 @@ def search(request):
"title": f"搜尋:{search_query}", "title": f"搜尋:{search_query}",
"items": results_page, "items": results_page,
"url": f"{request.path}?{query_string}", "url": f"{request.path}?{query_string}",
"pagination": build_pagination_context(request, results_page, paginator),
} }
] ]