# AI Search Citation & Brand Mention Tracker (`plusonets/ai-search-citation-tracker`) Actor

Collect reproducible AI-search answers and citations for a fixed prompt set. Bring your own OpenAI, Perplexity, Gemini, or Anthropic key. Reports mention and citation evidence, share of voice, and change versus the previous run.

- **URL**: https://apify.com/plusonets/ai-search-citation-tracker.md
- **Developed by:** [Olalekan Johnson](https://apify.com/plusonets) (community)
- **Categories:** AI, SEO tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $20.00 / 1,000 observations

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## AI Search Citation & Brand Mention Tracker

Collect the same prompts, on the engines you choose, and keep the evidence. Each row records the prompt version, the model that actually answered, the market you requested, the answer, the cited URLs, and whether your brand or a competitor was mentioned or cited.

This is for SEO and content agencies who already report AI-search visibility and need a repeatable sample they can drop into their own dashboard. It is not a rank tracker. **API answers can differ from logged-in consumer apps. Counts describe the sample you collected. This Actor does not produce a ranking.**

Unofficial, and not affiliated with OpenAI, Perplexity, Google, or Anthropic. You bring your own API keys and pay those providers yourself.

Contact: plusonecares@gmail.com

### What you put in

A run is one fixed prompt set, one brand, optional competitors, one or more markets, and a repetition count.

| Input | Role |
| --- | --- |
| Brand, aliases, domains | Text match for a mention. A domain match on a citation counts as cited. |
| Competitors | Same rules. Used for share of voice. |
| Prompts | The exact wording. The set is hashed. Edit a prompt and later runs are a new version, not a quiet change in score. |
| Prompts CSV or https file | Used only when the prompts list is empty. Private and local URLs are rejected. |
| Markets | Country, city, region, timezone, language, and optional coordinates. |
| Repetitions | 1 to 5. Repeats are kept on purpose. One answer is not a share. |
| Engines | OpenAI, Perplexity, Gemini, Anthropic. A live engine runs only when its key is present. |
| Monitor id | Leave blank to derive one, or set one so a schedule always compares against the same history. |

With no API keys, the default input runs **demo mode**. It replays a bundled sample for the fictional brand Northstar Packs and the fictional competitors Trailbird and Kestrel Outdoor. Every row has `demo: true`. Demo mode does not charge the `observation` event.

Set `demoMode` to `off` when a missing key should fail the run instead of replaying the sample. `on` always replays the sample, even if keys are saved on the task.

### What you get

The dataset is one flat row per prompt, engine, market, and repetition. The key-value record `OUTPUT` is the run summary: valid observations over attempts, share of voice, top cited domains, and the change versus the previous baseline.

A row is a **valid observation** when the engine returned a usable answer. These are reported separately and are not valid:

- **Failed collection** — timeout, auth error, rate limit, safety block, or an unsupported model/tool.
- **No answer** — the call succeeded and the answer was empty.

A valid answer can still be:

- **Answer without citations** — text came back, no cited URL.
- **Answer without a brand mention** — text and possibly citations, brand not in the text.
- Both at once (`answer_without_brand_mention_and_citations`).

Those flags can overlap. The `outcome` field is one label so the outcome counts add up to the number of attempts. `runValidLabel` on every row is the run denominator, such as `6/8`. Do not recompute a rate from a single exported row.

Share of voice is brand-mention observations divided by brand-mention observations plus competitor-mention observations, among valid answers only. If nobody you track was mentioned, share of voice is empty rather than 0%. Mention detection is a case-insensitive word match. It does not understand negation: “does not mention Northstar” still counts as a mention.

#### Sample observation

The default demo run returns 8 rows. This one is the first OpenAI replay. Domains ending in `.example` are fictional.

```json
{
  "demo": true,
  "engine": "openai",
  "model": "gpt-4.1-2025-04-14",
  "modelRequested": "gpt-4.1",
  "collectionMethod": "bundled_sample",
  "tool": "web_search",
  "promptSetVersion": "bc8b74db0c78",
  "promptText": "Which weekend hiking backpack brands are most often recommended?",
  "requestedLocation": "San Francisco, California, US",
  "actualLocation": null,
  "locationApplied": false,
  "outcome": "brand_mentioned_and_cited",
  "coverageStatus": "complete",
  "valid": true,
  "brandMentioned": true,
  "brandCited": true,
  "brandDomainsMatched": "northstarpacks.example",
  "citationCount": 2,
  "citedDomains": "fieldnotes.example; northstarpacks.example",
  "competitorsMentioned": "Trailbird",
  "charged": false,
  "previousOutcome": "answer_without_brand_mention",
  "mentionChanged": true,
  "runValidLabel": "6/8",
  "runBrandMentionRate": 0.6667,
  "runShareOfVoice": 0.4444
}
```

#### Sample summary

`6/8` means six usable answers out of eight attempts. One attempt had no answer. One collection failed. The headline mention rate is 4 of 6 valid answers. The paired comparison against the previous sample is a separate number, because a failed recollection is not treated as the brand disappearing.

```json
{
  "validLabel": "6/8",
  "coverageStatus": "partial",
  "brandMentionRate": 0.6667,
  "shareOfVoice": 0.4444,
  "shareOfVoiceCounts": {"brand": 4, "competitors": 5, "validObservations": 6},
  "chargedUnits": 0,
  "materialChange": true,
  "baselineUpdated": true,
  "domainsRemoved": ["oldreview.example"],
  "pairedBrandMentionRate": {"previous": 0.8, "current": 0.6, "delta": -0.2}
}
```

Coverage for the run is `complete`, `partial`, `empty`, `blocked`, or `unsupported`. A partial run can still contain valid answers. `confidence` describes whether the citation list parsed cleanly. It is not a ranking and it is not a probability that a consumer saw the brand.

### Engines

| Engine | Call | Location |
| --- | --- | --- |
| OpenAI | Responses API with the web search tool | Country, city, region, and timezone are sent when you supply them. If you omit them, OpenAI may default the search to the US. The response does not confirm the location used. |
| Perplexity | Sonar (`POST /v1/sonar`) | Country, city, and region. Coordinates are sent only together with a country. |
| Gemini | `generateContent` with Google Search grounding | Coordinates only. A country without latitude and longitude is requested but not applied. Grounding URLs are often redirect wrappers. A bare domain in the title is kept as the destination. Turn on **Resolve public citation redirects** to follow public https hops. |
| Anthropic | Messages API with the web search tool, called directly | Country, city, region, and timezone when you supply them. |

`actualLocation` stays empty unless a provider explicitly reports the location it used. These APIs do not. The requested market is never copied into that field.

The model id stored on the row is the id the provider returned when it returned one, plus the id you requested. A snapshot model such as `gpt-4.1-2025-04-14` is evidence. It is not a promise that a later call uses the same snapshot.

Retrieved search results are not the same thing as citations. Perplexity’s `citations` array and Anthropic’s citation objects count as cited. Extra search hits that the model did not cite are counted in `retrievedCount` only.

### Change versus the previous run

Successful runs save a baseline in the named key-value store `ai-search-citation-tracker-state`. Demo history and live history are separate, so a demo run cannot overwrite a live baseline.

- The same monitor, prompt-set hash, engine, market, and repetition is one slot.
- Mention-rate and share-of-voice deltas use only slots that had a valid answer in both runs.
- A failed recollection does not count as the brand disappearing, and it does not erase the last valid answer for that slot.
- A run that crashes, collects zero valid answers, stops because the charge limit was reached, or is clipped by `maxObservations` does not replace the baseline.
- A prompt-set edit is reported as not comparable.

`alertOnlyOnChange` writes only the changed slots to the dataset. The summary still covers every attempt. Live calls are still charged, because the engine was queried. A slot that keeps failing stays in the change list until a later run collects it again.

### Pricing

This Actor uses pay-per-event pricing. You pay only for these events:

| Event | When it is charged | Price |
| --- | --- | --- |
| `observation` | One completed prompt-engine observation: a usable answer, an answer with no citations, an answer that omits the brand, or a completed empty answer | $0.02 |
| `apify-actor-start` | Once per run | $0.005 |

Not charged: failed collection, auth and rate-limit blocks, unsupported model or tool, demo replays, and a retry of a slot already charged in the same run. There is no separate per-result charge.

Provider token and search fees are billed by OpenAI, Perplexity, Google, or Anthropic to the key you supply. They are not included in the prices above. `providerReportedCostUsd` is copied onto the row only when that provider returns a cost. It is not an Actor charge.

The platform also bills its own compute. This Actor asks for 512 MB.

#### Worked examples

These use the $0.02 observation price. Add one $0.005 start event per run. They are not a forecast of how many answers will mention a brand.

| Run | Attempts | Completed | Observation charge |
| --- | --- | --- | --- |
| Trial: 5 prompts, 1 engine, 1 market, 1 repetition, 2 calls fail | 5 | 3 | 3 × $0.02 = $0.06 |
| Weekly agency export: 20 prompts, 2 engines, 1 market, 2 repetitions, 75 of 80 complete | 80 | 75 | 75 × $0.02 = $1.50 per run, about $6.00 across four weekly runs |
| Daily watch: 40 prompts, 3 engines, 1 market, 1 repetition, all complete | 120 | 120 | 120 × $0.02 = $2.40 per run, $72.00 across 30 daily runs |

Set the platform’s maximum total charge and this Actor’s `maxObservations` before a large schedule. A run that hits the charge limit stops and leaves the previous baseline in place. The grid is prompts × markets × engines × repetitions. The defaults are 2 × 1 × 4 × 1 = 8 demo observations.

### Limits

- 40 prompts, 8 markets, 5 repetitions, and 500 observations per run. `maxObservations` defaults to 200. Crossing it clips the grid and does not update the baseline.
- No login to ChatGPT, Perplexity, Gemini, or Claude. The result is the API response for the key and tool you configured.
- No claim that a brand is “ranked”, recommended more often than the sample shows, or visible in a consumer app in a city.
- Gemini without coordinates does not receive a location. OpenAI without a location may search as the United States.
- Redirect resolution follows at most three public https hops and at most five wrapper URLs per answer. Private IPs, localhost, link-local addresses, and credentialed URLs are rejected.
- Answer text is clipped at 12,000 characters. The hash is of the full text.
- Mention matching does not understand negation, translation, or logos.
- A citation of a brand domain counts only when you supplied that domain, or when the alias appears as its own word in the title or URL.
- Demo mode replays only the bundled Northstar Packs prompt set. Other prompts in demo mode are returned as unsupported and are not charged.

### Data and provider terms

Live answers and citations are collected with your key under that provider’s API terms. They are stored in your Apify dataset and the baseline store for this Actor. This project does not ship a scrape of those products.

The demo dataset is fictional and written for this Actor. `.example` domains are not real sites. Do not treat the demo mention rate as a measurement of any company.

If you display an answer to an end user, keep the cited URLs visible. OpenAI, Google Search grounding, and Anthropic each have their own citation and search-suggestion display rules. This Actor does not store Gemini’s search-suggestion HTML.

### Integrations

The rows are flat so a spreadsheet or a webhook can use them without unpacking nested documents. `citedDomains`, `citedUrls`, and `competitorsMentioned` are semicolon-separated.

- **Schedule.** Save a task with a stable `monitorId`, the same prompts, and the keys as secrets. Run it weekly or daily. Read `OUTPUT.change.materialChange` before paging someone.
- **Webhook.** Put an https catch hook in `webhookUrl`, or use an Apify webhook on run succeeded. The body is the summary, including `validLabel`, `shareOfVoice`, `topCitedDomains`, and `change`. A rejected or failed hook does not fail the run.
- **n8n or Make.** Trigger on the Apify run webhook. Branch on `demo` so a sample run cannot enter a client report. Map `brandMentioned`, `citedDomains`, `outcome`, and `runValidLabel`.
- **Google Sheets.** Use the Apify integration to import the default dataset. The columns above are the ones to keep. Filter `valid` is true before you chart a rate, and use `runValidLabel` as the denominator rather than the number of rows in the sheet. In alert-only mode the sheet receives changed slots only, while `runValidLabel` still describes the full run.

### Run it locally

Python 3.12.

```bash
pip install -r requirements.txt
python -m src
```

With no input, the Actor writes the demo dataset under `storage/datasets/default` and the summary at `storage/key_value_stores/default/OUTPUT`. Put a live input at `storage/key_value_stores/default/INPUT.json` or an `INPUT.json` file in the working directory. Tests:

```bash
pip install pytest
pytest
```

### FAQ

**Why doesn’t this match the answer I just saw in the consumer app?**
The consumer app can use a different model, memory, location, and interface. This Actor stores the API call you configured. It does not claim to reproduce a logged-in session.

**Is 4/6 a ranking?**
No. It means the brand was mentioned in 4 of 6 valid answers in that run. Failed calls are not in the denominator. One answer is not a share of voice.

**What if an engine times out?**
That slot is `failed_collection` and is not charged. The last valid answer for that slot stays in the baseline. The summary shows the gap. A run where every slot fails does not replace the baseline.

**Will a bad run wipe last week?**
No. The baseline moves only after a finished run with at least one valid answer, and not when the charge limit or the observation cap stopped the grid early.

**Do you store my API key?**
The key is a secret input for that run. It is sent only to the provider you selected. It is not written to the dataset. Error text is redacted.

**Which engines run if I add one key?**
Only the engines that have a key, intersected with the engines you selected. The others are listed as skipped. They are not filled with demo answers in the same run as live answers.

# Actor input Schema

## `brand` (type: `string`):

Brand name to look for in the answer text.

## `brandAliases` (type: `array`):

Extra names. Matching is case-insensitive and does not fire inside a longer word.

## `brandDomains` (type: `array`):

Registrable domains. A citation of the brand site counts even when the name is not a separate word in the URL.

## `competitors` (type: `array`):

Objects with name, aliases, and domains. Used for share of voice. An empty list tracks only the brand.

## `prompts` (type: `array`):

Fixed prompts. The set is hashed. Change the wording and this run is not compared as the same sample.

## `promptsCsv` (type: `string`):

Optional CSV text used only when the prompts list is empty. The first column is the prompt. A header named prompt is skipped.

## `promptFileUrl` (type: `string`):

Optional https URL used only when the prompts list and CSV are empty. JSON list or CSV. Private hosts are rejected.

## `promptSetId` (type: `string`):

A stable name for this prompt set. The hash, not this label, decides whether two runs are the same version.

## `markets` (type: `array`):

Requested markets. Fields: code, country (ISO-2), region, city, timezone, language, latitude, longitude. Gemini sends a location only when both coordinates are set. The answer does not report the location actually used.

## `repetitions` (type: `integer`):

How many times to collect each prompt on each engine and market. Repeats are kept. Answers vary.

## `engines` (type: `array`):

Engines to collect. In auto demo mode an engine runs live only when its key is present. With no keys, every selected engine replays the bundled sample.

## `demoMode` (type: `string`):

auto uses the bundled sample when no selected engine has a key. on always replays the sample. off requires a key.

## `openaiApiKey` (type: `string`):

Optional. Responses API with the web search tool. Unofficial, not affiliated with OpenAI. You pay OpenAI separately.

## `openaiModel` (type: `string`):

Model sent to the Responses API. The model id returned by the API is stored on the observation.

## `openaiWebSearchTool` (type: `string`):

Tool type sent to the Responses API. Stored on each OpenAI observation.

## `perplexityApiKey` (type: `string`):

Optional. Sonar API. Unofficial, not affiliated with Perplexity. You pay Perplexity separately.

## `perplexityModel` (type: `string`):

Sonar model id, for example sonar or sonar-pro.

## `geminiApiKey` (type: `string`):

Optional. generateContent with Google Search grounding. Unofficial, not affiliated with Google. You pay Google separately.

## `geminiModel` (type: `string`):

Model id that supports Google Search grounding, for example gemini-2.5-flash.

## `anthropicApiKey` (type: `string`):

Optional. Messages API with the web search tool. Unofficial, not affiliated with Anthropic. You pay Anthropic separately.

## `anthropicModel` (type: `string`):

Model id sent to the Messages API, for example claude-sonnet-4-5.

## `anthropicWebSearchTool` (type: `string`):

Server tool version sent to the Messages API, for example web_search_20250305. Direct calling is requested so the run does not depend on code execution.

## `monitorId` (type: `string`):

Stable id for scheduled runs. Leave empty to derive one from the brand, prompt version, engines, and markets. Demo history is stored separately from live history.

## `alertOnlyOnChange` (type: `boolean`):

When on, the dataset receives changed slots only. The run summary still covers every slot. Completed live observations are still charged.

## `resolveCitations` (type: `boolean`):

Follow public https redirects for grounding wrapper URLs. Private and local addresses are rejected. Ignored in demo mode.

## `maxObservations` (type: `integer`):

Cap on prompt × market × engine × repetition. A run clipped by this cap does not replace the saved baseline.

## `maxOutputTokens` (type: `integer`):

Token cap sent to each provider so one answer cannot run away. This is your provider bill, not the Actor charge.

## `requestDelaySecs` (type: `integer`):

Pause between live provider calls. Demo mode does not wait.

## `webhookUrl` (type: `string`):

Optional https URL that receives the run summary JSON. Private hosts are rejected. A webhook failure does not fail the run.

## Actor input object example

```json
{
  "brand": "Northstar Packs",
  "brandAliases": [
    "Northstar"
  ],
  "brandDomains": [
    "northstarpacks.example"
  ],
  "competitors": [
    {
      "name": "Trailbird",
      "aliases": [
        "Trailbird Gear"
      ],
      "domains": [
        "trailbird.example"
      ]
    },
    {
      "name": "Kestrel Outdoor",
      "aliases": [
        "Kestrel"
      ],
      "domains": [
        "kestreloutdoor.example"
      ]
    }
  ],
  "prompts": [
    "Which weekend hiking backpack brands are most often recommended?",
    "What rain jacket brands hold up on multi-day trails?"
  ],
  "promptSetId": "northstar-weekend-gear",
  "markets": [
    {
      "code": "US",
      "country": "US",
      "region": "California",
      "city": "San Francisco",
      "timezone": "America/Los_Angeles",
      "language": "en"
    }
  ],
  "repetitions": 1,
  "engines": [
    "openai",
    "perplexity",
    "gemini",
    "anthropic"
  ],
  "demoMode": "auto",
  "openaiModel": "gpt-4.1",
  "openaiWebSearchTool": "web_search",
  "perplexityModel": "sonar",
  "geminiModel": "gemini-2.5-flash",
  "anthropicModel": "claude-sonnet-4-5",
  "anthropicWebSearchTool": "web_search_20250305",
  "alertOnlyOnChange": false,
  "resolveCitations": false,
  "maxObservations": 200,
  "maxOutputTokens": 1200
}
```

# Actor output Schema

## `observations` (type: `string`):

Flat observation records, including answers that omit the brand or omit citations.

## `summary` (type: `string`):

Valid-observation counts, share of voice, top cited domains, and the change versus the previous successful baseline.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("plusonets/ai-search-citation-tracker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("plusonets/ai-search-citation-tracker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call plusonets/ai-search-citation-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,plusonets/ai-search-citation-tracker"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/dgMihgz8gfaP4nXL7/builds/Qmafqf9vOsgBGwUo3/openapi.json
