DIY LLM Citation Monitor — 3-Layer Architecture Layer 1: Collect Layer 2: Store Layer 3: Present collector.py SQLite + schema dashboard.html • OpenAI API • Anthropic API • Perplexity API • queries table • runs table • results table • Appearance-rate trend lines • Average position • Per-platform breakdown cron daily run Time-series records Plain HTML / Plot.ly

Why You Need First-Party Data

There are two paths in the GEO monitoring tool market:

Path 1: Commercial SaaS (Profound, Otterly, Peec, etc.) runs generic queries for you, shows you trends, and provides a dashboard. The upside is that it’s quick to get started and requires no engineering resources; the downside is that the query list it gives you is the one it thinks you should track — not the one your target customers are actually asking.

Path 2: Build your own first-party measurement — you decide which questions to query, which model to use, and how to parse the results.

The two paths aren’t mutually exclusive, but for teams that genuinely want to understand how AI recommends their own brand, the first-party signal can’t be skipped. The reasons:

What this article provides is a minimal architecture for “run it yourself, store it yourself, view it yourself.” It’s not meant to replace commercial SaaS — for most teams, running both paths in parallel is the most pragmatic: SaaS for the big-picture trends, your own build for the deep questions.

What Building Your Own Gives You vs. What It Doesn’t

Building your own gives you Building your own does NOT give you
A fully controllable query list Cross-industry benchmarks (you only see your own)
The full original response text The training-corpus coverage depth of competitors
The ability to join with your other data sources Interpretation = knowing which signals to track and which to ignore
A traceable historical time series Turning monitoring results into a GEO action strategy

In other words: building your own solves data collection, not data interpretation or strategy. The latter still requires GEO domain knowledge — and that’s the core value of consulting services (see the end of the article).

Below is the minimal implementation architecture.


System Architecture

┌─────────────────┐
│ collector.py    │ ← Runs daily at 03:00 (cron / Task Scheduler)
│ • Read queries  │
│ • Call 3 APIs   │
│ • Parse responses│
└────────┬────────┘
         │ Write
         ▼
┌─────────────────┐
│ monitoring.db   │ ← SQLite, 3 tables
│ • queries       │
│ • runs          │
│ • results       │
└────────┬────────┘
         │ Read
         ▼
┌─────────────────┐
│ dashboard.py    │ ← Run anytime (or on a schedule)
│ • Generate HTML │ ← Plot trends with Plotly
│ • Open browser  │
└─────────────────┘

Step 1: Environment Setup

# Python 3.10+
pip install openai anthropic httpx plotly

The .env file (never commit this into git):

OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
PERPLEXITY_API_KEY=pplx-...
BRAND_NAME=Your Company Name
BRAND_DOMAIN=example.com

The .gitignore:

.env
*.db
__pycache__/

Step 2: Define Your 20 Target Queries

queries.yaml:

queries:
  - id: 1
    text: "Recommended customer management systems for small and medium businesses"
    category: "product_recommend"
  - id: 2
    text: "Best CRM software worth using in 2026"
    category: "product_recommend"
  - id: 3
    text: "B2B marketing automation tool comparison"
    category: "comparison"
  - id: 4
    text: "How to choose a CRM vendor"
    category: "how_to"
  # ... 20 in total

Principles for choosing queries:


Step 3: DB Schema

schema.sql:

-- Query list (stable)
CREATE TABLE IF NOT EXISTS queries (
    id INTEGER PRIMARY KEY,
    text TEXT NOT NULL,
    category TEXT,
    active INTEGER DEFAULT 1
);

-- Metadata for each run
CREATE TABLE IF NOT EXISTS runs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    started_at TEXT NOT NULL,
    completed_at TEXT,
    queries_count INTEGER,
    platforms TEXT,
    status TEXT  -- ok / partial / failed
);

-- Individual query results
CREATE TABLE IF NOT EXISTS results (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    run_id INTEGER NOT NULL,
    query_id INTEGER NOT NULL,
    platform TEXT NOT NULL,  -- openai / anthropic / perplexity
    response_text TEXT,      -- Full original response (for debugging)
    brand_mentioned INTEGER, -- 0/1
    brand_position INTEGER,  -- Which position it appeared in (NULL if not appearing)
    citation_url INTEGER,    -- Whether it contains a URL on my domain (Perplexity-specific)
    error TEXT,              -- API error message (if any)
    created_at TEXT,
    FOREIGN KEY (run_id) REFERENCES runs(id),
    FOREIGN KEY (query_id) REFERENCES queries(id)
);

CREATE INDEX IF NOT EXISTS idx_results_run ON results(run_id);
CREATE INDEX IF NOT EXISTS idx_results_query ON results(query_id);

Step 4: collector.py — The Collection Script

"""Runs daily: executes a query for each query × each platform, stores into the DB."""

import os
import re
import sqlite3
import time
from datetime import datetime, timezone

import httpx
import yaml
from anthropic import Anthropic
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

BRAND_NAME = os.environ["BRAND_NAME"]
BRAND_DOMAIN = os.environ["BRAND_DOMAIN"]
DB_PATH = "monitoring.db"

openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
anthropic_client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
perplexity_key = os.environ["PERPLEXITY_API_KEY"]


def init_db() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    with open("schema.sql") as f:
        conn.executescript(f.read())
    return conn


def load_queries(conn: sqlite3.Connection) -> list[dict]:
    """Syncs from yaml to the DB, returns active queries."""
    with open("queries.yaml") as f:
        config = yaml.safe_load(f)
    for q in config["queries"]:
        conn.execute(
            "INSERT OR REPLACE INTO queries (id, text, category, active) VALUES (?, ?, ?, 1)",
            (q["id"], q["text"], q.get("category", "")),
        )
    conn.commit()
    rows = conn.execute(
        "SELECT id, text FROM queries WHERE active = 1"
    ).fetchall()
    return [{"id": r[0], "text": r[1]} for r in rows]


def parse_brand_appearance(text: str) -> tuple[bool, int | None]:
    """Finds the position where the brand name appears in the response text.
    Returns (mentioned, position). position is "which listed option it is."""
    if not text or BRAND_NAME not in text:
        return False, None

    # Simplified: find all list items, see which item the brand appears in
    # In a real scenario you can switch to having the LLM parse it (higher cost but more accurate)
    items = re.findall(r"^\s*[\d•\-\*]+[\.\)]\s*(.+)$", text, re.MULTILINE)
    for idx, item in enumerate(items, start=1):
        if BRAND_NAME in item:
            return True, idx

    # Not in a list but mentioned → treat as mentioned but without ranking
    return True, None


def query_openai(query_text: str) -> dict:
    try:
        resp = openai_client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {
                    "role": "user",
                    "content": query_text,
                }
            ],
            temperature=0,
        )
        text = resp.choices[0].message.content or ""
        mentioned, pos = parse_brand_appearance(text)
        return {
            "response_text": text,
            "brand_mentioned": int(mentioned),
            "brand_position": pos,
            "error": None,
        }
    except Exception as exc:
        return {
            "response_text": None, "brand_mentioned": 0,
            "brand_position": None, "error": str(exc),
        }


def query_anthropic(query_text: str) -> dict:
    try:
        resp = anthropic_client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            messages=[{"role": "user", "content": query_text}],
        )
        text = resp.content[0].text if resp.content else ""
        mentioned, pos = parse_brand_appearance(text)
        return {
            "response_text": text,
            "brand_mentioned": int(mentioned),
            "brand_position": pos,
            "error": None,
        }
    except Exception as exc:
        return {
            "response_text": None, "brand_mentioned": 0,
            "brand_position": None, "error": str(exc),
        }


def query_perplexity(query_text: str) -> dict:
    """The Perplexity API is OpenAI-compatible, but it returns citation URLs."""
    try:
        with httpx.Client(timeout=30.0) as client:
            r = client.post(
                "https://api.perplexity.ai/chat/completions",
                headers={"Authorization": f"Bearer {perplexity_key}"},
                json={
                    "model": "llama-3.1-sonar-large-128k-online",
                    "messages": [{"role": "user", "content": query_text}],
                },
            )
            r.raise_for_status()
            data = r.json()
            text = data["choices"][0]["message"]["content"] or ""
            citations = data.get("citations", [])
            mentioned, pos = parse_brand_appearance(text)
            cited_url = int(any(BRAND_DOMAIN in c for c in citations))
            return {
                "response_text": text,
                "brand_mentioned": int(mentioned),
                "brand_position": pos,
                "citation_url": cited_url,
                "error": None,
            }
    except Exception as exc:
        return {
            "response_text": None, "brand_mentioned": 0,
            "brand_position": None, "citation_url": 0, "error": str(exc),
        }


def main() -> None:
    conn = init_db()
    queries = load_queries(conn)

    cur = conn.execute(
        "INSERT INTO runs (started_at, queries_count, platforms, status) "
        "VALUES (?, ?, ?, 'in_progress')",
        (datetime.now(timezone.utc).isoformat(), len(queries),
         "openai,anthropic,perplexity"),
    )
    run_id = cur.lastrowid
    conn.commit()

    fail_count = 0
    for q in queries:
        for platform_name, query_fn in [
            ("openai", query_openai),
            ("anthropic", query_anthropic),
            ("perplexity", query_perplexity),
        ]:
            print(f"[{platform_name}] q={q['id']}: {q['text'][:40]}...")
            r = query_fn(q["text"])
            if r.get("error"):
                fail_count += 1
            conn.execute(
                "INSERT INTO results "
                "(run_id, query_id, platform, response_text, brand_mentioned, "
                " brand_position, citation_url, error, created_at) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (
                    run_id, q["id"], platform_name,
                    r["response_text"], r["brand_mentioned"], r["brand_position"],
                    r.get("citation_url", 0), r.get("error"),
                    datetime.now(timezone.utc).isoformat(),
                ),
            )
            conn.commit()
            time.sleep(1)  # be rate-limit friendly

    status = "ok" if fail_count == 0 else ("partial" if fail_count < 10 else "failed")
    conn.execute(
        "UPDATE runs SET completed_at = ?, status = ? WHERE id = ?",
        (datetime.now(timezone.utc).isoformat(), status, run_id),
    )
    conn.commit()
    print(f"Run {run_id} done: {status} ({fail_count} errors)")


if __name__ == "__main__":
    main()

Step 5: dashboard.py — Visualization

"""Reads monitoring.db, generates an HTML dashboard and opens the browser."""

import sqlite3
import webbrowser
from pathlib import Path

import plotly.graph_objects as go

DB_PATH = "monitoring.db"
OUTPUT = "dashboard.html"


def main() -> None:
    conn = sqlite3.connect(DB_PATH)

    # 1. Daily overall appearance rate (across all platforms + queries)
    rows = conn.execute("""
        SELECT
            DATE(r.started_at) as day,
            res.platform,
            AVG(res.brand_mentioned) * 100 as appearance_rate,
            AVG(CASE WHEN res.brand_position IS NOT NULL
                THEN res.brand_position ELSE NULL END) as avg_position
        FROM runs r
        JOIN results res ON res.run_id = r.id
        WHERE r.status IN ('ok', 'partial')
        GROUP BY day, res.platform
        ORDER BY day
    """).fetchall()

    # Reshape into platform → [(day, rate)]
    platform_data: dict[str, dict] = {}
    for day, plat, rate, avg_pos in rows:
        platform_data.setdefault(plat, {"days": [], "rates": [], "positions": []})
        platform_data[plat]["days"].append(day)
        platform_data[plat]["rates"].append(rate)
        platform_data[plat]["positions"].append(avg_pos)

    # 2. Appearance-rate trend chart
    fig1 = go.Figure()
    for plat, data in platform_data.items():
        fig1.add_trace(go.Scatter(
            x=data["days"], y=data["rates"],
            mode="lines+markers", name=plat,
        ))
    fig1.update_layout(
        title="Daily brand appearance rate (%) — by platform",
        xaxis_title="Date", yaxis_title="Appearance rate (%)",
        height=400,
    )

    # 3. Average-position trend
    fig2 = go.Figure()
    for plat, data in platform_data.items():
        fig2.add_trace(go.Scatter(
            x=data["days"], y=data["positions"],
            mode="lines+markers", name=plat,
        ))
    fig2.update_layout(
        title="Average recommendation position — by platform (lower is better)",
        xaxis_title="Date", yaxis_title="Average position",
        yaxis=dict(autorange="reversed"),  # 1 on top
        height=400,
    )

    # 4. Per-query hit heatmap for the last 7 days
    rows = conn.execute("""
        SELECT q.text, res.platform,
               AVG(res.brand_mentioned) * 100 as rate
        FROM results res
        JOIN queries q ON q.id = res.query_id
        JOIN runs r ON r.id = res.run_id
        WHERE r.started_at >= datetime('now', '-7 days')
        GROUP BY q.text, res.platform
    """).fetchall()

    queries_seen = sorted(set(r[0] for r in rows))
    platforms_seen = sorted(set(r[1] for r in rows))
    matrix = [[0] * len(platforms_seen) for _ in queries_seen]
    for q_text, plat, rate in rows:
        i = queries_seen.index(q_text)
        j = platforms_seen.index(plat)
        matrix[i][j] = rate

    fig3 = go.Figure(data=go.Heatmap(
        z=matrix, x=platforms_seen, y=queries_seen,
        colorscale="RdYlGn", zmin=0, zmax=100,
    ))
    fig3.update_layout(
        title="Hit-rate heatmap for the last 7 days",
        height=600,
    )

    # 5. Write the HTML
    html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>LLM Citation Monitor</title>
<style>
body {{ font-family: system-ui; max-width: 1200px; margin: 2em auto; padding: 0 1em; }}
h1 {{ color: #1a56db; }}
section {{ margin: 2em 0; }}
</style></head>
<body>
<h1>LLM Citation Monitor dashboard</h1>
<p>Updated at: <code>{__import__('datetime').datetime.now().isoformat()}</code></p>
<section>{fig1.to_html(full_html=False, include_plotlyjs="cdn")}</section>
<section>{fig2.to_html(full_html=False, include_plotlyjs=False)}</section>
<section>{fig3.to_html(full_html=False, include_plotlyjs=False)}</section>
</body></html>"""

    Path(OUTPUT).write_text(html, encoding="utf-8")
    print(f"Dashboard generated: {OUTPUT}")
    webbrowser.open(f"file://{Path(OUTPUT).absolute()}")


if __name__ == "__main__":
    main()

Step 6: Scheduling

Linux / macOS (cron)

# Run the collector daily at 03:00 in the morning
0 3 * * * cd /path/to/monitor && /path/to/python collector.py >> collector.log 2>&1

Windows (Task Scheduler)

# Register via schtasks
schtasks /Create /SC DAILY /ST 03:00 /TN "LLM Monitor" `
  /TR "C:\Python312\python.exe C:\monitor\collector.py"

Cost Estimate

20 queries × 3 platforms per day = 60 API calls.

Platform Model Approx. cost / month
OpenAI gpt-4o 600 tokens in + 800 out ~$3–5
Anthropic Claude 600 in + 800 out ~$2–3
Perplexity sonar 600 in + 800 out ~$2–3
Total $7–11/month

Plus, if you run it once daily via a cloud function and use SQLite locally: total cost is about $7–15/month.

vs. commercial SaaS at $200–500/month, that’s a 95%+ saving.


Directions for Advanced Improvement

Improvement 1: Use an LLM to Parse the Brand Position in the Response

parse_brand_appearance has limited accuracy with regex against Chinese text + irregularly formatted LLM responses. Switch to a lightweight model (gpt-4o-mini / claude-haiku) that reads the response and asks “in which position is brand X mentioned.” Each call adds + $0.001 in cost, but accuracy improves dramatically.

Improvement 2: Multi-Brand (Including Competitor) Tracking

Change BRAND_NAME into a list (your own + 5 main competitors), and track each company’s appearance rate and position across each query simultaneously. Competitor comparison is the most persuasive data in GEO measurement.

Improvement 3: Alert Notifications

When:

→ send an email / push to Slack.

Improvement 4: Historical Backtracking and Weekly/Monthly Reports


First Step: Start with Steps 1–4 (the Minimal Viable Version)

Get collector.py + queries.yaml + schema.sql running, and gather data for 7 consecutive days. The first week’s numbers will serve as your “baseline.”

👉 GeoWeb Health Check provides a quantitative assessment of on-site signals, but real-time off-site citation rate can only be seen with this kind of active measurement script.

If you want to build a fully customized monitoring system (including multi-brand tracking, alert notifications, automated weekly/monthly reports, and team dashboard integration), that falls within the scope of GEO consulting services: [email protected]


The GEO deep-dive series. Previous article: “Subdomain vs. Subdirectory vs. Multi-Site Architecture — Impact on GEO and a Decision Framework”