Tutorial7 min read2026-10-02

Find competitor mentions in AI answers

Finding competitor mentions in AI answers means parsing answerText — the prose the engine writes — for each competitor's name, not just checking who got cited. A citation lives in sources[]; a mention lives in the sentence "For most teams, Acme and Globex lead the field." Those are different signals, and the mention is the one that reads to a buyer as a recommendation. To capture it you POST your query to https://api.agentgeo.org/v1/fetches, read answerText from each surface, and run a word-boundary match over an explicit alias list for every brand you track. The naive version — "globex" in text — is where this goes wrong. Substrings fire on unrelated words, product names hide behind the company name, and two competitors that share a token collide. This guide covers the request, a matcher that survives those traps, how to tally share of mentions against your own brand, and the honest limits of string matching over generated prose. It pairs with the citation-focused cross-engine tracker; this page is about the text.

Tutorial

Read this page with an AI

A competitor mention is a name in answerText — the model wrote "Globex" into the answer it gave a buyer. That's separate from a citation, which is a domain in sources[]. Both matter, but they answer different questions: a citation tells you the engine read a page; a mention tells you the engine recommended a name. This page is the mention half — extracting names from generated prose reliably, across every engine, without inventing hits.

Why answerText, not sources

Two reasons the prose is where competitor tracking really lives:

  • Recommendations happen in sentences. When ChatGPT tells someone "the tools most teams pick are Acme and Globex," that ranking is in the text, not in a citation list. A buyer never sees sources[]. If you only track citations, you miss the exact place a competitor gets endorsed.
  • Some engines barely cite. ChatGPT returns an empty sources[] whenever it didn't browse — and it often doesn't. On those answers the mention is the only competitor signal available. Parse answerText or you're blind to the surface where naming matters most.

answerText is always present in a delivered record, on every surface. That's the guarantee the matcher relies on: the field you're parsing is never missing, even when sources[] is empty.

Fetch the answer text

Send the query with whichever surfaces you track — one, several, or all six in a single call. The response carries an answers[] entry per surface, each with its own answerText. Here it's ChatGPT and Perplexity together, so you see both a sparse-citation and a dense-citation engine in one envelope.

Fetch answer text from two engines
curl -X POST https://api.agentgeo.org/v1/fetches \
  -H "Authorization: Bearer ag_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "best help desk software",
    "surfaces": ["chatgpt", "perplexity"]
  }'
Response, trimmed — the mentions live in answerText
{
  "id": "run_9a1e4c72d5b8",
  "query": "best help desk software",
  "surfaces": ["chatgpt", "perplexity"],
  "status": "completed",
  "recordsDelivered": 2,
  "creditsCharged": 2,
  "answers": [
    {
      "surfaceKey": "chatgpt",
      "answerText": "For growing teams the usual picks are Acme, praised for its automation, and Globex, which is strong on ticketing. Initech is a lighter option ...",
      "sources": [],
      "fetchedAt": "2026-10-02T10:02:11Z"
    },
    {
      "surfaceKey": "perplexity",
      "answerText": "The most-recommended help desks are Globex and Acme, with Umbrella cited for enterprise support ...",
      "sources": [
        { "title": "Help desk software compared", "url": "https://example.com/help-desk", "position": 1 }
      ],
      "fetchedAt": "2026-10-02T10:02:18Z"
    }
  ]
}

ChatGPT named three competitors with an empty sources[]. Perplexity named three and cited a page. If you tracked citations only, ChatGPT's answer would read as "no competitors" — plainly false. The names are right there in the prose; the job is pulling them out without also pulling out noise.

Match names without inventing them

The matcher is a word-boundary regex built from an explicit alias list per competitor, run over the lowercased answerText. Word boundaries stop substrings from firing; the alias list catches the product names and spellings a company actually goes by. Keep each competitor as a canonical name plus every alias you've seen it called.

competitor_mentions.py
#!/usr/bin/env python3
"""Parse answerText across engines for competitor mentions; tally share."""
import csv
import re
from collections import Counter
from pathlib import Path

import requests

API = "https://api.agentgeo.org/v1/fetches"
KEY = "ag_live_your_key_here"
OUT = Path("competitor_mentions.csv")
SURFACES = ["chatgpt", "perplexity", "gemini", "copilot"]
FIELDS = ["query", "surface", "brand", "mentioned", "fetchedAt"]

# Canonical name -> aliases. Include product names and spelling variants.
# "You" is your own brand, tracked alongside the competitors for share.
BRANDS = {
    "You":      ["Acme", "Acme Desk", "AcmeHQ"],
    "Globex":   ["Globex", "Globex Support", "GlobexHD"],
    "Initech":  ["Initech", "Initech Helpdesk"],
    "Umbrella": ["Umbrella", "Umbrella Cloud"],
}

QUERIES = [
    "best help desk software",
    "help desk tools for small teams",
    "zendesk alternatives",
    "customer support ticketing software",
]

# One compiled word-boundary regex per brand, over its alias list.
MATCHERS = {
    brand: re.compile(
        r"\b(?:" + "|".join(re.escape(a) for a in aliases) + r")\b", re.I
    )
    for brand, aliases in BRANDS.items()
}


def fetch(query):
    resp = requests.post(
        API,
        json={"query": query, "surfaces": SURFACES},
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=200,  # the API holds slow scrapes up to 180s
    )
    resp.raise_for_status()
    return resp.json()


rows = []
share = Counter()          # total mentions per brand, all engines
by_surface = {}            # {surface: Counter(brand -> mentions)}

for query in QUERIES:
    run = fetch(query)
    for answer in run["answers"]:
        if answer.get("status") == "failed":
            print(f"failed record, skipping: {answer['surfaceKey']} / {query}")
            continue
        surface = answer["surfaceKey"]
        text = answer.get("answerText") or ""
        counter = by_surface.setdefault(surface, Counter())
        for brand, matcher in MATCHERS.items():
            hit = bool(matcher.search(text))
            if hit:
                share[brand] += 1
                counter[brand] += 1
            rows.append({
                "query": query,
                "surface": surface,
                "brand": brand,
                "mentioned": hit,
                "fetchedAt": answer.get("fetchedAt", ""),
            })

new_file = not OUT.exists()
with OUT.open("a", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=FIELDS)
    if new_file:
        writer.writeheader()
    writer.writerows(rows)

total = sum(share.values()) or 1
print(f"appended {len(rows)} rows to {OUT}\n")
print("share of mentions (all engines)")
for brand, n in share.most_common():
    print(f"  {brand:<10} {n:>3} mentions  ({100 * n / total:4.1f}% of total)")

The output is a share-of-mentions table — how often each brand, yours included, got named across your query set and engines — plus a CSV row per brand per surface per query, stamped with fetchedAt. Because your own brand sits in the same BRANDS dict, share of voice falls out for free: your mentions over the total. And because every row is timestamped, appending across runs lets you diff share month over month instead of trusting one snapshot.

The script runs as-is once KEY is real — dry-run it first with a zero-credit ag_test_ key. Not ready for code? Get a free AI-visibility audit → — no card, no account.

Get my free audit

The four ways mention matching lies

String matching over generated prose is genuinely useful and genuinely imperfect. Every failure below is fixable, but none of them fixes itself — and no matcher is ever perfect, so treat the output as evidence, not truth.

  • Substring false positives. "notion" in text fires on "notionally"; a bare "acme" fires inside "acmestudio". Word boundaries (\bacme\b) kill most of these. What they can't kill: a name that is also a common word — "Monday," "Notion," "Craft." For those, keep a stop-context list or read the surrounding sentence before you count.
  • Aliases and product names. A company gets named by its product as often as its company name — "Acme Desk" in the answer, "Acme" in your list, zero matches. Enumerate the aliases explicitly, the way the BRANDS dict does, and add new ones the first time you see them in an answer you're reading anyway.
  • Competitor name collisions. When two brands share a token — "Acme" and "Acme Analytics," unrelated companies — a match on one over-counts the other. Match the longer alias first, or disambiguate by the sentence context. Accept that overlapping names need a manual pass; a regex can't read intent.
  • Mentioned negatively still counts as mentioned. "Unlike Globex, which struggles with scale, Acme ..." names Globex — the matcher counts it, correctly, as a mention. But it's a hostile one. Presence in the text is not endorsement. If sentiment matters, that's a separate read on the sentence, not something the mention count captures.

Never claim perfect precision. A word-boundary matcher over a good alias list will catch the large majority of real mentions and reject most noise — but "large majority" is not "all," and a share-of-mentions number is only as honest as your alias list and your handling of collisions. Report it as an estimate from a defined query set, spot-check a sample of answers by hand each run, and let a human overrule the regex when a name is ambiguous.

From one run to a trend

One run is a sample. Generated answers vary between identical calls, and each engine browses differently, so a competitor's mention count wobbles run to run with nobody's content changing. The signal you want is movement that holds:

  • Append, never overwrite. Each run adds rows with fresh fetchedAt values; the file becomes a history. Share of mentions this week means little; share of mentions trending up over six weeks means a competitor is gaining ground in the answers your buyers read.
  • Freeze the query set. "Best help desk software" and "top support tools for small teams" return different names. Fix a query set that mirrors how buyers actually phrase it, and change it deliberately — editing a query invalidates the comparison to last month.
  • Require several runs before you call it. A name that enters the mention set once might vanish next fetch. Wait for it to hold across multiple runs — or two consecutive days — before you report a competitor as newly prominent.

Summary

Competitor mentions live in answerText, not sources[] — parse the prose for names with a word-boundary regex over an explicit alias list, one per competitor, and include your own brand so share of voice falls out of the same tally. Watch the four failure modes: substrings, aliases, collisions, and hostile mentions. Append every run with its fetchedAt and require movement across several fetches before calling a trend. For the citation side of the same picture — which engines cited you versus named you — see the cross-engine brand tracker; to turn mention counts into a share-of-voice metric, see measuring share of voice in ChatGPT.

FAQ

Direct answers to the questions this page raises.

POST your query to https://api.agentgeo.org/v1/fetches, read answerText from each surface in the answers[] array, and run a word-boundary regex for every competitor's name and aliases over the lowercased text. answerText is always present, even on ChatGPT answers where sources[] is empty, so the prose is where you catch a name the engine cited nowhere.

Because a recommendation happens in the sentence, not the citation list — a buyer reads "Acme and Globex lead the field," never sources[]. And some engines barely cite: ChatGPT returns an empty sources[] whenever it didn't browse, so on those answers the mention in answerText is the only competitor signal there is. Track mentions and citations as two separate columns.

Match on a word boundary (\bglobex\b) rather than a raw substring, so a name doesn't fire inside an unrelated word, and keep an explicit alias list per brand to catch product names and spelling variants. For names that are also common words, or for two brands that share a token, add a stop-context check or a manual disambiguation pass — no regex reads intent, so never claim perfect precision.

Yes — put your own brand in the same alias dictionary as the competitors and tally all of them the same way. Your mentions over the total across your query set is a share-of-mentions estimate. Append every run with its fetchedAt timestamp and compare across weeks, since a single run varies too much to trust as a measurement.

Not necessarily. Presence in answerText counts as a mention even when the sentence is critical — "unlike Globex, which struggles with scale" still names Globex. A mention is that the engine named the brand, not that it endorsed it. If you need sentiment, read the surrounding sentence separately; the mention count alone doesn't carry it.

Keep reading

Where this page leads next.

Run these checks on your own brand

Two ways in. Send a URL and a person runs the fetches for you, free — or connect your agent over MCP, on a plan, and run them yourself. Either way you get the raw answers and their citations, never a score.