Parse AI Citations: ChatGPT, Gemini, Perplexity & Claude APIs

How to parse AI citations from ChatGPT, Gemini, Perplexity and Claude APIs: four formats, Gemini redirects, missing markers, URL normalisation and a parser.

· BugViso Team

15 min read

To parse AI citations, read each provider's own structure: OpenAI's url_citation annotations, Gemini's grounding chunks filtered by grounding supports, Perplexity's annotations or numbered markers, and Claude's web_search_result_location citations. Then normalise every URL before you count it. Across 2,525 real citations, skipping normalisation undercounted URLs cited by more than one engine by 16 %.

Getting this wrong quietly breaks every AI visibility metric built on top: own-site citation rates, source gaps, cross-engine overlap. By the end you'll know the four response shapes, the three traps we hit in production (Gemini's redirect URIs, Perplexity answers with no markers, tracking parameters), and you'll have a tested parser for all four.

Data used: 400 answers from BugViso's AI Visibility Tracker (50 buyer prompts × ChatGPT, Gemini, Perplexity and Claude × 2 runs on 2026-10-20, web search on), holding 2,525 citations after parsing. Response formats were checked against each provider's API documentation in October 2026. Gemini ran through OpenRouter's native Google Search grounding, Perplexity through its Agent API.

Four Engines, Four Citation Formats

No two providers return citations the same way, and only two of the four tie each citation to the text it supports.

Engine (API)Where citations liveTied to text?Search queries exposed?
ChatGPT (OpenAI Responses API, web_search tool)output[].content[].annotations[] of type url_citationYes, start_index / end_indexYes, in web_search_call items
Gemini (generateContent, google_search tool)groundingMetadata.groundingChunks[], linked from groundingSupports[]Yes, via groundingChunkIndicesYes, webSearchQueries (direct API)
Perplexity (Agent API)annotations on the message, or [n] markers pointing at a search_results itemSometimesYes, in search_results
Claude (Messages API, web search tool)citations[] of type web_search_result_location on text blocksYes, with cited_textYes, server_tool_use inputs

OpenAI documents the url_citation annotation in its web search tool guide: it carries the URL, title and location of the cited source. Gemini's GenerateContentResponse reference defines groundingSupports[].groundingChunkIndices as the indices of the chunks attributed to each claim. Anthropic's web search tool docs show web_search_result_location citations with the cited_text they quote.

Perplexity's API reference describes the Agent API's typed output array: a message item for the answer and a search_results item for the sources. That second item is where the trap starts.

What 2,525 Parsed Citations Look Like

The engines differ by a factor of seven in citations per answer, and each one leaves a different kind of mess in the URLs.

Engine (100 answers each)CitationsMedian per answerAnswers with noneQuirk in the raw URLs
ChatGPT21423181 carry utm_source=openai
Gemini461511Every chunk title was a bare domain (461 of 461)
Perplexity1,44015496 answers had no annotations or markers
Claude410418Clean, with cited_text per citation

Each quirk needs its own handling. Our Perplexity sources study covers what 15 sources per answer means for visibility. Here we're only concerned with getting the list right.

Trap 1: Gemini's Grounding URIs Are Redirects

Gemini's grounding chunks don't contain the cited page URL. The web.uri is a redirect on vertexaisearch.cloud.google.com, and the web.title holds only the source's domain. All 461 Gemini citations in our data had a domain as their title.

json
{"web": {"uri": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbC123",
         "title": "example.com"}}

If you store that URI, every Gemini citation lands on one Google host, and your own-site citation rate for Gemini reads zero. If you use the title, you get the domain but lose the page, so you can't tell your pricing page from your blog.

The fix is to read the redirect's Location header without following it. A HEAD request with redirects disabled returns the target URL and never loads the third-party page. Fall back to the title domain only when the redirect can't be read. After this step, none of our 461 Gemini citations still pointed at the redirect host.

python
# ❌ Broken: counts the redirect host as the cited site
domain = urlsplit(chunk["web"]["uri"]).hostname   # vertexaisearch.cloud.google.com

# ✅ Fixed: read Location without following it; fall back to the title's domain
url = read_location(chunk["web"]["uri"]) or f"https://{chunk['web']['title']}/"

Two more Gemini details. First, chunks that no groundingSupports entry points at were retrieved but not used, so drop them. Second, when you stream, Google's reference says groundingChunkIndices refer to chunks accumulated across all streamed responses. Collect every chunk before you resolve indices.

Trap 2: Perplexity Answers With No Markers

In 96 of 100 Perplexity answers, there were no annotations and no [n] markers in the text, only a list of search results. The parser has three options, in order:

  1. Annotations present: use them. They're explicit citations.
  2. [n] or [web:n] markers in the text: map each number to the id in search_results. Only those results are cited.
  3. Neither: every search result was consulted, and the answer doesn't say which ones it used.

Case 3 is the default in our data, so a choice is unavoidable. Dropping all those sources would zero Perplexity's citations. Counting them all as cited overstates them. We count them and flag the answer as "consulted, not cited", so reports can say what the number means. Those 1,440 Perplexity citations are therefore an upper bound.

A smaller issue: within single Perplexity answers, the same page appeared twice 10 times under slightly different URLs. Exact-string deduplication misses those, which brings us to the third trap.

Trap 3: The Same Page Under Different URLs

The same page arrives as https://www.example.com/pricing?utm_source=openai from ChatGPT and https://example.com/pricing from Perplexity. Count them as different and your cross-engine overlap shrinks.

ChatGPT appends utm_source=openai to the URLs it cites through the API. 181 of its 214 citations had it. Some cited URLs also carry text fragments (#:~:text=…), sometimes percent-encoded inside the path. Normalise before counting:

StepExample inExample out
Lower-case the host, drop www.https://WWW.Example.com/ahttps://example.com/a
Strip tracking parameters (utm_*, gclid, fbclid, ref)…/pricing?utm_source=openai&id=7…/pricing?id=7
Drop fragments, including encoded #:~:text=…/faq%23%3A~%3Atext%3DChatGPT……/faq
Deduplicate per answer on the resulttwo spellings of one pageone citation

On our data, normalising cut distinct URLs from 1,404 to 1,368. More importantly, URLs cited by two or more engines went from 122 to 145. Without the fix, 23 shared pages, 16 % of the true total, looked engine-specific, which distorts any comparison of the sources each engine cites. Keep real query parameters (?id=7 can be a different page) and leave path case alone, since paths can be case-sensitive under RFC 3986.

Claude: Clean, but Watch the Tool Version

Claude's basic web search tool returns the cleanest citations of the four: a URL, a title and the exact cited_text per citation. 18 of our 100 Claude answers had no citations, typically when Claude answered from its own knowledge.

The trap here is configuration, not parsing. Our engine notes record that Claude's newer dynamic-filtering web search versions ran the search inside code execution, and in our check on 2026-10-12 the final text carried no citations at all. We pin the basic web_search_20250305 tool for that reason. If your Claude citation count suddenly drops to zero after an upgrade, check the tool version before the parser.

Parse AI Citations From All Four APIs: The Script

This standard-library script parses a saved API response from any of the four engines, applies the Gemini, Perplexity and URL rules above, and prints the citations. --demo runs it on built-in examples of each documented shape. --resolve reads Gemini redirect targets over the network.

python
#!/usr/bin/env python3
"""citation_parse.py: pull cited URLs out of ChatGPT, Gemini, Perplexity and Claude API responses.

Usage: python3 citation_parse.py <engine> response.json [--resolve]
       python3 citation_parse.py --demo        # parse built-in examples of each response shape

engine: openai | gemini | perplexity | claude. Standard library only.
--resolve reads each Gemini redirect's Location header (HEAD, redirects not followed)
instead of falling back to the domain Gemini puts in the chunk title.
"""
import json, re, sys, urllib.error, urllib.request
from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit

TRACKING = re.compile(r"^(utm_|gclid$|fbclid$|mc_|ref$|ref_src$)")
MARKER = re.compile(r"\[(?:web:)?(\d{1,3})\]")
REDIRECT_HOST = "vertexaisearch.cloud.google.com"


def normalise(url):
    s = urlsplit(unquote(url) if "%23%3A~%3Atext" in url else url)
    path = s.path.split("#:~:text=")[0] or "/"
    query = urlencode([(k, v) for k, v in parse_qsl(s.query) if not TRACKING.match(k)])
    host = (s.hostname or "").lower().removeprefix("www.")
    return urlunsplit((s.scheme.lower(), host, path, query, ""))


def openai(d):
    out, queries = [], []
    for item in d.get("output") or []:
        if item.get("type") == "web_search_call":
            q = (item.get("action") or {}).get("query")
            queries += [q] if q else []
        if item.get("type") == "message":
            for part in item.get("content") or []:
                for a in part.get("annotations") or []:
                    if a.get("type") == "url_citation":
                        out.append({"url": a["url"], "title": a.get("title", "")})
    return out, queries, False


def gemini(d, resolve=False):
    cand = (d.get("candidates") or [{}])[0]
    gm = cand.get("groundingMetadata") or {}
    chunks, supports = gm.get("groundingChunks") or [], gm.get("groundingSupports") or []
    used = {i for s in supports for i in s.get("groundingChunkIndices") or []}
    out = []
    for i, ch in enumerate(chunks):
        if supports and i not in used:
            continue  # retrieved but never attributed to a sentence
        web = ch.get("web") or {}
        url, title = web.get("uri", ""), web.get("title", "")
        if urlsplit(url).hostname == REDIRECT_HOST:
            url = (read_location(url) if resolve else None) or f"https://{title}/"
        out.append({"url": url, "title": title})
    return out, gm.get("webSearchQueries") or [], False


def read_location(url):
    class Stop(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, *a, **k):
            return None
    try:
        urllib.request.build_opener(Stop).open(urllib.request.Request(url, method="HEAD"), timeout=8)
    except urllib.error.HTTPError as e:
        return e.headers.get("Location")
    except Exception:
        return None
    return None


def perplexity(d):
    results, text, annotated, queries = {}, "", [], []
    ordered = []
    for item in d.get("output") or []:
        if item.get("type") == "search_results":
            queries += item.get("queries") or []
            for r in item.get("results") or []:
                ordered.append(r)
                results[int(r["id"])] = r
        if item.get("type") == "message":
            for part in item.get("content") or []:
                text += part.get("text", "")
                annotated += [{"url": a["url"], "title": a.get("title", "")} for a in part.get("annotations") or [] if a.get("url")]
    if annotated:
        return annotated, queries, False
    marked = [results[int(m.group(1))] for m in MARKER.finditer(text) if int(m.group(1)) in results]
    if marked:
        return [{"url": r["url"], "title": r.get("title", "")} for r in marked], queries, False
    return [{"url": r["url"], "title": r.get("title", "")} for r in ordered], queries, True


def claude(d):
    out, queries = [], []
    for b in d.get("content") or []:
        if b.get("type") == "server_tool_use" and b.get("name") == "web_search":
            queries.append((b.get("input") or {}).get("query"))
        if b.get("type") == "text":
            out += [{"url": c["url"], "title": c.get("title", "")} for c in b.get("citations") or []
                    if c.get("type") == "web_search_result_location"]
    return out, queries, False


def parse(engine, data, resolve=False):
    raw, queries, consulted = (gemini(data, resolve) if engine == "gemini" else
                               {"openai": openai, "perplexity": perplexity, "claude": claude}[engine](data))
    seen, cites = set(), []
    for c in raw:
        key = normalise(c["url"])
        if key not in seen:
            seen.add(key)
            cites.append({"url": key, "domain": urlsplit(key).hostname, "title": c["title"]})
    return {"engine": engine, "raw_citations": len(raw), "citations": cites,
            "queries": [q for q in queries if q], "consulted_not_cited": consulted}


DEMO = {
    "openai": {"output": [
        {"type": "web_search_call", "action": {"type": "search", "query": "official pricing example audit tool"}},
        {"type": "message", "content": [{"type": "output_text", "text": "Plans start at $19 [1].", "annotations": [
            {"type": "url_citation", "url": "https://www.example.com/pricing?utm_source=openai", "title": "Pricing", "start_index": 0, "end_index": 22},
            {"type": "url_citation", "url": "https://example.com/pricing", "title": "Pricing"}]}]}]},
    "gemini": {"candidates": [{"content": {"parts": [{"text": "Example audits 25 areas."}]}, "groundingMetadata": {
        "webSearchQueries": ["example audit tool features"],
        "groundingChunks": [{"web": {"uri": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/AbC123", "title": "example.com"}},
                            {"web": {"uri": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/XyZ789", "title": "unused-source.org"}}],
        "groundingSupports": [{"segment": {"startIndex": 0, "endIndex": 24}, "groundingChunkIndices": [0]}]}}]},
    "perplexity": {"output": [
        {"type": "search_results", "queries": ["example audit tool review"], "results": [
            {"id": 1, "url": "https://reviews.example.net/a", "title": "Review"},
            {"id": 2, "url": "https://example.com/", "title": "Home"},
            {"id": 3, "url": "https://blog.example.org/b", "title": "List"}]},
        {"type": "message", "content": [{"type": "output_text", "text": "It is well reviewed [1][web:3].", "annotations": []}]}]},
    "claude": {"content": [
        {"type": "server_tool_use", "name": "web_search", "input": {"query": "example audit tool"}},
        {"type": "web_search_tool_result", "content": []},
        {"type": "text", "text": "It has a free tier.", "citations": [
            {"type": "web_search_result_location", "url": "https://example.com/pricing#:~:text=free", "title": "Pricing", "cited_text": "Free tier"}]}]},
}

if __name__ == "__main__":
    a = sys.argv[1:]
    if a[:1] == ["--demo"]:
        for eng, data in DEMO.items():
            r = parse(eng, data)
            print(f"{eng:<11} raw {r['raw_citations']}  kept {len(r['citations'])}  consulted-only {r['consulted_not_cited']}  queries {r['queries']}")
            for c in r["citations"]:
                print(f"{'':<11} {c['url']}")
    elif len(a) >= 2:
        print(json.dumps(parse(a[0], json.load(open(a[1])), "--resolve" in a), indent=1))
    else:
        sys.exit(__doc__)

The demo output shows each rule firing:

text
$ python3 citation_parse.py --demo
openai      raw 2  kept 1  consulted-only False  queries ['official pricing example audit tool']
            https://example.com/pricing
gemini      raw 1  kept 1  consulted-only False  queries ['example audit tool features']
            https://example.com/
perplexity  raw 2  kept 2  consulted-only False  queries ['example audit tool review']
            https://reviews.example.net/a
            https://blog.example.org/b
claude      raw 1  kept 1  consulted-only False  queries ['example audit tool']
            https://example.com/pricing

ChatGPT's two spellings of the pricing page collapse to one. Gemini's unused chunk is dropped, and the used one falls back to its title domain because the demo doesn't resolve redirects. Perplexity's [1][web:3] markers keep two of three results. Claude's text fragment is stripped.

How BugViso Normalises Citations From Five Engines

BugViso's AI Visibility Tracker runs one adapter per engine and turns every response into the same shape: answer text, cited URLs and the search queries the engine ran. It covers ChatGPT, Gemini, Perplexity, Claude and Google AI Overviews, each with web search on.

The adapters apply the rules in this post: Gemini redirects are read from the Location header (never fetched), Perplexity answers without markers are kept but flagged as consulted rather than cited, and Claude is pinned to the citation-returning search tool. Each cited domain is then typed as your own site, a competitor's, a review site, forum, news, reference, directory or other. That typing powers the own-site citation rate, the sources each engine relies on and the citation gaps against competitors, all on the AI visibility tracker.

Edge Cases Worth a Test Each

  • Empty answers with citations. A refusal can still list sources. Count citations only on successful answers.
  • Homepage-only citations. 38 of ChatGPT's citations were bare homepages. That's real, not a parsing loss, but check that a Gemini title fallback isn't inflating that count.
  • Duplicate annotations. Annotations are per text span, so one URL can appear more than once in a single answer. Deduplicate per answer before counting.
  • Non-HTTP URLs. Drop anything that isn't http or https before it reaches your database.
  • Engines that answer without searching. 18 Claude answers had no citations. Record "no citations" as data, not a failure.
  • Index drift in streams. Gemini's chunk indices span the whole stream. Resolve them after the last chunk arrives.

Frequently Asked Questions

How do I get citations from the OpenAI API?

Use the Responses API with the web search tool, then read the annotations on each output_text part of the message. Citations have type: "url_citation" with a URL, a title and the character range they support. In our data ChatGPT cited a median of 2 sources per answer, and 181 of 214 URLs carried utm_source=openai, so strip tracking parameters before counting.

Why do Gemini citations show vertexaisearch.cloud.google.com?

Because Gemini's grounding chunks return a Google redirect URI, not the cited page, and the chunk title is only the source domain. Read the redirect's Location header with redirects disabled to get the real URL without loading the page. Also drop chunks that no groundingSupports entry references, since those weren't used in the answer.

Does Perplexity's API say which sources it used?

Not always. In 96 of our 100 Perplexity answers there were no annotations and no [n] markers, only the list of search results. When markers exist, map them to result ids. When they don't, you can only say which sources were consulted, so flag those citations instead of treating them as confirmed.

How should I deduplicate AI citation URLs?

Lower-case the host, drop www., strip tracking parameters such as utm_*, remove fragments including #:~:text=, then deduplicate per answer. Keep real query parameters and path case. On our data this raised the number of URLs found to be cited by two or more engines from 122 to 145.

Can I parse citations from the ChatGPT app instead of the API?

Not reliably. The consumer app has no stable, documented response format, and scraping it breaks without notice. The APIs return documented structures with web search turned on, which is why trackers use them. Answers may differ from a signed-in app session with memory or location, so note the method in any report.

Get the Citations Right Before the Metrics

Every AI citation metric (own-site rate, source gaps, overlap between engines) inherits the parser's mistakes. Resolve Gemini's redirects, flag Perplexity's unmarked sources, and normalise URLs before you count anything. Then the numbers mean what they say, which is the standard BugViso's AI visibility tracking reports against.

Found this useful? Share it.

See where your site stands

Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.