Byg en Django-nyhedsportal i 2026: Full stack-tutorial
En Django nyhedsportal er en fuld-stack webapplikation, der henter artikler fra en nyheds-API på en tidsplan, gemmer dem med metadata (enheder, kategorier, sentiment), leverer dem via cachede visninger med uendelig scroll og eksponerer søgning på tværs af korpusset — bygget med Django 5, Celery beat, Redis og HTMX. Denne guide bygger en portal, der trækker live-artikler hvert 15 minut og leverer dem under 50 ms, når de er cachede.
Denne vejledning gennemgår opbygningen af portalen end-to-end på omkring 350 linjer Django kode. Dataene er live fra en reel nyheds-API. Frontend bruger HTMX til uendelig scroll i stedet for en JavaScript build pipeline. Den sidste sektion giver dig et beslutningsframework for, hvornår hver kompleksitetsdel (Celery, Redis, Postgres) er værd at tilføje.
Hvad du vil bygge
En fungerende Django nyhedsportal med syv komponenter:
- Modeller —
Article,Source,Category,Topicmed JSONFields for entiteter og sentiment - Celery beat ingestion — opgave, der henter nye artikler hver 15 minut med idempotent upsert
- Redis cache — Django cache-framework med per-artikel TTL tilpasset publikationsfriskhed
- Klassebaserede visninger —
ListView+DetailViewmed cache-dekoratører - HTMX uendelig scroll —
hx-trigger="revealed"delvis skabelon, ingen JavaScript build - Postgres fuldtekstsøgning —
SearchVector+ GIN-indeks på tværs af titel og brødtekst - Beslutningsramme — hvornår skal hver lag tilføjes i forhold til at starte med SQLite
Hvem dette er for: Django udviklere, der bygger en nyhedsaggregator, en brand-monitoreringsportal, et internt nyhedsrum-dashboard eller et hvilket som helst indholdswebsted, der forbruger et tredjeparts nyhedsfeed.
Forudsætninger
- Python 3.12+ og Django 5.1+
- En nyheds-API-nøgle – denne guide bruger APITube, fordi hver artikel kommer tilbage med kategorier, emner, entiteter og sentiment allerede vedhæftet, hvilket holder model-laget lille. Enhver nyheds-API med struktureret metadata virker.
- PostgreSQL 16+ og Redis 7+ (spring begge over i starten; beslutningsrammen forklarer, hvornår de skal tilføjes)
- Pakker:
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"
Trin 1 — Modeller for nyhedsartikler
En reel nyhedsportal har brug for mere end en title + body. Hver artikel kommer fra en kilde, tilhører en eller flere kategorier og emner, nævner navngivne entiteter og bærer sentiment. Brug JSONField til de åbne dele for at undgå for tidlig normalisering:
# 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
To designnoter. Først er external_id unik og indekseret — det er det, der gør upsert i Celery-opgaven idempotent over for gentagne afstemninger. For det andet er search_vector et Postgres-specifikt felt med et GIN-indeks; vi vil udfylde det i ingestion-opgaven, så søgningen forbliver hurtig, efterhånden som korpusset vokser.
Kør migreringer og registrer en minimal administrator, så du visuelt kan verificere indtagelsen:
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"))
Trin 2 — Celery beat til live nyheds-ingestion
En reel nyhedsportal trækker artikler automatisk, ikke via admin-formularen. Celery-beat er Django's foretrukne planlægger.
Konfigurer Celery i dit 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
},
}
Fetcher-opgaven udfører tre ting: trækker seneste artikler, indsætter dem på external_id og opdaterer søgevektoren for nye rækker.
# 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)}
To produktionsberøringer. 30-minutters lookback-vinduet med 15-minutters planlægning garanterer overlap, så et misset kørsel ikke mister artikler. update_or_create, der er nøglet på external_id, gør genhentninger idempotente — den samme artikel kan ankomme i tre på hinanden følgende afstemninger, og du vil aldrig duplikere.
Start arbejderne:
celery -A project worker -l info
celery -A project beat -l info
Trin 3 — Redis cache for populære artikler
Visningen af artikeldetaljer kører den samme databaseforespørgsel tusindvis af gange i timen for enhver populær artikel. Django's cache-framework foran Redis løser det.
Konfigurer cachen:
# settings.py
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/1"),
}
}
Cache artikelobjekter med en TTL, der forfalder med artiklens alder — en 5-minutter gammel breaking story bør opdateres oftere end et uge-gammelt arkivstykke:
# 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-invalidering ved opdatering er en enkelt linje via 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}")
Kobl signaler i apps.py, så de indlæses ved opstart:
class NewsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "news"
def ready(self):
from . import signals # noqa
Trin 4 — Klassebaserede views
Django's ListView og DetailView dækker de to sider, du har brug for. Hold dem tynde — tungt arbejde hører hjemme i modeller og managere.
# 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"),
]
Trin 5 — HTMX uendelig scroll (ingen JavaScript build)
HTMX tilføjer uendelig scrolling i 15 linjer af HTML, uden React, Webpack eller et build-trin.
Installer HTMX i din basisskabelon:
<!-- templates/base.html -->
<head>
<script src="https://unpkg.com/[email protected]" defer></script>
</head>
Listevisningsskabelonen gengiver den første side og beder derefter HTMX om at hente den næste side, når sentinel ruller ind i visningen:
<!-- 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 %}
Den delvise visning returnerer kun artikelblokken plus den næste afsender:
# news/views.py — append
from django.views.generic import ListView
class ArticleListPartial(ArticleListView):
template_name = "news/_articles.html"
Det er det komplette mønster. Når sentinel div kommer ind i viewport, affyrer HTMX en GET, bytter svaret ind i sit eget slot, og det nye svar medbringer sin egen næste-side sentinel. Ingen JavaScript du skrev.
Trin 6 — Postgres fuldtekstsøgning
Almindelig Article.objects.filter(title__icontains=q) kollapser ved ~50,000 artikler. Postgres fuldtekstsøgning med GIN-indekset fra trin 1 forbliver sub-100ms forbi en million rækker.
# 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" accepterer operatorer i Google-stil ("exact phrase", -exclude, OR) uden at du skal parse noget. SearchRank-sorteringen viser de mest relevante artikler først; sekundær published_at-sortering prioriterer friskhed.
Søge-skabelonen er bare en formular, der peger på denne visning, og gengiver derefter den samme _articles.html-del – paginering og HTMX uendelig scroll fungerer gratis på søgeresultater også.
Beslutningsramme: hvornår skal hver lag tilføjes
En almindelig Django-fejl er at provisionere Postgres + Redis + Celery fra dag ét for en portal, der har 12 artikler og 3 brugere. Den rigtige rækkefølge:
| Fase | Artikler | Stack | Hvorfor |
|---|---|---|---|
| Prototype | < 1,000 | SQLite + synkroniser fetch_news via cron | Nul drift; levering på en dag |
| Tidlig produktion | 1k – 50k | Postgres + synkroniseret hentning via cron | FTS retfærdiggør Postgres; ingen Redis endnu |
| Rigtig trafik | > 50k artikler eller > 1k DAU | + Celery slog + Redis cache | Async absorberer læsninger; cache absorberer læsninger |
| Multi-kilde | > 5 fetchers | + Celery worker pool, separat beat container | Isolér fetcher-fejl fra weblaget |
To regler bag tabellen. Tilføj ikke Celery, før din synkrone hentning begynder at blokere webanmodninger — indtil da er et cron-job, der kalder en management-kommando, enklere og lige så effektivt. Tilføj ikke Redis-caching, før du kan måle gentagne læsninger af den samme artikel i dine logs; for websteder med lav trafik koster cachen mere i operationel kompleksitet, end den sparer i databasebelastning.
Ofte stillede spørgsmål
Hvordan bygger du en nyhedswebside med Django?
For at bygge en nyhedshjemmeside med Django, definer en Article model med felter for titel, brødtekst, kilde, udgivelsesdato, kategorier og metadata. Planlæg en Celery beat task, der afstemmer en nyheds-API hvert 15 minut og upsert'er artikler efter ekstern ID. Render lister med ListView, cache populære artikler i Redis via Django's cache framework, tilføj HTMX-drevet uendelig scroll til feedet, og aktiver Postgres fuldtekstsøgning på tværs af titler og brødtekster.
Kan Django hente data fra en REST API?
Ja — Django kan trække data fra enhver REST API ved hjælp af httpx eller requests inde i en management kommando eller Celery opgave. Standardmønsteret er en Celery beat-plan, der udløser fetcheren hvert N minut, bruger Model.objects.update_or_create med nøgle på den upstream API's stabile identifikator for at holde upserts idempotente og pakker hver indsættelse ind i en transaktion. Prøv igen ved forbigående fejl med @shared_task(bind=True, max_retries=3).
Hvordan planlægger du periodiske opgaver i Django?
Planlæg periodiske Django-opgaver med Celery beat, planlæggeren der følger med Celery. Tilføj en CELERY_BEAT_SCHEDULE dict til indstillinger med opgavebanen og schedule (i sekunder eller en crontab-instans), kør derefter celery -A project beat sammen med din worker-proces. For meget simple tilfælde uden Celery er et system cron-job, der kalder python manage.py custom_command, også gyldigt.
Hvad er den bedste måde at cache nyhedsindhold på i Django?
Den bedste måde at cache nyhedsindhold på i Django er frameworkets cache-lag understøttet af Redis, med en TTL pr. artikel, der forfalder baseret på artikelalder. En 1-minutters TTL for breaking news under en time gammel, 5 minutter for nylige artikler op til 24 timer, og 1 time for arkiver. Invalider via et post_save signal, så en opdateret artikel fjerner sin cache-post øjeblikkeligt.


