はじめに
前章で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">
← {{ 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 }} →
</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_atがNoneのケアも忘れない:未公開記事は公開日時が未設定なので、前後記事を引く 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;
これは本番環境では必須のテクニックです。
📚 参考URL:Django 公式 - F() 式
post.view_count += 1 と Post.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">続きを読む →</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継承のおかげで意外とコード量は少なかったでしょ? 次章ではいよいよユーザー機能だよ!
