Erstellen Sie ein Django-Nachrichtenportal in 2026: Full-Stack-Tutorial
Ein Django-Nachrichtenportal ist eine Full-Stack-Webanwendung, die Artikel nach einem Zeitplan aus einem Nachrichten-API abruft, sie mit Metadaten (Entitäten, Kategorien, Sentiment) speichert, über gecachte Ansichten mit unendlichem Scrollen bereitstellt und die Suche im Korpus ermöglicht — erstellt mit Django 5, Celery Beat, Redis und HTMX. Diese Anleitung erstellt ein Portal, das Live-Artikel alle 15 Minuten abruft und diese nach dem Caching unter 50 ms bereitstellt.
Dieses Tutorial führt durch den Aufbau des Portals von Ende zu Ende in etwa 350 Zeilen Django Code. Die Daten stammen live von einer echten Nachrichten-API. Das Frontend verwendet HTMX für unendliches Scrollen anstelle einer JavaScript-Build-Pipeline. Der letzte Abschnitt gibt Ihnen ein Entscheidungsframework, wann jedes Komplexitätsteil (Celery, Redis, Postgres) hinzufügenswert ist.
Was Sie bauen werden
Ein funktionierendes Django Nachrichtenportal mit sieben Komponenten:
- Modelle –
Article,Source,Category,Topicmit JSONFields für Entitäten und Sentiment - Celery beat ingestion — Aufgabe, die alle 15 Minuten neue Artikel mit idempotenter Upsert abruft
- Redis Cache – Django Cache-Framework mit pro Artikel TTL, ausgerichtet an der Aktualität der Veröffentlichung
- Klassenbasierte Ansichten —
ListView+DetailViewmit Cache-Dekoratoren - HTMX Endlos-Scrollen —
hx-trigger="revealed"Teilvorlage, kein JavaScript Build - Postgres Volltextsuche –
SearchVector+ GIN-Index über Titel und Body - Entscheidungsrahmen — wann jede Schicht hinzugefügt werden soll im Vergleich zum Start mit SQLite
Für wen das geeignet ist: Django Entwickler, die einen Nachrichtenaggregator, ein Markenüberwachungsportal, ein internes Nachrichtenraum-Dashboard oder eine beliebige Content-Website erstellen, die einen externen Nachrichten-Feed nutzt.
Voraussetzungen
- Python 3.12+ und Django 5.1+
- Ein News-API-Schlüssel – dieser Leitfaden verwendet APITube, da jeder Artikel mit bereits angehängten Kategorien, Themen, Entitäten und Stimmungen zurückgegeben wird, was die Modellschicht klein hält. Jeder News-API mit strukturierten Metadaten funktioniert.
- PostgreSQL 16+ und Redis 7+ (überspringen Sie beide am Anfang; der Entscheidungsrahmen erklärt, wann sie hinzugefügt werden sollen)
- Pakete:
pip install django celery[redis] redis psycopg httpx django-htmx
export APITUBE_KEY="your_key_here"
export DATABASE_URL="postgres://user:pass@localhost:5432/newsportal"
export CELERY_BROKER_URL="redis://localhost:6379/0"
Schritt 1 – Modelle für Nachrichtenartikel
Ein echtes Nachrichtenportal benötigt mehr als einen title + body. Jeder Artikel stammt von einer Quelle, gehört zu einer oder mehreren Kategorien und Themen, erwähnt benannte Entitäten und trägt eine Stimmung. Verwenden Sie JSONField für die offenen Teile, um eine vorzeitige Normalisierung zu vermeiden:
# news/models.py
from django.db import models
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
class Source(models.Model):
domain = models.CharField(max_length=255, unique=True)
country_code = models.CharField(max_length=2, blank=True)
def __str__(self):
return self.domain
class Category(models.Model):
slug = models.SlugField(max_length=80, unique=True)
name = models.CharField(max_length=120)
class Article(models.Model):
external_id = models.CharField(max_length=64, unique=True, db_index=True)
title = models.CharField(max_length=500)
description = models.TextField(blank=True)
body = models.TextField(blank=True)
href = models.URLField(max_length=2000)
image = models.URLField(max_length=2000, blank=True)
published_at = models.DateTimeField(db_index=True)
source = models.ForeignKey(Source, on_delete=models.CASCADE, related_name="articles")
categories = models.ManyToManyField(Category, related_name="articles", blank=True)
# Open-ended metadata — APITube returns rich nested structures here
entities = models.JSONField(default=list, blank=True)
topics = models.JSONField(default=list, blank=True)
sentiment = models.JSONField(default=dict, blank=True)
search_vector = SearchVectorField(null=True, editable=False)
class Meta:
ordering = ("-published_at",)
indexes = [
GinIndex(fields=["search_vector"], name="article_search_idx"),
models.Index(fields=["-published_at"], name="article_pub_idx"),
]
def __str__(self):
return self.title
Zwei Designhinweise. Erstens ist external_id einzigartig und indiziert – das macht das Upsert in der Celery-Aufgabe idempotent gegenüber wiederholten Abfragen. Zweitens ist search_vector ein Postgres-spezifisches Feld mit einem GIN-Index; wir werden es in der Ingestionsaufgabe befüllen, damit die Suche schnell bleibt, wenn der Korpus wächst.
Führen Sie Migrationen aus und registrieren Sie einen minimalen Admin, damit Sie die Aufnahme visuell überprüfen können:
python manage.py makemigrations news && python manage.py migrate
# news/admin.py
from django.contrib import admin
from .models import Article, Source, Category
admin.site.register([Source, Category])
admin.site.register(Article, list_display=("title", "source", "published_at"))
Schritt 2 – Celery Beat für Live-Nachrichten-Ingestion
Ein echtes Nachrichtenportal ruft Artikel automatisch ab, nicht über das Admin-Formular. Celery Beat ist der bevorzugte Scheduler von Django.
Konfigurieren Sie Celery in Ihrem Projekt:
# project/celery.py
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
app = Celery("project")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
# project/__init__.py
from .celery import app as celery_app
__all__ = ("celery_app",)
# project/settings.py — relevant pieces
CELERY_BROKER_URL = os.environ["CELERY_BROKER_URL"]
CELERY_TIMEZONE = "UTC"
CELERY_BEAT_SCHEDULE = {
"fetch-news-every-15-min": {
"task": "news.tasks.fetch_news",
"schedule": 900.0, # 15 minutes
},
}
Die Fetcher-Aufgabe erledigt drei Dinge: aktuelle Artikel abrufen, sie auf external_id einfügen und den Suchvektor für neue Zeilen aktualisieren.
# news/tasks.py
import os
import httpx
from celery import shared_task
from datetime import datetime, timedelta, timezone
from django.contrib.postgres.search import SearchVector
from django.db import transaction
from .models import Article, Source, Category
APITUBE_KEY = os.environ["APITUBE_KEY"]
BASE = "https://api.apitube.io/v1/news/everything"
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def fetch_news(self, category="medtop:13000000", per_page=50): # IPTC media-topic id (e.g. technology)
since = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
params = {
"language.code": "en",
"category.id": category,
"published_at.start": since,
"per_page": per_page,
}
try:
r = httpx.get(BASE, params=params, headers={"X-API-Key": APITUBE_KEY}, timeout=15)
r.raise_for_status()
except httpx.HTTPError as exc:
raise self.retry(exc=exc)
new_ids = []
for item in r.json().get("results", []):
source, _ = Source.objects.get_or_create(
domain=item["source"]["domain"],
defaults={"country_code": item["source"].get("location", {}).get("country_code", "")[:2]},
)
with transaction.atomic():
article, created = Article.objects.update_or_create(
external_id=str(item["id"]),
defaults={
"title": item["title"][:500],
"description": item.get("description", ""),
"body": item.get("body", ""),
"href": item["href"],
"image": item.get("image", "") or "",
"published_at": item["published_at"],
"source": source,
"entities": item.get("entities", []),
"topics": item.get("topics", []),
"sentiment": item.get("sentiment", {}),
},
)
if created:
new_ids.append(article.pk)
cats = [Category.objects.get_or_create(slug=c["id"], defaults={"name": c.get("name", c["id"])})[0]
for c in item.get("categories", [])]
article.categories.set(cats)
if new_ids:
Article.objects.filter(pk__in=new_ids).update(
search_vector=SearchVector("title", weight="A") + SearchVector("body", weight="B")
)
return {"fetched": len(r.json().get("results", [])), "new": len(new_ids)}
Zwei Produktionsaspekte. Das 30-Minuten-Lookback-Fenster mit 15-Minuten-Planung garantiert Überlappungen, sodass ein verpasster Lauf keine Artikel verliert. Der update_or_create-Schlüssel, der auf external_id basiert, macht erneute Abrufe idempotent – derselbe Artikel kann bei drei aufeinanderfolgenden Abfragen eintreffen und Sie werden niemals Duplikate erhalten.
Starten Sie die Worker:
celery -A project worker -l info
celery -A project beat -l info
Schritt 3 – Redis Cache für heiße Artikel
Die Artikeldetailansicht führt für jeden beliebten Artikel tausende Male pro Stunde dieselbe Datenbankabfrage aus. Das Django-Cache-Framework vor Redis behebt dies.
Konfigurieren Sie den Cache:
# settings.py
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/1"),
}
}
Zwischenspeichern Sie Artikelobjekte mit einem TTL, das mit dem Alter des Artikels verfällt — eine 5 Minuten alte Eilmeldung sollte häufiger aktualisiert werden als ein Woche altes Archivstück:
# news/cache.py
from django.core.cache import cache
from datetime import datetime, timezone
from .models import Article
def article_ttl(published_at):
age_hours = (datetime.now(timezone.utc) - published_at).total_seconds() / 3600
if age_hours < 1: return 60 # 1 min — breaking
if age_hours < 24: return 300 # 5 min — recent
return 3600 # 1 hour — older
def get_article_cached(external_id):
key = f"article:{external_id}"
article = cache.get(key)
if article is None:
article = Article.objects.select_related("source").prefetch_related("categories").get(external_id=external_id)
cache.set(key, article, timeout=article_ttl(article.published_at))
return article
Cache-Invalidierung bei Aktualisierung ist eine Einzeiler über Signal:
# news/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.cache import cache
from .models import Article
@receiver(post_save, sender=Article)
def invalidate_article_cache(sender, instance, **kwargs):
cache.delete(f"article:{instance.external_id}")
Verdrahten Sie Signale in apps.py, damit sie beim Start geladen werden:
class NewsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "news"
def ready(self):
from . import signals # noqa
Schritt 4 – Klassenbasierte Ansichten
Djangos ListView und DetailView decken die beiden benötigten Seiten ab. Halten Sie diese schlank – die Hauptarbeit gehört in Modelle und Manager.
# news/views.py
from django.views.generic import ListView, DetailView
from django.shortcuts import render
from django.views.decorators.cache import cache_page
from django.utils.decorators import method_decorator
from .cache import get_article_cached
from .models import Article
PAGE_SIZE = 20
@method_decorator(cache_page(60), name="dispatch") # 60s page cache for the list
class ArticleListView(ListView):
model = Article
template_name = "news/list.html"
context_object_name = "articles"
paginate_by = PAGE_SIZE
def get_queryset(self):
return Article.objects.select_related("source").only(
"id", "external_id", "title", "description", "image", "published_at", "source"
)
class ArticleDetailView(DetailView):
template_name = "news/detail.html"
context_object_name = "article"
def get_object(self, queryset=None):
return get_article_cached(self.kwargs["external_id"])
URL-Konfiguration:
# news/urls.py
from django.urls import path
from .views import ArticleListView, ArticleDetailView, ArticleListPartial, ArticleSearchView
urlpatterns = [
path("", ArticleListView.as_view(), name="article-list"),
path("page/", ArticleListPartial.as_view(), name="article-list-partial"),
path("search/", ArticleSearchView.as_view(), name="article-search"),
path("<str:external_id>/", ArticleDetailView.as_view(), name="article-detail"),
]
Schritt 5 – HTMX Endlos-Scroll (kein JavaScript Build)
HTMX fügt unendliches Scrollen in 15 Zeilen von HTML hinzu, ohne React, Webpack oder einen Build-Schritt.
Installieren Sie HTMX in Ihrer Basisschablone:
<!-- templates/base.html -->
<head>
<script src="https://unpkg.com/[email protected]" defer></script>
</head>
Die Listenansicht-Vorlage rendert die erste Seite und fordert dann HTMX auf, die nächste Seite abzurufen, wenn der Sentinel in Sichtweite scrollt:
<!-- templates/news/list.html -->
{% extends "base.html" %}
{% block content %}
<h1>Latest News</h1>
<div id="articles">
{% include "news/_articles.html" %}
</div>
{% endblock %}
<!-- templates/news/_articles.html -->
{% for article in articles %}
<article class="card">
<h2><a href="{% url 'article-detail' article.external_id %}">{{ article.title }}</a></h2>
<p>{{ article.description|truncatechars:160 }}</p>
<small>{{ article.source.domain }} — {{ article.published_at|date:"j M, H:i" }}</small>
</article>
{% endfor %}
{% if page_obj.has_next %}
<div hx-get="{% url 'article-list-partial' %}?page={{ page_obj.next_page_number }}"
hx-trigger="revealed"
hx-swap="outerHTML">
Loading...
</div>
{% endif %}
Die partielle Ansicht gibt nur den Artikelblock plus den nächsten Begrenzer zurück:
# news/views.py — append
from django.views.generic import ListView
class ArticleListPartial(ArticleListView):
template_name = "news/_articles.html"
Das ist das vollständige Muster. Wenn der Sentinel-div in den Viewport eintritt, löst HTMX einen GET aus, tauscht die Antwort in seinen eigenen Slot und die neue Antwort bringt ihren eigenen Nächste-Seite-Sentinel mit. Kein von Ihnen geschriebener JavaScript.
Schritt 6 — Postgres Volltextsuche
Einfaches Article.objects.filter(title__icontains=q) bricht bei ~50,000 Artikeln zusammen. Postgres Volltextsuche mit dem GIN-Index aus Schritt 1 bleibt auch bei über einer Million Zeilen unter -100m.
# news/views.py — append
from django.views.generic import ListView
from django.contrib.postgres.search import SearchQuery, SearchRank
from django.db.models import F
class ArticleSearchView(ListView):
template_name = "news/search.html"
context_object_name = "articles"
paginate_by = PAGE_SIZE
def get_queryset(self):
q = self.request.GET.get("q", "").strip()
if not q:
return Article.objects.none()
query = SearchQuery(q, search_type="websearch")
return (
Article.objects.annotate(rank=SearchRank(F("search_vector"), query))
.filter(search_vector=query)
.order_by("-rank", "-published_at")
.select_related("source")
)
search_type="websearch" akzeptiert Operatoren im Google-Stil ("exact phrase", -exclude, OR), ohne dass Sie etwas parsen müssen. Die SearchRank-Sortierung zeigt zuerst die relevantesten Artikel an; die sekundäre published_at-Sortierung bevorzugt Aktualität.
Die Suchvorlage ist nur ein Formular, das auf diese Ansicht zeigt, und rendert dann denselben _articles.html-Teil – Paginierung und HTMX Infinite Scroll funktionieren auch für Suchergebnisse kostenlos.
Entscheidungsrahmen: Wann jede Ebene hinzufügen?
Ein häufiger Django-Fehler besteht darin, am ersten Tag für ein Portal mit 12 Artikeln und 3 Benutzern Postgres + Redis + Celery bereitzustellen. Die richtige Reihenfolge:
| Stufe | Artikel | Stack | Warum |
|---|---|---|---|
| Prototyp | < 1,000 | SQLite + Synchronisierung fetch_news über Cron | Null Ops; Versand an einem Tag |
| Frühe Produktion | 1k – 50k | Postgres + synchrone Abfrage über Cron | FTS rechtfertigt Postgres; noch keine Redis |
| Echter Traffic | > 50k Artikel oder > 1k DAU | + Celery schlägt + Redis Cache | Asynchroner Abruf schützt die Latenz der Anfrage; Cache absorbiert Lesevorgänge |
| Mehrere Quellen | > 5 Fetcher | + Celery Worker-Pool, separater Beat-Container | Fetcher-Fehler vom Web-Tier isolieren |
Zwei Regeln hinter der Tabelle. Fügen Sie Celery erst hinzu, wenn Ihr synchroner Abruf Webanfragen blockiert – bis dahin ist ein cron-Job, der einen Management-Befehl aufruft, einfacher und genauso effektiv. Fügen Sie Redis-Caching erst hinzu, wenn Sie wiederholte Lesevorgänge desselben Artikels in Ihren Protokollen messen können; für Seiten mit geringem Traffic kostet der Cache mehr an betrieblicher Komplexität, als er an Datenbanklast spart.
Häufig gestellte Fragen
Wie baut man eine Nachrichten-Website mit Django?
Um eine Nachrichten-Website mit Django zu erstellen, definieren Sie ein Article-Modell mit Feldern für Titel, Text, Quelle, Veröffentlichungsdatum, Kategorien und Metadaten. Planen Sie eine Celery-Beat-Aufgabe, die alle 15 Minuten einen Nachrichten-API abfragt und Artikel anhand der externen ID einfügt oder aktualisiert. Rendern Sie Listen mit ListView, cachen Sie heiße Artikel in Redis über das Cache-Framework von Django, fügen Sie unendliches Scrollen für den Feed hinzu, das von HTMX gesteuert wird, und aktivieren Sie die Postgres Volltextsuche über Titel und Texte.
Kann Django Daten von einem REST API abrufen?
Ja – Django kann Daten aus jeder REST API mithilfe von httpx oder requests innerhalb eines Management-Befehls oder einer Celery-Aufgabe abrufen. Das Standardmuster ist ein Celery-Beat-Zeitplan, der den Fetcher alle N Minuten auslöst, Model.objects.update_or_create verwendet, das auf der stabilen Kennung des Upstream-API basiert, um Upserts idempotent zu halten, und jede Einfügung in eine Transaktion einschließt. Versuchen Sie, transiente Fehler mit @shared_task(bind=True, max_retries=3) zu beheben.
Wie plant man periodische Aufgaben in Django?
Planen Sie periodische Django-Aufgaben mit Celery beat, dem Scheduler, der mit Celery gebündelt ist. Fügen Sie ein CELERY_BEAT_SCHEDULE-Dict zu den Einstellungen mit dem Aufgabenspeicherort und schedule (in Sekunden oder einer crontab-Instanz) hinzu, und führen Sie dann celery -A project beat neben Ihrem Worker-Prozess aus. Für sehr einfache Fälle ohne Celery ist ein System-cron-Job, der python manage.py custom_command aufruft, ebenfalls gültig.
Was ist der beste Weg, Nachrichten-Inhalte in Django zu cachen?
Der beste Weg, Nachrichten-Inhalte in Django zu cachen, ist die Cache-Schicht des Frameworks, unterstützt von Redis, mit einem pro-Artikel-TTL, der basierend auf dem Alter des Artikels verfällt. Ein 1-Minuten-TTL für Eilmeldungen unter einer Stunde, 5 Minuten für aktuelle Artikel bis zu 24 Stunden und 1 Stunden für Archive. Invalidieren Sie über ein post_save-Signal, damit ein aktualisierter Artikel seinen Cache-Eintrag sofort löscht.


