How to Track AI Overview Citations Yourself, by Query and Country

On this page
To track AI overview citations, keep a list of the searches that matter to you and run them every day, in every country you care about. For each one, record three things: whether an AI overview appeared, whether its sources link to your site, and which other sites it cited. Stored day by day, those records show when you gain or lose a citation and who replaced you.
This post builds that tracker: a Python script with no dependencies beyond the standard library, a SQLite file, and a second script that prints what changed since the last run. It also checks AI search answers (the chat-style AI mode), which cite sources in the same way. The examples use the PacketStream Search API, which returns AI overviews and AI search answers as JSON with their sources. 100 searches a day costs $0.25.
What can Search Console tell you, and what can’t it?
Search Console now has a generative AI performance report. Its help page says it was rolled out to all websites worldwide as of August 31, 2026. It counts impressions, meaning how many times links to your site were shown in AI overviews and AI search answers, and groups them by page, country, device or date.
That makes it a good first check. It leaves out what you need to act on:
| Question | Search Console’s AI report | Your own tracker |
|---|---|---|
| How often were my pages shown in AI answers? | Yes, as impressions | Only for the searches you track |
| Which searches showed an AI overview? | No query dimension | Yes |
| Which competitors were cited instead of me? | No | Yes, every source |
| What position was my link among the sources? | No | Yes |
| Did the answer name my brand without linking? | No | Yes, by searching the answer text |
| Clicks from AI answers | No | No |
Use both. Search Console tells you whether AI answers are sending impressions your way at all, and the tracker tells you which searches to work on.
What counts as a citation, and what counts as a mention?
A citation is a link to your site in the AI answer’s list of sources. A mention is your brand or domain named in the answer’s text. They are independent, so track them separately.
Our own test shows why. We asked the AI search answer “proxy vs vpn for web scraping” from four countries on November 9, 2026. It cited packetstream.io every time, as the first source in the US and the UK and the second in India and Germany. The answer text named PacketStream in the US and the UK, but not in India or Germany. A tracker that only looked for the brand name would have missed two of the four citations.
Which searches should you track?
Start with 20 to 50 searches, not your whole keyword list. Good candidates:
- Questions your pages answer. “how much bandwidth does web scraping use” is more likely to get an AI answer than a two-word head term.
- Searches where you rank in the top 10. These show whether ranking well also gets you cited, or whether the AI answer cites someone else.
- Comparison and “best” searches in your category. These are where competitors get cited instead of you.
- Your brand plus a question, such as “is [brand] safe” or “[brand] pricing”, so you see what AI answers say about you.
Put one search per line in keywords.txt. Pick the countries where you have customers, using two-letter country codes such as us, gb and de.
The tracker script
The script runs each search in each country, reads the ai_overview block from a normal search and, with --ai-mode, the ai_mode block from an AI search answer. Both blocks have the same shape: text_blocks with the answer’s text and up to 20 references, each with a title, url and source. The response reference lists every field.
Create an API key on the dashboard and set it as PACKETSTREAM_API_KEY. Then save this as aio_tracker.py:
"""Track AI overview and AI search answer citations for a list of searches.
Usage:
python aio_tracker.py keywords.txt example.com --countries us,gb,de [--ai-mode]
Needs PACKETSTREAM_API_KEY in the environment. Standard library only.
"""
import argparse
import datetime
import http.client
import json
import os
import sqlite3
import sys
import time
import urllib.error
import urllib.request
from urllib.parse import urlparse
API = "https://api.packetstream.io/v1/search"
KEY = os.environ["PACKETSTREAM_API_KEY"]
def search(query, country, kind):
body = {"query": query, "country": country}
if kind == "ai_mode":
body["type"] = "ai_mode"
req = urllib.request.Request(
API,
data=json.dumps(body).encode(),
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
)
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=40) as resp:
return json.load(resp)
except urllib.error.HTTPError as err:
# Failed requests are not billed. Retry rate limits and upstream errors.
if err.code in (429, 502, 503, 504) and attempt < 2:
time.sleep(int(err.headers.get("Retry-After", 2)))
continue
raise
except (OSError, http.client.HTTPException):
# Timeouts, dropped or cut-off connections, DNS failures: wait and retry.
if attempt < 2:
time.sleep(2)
continue
raise
def host(url):
h = urlparse(url).hostname or ""
return h.removeprefix("www.")
def owns(url, domain):
h = host(url)
return h == domain or h.endswith("." + domain)
def answer_text(block):
parts = []
for b in block.get("text_blocks", []):
parts.append(b.get("text", ""))
parts.extend(b.get("items", []))
return " ".join(parts).lower()
def check(query, country, kind, domain, brand):
data = search(query, country, kind)
block = data.get("ai_mode" if kind == "ai_mode" else "ai_overview")
if not block:
return {"shown": 0, "cited": 0, "position": None, "mentioned": 0, "sources": []}
sources = [host(r["url"]) for r in block.get("references", [])]
position = next(
(i + 1 for i, r in enumerate(block.get("references", [])) if owns(r["url"], domain)),
None,
)
text = answer_text(block)
return {
"shown": 1,
"cited": int(position is not None),
"position": position,
"mentioned": int(brand.lower() in text or domain in text),
"sources": sources,
}
def main():
p = argparse.ArgumentParser()
p.add_argument("keywords")
p.add_argument("domain")
p.add_argument("--brand", help="name to look for in the answer text")
p.add_argument("--countries", default="us")
p.add_argument("--ai-mode", action="store_true", help="also check AI search answers")
p.add_argument("--db", default="aio.sqlite")
a = p.parse_args()
domain = a.domain.lower().removeprefix("www.")
brand = a.brand or domain.split(".")[0]
queries = [q.strip() for q in open(a.keywords) if q.strip()]
kinds = ["overview"] + (["ai_mode"] if a.ai_mode else [])
today = datetime.date.today().isoformat()
db = sqlite3.connect(a.db)
db.execute(
"""CREATE TABLE IF NOT EXISTS checks (
day TEXT, query TEXT, country TEXT, kind TEXT,
shown INTEGER, cited INTEGER, position INTEGER,
mentioned INTEGER, sources TEXT,
PRIMARY KEY (day, query, country, kind))"""
)
stored = skipped = 0
for country in a.countries.split(","):
for query in queries:
for kind in kinds:
try:
r = check(query, country, kind, domain, brand)
except (OSError, ValueError, http.client.HTTPException) as err:
# HTTP errors, timeouts, dropped connections, bad JSON.
print(f"skip {country} {kind} {query!r}: {err}")
skipped += 1
continue
db.execute(
"INSERT OR REPLACE INTO checks VALUES (?,?,?,?,?,?,?,?,?)",
(today, query, country, kind, r["shown"], r["cited"],
r["position"], r["mentioned"], json.dumps(r["sources"])),
)
db.commit()
stored += 1
print(f"{country} {kind:8} shown={r['shown']} cited={r['cited']} "
f"pos={r['position']} mentioned={r['mentioned']} {query}")
print(f"stored {stored}, skipped {skipped}")
if stored == 0:
# Out of balance, bad key or no network: fail loudly instead of quietly.
sys.exit("No checks stored today. See the skip lines above.")
if __name__ == "__main__":
main()
A few details matter:
owns()matches subdomains. A link todocs.example.comcounts as a citation forexample.com. The domain you pass is lowercased and a leadingwww.is dropped, sowww.Example.comandexample.comgive the same results. Changeowns()if you want to track one subdomain on its own.--brandsets the mention check. It defaults to the first part of the domain, soexample.comlooks for “example”. Pass--brand "Example Corp"when your name differs.- Errors skip a row instead of stopping the run. Rate limits, server errors, timeouts and dropped connections are retried twice. A search that still fails is not billed and leaves no row for that day, so it is never recorded as “not cited”.
- A run that stores nothing fails. If every search fails, for example because your balance ran out or the key was revoked, the script exits with an error, so a scheduled job can tell you instead of staying silent.
- One row per search, country, kind and day. Running twice in a day overwrites that day’s rows instead of adding duplicates.
Run it:
python3 aio_tracker.py keywords.txt packetstream.io --countries us,gb,de --ai-mode
Here are a few lines from our own US run on November 9, 2026, with packetstream.io as the domain:
us overview shown=0 cited=0 pos=None mentioned=0 proxy vs vpn for web scraping
us ai_mode shown=1 cited=1 pos=1 mentioned=1 proxy vs vpn for web scraping
us overview shown=1 cited=0 pos=None mentioned=0 what is ip rotation
us ai_mode shown=1 cited=0 pos=None mentioned=0 what is ip rotation
us overview shown=0 cited=0 pos=None mentioned=0 how much bandwidth does web scraping use
us ai_mode shown=1 cited=1 pos=2 mentioned=1 how much bandwidth does web scraping use
How do you see what changed since yesterday?
Save this as compare.py. It compares today with the previous day that has checks and prints only the differences:
"""Print what changed between the last two days in aio.sqlite."""
import datetime
import json
import sqlite3
import sys
db = sqlite3.connect(sys.argv[1] if len(sys.argv) > 1 else "aio.sqlite")
days = [d for (d,) in db.execute("SELECT DISTINCT day FROM checks ORDER BY day DESC LIMIT 2")]
if len(days) < 2:
sys.exit("Need two days of checks to compare.")
today, before = days
if today != datetime.date.today().isoformat():
sys.exit(f"No checks stored today; the latest are from {today}.")
print(f"Changes from {before} to {today}:")
rows = db.execute(
"""SELECT t.query, t.country, t.kind,
b.shown, t.shown, b.cited, t.cited, b.position, t.position,
b.sources, t.sources
FROM checks t JOIN checks b
ON b.query = t.query AND b.country = t.country AND b.kind = t.kind
WHERE t.day = ? AND b.day = ?""",
(today, before),
)
for q, country, kind, s0, s1, c0, c1, p0, p1, src0, src1 in rows:
label = f"{country} {kind} {q!r}"
if s0 != s1:
print(f"{label}: answer {'appeared' if s1 else 'not seen'}")
if c0 != c1:
print(f"{label}: citation {'gained' if c1 else 'lost'}")
elif c1 and p0 != p1:
print(f"{label}: cited position {p0} -> {p1}")
new = set(json.loads(src1)) - set(json.loads(src0))
gone = set(json.loads(src0)) - set(json.loads(src1))
if s0 and s1 and (new or gone):
print(f"{label}: new sources {sorted(new)}, dropped {sorted(gone)}")
Cron does not read your shell profile, so a key you exported in your terminal is not there when the job runs. Put it in a file only you can read:
cd ~/aio
printf 'export PACKETSTREAM_API_KEY=%s\n' "your-key" > .env
chmod 600 .env
Then add a cron entry that loads it, runs both scripts every morning, and sends the output wherever your team reads it. If the tracker or the comparison fails, the last lines of the log are printed instead, so a failed run never looks like a quiet day:
0 7 * * * cd ~/aio && . ./.env && python3 aio_tracker.py keywords.txt example.com --countries us,gb,de --ai-mode > run.log 2>&1 && python3 compare.py || tail -n 20 run.log
Daily changes are noisy, so judge each search by its rate over a week. This query gives the share of days each search showed an AI answer and cited you, over the last seven days:
SELECT query, country, kind,
COUNT(*) AS days_checked,
ROUND(AVG(shown), 2) AS answer_rate,
ROUND(AVG(cited), 2) AS citation_rate,
ROUND(AVG(position), 1) AS avg_position
FROM checks
WHERE day > date('now', '-7 days')
GROUP BY query, country, kind
ORDER BY citation_rate DESC, answer_rate DESC;
Run it with sqlite3 aio.sqlite < weekly.sql. A search with a high answer rate and a citation rate of zero is where you lose to a competitor. The sources column shows which one.
Why do AI overview results change?
AI overviews are generated for each search, so the same query can return a different answer, different sources, or no overview at all. In our test on November 9, 2026:
- AI overviews came and went within minutes. We ran 15 proxy and scraping searches in the US twice, about 20 minutes apart. The first pass found an AI overview on 3 of them and the second on 5. Two searches showed one only on the second pass.
- AI search answers always answered, but cited different sites by country. “proxy vs vpn for web scraping” cited packetstream.io in all four countries, alongside a different set of other sources in each: freecodecamp.org and browserless.io in the UK, octoparse.com and oxylabs.io in India, and only reddit.com in Germany.
- AI overviews were rarer than AI search answers. Across the US, the UK and India, 7 of 45 searches returned an AI overview, while every AI search answer we requested came back.
Treat these as one day’s sample from a narrow set of technical searches, not a benchmark. Your rates will differ by topic. The pattern is general, though: one check is an observation, and a week of checks is a measurement.
How often should you check?
Once a day is enough for most sites. Run at the same time each day so that time of day is not a variable. Check more often only around a change you made, such as rewriting a page you want cited, and then return to daily.
Check every country you sell to. Answers and sources differ by country even for the same English query, as the test above shows.
What does tracking cost?
Each successful search costs $0.0025, and AI search answers cost the same as normal searches. A search that returns no AI overview is still billed, because the search ran. Failed requests are free.
| Setup | Searches a day | Per day | Per 30 days |
|---|---|---|---|
| 50 searches, 1 country, AI overviews only | 50 | $0.125 | $3.75 |
| 50 searches, 1 country, plus AI search answers | 100 | $0.25 | $7.50 |
| 50 searches, 3 countries, plus AI search answers | 300 | $0.75 | $22.50 |
| 200 searches, 3 countries, plus AI search answers | 1,200 | $3.00 | $90.00 |
The balance is prepaid with a $50 minimum and does not expire, so $50 runs the 100-a-day setup for more than six months. GET /v1/account returns your balance and today’s usage for free, and you can add it to the cron job as a spending check.
What are the limits?
- A missing AI overview means “not seen”, not “none”. The API returns the
ai_overviewblock only when the results page showed one and it could be read reliably. Record it as not seen and look at the weekly rate. - Mobile and desktop differ. Normal searches accept
"device": "mobile", and mobile results usually carry fewer blocks. AI search answers accept onlycountryandlanguage, and any other option returns a free400 unsupported_optionerror. - Some answers have no sources. The
referencesarray is then empty. The search is still billed, and the tracker records it as shown but not cited. - Text is capped. Each text value is at most 2,000 bytes, and there are at most 40 text blocks, so a mention near the end of a very long answer can be cut off.
- Personalization is not included. These are signed-out results for a country, not what a signed-in user with a search history sees.
- Speed. Searches run one at a time in the script, a few seconds each, which is fine for a few hundred a day. For thousands, run countries in parallel. A key allows 20 requests a second and 32 searches at once.
Where PacketStream fits
The Search API returns live results from the world’s most-used search engine as JSON or Markdown, including AI overviews and AI search answers with their sources, in any country and language. It costs $0.0025 per successful search, with no monthly plan. The same key also works for rank tracking, so you can store organic positions next to AI citations in the same database. For how country targeting changes results, see how to scrape search results from another country, and for keeping daily snapshots clean, see data quality, deduplication and change detection.
FAQ
How do I track whether AI overviews cite my site?
Keep a list of the searches you care about, run each one every day in each country you sell to, and record whether an AI overview appeared, whether its sources include your domain, and which other sites it cited. A search API that returns the AI overview and its sources as JSON lets a short script do this and store the results for day-to-day comparison.
Is there a free AI overview tracker?
Search Console’s generative AI performance report is free and counts how often links to your pages appeared in AI overviews and AI search answers, by page, country, device and date. It does not show which searches triggered them, clicks, or which competitors were cited. For that you need a tracker. Running your own costs only the searches: 100 searches a day at $0.0025 each is $0.25 a day.
What is the difference between an AI overview citation and a mention?
A citation is a link to your site in the answer’s list of sources. A mention is your brand or domain named in the answer text. They are independent: an answer can link to your page without naming you, or name you without linking. Track both.
Why does my site appear in the AI overview one day and not the next?
AI overviews are generated per search, so whether one appears and which sources it cites can change between runs, between countries and between devices. Judge a query by its citation rate over a week of daily checks, not by a single result.
Does Search Console show AI overview data?
Yes. Since August 31, 2026 every site has a generative AI performance report with impressions from AI overviews and AI search answers, grouped by page, country, device or date. It has no query dimension, no clicks and no competitor data.