Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
PRERENDER_TOKEN=
PRERENDER_SERVICE_URL=
38 changes: 38 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Test

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip

- name: Install package with dev extras
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Setup Node (for contract mock server)
uses: actions/setup-node@v4
with:
node-version: 20.x

- name: Fetch contract mock server
run: curl -fsSL -o mock-server.mjs https://raw.githubusercontent.com/prerender/integration-contract/main/mock-server.mjs

- name: Run tests
run: pytest
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.env
mock-server.mjs
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# prerender-django

Django middleware for [Prerender.io](https://prerender.io). Intercepts requests from bots and crawlers and serves prerendered HTML, so your JavaScript-rendered app is fully indexable by search engines and social media scrapers.

Compatible with **Django 5+** and **Python 3.10+**.

## Installation

```bash
pip install prerender-django
```

## Setup

Add the middleware to your `settings.py`:

```python
MIDDLEWARE = [
'prerender_django.middleware.PrerenderMiddleware',
# ... your other middleware
]

PRERENDER_TOKEN = 'YOUR_PRERENDER_TOKEN'
```

The middleware must be placed **before** any session or authentication middleware to intercept bot requests early.

## Settings

| Setting | Default | Description |
|---------|---------|-------------|
| `PRERENDER_TOKEN` | `None` | Your Prerender.io token |
| `PRERENDER_SERVICE_URL` | `https://service.prerender.io/` | Prerender service URL (use this for self-hosted Prerender) |

## Self-hosted Prerender

```python
PRERENDER_SERVICE_URL = 'http://your-prerender-server:3000'
```

## How it works

Requests are prerendered when **all** of the following are true:

- The HTTP method is `GET`
- The `User-Agent` matches a known bot/crawler (Googlebot, Bingbot, Twitterbot, GPTBot, ClaudeBot, etc.)
— OR the URL contains `_escaped_fragment_`
— OR the `X-Bufferbot` header is present
- The URL does not end with a static asset extension (`.js`, `.css`, `.png`, etc.)

Everything else passes through to your normal Django views.

If the Prerender service is unreachable, the middleware falls back gracefully and serves the normal response.

## License

MIT
1 change: 1 addition & 0 deletions prerender_django/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = '1.0.1'
95 changes: 95 additions & 0 deletions prerender_django/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import logging
import urllib.error
import urllib.request
import uuid

from django.conf import settings
from django.http import HttpResponse

from . import __version__

logger = logging.getLogger(__name__)

CRAWLER_USER_AGENTS = [
'googlebot', 'yahoo', 'bingbot', 'baiduspider',
'facebookexternalhit', 'twitterbot', 'rogerbot', 'linkedinbot',
'embedly', 'quora link preview', 'showyoubot', 'outbrain',
'pinterest', 'slackbot', 'developers.google.com/+/web/snippet',
'w3c_validator', 'perplexity', 'oai-searchbot', 'chatgpt-user',
'gptbot', 'claudebot', 'amazonbot',
]

EXTENSIONS_TO_IGNORE = frozenset([
'.js', '.css', '.xml', '.less', '.png', '.jpg', '.jpeg', '.gif',
'.pdf', '.doc', '.txt', '.ico', '.rss', '.zip', '.mp3', '.rar',
'.exe', '.wmv', '.avi', '.ppt', '.mpg', '.mpeg', '.tif', '.wav',
'.mov', '.psd', '.ai', '.xls', '.mp4', '.m4a', '.swf', '.dat',
'.dmg', '.iso', '.flv', '.m4v', '.torrent', '.ttf', '.woff', '.svg',
])


def _setting(name, default=None):
return getattr(settings, f'PRERENDER_{name}', default)


def _is_bot(user_agent):
ua = user_agent.lower()
return any(bot in ua for bot in CRAWLER_USER_AGENTS)


def _is_static_asset(path):
return any(path.endswith(ext) for ext in EXTENSIONS_TO_IGNORE)


def _should_prerender(request):
user_agent = request.META.get('HTTP_USER_AGENT', '')
if not user_agent or request.method != 'GET':
return False
if _is_static_asset(request.path):
return False
if '_escaped_fragment_' in request.GET:
return True
if request.META.get('HTTP_X_BUFFERBOT'):
return True
return _is_bot(user_agent)


def _build_api_url(request):
service_url = _setting('SERVICE_URL', 'https://service.prerender.io/')
if not service_url.endswith('/'):
service_url += '/'
return f'{service_url}{request.build_absolute_uri()}'


def _fetch_prerendered(api_url, user_agent):
token = _setting('TOKEN')
req = urllib.request.Request(api_url)
req.add_header('User-Agent', user_agent)
if token:
req.add_header('X-Prerender-Token', token)
req.add_header('X-Prerender-Int-Type', 'Django')
req.add_header('X-Prerender-Int-Version', __version__)
req.add_header('X-Prerender-Request-Id', str(uuid.uuid4()))
try:
with urllib.request.urlopen(req) as resp:
return resp.status, resp.read().decode('utf-8')
except urllib.error.HTTPError as e:
return e.code, e.read().decode('utf-8')


class PrerenderMiddleware:
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
if not _should_prerender(request):
return self.get_response(request)

try:
api_url = _build_api_url(request)
user_agent = request.META.get('HTTP_USER_AGENT', '')
status, body = _fetch_prerendered(api_url, user_agent)
return HttpResponse(body, status=status, content_type='text/html')
except urllib.error.URLError as e:
logger.error('Prerender error, falling back: %s', e)
return self.get_response(request)
31 changes: 31 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "prerender-django"
version = "1.0.1"
description = "Django middleware for prerendering JavaScript-rendered pages for SEO via Prerender.io"
authors = [{ name = "Prerender.io" }]
license = "MIT"
readme = "README.md"
requires-python = ">=3.10"
keywords = ["django", "prerender", "prerender.io", "seo", "middleware"]
dependencies = []

[project.urls]
Repository = "https://github.com/prerender/integrations"

[project.optional-dependencies]
dev = [
"django>=5.0",
"pytest>=8.0",
"pytest-django>=4.8",
]

[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings"
pythonpath = ["."]

[tool.setuptools.packages.find]
include = ["prerender_django*"]
Empty file added tests/__init__.py
Empty file.
81 changes: 81 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Shared pytest fixtures for django integration tests.

The `mock_server` fixture spawns the prerender integration-contract mock
server (a Node script) for the duration of the test session. CI fetches
mock-server.mjs into the repo root before running tests; locally:

curl -fsSL -o mock-server.mjs https://raw.githubusercontent.com/prerender/integration-contract/main/mock-server.mjs
"""

import os
import socket
import subprocess
import time
import urllib.request
from pathlib import Path

import pytest

MOCK_SERVER_PATH = Path(os.environ.get(
'MOCK_SERVER_PATH',
Path(__file__).parent.parent / 'mock-server.mjs',
))


def _free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
return s.getsockname()[1]


def _wait_for_health(url, attempts=50, delay=0.1):
for _ in range(attempts):
try:
with urllib.request.urlopen(url, timeout=1) as resp:
if resp.status == 200:
return
except Exception:
pass
time.sleep(delay)
raise RuntimeError(f'mock server at {url} did not become ready')


@pytest.fixture(scope='session')
def mock_server():
if not MOCK_SERVER_PATH.exists():
pytest.skip(
f'mock-server.mjs not found at {MOCK_SERVER_PATH}; fetch it via '
'curl -fsSL -o mock-server.mjs '
'https://raw.githubusercontent.com/prerender/integration-contract/main/mock-server.mjs'
)

port = _free_port()
proc = subprocess.Popen(
['node', str(MOCK_SERVER_PATH)],
env={**os.environ, 'PORT': str(port)},
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
url = f'http://127.0.0.1:{port}'
try:
_wait_for_health(f'{url}/__health')
yield url
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()


@pytest.fixture(autouse=True)
def reset_mock(mock_server):
req = urllib.request.Request(f'{mock_server}/__reset', method='POST')
urllib.request.urlopen(req).read()
yield


def get_recorded(mock_server):
with urllib.request.urlopen(f'{mock_server}/__requests') as resp:
import json
return json.loads(resp.read())
3 changes: 3 additions & 0 deletions tests/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SECRET_KEY = 'test-secret-key'
DATABASES = {}
INSTALLED_APPS = []
Loading
Loading