Django記事詳細・カテゴリ・キーワード検索ページの実装【AIと一緒に作るDjangoブログ入門 #5】

Django記事詳細・カテゴリ・キーワード検索ページの実装【AIと一緒に作るDjangoブログ入門 #5】

slug方式の記事詳細ページ、カテゴリ別の一覧、キーワード検索を実装。base.htmlでの共通レイアウト化、F()式での閲覧数カウント、Qオブジェクトを使ったOR検索など、実用的なテクニックも紹介。

はじめに

前章でDBから記事を取得できるようになりました。ただ今のままでは記事タイトルをクリックしても何も起きません。

この章では記事の中身を読めるようにすることから始めて、カテゴリ絞り込み検索機能まで作ります。

ココココ

ここからが本番だよ。詳細ページ・カテゴリ・検索の3つを一気に作るから、終わるころにはちゃんとブログっぽくなってるはず!

この章でやること:

  • 記事詳細ページ(第4章で入れた slug を使ったURL)
  • カテゴリ別の記事一覧ページ
  • キーワード検索
  • 第3章で作った base.html のヘッダーに検索ボックスを追加

設計:どんなURL構造にするか

まずURLをどう設計するか決めます。

メリット デメリット
ID方式 /posts/1/ 実装が簡単 URLから内容が分からない・SEO弱い
slug方式 /posts/django-tutorial/ SEOが強い・人間が読める フィールド追加が必要

slug方式を必ず採用する。 後から変えるのはリダイレクト地獄になるので、最初から入れる。

ココココ

「ID 方式のほうが楽そう」って思うかもしれないけど、URL に django-tutorial って入ってると、人もGoogleも「何の記事か」が一発で分かるんだよ。SEO 的にも slug が圧倒的に強い。ID にしちゃうと、後で SEO を意識して変えたくなったとき URL が全部変わって被リンクが死んじゃうから、最初から slug で行くのが鉄則だね。

📚 参考URL
- Django 公式 - SlugField
- Google 検索セントラル - URL 構造のベストプラクティス

最終的なURL構造:

URL ページ
/ トップページ
/posts/django-tutorial/ 記事詳細
/category/django/ カテゴリ別一覧
/search/?q=docker 検索結果

始める前に(第3章・第4章のおさらい)

この章は、前の章で用意した土台の上に積み上げます。新しく作り直すものはありません。

  • slug(URL用識別子):第4章で Post モデルに追加済み(title から自動生成する prepopulated_fields も admin に設定済み)。この章ではこの slug を実際のURLとして使えるようにします。モデル変更は無いので、マイグレーションやDBリセットは不要です。
  • base.html(共通レイアウト):第3章で作成済み。各ページが {% extends 'blog/base.html' %} で継承しています。この章で作る詳細・カテゴリ・検索ページもすべてこれを継承します。
ココココ

第3章で base.html、第4章で slug を入れておいたおかげで、この章は「足す」だけ。共通の土台を先に用意しておくと、章が進むほどラクになるんだよ〜

なお、検索ボックスは base.html に置きますが、{% url 'blog:search' %} を参照するため、先にURL(Step6)を定義してから最後に足します(章の終盤)。順番を逆にすると NoReverseMatch エラーになるので注意です。


Step1. 詳細ページのテンプレートを作る

touch ~/myblog/blog/templates/blog/detail.html

詳細ページに入れる要素:

  • パンくずリスト(ホーム → カテゴリ → 記事名)
  • 記事タイトル・公開日・カテゴリ・閲覧数
  • 本文
  • 前後の記事リンク
  • 関連記事3件

~/myblog/blog/templates/blog/detail.html の全文:

{% extends 'blog/base.html' %}

{% block title %}{{ post.title }} - My Blog{% endblock %}

{# SEO: 記事ごとの説明文として要約を使う #}
{% block meta_description %}{{ post.summary }}{% endblock %}

{# OGP: 記事ページは og:type を article にする #}
{% block og_title %}{{ post.title }}{% endblock %}
{% block og_description %}{{ post.summary }}{% endblock %}
{% block og_type %}article{% endblock %}

{# 構造化データ(JSON-LD):Google検索結果でのリッチ表示用 #}
{% block structured_data %}
<script type="application/ld+json">
{
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    "headline": "{{ post.title|escapejs }}",
    "description": "{{ post.summary|escapejs }}",
    "datePublished": "{{ post.published_at|date:'c' }}",
    "dateModified": "{{ post.updated_at|date:'c' }}",
    "author": {
        "@type": "Person",
        "name": "管理者"
    },
    "publisher": {
        "@type": "Organization",
        "name": "My Blog"
    },
    "mainEntityOfPage": {
        "@type": "WebPage",
        "@id": "{{ request.scheme }}://{{ request.get_host }}{{ request.path }}"
    }
}
</script>
{% endblock %}

{% block content %}

    {# パンくずリスト #}
    <nav aria-label="breadcrumb" class="mb-4">
        <ol class="breadcrumb">
            <li class="breadcrumb-item"><a href="{% url 'blog:index' %}">ホーム</a></li>
            {% if post.category %}
            <li class="breadcrumb-item"><a href="{% url 'blog:category' post.category.slug %}">{{ post.category.name }}</a></li>
            {% endif %}
            <li class="breadcrumb-item active" aria-current="page">{{ post.title }}</li>
        </ol>
    </nav>

    {# 記事本体 #}
    <article class="blog-post">
        <h1 class="display-5 link-body-emphasis mb-2">{{ post.title }}</h1>

        <p class="blog-post-meta">
            {{ post.published_at|date:"Y年n月j日" }}
            {% if post.category %}
                ・ <a href="{% url 'blog:category' post.category.slug %}">{{ post.category.name }}</a>
            {% endif %}
            ・ {{ post.view_count }} views
        </p>

        {# 要約 #}
        {% if post.summary %}
        <div class="lead mb-4 p-3 bg-body-tertiary rounded">{{ post.summary }}</div>
        {% endif %}

        {# 本文。いまは改行だけ反映する素の表示。第6章でMarkdown整形に差し替える #}
        <div class="blog-post-body">
            {{ post.body|linebreaks }}
        </div>
    </article>

    {# 前後の記事 #}
    <nav class="d-flex justify-content-between border-top pt-4 mb-5" aria-label="前後の記事">
        {% if prev_post %}
        <a href="{% url 'blog:detail' prev_post.slug %}" class="btn btn-outline-primary rounded-pill">
            &larr; {{ prev_post.title|truncatechars:20 }}
        </a>
        {% else %}
        <span></span>
        {% endif %}

        {% if next_post %}
        <a href="{% url 'blog:detail' next_post.slug %}" class="btn btn-outline-primary rounded-pill">
            {{ next_post.title|truncatechars:20 }} &rarr;
        </a>
        {% endif %}
    </nav>

    {# 関連記事 #}
    {% if related_posts %}
    <section class="mb-5">
        <h3 class="pb-3 mb-4 fst-italic border-bottom">関連記事</h3>
        <div class="row g-4">
            {% for related in related_posts %}
            <div class="col-md-4">
                <div class="card h-100 shadow-sm">
                    <div class="card-body">
                        {% if related.category %}
                        <span class="badge bg-secondary mb-2">{{ related.category.name }}</span>
                        {% endif %}
                        <h5 class="card-title">
                            <a href="{% url 'blog:detail' related.slug %}" class="text-decoration-none link-body-emphasis">{{ related.title }}</a>
                        </h5>
                        <p class="card-text text-muted small">{{ related.summary|truncatechars:60 }}</p>
                    </div>
                    <div class="card-footer text-muted small">
                        {{ related.published_at|date:"Y年n月j日" }}
                    </div>
                </div>
            </div>
            {% endfor %}
        </div>
    </section>
    {% endif %}

{% endblock %}
ココココ

本文は今 {{ post.body|linebreaks }} で「改行だけ反映」の素の表示。# 見出し**太字** はまだそのまま文字で出るよ。Markdown整形は第6章でちゃんとやるから、ここでは中身が読めればOK!


Step2. detailビューを実装する

~/myblog/blog/views.py に追加:

from django.db.models import F
from django.shortcuts import get_object_or_404, render


def detail(request, slug):
    """記事詳細ページを表示する。"""

    # 公開済みの記事のみ表示。未公開はURL直打ちでも404
    # ただし管理者(is_staff)はログインしていれば未公開記事もプレビューできる
    # (公開ボタンを押す前に「本物のページでどう見えるか」を必ず確認するため)
    if request.user.is_authenticated and request.user.is_staff:
        post = get_object_or_404(Post, slug=slug)
    else:
        post = get_object_or_404(Post, slug=slug, is_published=True)

    # 閲覧数を1加算する
    # F()式を使うことで、同時アクセスでもカウントがズレない
    # (DBレベルでアトミックに +1 する)
    Post.objects.filter(pk=post.pk).update(view_count=F('view_count') + 1)

    # 前後の記事:公開日時を基準に検索する
    # 未公開(プレビュー時)は published_at が None なので前後記事なし
    prev_post = None
    next_post = None
    if post.published_at:
        prev_post = Post.objects.filter(
            is_published=True, published_at__lt=post.published_at
        ).order_by('-published_at').first()

        next_post = Post.objects.filter(
            is_published=True, published_at__gt=post.published_at
        ).order_by('published_at').first()

    related_posts = []
    if post.category:
        related_posts = Post.objects.filter(
            is_published=True, category=post.category
        ).exclude(pk=post.pk).order_by('-published_at')[:3]

    context = {
        'post': post,
        'prev_post': prev_post,
        'next_post': next_post,
        'related_posts': related_posts,
    }
    return render(request, 'blog/detail.html', context)

管理者だけ未公開記事をプレビューできる仕掛け

詳細ビューの先頭で is_staff を判定して is_published=True の条件を外しているのには理由があります。

想定シナリオ 振る舞い
一般ユーザー 公開済みの記事だけ閲覧可。未公開は 404
ログイン中の管理者 未公開記事も /posts/<slug>/ で表示できる

なぜこの分岐が必要か:

  • 公開前に本物のレイアウトで確認したい:管理画面のプレビューだけでは、ヘッダー・サイドバー・関連記事まで含めた「実際に読者が見る画面」を確認できません。is_published=False のまま URL を直接叩いて、最終確認してから公開ボタンを押す運用にしたいからです。
  • is_staff でガードする:誰でもアクセスできてしまうと未公開記事がリークします。Djangoが標準で持っている is_staff フラグ(管理画面ログイン可能なユーザー)を必ず使い、それ以外は従来通り 404 にします。
  • published_atNone のケアも忘れない:未公開記事は公開日時が未設定なので、前後記事を引く SQL で published_at__lt=None を渡すとエラーになります。if post.published_at: で必ず守ること。

却下した代替案:「プレビュー専用URL(?preview=トークン)を発行する」案もありますが、自分一人で運用するブログでは過剰実装です。is_staff 判定で十分シンプルかつ安全に達成できます。


F()式とは?

普通に書くとこうなります:

post.view_count += 1
post.save()

しかしこれだと 同時に100人がアクセスしたとき に問題が起きます。

ユーザーA: 取得(view_count=10)→ +1 → 保存(11)
ユーザーB: 取得(view_count=10)→ +1 → 保存(11)  ← 12になるはずが11

F() を使うとDB側で計算するので、同時アクセスでもズレません:

Post.objects.filter(pk=post.pk).update(view_count=F('view_count') + 1)

実際のSQL: UPDATE blog_post SET view_count = view_count + 1 WHERE id = 1;

これは本番環境では必須のテクニックです。

📚 参考URLDjango 公式 - F() 式

ココココ

post.view_count += 1Post.objects.update(view_count=F('view_count')+1) は、見た目はそっくりでも中身は別物。前者はPython側で計算、後者はDB側で計算。同時アクセスが来るブログなら必ず後者を使う!


Step3. カテゴリページを実装する

views.py にさらに追加:

def category(request, slug):
    """カテゴリ別の記事一覧ページを表示する。"""

    # URLのslugでカテゴリを取得。存在しなければ404
    category = get_object_or_404(Category, slug=slug)

    # このカテゴリに属する公開済み記事を新しい順に取得する
    post_list = Post.objects.filter(
        is_published=True, category=category
    ).order_by('-published_at')

    context = {
        'page_title': f'カテゴリ:{category.name}',
        'post_list': post_list,
    }
    return render(request, 'blog/list.html', context)

Step4. 検索機能を実装する

views.py に追加:

from django.db.models import Q


def search(request):
    """キーワード検索ページを表示する。"""

    # GETパラメータ q を取得(未指定なら空文字)
    query = request.GET.get('q', '').strip()

    if query:
        # Qオブジェクトで title・summary・body の OR検索を実現する
        post_list = Post.objects.filter(is_published=True).filter(
            Q(title__icontains=query)
            | Q(summary__icontains=query)
            | Q(body__icontains=query)
        ).order_by('-published_at')
    else:
        post_list = Post.objects.none()

    context = {
        'page_title': f'検索結果:{query}' if query else '検索',
        'query': query,
        'post_list': post_list,
    }
    return render(request, 'blog/list.html', context)

Qオブジェクトとは?

複雑な条件(OR・NOT・グルーピング)を書くためのDjangoの機能です。

# AND(普通のfilter)
Post.objects.filter(title__icontains='django', is_published=True)

# OR(Qオブジェクトが必要)
Post.objects.filter(Q(title__icontains='django') | Q(body__icontains='django'))

icontains とは?

  • contains → 大文字小文字を区別する部分一致
  • icontains → 大文字小文字を区別しない部分一致(i = insensitive)

「Django」「django」「DJANGO」すべてヒットさせたいので icontains を必ず使う。

ココココ

「全文検索エンジン(Elasticsearch とか)入れなくていいの?」って気になるかもしれないけど、記事が数百件くらいまでなら icontains で全然戦える。検索が遅くなったり、形態素解析で「Django入門」を「Django」でヒットさせたい、みたいな話が出てきたら初めて検討する話だね。最初から大砲は構えなくていい!

却下した代替案:PostgreSQL の SearchVector や Elasticsearch の導入。今のスケールでは過剰。icontains で始めて、必要になったら載せ替える。


Step5. カテゴリと検索の共通テンプレートを作る

カテゴリと検索は表示内容が似ているので1つのテンプレートを使い回します

touch ~/myblog/blog/templates/blog/list.html

~/myblog/blog/templates/blog/list.html の全文:

{% extends 'blog/base.html' %}

{% block title %}{{ page_title }} - My Blog{% endblock %}

{% block content %}

    {# ページタイトル #}
    <h2 class="pb-4 mb-4 fst-italic border-bottom">{{ page_title }}</h2>

    {# 検索ページのときだけ表示するヒット件数 #}
    {% if query %}
    <p class="text-muted">「{{ query }}」の検索結果:{{ post_list|length }}件</p>
    {% endif %}

    {# 記事一覧 #}
    {% for post in post_list %}
    <article class="blog-post">
        <h2 class="h3 link-body-emphasis mb-1">
            <a href="{% url 'blog:detail' post.slug %}" class="text-decoration-none link-body-emphasis">{{ post.title }}</a>
        </h2>
        <p class="blog-post-meta">
            {{ post.published_at|date:"Y年n月j日" }}
            {% if post.category %}
                ・ <a href="{% url 'blog:category' post.category.slug %}">{{ post.category.name }}</a>
            {% endif %}
        </p>
        <p>{{ post.summary }}</p>
    </article>
    {% empty %}
    <p class="text-muted">該当する記事がありません。</p>
    {% endfor %}

{% endblock %}

カテゴリページと検索ページが、この1枚のテンプレートを共有します。記事が増えたときのページ送り(ページネーション)は第7章で追加します。


Step6. URLを設定する

~/myblog/blog/urls.py に追加:

urlpatterns = [
    path('', views.index, name='index'),

    # 検索(例: /search/?q=docker)
    # 詳細ページの slug パターンに飲まれないよう posts/<slug>/ より前に置く
    path('search/', views.search, name='search'),

    # カテゴリ別一覧(例: /category/django/)
    path('category/<slug:slug>/', views.category, name='category'),

    # 記事詳細ページ(例: /posts/django-blog-intro/)
    path('posts/<slug:slug>/', views.detail, name='detail'),
]

URLの順序に注意

path('posts/<slug:slug>/', ...) を先に書くと /search/ も slug として解釈されてしまう場合があります。より具体的なURLを先に書くのが鉄則です。


Step7. トップページの記事に詳細ページへのリンクを張る

第4章まではトップページ(index.html)の記事はただのテキストで、クリックしても何も起きませんでした。blog:detail を定義できたので、ここで「続きを読む」リンクを張って詳細ページへ飛べるようにします。

特集バナー

~/myblog/blog/templates/blog/index.html の特集バナー部分に「続きを読む」リンクを足します。

{# 特集バナー #}
<div class="p-4 p-md-5 mb-4 rounded text-body-emphasis bg-body-secondary">
    <div class="col-lg-6 px-0">
        <h1 class="display-4 fst-italic">{{ featured.title }}</h1>
        <p class="lead my-3">{{ featured.summary }}</p>
        {# ここを追加:特集記事の詳細ページへ飛ぶ #}
        <a href="{% url 'blog:detail' featured.slug %}" class="text-body-emphasis fw-bold">続きを読む...</a>
    </div>
</div>

記事一覧

第4章で作った記事一覧ループに、タイトルのリンクと「続きを読む」を足します。

{# 記事一覧(DBの公開記事。特集・ピックアップを除いた残り) #}
{% for post in post_list %}
<article class="blog-post mb-4">
    <h2 class="display-5 mb-1">
        <a href="{% url 'blog:detail' post.slug %}" class="text-decoration-none link-body-emphasis">{{ post.title }}</a>
    </h2>
    <p class="blog-post-meta text-secondary">
        {{ post.published_at|date:"Y年n月j日" }}
        {% if post.category %}・<a href="{% url 'blog:category' post.category.slug %}">{{ post.category.name }}</a>{% endif %}
    </p>
    <p>{{ post.summary }}</p>
    <a href="{% url 'blog:detail' post.slug %}" class="icon-link gap-1">続きを読む &rarr;</a>
</article>
{% empty %}
<p>まだ記事がありません。</p>
{% endfor %}
ココココ

これでトップページからタイトルや「続きを読む」をクリックすると、さっき作った詳細ページにちゃんと飛べるようになったよ!ピックアップ記事にも同じように {% url 'blog:detail' post.slug %} でリンクを張れるからね。


Step8. base.htmlに検索ボックスを配置する

第3章で作った base.html のヘッダーは、いまロゴだけの状態です。

{# いまの base.html のヘッダー(第3章で作成) #}
<header class="border-bottom lh-1 py-3">
    <div class="row justify-content-center">
        <div class="col-4 text-center">
            <a class="blog-header-logo text-body-emphasis text-decoration-none"
               href="{% url 'blog:index' %}">My Blog</a>
        </div>
    </div>
</header>

これを、ロゴを中央に置いたまま右側に検索ボックスを出す3カラム構成に置き換えます。<header>...</header> をまるごと次のコードに差し替えてください。

<header class="border-bottom lh-1 py-3">
    <div class="row flex-nowrap justify-content-between align-items-center">
        {# 左カラム:今は空。左右の幅を揃えてロゴを中央に保つためのスペーサー #}
        <div class="col-4 pt-1"></div>

        {# 中央カラム:サイトロゴ #}
        <div class="col-4 text-center">
            <a class="blog-header-logo text-body-emphasis text-decoration-none"
               href="{% url 'blog:index' %}">My Blog</a>
        </div>

        {# 右カラム:検索フォーム。GETでsearchビューに送る #}
        <div class="col-4 d-flex justify-content-end align-items-center">
            <form class="d-flex" action="{% url 'blog:search' %}" method="get" role="search">
                <input class="form-control form-control-sm" type="search" name="q"
                       placeholder="検索" value="{{ request.GET.q|default:'' }}" aria-label="検索">
            </form>
        </div>
    </div>
</header>

ポイント:

  • method="get" で送信する(URLにクエリが残るのでブックマーク可能)
  • value="{{ request.GET.q|default:'' }}" で検索後もキーワードを保持する
  • 検索フォームの action{% url 'blog:search' %} を参照するので、Step6でURLを定義したあとに置き換えること(先にやると NoReverseMatch になる)
ココココ

カテゴリ別ページ(/category/django/)へは、記事のパンくず・メタ情報にあるカテゴリ名リンクから飛べるよ。ヘッダーに横並びのカテゴリナビを出すのは、サイドバーなどと一緒に後の章で足していくね。


Step9. 起動確認

cd ~/myblog
docker compose up

確認すべきURL:

URL 期待する動作
http://localhost/ トップページ
http://localhost/posts/django-blog-intro/ 詳細ページ
http://localhost/category/django/ Djangoカテゴリ一覧
http://localhost/search/?q=docker 「docker」の検索結果

ヘッダーの検索ボックス・サイドバーのカテゴリリンクも全部繋がっているはずです。


まとめ

この章でブログとしての主要な導線が完成しました。

トップページ
  ↓ クリック
詳細ページ
  ↓ カテゴリ・検索
一覧ページ
  ↓ クリック
詳細ページ...

次章では記事を「魅力的に書く」ための仕組み——Markdown整形(見出し・コードハイライト・目次)と、WordPress風の記事エディタ(Toast UI Editor)、画像アップロード、OGP——を作り込みます。

ココココ

3つの機能を一気に作ったけど、共通テンプレートとblock継承のおかげで意外とコード量は少なかったでしょ? 次章ではいよいよユーザー機能だよ!

関連記事

Markdownで記事を綺麗に表示する — コードハイライトと目次【AIと一緒に作るDjangoブログ入門 #6.1】
Django
Markdownで記事を綺麗に表示する — コードハイライトと目次【AIと一緒に作るDjangoブログ入門 #6.1】

第5章まで素のテキストだった記事本文を、Markdownで整形して表示できるようにする章。Python-Markdow…

Djangoのモデル設計とfixtureでブログのデータベースを作る【AIと一緒に作るDjangoブログ入門 #4】
Django
Djangoのモデル設計とfixtureでブログのデータベースを作る【AIと一緒に作るDjangoブログ入門 #4】

Post・Category・DisplaySlotの3つのモデルを設計し、管理画面で記事を管理できるようにする章。fi…

テンプレート継承でDjangoブログのトップページを作る【AIと一緒に作るDjangoブログ入門 #3】
Django
テンプレート継承でDjangoブログのトップページを作る【AIと一緒に作るDjangoブログ入門 #3】

views.pyでダミーデータを用意し、Djangoのテンプレート継承(base.html)でブログのトップページを作…