CrawlPodScan your site

Django integration

AIVisibilityMiddleware from ai_visibility.middleware.django detects AI crawlers, optionally serves them a stripped/JSON-LD-injected HTML response, and logs the visit — configured entirely through an AI_VISIBILITY dict in settings.py. Regular visitors pass through untouched.

Install

pip install ai-visibility[django]

Adds Django (≥3.2) as a dependency. The core package remains dependency-free otherwise.

Middleware setup

Add AIVisibilityMiddleware to MIDDLEWARE — placement matters: it needs to run after Django has produced a response (it reads and can rewrite response.content), and early enough that no other middleware short-circuits or has already streamed the response. Near the top of the list, right after Django's own security/session middleware, is the safe default:

settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "ai_visibility.middleware.django.AIVisibilityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...the rest of your middleware
]

Internally, the middleware reads request.META['HTTP_USER_AGENT'] to detect a crawler, and only touches the response body when Content-Type is text/html — non-HTML responses (JSON APIs, static files, redirects) pass through with zero overhead for every request, crawler or not.

AI_VISIBILITY settings

settings.py
AI_VISIBILITY = {
    "optimize": True,          # strip scripts/styles/tracking for detected crawlers
    "inject_schemas": True,    # inject JSON-LD from "schemas" below
    "schemas": [],             # list[dict] — JSON-LD schema dicts, e.g. from ai_visibility.schema builders
    "log_visits": True,        # log each detected visit via CrawlerAnalytics
    "analytics": True,         # master switch; False also disables log_visits
    "on_detect": None,         # optional callable(crawler: CrawlerInfo, path: str) -> None
    "optimize_options": None,  # optional OptimizeOptions() for fine-grained stripping control
}
KeyTypeDefaultDescription
optimizeboolTrueServe an optimized (script/style/tracking-stripped) HTML snapshot to detected crawlers.
inject_schemasboolFalseInject schemas as JSON-LD into responses served to detected crawlers.
schemaslist[dict][]JSON-LD schema dicts to inject when inject_schemas is set — built once at import time, not per-request (see below for per-view schemas instead).
log_visitsboolTrueLog detected visits to an internal CrawlerAnalytics instance.
analyticsboolTrueMaster switch — False forces log_visits off regardless of its own value.
on_detectCallable[[CrawlerInfo, str], None] | NoneNoneCalled with (crawler, path) on every detected visit — for custom side effects (your own logging table, a metric).
optimize_optionsOptimizeOptions | NoneNoneFine-grained control over what optimize strips — see the optimizer reference.

The schemas list here is static— it's read once when the middleware is constructed at process start, so it's suited to site-wide schema (an Organization or WebSite schema), not per-page data. For page-specific schema (an Article on a blog post, a Product on a product page), inject it in the view instead — next section.

Adding JSON-LD schemas to specific views

Build the schema in the view and inject it directly with inject_schemas()— this works whether or not the middleware's own inject_schemas setting is on, since it operates on the already-rendered HTML string:

views.py
from django.http import HttpResponse
from django.template.loader import render_to_string
from ai_visibility import article_schema, inject_schemas

def post_detail(request, slug):
    post = get_object_or_404(Post, slug=slug)
    html = render_to_string("blog/post_detail.html", {"post": post}, request=request)

    schema = article_schema(
        headline=post.title,
        description=post.excerpt,
        author_name=post.author.get_full_name(),
        date_published=post.published_at.isoformat(),
        url=request.build_absolute_uri(),
    )
    return HttpResponse(inject_schemas(html, [schema]))

This runs for every visitor, not just detected crawlers — JSON-LD is inert markup with no rendering cost, so there's no reason to gate it behind bot detection the way HTML optimization is gated.

Serving llms.txt and robots.txt

Plain function-based views returning text/plain, wired up like any other URL pattern:

views.py
from django.http import HttpResponse
from ai_visibility import generate_llms_txt, generate_robots_txt
from ai_visibility.types import LlmsTxtConfig, LlmsTxtSection, LlmsTxtLink, RobotsTxtConfig

def llms_txt(request):
    body = generate_llms_txt(LlmsTxtConfig(
        title="Acme",
        summary="Acme makes widgets.",
        sections=[LlmsTxtSection(name="Docs", links=[
            LlmsTxtLink(title="Getting started", url="https://acme.com/docs"),
        ])],
    ))
    return HttpResponse(body, content_type="text/plain; charset=utf-8")

def robots_txt(request):
    body = generate_robots_txt(RobotsTxtConfig(
        block_training_bots=True,
        sitemap_urls=["https://acme.com/sitemap.xml"],
    ))
    return HttpResponse(body, content_type="text/plain; charset=utf-8")
urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("llms.txt", views.llms_txt),
    path("robots.txt", views.robots_txt),
    # ...
]

There is no dedicated Django template tag library for schema/generator output in 0.5.0 — every example above is plain Python called from a view, which composes with any Django project regardless of templating setup.

Django REST Framework integration notes

AIVisibilityMiddleware only acts on text/html responses, so it's already a no-op for a DRF API's JSON responses — nothing to configure or exclude. Where GEO actually applies to a DRF-backed project is the HTML-rendering side: server-rendered detail pages (or a DRF-powered SPA's prerendered/SSR shell) are what benefit from optimize_html()/schema injection, not the API endpoints themselves — those aren't what an AI crawler fetches or cites.

Testing with AI crawler User-Agents

Django's test client accepts arbitrary headers on any request — pass HTTP_USER_AGENT to simulate a known crawler and assert on the optimized response:

tests.py
from django.test import TestCase

GPTBOT_UA = "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.2"

class AIVisibilityMiddlewareTests(TestCase):
    def test_strips_script_tags_for_gptbot(self):
        response = self.client.get("/", HTTP_USER_AGENT=GPTBOT_UA)
        self.assertNotIn(b"<script>", response.content)

    def test_regular_visitor_untouched(self):
        response = self.client.get("/", HTTP_USER_AGENT="Mozilla/5.0 (normal browser)")
        self.assertIn(b"<script>", response.content)

For a real GPTBot User-Agent string to test against, use one from the crawler registry rather than inventing one — detect_crawler() matches on the exact lowercase substrings published there.

See the middleware API reference for the shared AIVisibilityCore/MiddlewareConfig both Flask and FastAPI adapters also wrap, and the Flask guide / the FastAPI guide for the other two frameworks.