
Apify Ai Search Visibility Tracker
- 101 installs
- 239 repo stars
- Updated June 29, 2026
- apify/awesome-skills
apify-ai-search-visibility-tracker is a Claude skill that tracks whether a brand and competitors are cited across Google AI Overviews, ChatGPT, Perplexity, Copilot, and Gemini for a set of prompts.
About
A skill that tracks whether a brand and its competitors are cited across AI search engines like Google AI Overviews, ChatGPT Search, Perplexity, Copilot, and Gemini. It runs four workflows: discover which prompts matter, find citation opportunities, audit a site's AI readiness, and track visibility over time. A marketer uses it for generative engine optimization (GEO) and answer engine optimization (AEO).
- Tracks brand and competitor citations across six AI search engines
- Four workflows: discover prompts, find citation opportunities, audit site, track over time
- Uses apify/google-search-scraper plus website-content-crawler for GEO/AEO
Apify Ai Search Visibility Tracker by the numbers
- 101 all-time installs (skills.sh)
- Ranked #1,144 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
apify-ai-search-visibility-tracker capabilities & compatibility
Requires an Apify account and APIFY_TOKEN; google-search-scraper and website-content-crawler runs consume Apify credits.
- Capabilities
- seo audit · ai visibility tracking · competitor analysis
- Use cases
- seo · marketing · research
- Pricing
- Bring your own API key
What apify-ai-search-visibility-tracker says it does
Four workflows covering the full AI visibility lifecycle: **discover** which prompts matter → **find** citation opportunities → **audit** your site → **track** over time.
All workflows use `apify/google-search-scraper` for AI search. Workflow C also uses `apify/website-content-crawler`.
GEO Website Audit
npx skills add https://github.com/apify/awesome-skills --skill apify-ai-search-visibility-trackerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 101 |
|---|---|
| repo stars | ★ 239 |
| Last updated | June 29, 2026 |
| Repository | apify/awesome-skills ↗ |
What it does
Track and improve brand citations across AI search engines through prompt discovery, audits, and monitoring.
Who is it for?
Measuring and improving brand citation in AI search answers
Skip if: Classic blue-link SEO ranking outside AI answer surfaces
When should I use this skill?
The user asks to track AI visibility, monitor AI-search citations, or run GEO/AEO tracking and audits.
What you get
A tracked set of prompts shows which AI engines cite the brand and where citation gaps exist.
- Prompt-major citation tables
- Top-cited domain rankings
- Per-prompt GEO gap cards
By the numbers
- 6 AI search sources
- 4 workflows
Files
AI Search Visibility Tracker
Four workflows covering the full AI visibility lifecycle: discover which prompts matter → find citation opportunities → audit your site → track over time.
All workflows use apify/google-search-scraper for AI search. Workflow C also uses apify/website-content-crawler.
Recommended flow: Run Workflow A to discover prompts → Workflow B to find citation opportunities → Workflow C to audit your site → Workflow D to track everything on a schedule.
---
Workflow A — Competitor Prompt Discovery
Goal: Find which queries surface a competitor in AI search answers, so you know which prompts are worth monitoring.
Inputs to collect
| # | Input | Notes |
|---|---|---|
| 1 | Competitor domain(s) | e.g. brightdata.com, scraperapi.com |
| 2 | Seed topic keywords | e.g. "web scraping", "data extraction API" |
| 3 | AI sources | Default: all six (AI Overviews, AI Mode, ChatGPT, Perplexity, Copilot, Gemini) |
Workflow
1. Generate 15–30 candidate queries from seed keywords using these templates:
best [topic],[topic] tools,how to [topic],[topic] for [use case][topic] vs [competitor brand],[competitor brand] alternative[topic] API,[topic] pricing,[topic] tutorial
2. Run apify/google-search-scraper for each candidate query. For each result, extract:
aiOverview.sources[],aiMode.sources[],chatGptAnswer.sources[],perplexityAnswer.sources[],copilotAnswer.sources[],geminiAnswer.sources[]- Also check
answer_text/aiOverview.textfor competitor brand name mentions (word-boundary match:\bBrand\b)
3. For each (query, source) pair where the competitor domain or brand appears: record a hit.
4. Output a prompt-major table sorted by total hit count descending:
| Query | ChatGPT | Perplexity | AI Overviews | AI Mode | Copilot | Gemini | Total |
|-------|---------|------------|--------------|---------|---------|--------|-------|
| "best web scraping API" | ✓ | ✓ | — | ✓ | — | ✓ | 4 |
| "how to scrape Google" | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 6 |5. Deliver the top-N queries (default 10) as a ready-to-paste list for Workflow D's config.json prompts.
---
Workflow B — Citation Opportunity Finder
Goal: For a target topic, identify which domains and content types AI engines most often cite — revealing where to publish or pitch content.
Inputs to collect
| # | Input | Notes |
|---|---|---|
| 1 | Target topic / industry | e.g. "web scraping", "ecommerce automation" |
| 2 | Seed queries | 5–20 queries covering the topic space |
| 3 | AI sources | Default: all six |
| 4 | Deep-crawl top cited? | Optional: crawl top-3 cited pages with website-content-crawler for structure patterns |
Workflow
1. Run apify/google-search-scraper for each seed query across selected AI sources.
2. Collect every URL from sources[] across all results. Normalise to registrable domain (blog.example.com → example.com).
3. Aggregate:
- By domain: count citations, list which AI sources cite it, list which queries triggered it
- By content type: infer from URL path patterns (docs →
/docs/,/reference/; blog →/blog/; news → known news domains)
4. Rank by total citation count. Output:
Top-cited domains for "web scraping" (42 queries × 6 sources):
| Domain | Citations | AI Sources | Inferred type |
|--------|-----------|------------|--------------|
| docs.apify.com | 38 | ChatGPT, Perplexity, AI Mode | Documentation |
| scraperapi.com/blog | 21 | AI Overviews, Gemini | Long-form blog |5. If deep-crawl enabled: run apify/website-content-crawler on the top-3 cited URLs per domain. From the markdown output, extract:
- First heading that directly answers the query
- Presence of code blocks in first 500 words
- Word count
- Whether an H2/H3 contains the exact query phrase
6. Summarise patterns: "AI engines in this topic prefer [long-form docs / short direct-answer posts]. Typical cited page: [word count range], [has/lacks direct-answer H2], [has/lacks code example above the fold]."
---
Workflow C — GEO Website Audit
Goal: Check whether a specific website's content is structured for AI citation; compare it against what AI engines actually cite for your target prompts.
Inputs to collect
| # | Input | Notes |
|---|---|---|
| 1 | Your website URL | e.g. https://apify.com |
| 2 | Target prompts | Use Workflow A output, or supply 5–10 directly |
| 3 | AI sources | Default: all six |
Workflow
1. Run apify/google-search-scraper for each target prompt. For each (prompt × source) record whether your registrable domain appears in sources[].
2. For prompts where your domain is not cited: identify the top-cited competitor URL for that prompt.
3. Run apify/website-content-crawler on:
- Your most relevant page(s) for each un-cited prompt
- The top-cited competitor page for each un-cited prompt
4. For each un-cited prompt, produce a gap card:
Prompt: "how to scrape Google search results"
Your page: apify.com/blog/scraping-google → NOT cited on ChatGPT, Perplexity, AI Mode
Top-cited: docs.brightdata.com/scraping/google (cited 5/6 sources)
Structural gaps:
✗ Your page: answer buried after 900 words, no direct-answer H2
✓ Competitor: H2 "How to scrape Google in 3 steps" at word 120 + code block at word 180
Recommended actions (priority order):
1. Add H2 that mirrors the query phrase within first 300 words
2. Move code example above the fold
3. Add a "Quick answer" summary box at the top5. Deliver: per-prompt gap cards + a consolidated action table ranked by expected impact.
---
Workflow D — Recurring Visibility Tracker
Goal: Snapshot brand citations and mentions across all six AI surfaces on a recurring schedule and track changes over time.
Prerequisites
(No need to check upfront)
APIFY_TOKENsaved in a.envfile next toconfig.json(the runner auto-loads it).- Python 3.9+ on PATH.
pip3 install requests(only third-party dependency);pip3 install tldextractrecommended for accurate registrable-domain matching on multi-part TLDs. - For automated daily runs: macOS / Linux with launchd or cron available (the installer handles both). Windows users get printed
schtasksinstructions.
Steps
Copy this checklist and track progress:
Task Progress:
- [ ] Step 1: Load or collect the seven required inputs
- [ ] Step 2: Confirm AI sources and cadence
- [ ] Step 3: Write config.json + .env, then install the OS schedule
- [ ] Step 4: Run a snapshot now so the user sees the first report
- [ ] Step 5: Deliver the history report (diff vs. all prior runs)Step 1: Load or Collect the Seven Required Inputs
If config.json exists in the user's working directory, load it and skip to Step 4 unless the user asks to reconfigure. On first run, ask all seven anchors before any Actor call:
| # | Input | Why it matters |
|---|---|---|
| 1 | Brand URL | Primary domain. Drives registrable-domain citation matching (blog.apify.com -> apify.com). |
| 2 | Brand name(s) | Surface forms for text-mention matching (e.g., Apify, apify.com, @apify). URL-only matching misses mentions without links. |
| 3 | Competitor brands | Ask explicitly: "Which competitors do you want tracked alongside your brand?" Accept name + domain pairs. Zero is allowed; the question must still be asked on first run. |
| 4 | Prompts to monitor | One or more search queries. Each runs through every enabled AI source. If you don't know which prompts to use yet, run Workflow A first — it discovers competitor-visible prompts you can paste here. |
| 5 | Cadence | daily / weekly / monthly. Drives the schedule entry that install_cron.sh writes. |
| 6 | Which AI sources | Present the six (AI Overviews, AI Mode, ChatGPT, Perplexity, Copilot, Gemini), all enabled by default. Each adds per-result cost -- current pricing on the Actor page (https://apify.com/apify/google-search-scraper). |
| 7 | Apify Dataset name | The named dataset to append to. If absent, created on first run; the name is recorded in config.json. |
After those seven, ask optional follow-ups: countryCode, languageCode, location (UULE), preferred run hour (default 09:00 local).
Then one verbosity question -- save as config.json:include_full_answers:
- `on_demand` (default): report shows short quoted snippets around each surface-form match. Full LLM answers live in the named KV store; user can ask later.
- `always`: report embeds the full LLM answer verbatim whenever any entity is mentioned. Useful for one prompt; gets unwieldy at 5+ prompts.
Step 2: Confirm AI Sources and Cadence
Echo back the user's seven choices in a single paragraph for confirmation. If the user toggles sources, update the in-memory config before writing.
Step 3: Write config.json + .env, Then Install the OS Schedule
Create the working directory layout next to where the user wants reports to land:
working-dir/
config.json # copied from the skill's config.example.json, edited with collected values
.env # APIFY_TOKEN=apify_api_xxx (chmod 600)cp ${CLAUDE_PLUGIN_ROOT}/reference/scripts/config.example.json ./config.json
# then edit with the collected values, save
echo 'APIFY_TOKEN=your_token_here' > ./.env
chmod 600 ./.envThen install the OS schedule:
bash ${CLAUDE_PLUGIN_ROOT}/reference/scripts/install_cron.sh --cadence daily --hour 9Cron expression mapping:
| Cadence | Cron expression | When |
|---|---|---|
| daily | 0 H * * * | every day at H:00 local |
| weekly | 0 H * * 1 | every Monday at H:00 |
| monthly | 0 H 1 * * | the 1st of every month at H:00 |
Step 4: Run a Snapshot Now
macOS:
launchctl kickstart "gui/$(id -u)/com.apify.ai-visibility-tracker"
tail -f ~/Library/Logs/ai-visibility-tracker.logLinux / generic:
python3 ${CLAUDE_PLUGIN_ROOT}/reference/scripts/run_snapshot.py --config ./config.jsonBoth paths:
1. Call apify/google-search-scraper with the configured prompts + AI-source toggles. 2. Parse each (prompt x source) cell for citations and brand/competitor mentions. 3. Append rows to the named Apify Dataset (schema in reference/output-schema.md). 4. Write the raw item to the named KV store (long-term archive). 5. Compute the history vs. all prior runs. 6. Write reports/snapshot-<ISO-date>.md next to config.json.
Step 5: Deliver the History Report
Open reports/snapshot-<ISO-date>.md and surface the top findings in chat. Lead with:
- First-ever citations / mentions today -- entity x source combinations crossing the threshold for the first time.
- Drops -- entity was cited in the latest prior run but isn't today.
- For every cited entity, the exact matched URL(s).
Output format
Snapshot summaries are entity-major: one Markdown table per tracked entity (brand, then competitors), one row per AI source, columns Source | Cited | Mentioned | SoV% | Matched URLs | History.
---
Actors
| Actor | Used in | Key input fields |
|---|---|---|
apify/google-search-scraper | A, B, C, D | queries[], aiOverview, chatGptSearch, perplexitySearch, googleAiMode, bingCopilotSearch, googleGeminiSearch |
apify/website-content-crawler | B (optional), C | startUrls[], maxCrawlPages, outputMarkdown: true |
Pricing changes; check the pricing tab before quoting numbers. Disable unused AI sources to reduce cost.
Telemetry. Workflow D runs through reference/scripts/run_snapshot.py, which already sets User-Agent: apify-awesome-skills/ai-search-visibility-tracker-... on every Apify API call. When calling these Actors ad-hoc in Workflows A-C, pass the matching flags so usage is attributed: --user-agent apify-awesome-skills/apify-ai-search-visibility-tracker and --json (use --format json for apify datasets get-items), and append 2>/dev/null. Example:
apify actors call apify/google-search-scraper \
--user-agent apify-awesome-skills/apify-ai-search-visibility-tracker \
--json 2>/dev/nullQuality Rules
- Non-interactive. No stdin reads in
run_snapshot.py-- launchd / cron has no stdin. - Word-boundary brand matching (
\bbrand\b, case-insensitive). Seereference/citation-matching.md. - Registrable-domain citation matching (
blog.apify.comcounts asapify.com). Seereference/citation-matching.md. - Never skip a row. If an AI source returns nothing, write a row with
cited: false, mentioned: false, answer_text: "[no answer returned]". - Every row carries the Apify run ID so any finding can be reverified.
Error Handling
APIFY_TOKEN not found -- Tell the user to put it in .env next to config.json (echo 'APIFY_TOKEN=...' > .env && chmod 600 .env). Token at https://console.apify.com/account/integrations. config.json not found -- Run Step 3 first to create it from the template. Dataset name not set -- Ask the user for a name; the runner will create the dataset on first append. Actor run FAILED -- Print the console link from the runner output and ask the user to inspect it. AI source returned no answer -- The row is still written with [no answer returned]. Not an error. website-content-crawler returns no markdown -- Page may be JS-heavy; try with useBrowserCrawler: true. Schedule not firing -- See reference/scheduling.md troubleshooting section. No previous run to diff against -- First run only. The report renders the snapshot without a history section.
[
{
"run_timestamp": "2026-05-20T07:04:24Z",
"run_id": "a1B2c3D4e5F6g7H8",
"prompt": "How to get data from ExampleSite.com",
"source": "ai_mode",
"entity": "brand",
"entity_name": "Acme",
"entity_domain": "acme.com",
"cited": false,
"mentioned": true,
"matched_citation_urls": [],
"citation_urls": [
"https://developers.examplesite.com/demand",
"https://www.northwind.com/blog/example-site-data/",
"https://contoso.com/blog/how-to-pull-examplesite"
],
"citations": [
{"url": "https://www.northwind.com/blog/example-site-data/", "title": "Pulling Data from ExampleSite.com - Northwind", "description": "..."}
],
"answer_text": "You can get data from ExampleSite.com through three main methods... Acme or Tailspin: Cloud platforms that run pre-configured 'actors'... Northwind or Contoso: handle JavaScript rendering...",
"share_of_voice_pct": 0.0
},
{
"run_timestamp": "2026-05-20T07:04:24Z",
"run_id": "a1B2c3D4e5F6g7H8",
"prompt": "How to get data from ExampleSite.com",
"source": "ai_mode",
"entity": "competitor",
"entity_name": "Northwind",
"entity_domain": "northwind.com",
"cited": true,
"mentioned": true,
"matched_citation_urls": ["https://www.northwind.com/blog/example-site-data/"],
"citation_urls": [
"https://developers.examplesite.com/demand",
"https://www.northwind.com/blog/example-site-data/",
"https://contoso.com/blog/how-to-pull-examplesite"
],
"citations": [
{"url": "https://www.northwind.com/blog/example-site-data/", "title": "Pulling Data from ExampleSite.com - Northwind", "description": "..."}
],
"answer_text": "You can get data from ExampleSite.com through three main methods... Northwind or Contoso: handle JavaScript rendering...",
"share_of_voice_pct": 33.3
},
{
"run_timestamp": "2026-05-20T07:04:24Z",
"run_id": "a1B2c3D4e5F6g7H8",
"prompt": "How to get data from ExampleSite.com",
"source": "ai_overviews",
"entity": "brand",
"entity_name": "Acme",
"entity_domain": "acme.com",
"cited": false,
"mentioned": false,
"matched_citation_urls": [],
"citation_urls": [],
"citations": [],
"answer_text": "[no answer returned]",
"share_of_voice_pct": 0.0
}
]
Example setup -- Acme, two competitors, five prompts, weekly cadence
A worked walkthrough of a first-run setup, starting from an empty working directory.
1. The seven anchors + optional follow-ups
> /skills load ai-search-visibility-tracker
Claude: Before the first snapshot I need seven things:
1. Brand URL? > acme.com
2. Surface forms to match in answer text (comma-sep)? > Acme, acme.com, @acme
3. Competitors (name + domain pairs, or "none")? > Northwind northwind.com; Contoso contoso.com
4. Prompts to monitor (blank line ends list)?
> best web scraping tool
> how to scrape marketplace prices
> Acme vs Fabrikam
> no-code web scraping
> scraping API for developers
>
5. Cadence (daily / weekly / monthly)? > weekly
6. AI sources (default: all 6 on)? > keep all
7. Apify Dataset name? > ai-visibility-acme
Claude: Optional:
countryCode (us) / languageCode (en) / hour (9)? > defaults
Verbosity (on_demand keeps full answers in KV store)? > on_demand2. Confirmation
Claude: Setup:
Brand: Acme (acme.com) | Competitors: Northwind, Contoso
Prompts: 5 | Cadence: weekly Mondays 09:00 local (0 9 * * 1)
Sources: all 6 | Dataset: ai-visibility-acme | KV: ai-visibility-acme-raw
Cost: ~$0.18/run, ~$9.40/year (52 runs)
Proceed? > y3. Files written
./config.json # the seven anchors + acme section
./.env # APIFY_TOKEN=... (chmod 600)
./reports/ # created on first snapshot4. Install the OS schedule
Claude: macOS detected -> writing ~/Library/LaunchAgents/com.acme.ai-visibility-tracker.plist
Schedule: weekly Mondays 09:00 local
Runner: /usr/bin/python3 .../run_snapshot.py --config /abs/path/config.json
Token: read from ./.env at fire time (NOT embedded in the plist)
Logs: ~/Library/Logs/ai-visibility-tracker.log
Proceed? > y
Installed. Verify: launchctl list | grep ai-visibility
Fire now: launchctl kickstart "gui/$(id -u)/com.acme.ai-visibility-tracker"On Linux the installer writes a crontab line tagged # ai-visibility-tracker (log: ~/.ai-visibility-tracker.log). On Windows it prints a schtasks /Create command.
5. Immediate first snapshot
Kickstarting the launchd job streams these lines to the log:
[09:00:12Z] Loaded config / Dataset ID: a1B2c3D4e5F6g7H8
[09:00:13Z] Starting actor: apify/google-search-scraper (Run ID: a1B2c3D4)
[09:01:45Z] Run status: SUCCEEDED
[09:01:46Z] Parsed 90 snapshot rows (5 prompts x 6 sources x 3 entities)
[09:01:47Z] Appended 90 rows to 'ai-visibility-acme'
[09:01:48Z] Wrote 5 raw records to 'ai-visibility-acme-raw'
[09:01:48Z] Wrote report: ./reports/snapshot-2026-05-20.md6. Subsequent weekly runs
Every Monday at 09:00 local, launchd (or cron on Linux) invokes the runner. No interaction needed.
Caveats
- Machine must be on at fire time. launchd and cron skip fires when asleep / off; missed runs aren't caught up. For laptops with closed lids overnight, expect occasional misses -- or host the runner on an always-on machine.
- DST is handled. launchd
StartCalendarIntervaland cron both use local time and follow DST.
AI Visibility Snapshot -- 2026-05-20
- Run ID:
a1B2c3D4e5F6g7H8(console) - Prompt: "How to get data from ExampleSite.com"
- Sources enabled: ai_overviews, ai_mode, chatgpt, perplexity, copilot, gemini
- Entities: Acme, Contoso, Northwind
- Prior runs compared against: 3
- Verbosity:
include_full_answers = on_demand
Summary
- First-ever citations today: 1
- copilot / "How to get data from ExampleSite.com" -> Acme
- First-ever mentions today: 1
- perplexity / "How to get data from ExampleSite.com" -> Northwind
- Drops today: 1
- ai_mode / "How to get data from ExampleSite.com" -> Contoso
Per-entity scorecards
Acme (brand)
| Source | Cited | Mentioned | SoV% | Matched URLs | History |
|---|---|---|---|---|---|
| ai_overviews | no | no | 0.0 | -- | cited 0/3 prior runs |
| ai_mode | no | no | 0.0 | -- | cited 0/3 prior runs |
| chatgpt | no | no | 0.0 | -- | cited 0/3 prior runs |
| perplexity | no | no | 0.0 | -- | cited 0/3 prior runs |
| copilot | yes | yes | 25.0 | https://blog.acme.com/crawling-examplesite-47511a59eef/ | cited 0/3 prior runs (first-ever cited) |
| gemini | no | yes | 0.0 | -- | cited 0/3 prior runs |
Acme is cited in copilot (https://blog.acme.com/crawling-examplesite-47511a59eef/); mentioned by name in copilot ("Use a scraping API (Northwind, Fabrikam, Acme)") and gemini ("Acme (ExampleSite.com Actor): Acme features ready-made cloud scrapers").
Contoso (competitor)
| Source | Cited | Mentioned | SoV% | Matched URLs | History |
|---|---|---|---|---|---|
| ai_overviews | no | no | 0.0 | -- | cited 0/3 prior runs |
| ai_mode | no | no | 0.0 | -- | cited 2/3 prior runs (dropped vs last) |
| chatgpt | no | no | 0.0 | -- | cited 0/3 prior runs |
| perplexity | no | no | 0.0 | -- | cited 0/3 prior runs |
| copilot | no | no | 0.0 | -- | cited 0/3 prior runs |
| gemini | no | no | 0.0 | -- | cited 0/3 prior runs |
Contoso has zero citations and zero mentions across all enabled sources.
Northwind (competitor)
| Source | Cited | Mentioned | SoV% | Matched URLs | History |
|---|---|---|---|---|---|
| ai_overviews | no | no | 0.0 | -- | cited 0/3 prior runs |
| ai_mode | yes | no | 8.3 | https://www.northwind.com/blog/example-site-data/ | cited 2/3 prior runs |
| chatgpt | no | no | 0.0 | -- | cited 1/3 prior runs |
| perplexity | yes | yes | 11.1 | https://www.northwind.com/blog/example-site-data/ | cited 1/3 prior runs (first-ever mentioned) |
| copilot | yes | yes | 25.0 | https://www.northwind.com/blog/how-to-pull-examplesite/ | cited 1/3 prior runs |
| gemini | yes | yes | 7.1 | https://www.northwind.com/blog/example-site-data/ | cited 2/3 prior runs |
Northwind is cited in ai_mode, perplexity, copilot, gemini; mentioned by name in perplexity ("Scraping frameworks like Northwind or Fabrikam"), copilot ("Use a scraping API (Northwind, Fabrikam, Acme)"), and gemini ("web scraping APIs (like Fabrikam or Northwind)").
Top 10 most-cited URLs (this run)
ai_mode
- (1x) https://developers.examplesite.com/demand
- (1x) https://www.northwind.com/blog/example-site-data/
- (1x) https://tailspin.com/examplesite-scraper/
- [...]
[... one top-10 per source, see the runner's actual output ...]
Apify Actor Usage -- apify/google-search-scraper
The skill uses a single Actor for all six AI surfaces. Each AI surface is an add-on toggled on via a nested input object.
Input schema (only the fields the skill sets)
| Field | Type | Skill default | Notes |
|---|---|---|---|
queries | string | from config.json:prompts | One prompt per line. |
maxPagesPerQuery | int | 1 | One page is enough to surface AI Overviews. |
resultsPerPage | int | 10 | Default. |
countryCode | string | from config, default us | ISO 3166-1 alpha-2. |
languageCode | string | from config, default en | ISO 639-1. |
disableGoogleSearchResults | bool | must remain `false` | AI Overviews ride alongside organic SERP results -- turning off SERP suppresses Overviews too. |
aiModeSearch | object | { "enableAiMode": <user> } | Google AI Mode add-on. |
chatGptSearch | object | { "enableChatGpt": <user> } | ChatGPT Search add-on. |
perplexitySearch | object | { "enablePerplexity": <user> } | Perplexity add-on. |
copilotSearch | object | { "enableCopilot": <user> } | Microsoft Copilot add-on. |
geminiSearch | object | { "enableGemini": <user> } | Google Gemini add-on. |
Example input the runner constructs
{
"queries": "best web scraping tool\nhow to scrape Amazon prices\nApify vs Scrapy",
"maxPagesPerQuery": 1,
"countryCode": "us",
"languageCode": "en",
"aiModeSearch": { "enableAiMode": true },
"chatGptSearch": { "enableChatGpt": true },
"perplexitySearch": { "enablePerplexity": true },
"copilotSearch": { "enableCopilot": true },
"geminiSearch": { "enableGemini": true }
}Expected dataset shape (per query, per page)
Each Actor dataset item represents one query result. Field names verified against a live run on 2026-05-18:
| Source | Top-level field | Answer text | Citations |
|---|---|---|---|
| AI Overviews | aiOverview | aiOverview.content (often missing -- see below) | aiOverview.sources[].url |
| AI Mode | aiModeResult | aiModeResult.text | aiModeResult.sources[].url |
| ChatGPT Search | chatGptSearchResult | chatGptSearchResult.text | chatGptSearchResult.sources[].url |
| Perplexity | perplexitySearchResult | perplexitySearchResult.text | perplexitySearchResult.sources[].url (also .citationUrls[] as a flat fallback) |
| Copilot | copilotSearchResult | copilotSearchResult.text | copilotSearchResult.sources[].url |
| Gemini | geminiSearchResult | geminiSearchResult.text | geminiSearchResult.sources[].url |
Each entry under sources[] is an object with url, title, and description (or snippet) -- the runner preserves all three so the user can later inspect the metadata behind every citation.
Parser fallbacks
The runner's _block_text(...) helper tries text first (the dominant key across all five working surfaces), then falls back to content, answer, markdown. _block_citations(...) tries sources / citations / references / links as arrays of objects, falling back to citationUrls as a flat URL list. If everything is missing, the row is written with answer_text = "[no answer returned]" and empty citations[].
Per-source cost flags
Pricing shifts -- check the Actor's pricing tab for current per-result rates. Disable any source the user does not care about (Step 1 of SKILL.md): each AI surface adds a per-result fee on top of the base SERP page price.
What the runner ignores
customDataFunction,saveHtml,saveHtmlToKeyValueStore-- not needed; the parser walks structured fields.mobileResults-- irrelevant for citation tracking.forceExactMatch-- never set; we want broad SERP behavior, not literal-quote matching.
Citation and Mention Matching
Two independent matchers run on every (prompt x source) cell. An entity can be cited: true, mentioned: false (linked but unnamed) or cited: false, mentioned: true (named but not linked).
URL citation matching -- registrable domain
Compare the registrable domain of every cited URL to the entity's domain. https://blog.apify.com/x counts as a citation for apify.com; https://github.com/apify/x does not (registrable domain is github.com).
The runner uses tldextract if installed (handles multi-part TLDs like .co.uk correctly):
import tldextract
def registrable(url):
ext = tldextract.extract(url)
return f"{ext.domain}.{ext.suffix}".lower()Stdlib fallback (covers ~99% of cases, misses some multi-part TLDs):
from urllib.parse import urlparse
def registrable_fallback(url):
host = (urlparse(url).hostname or "").lower()
parts = host.split(".")
return ".".join(parts[-2:]) if len(parts) >= 2 else hostURLs without a scheme get https:// prepended before parsing. IDN domains should be normalised to ASCII (xn--...) before comparing.
Brand-name mention matching -- word boundary
Match surface forms against the answer text with \b<form>\b, case-insensitive:
import re
def mentions(text, surface_forms):
return any(
re.search(r"\b" + re.escape(f) + r"\b", text, flags=re.IGNORECASE)
for f in surface_forms if f
)\b anchors prevent Apify from matching Apifying or Happify. For multi-word brands (Open AI), pass each surface form literally (["OpenAI", "Open AI"]). re.escape handles punctuation (O'Reilly, Yahoo!) correctly.
Test matrix
| Citation URL | Brand domain | cited |
|---|---|---|
https://apify.com/store | apify.com | true |
https://blog.apify.com/x | apify.com | true |
https://APIFY.COM/x | apify.com | true |
https://github.com/apify/x | apify.com | false |
https://apifyclone.com/x | apify.com | false |
| Answer text | Surface form | mentioned |
|---|---|---|
Use Apify for scraping | Apify | true |
apify is great | Apify | true |
See apify.com/docs | Apify | true |
When apifying data | Apify | false |
Happify the user | Apify | false |
Output Schema
Three outputs per run:
1. Named Apify Dataset rows -- append-only, machine-readable history. 2. Named Apify Key-Value Store records -- one record per (run, prompt) with the full raw Apify dataset item. Long-term archive for "what did Perplexity actually say two months ago?" queries. 3. Markdown report at reports/snapshot-<ISO-date>.md -- human-readable, entity-major.
Dataset row schema
One row per (run, prompt, source, entity). For N prompts x 6 sources x (1 brand + M competitors), a run appends N x 6 x (1 + M) rows.
{
"run_timestamp": "2026-05-18T09:00:14Z",
"run_id": "a1B2c3D4e5F6g7H8",
"prompt": "best web scraping tool",
"source": "ai_overviews",
"entity": "brand",
"entity_name": "Apify",
"entity_domain": "apify.com",
"cited": true,
"mentioned": true,
"matched_citation_urls": ["https://apify.com/store"],
"citation_urls": ["https://apify.com/store", "https://en.wikipedia.org/wiki/Web_scraping"],
"citations": [{"url": "https://apify.com/store", "title": "Apify Store", "description": "..."}],
"answer_text": "Apify is a cloud platform for web scraping...",
"share_of_voice_pct": 50.0
}| Field | Notes |
|---|---|
run_timestamp | ISO 8601 UTC; start of the Apify run. |
run_id | Apify run ID. Click: console.apify.com/actors/runs/<id>. |
source | One of ai_overviews, ai_mode, chatgpt, perplexity, copilot, gemini. |
cited | True if any citation URL's registrable domain == entity_domain. |
mentioned | True if answer_text contains any surface form (\bform\b, case-insensitive). |
matched_citation_urls | Subset of citation_urls that drove the "cited" flag. Empty when cited is false. |
citation_urls | All citations for this (prompt, source) cell. Same across every entity row of the cell. |
citations | [{url, title, description}] -- preserves the metadata each AI surface returned. Same across every entity row. |
answer_text | Full AI answer text. "[no answer returned]" if the source returned nothing. |
share_of_voice_pct | len(matched_citation_urls) / len(citation_urls) * 100. 0.0 if no citations. |
Why every entity gets a row even when not cited: continuity. The history diff in run 2+ needs the prior row to compare against.
Key-value store records
| Aspect | Value |
|---|---|
| Store name | <dataset_name>-raw by default (configurable via apify.kv_store_name). Created on first run. |
| Record key | <run_timestamp with ':' -> '-'>__<prompt-slug> -- e.g. 2026-05-18T09-00-14Z__how-to-get-data-from-booking-com. |
| Record value | The full Apify dataset item: searchQuery, organicResults[], peopleAlsoAsk[], aiOverview, aiModeResult, chatGptSearchResult, perplexitySearchResult, copilotSearchResult, geminiSearchResult. Untouched. |
| Why named, not the run's default | Default KV stores follow the run's retention policy. A named store persists indefinitely. |
Read a historical snapshot:
import requests, os
token = os.environ["APIFY_TOKEN"]
stores = requests.get(f"https://api.apify.com/v2/key-value-stores?token={token}&unnamed=false").json()["data"]["items"]
store_id = next(s["id"] for s in stores if s["name"] == "ai-visibility-apify-raw")
record = requests.get(
f"https://api.apify.com/v2/key-value-stores/{store_id}/records/"
f"2026-05-18T09-00-14Z__how-to-get-data-from-booking-com?token={token}"
).json()History (run 2+)
From the second run onwards the report compares today to all prior runs in the named dataset. Per (prompt, source, entity) the renderer computes:
total_prior_runs/cited_in_prior_runs/mentioned_in_prior_runsfirst_cited_at/last_cited_at(orNone)newly_cited-- cited today ANDcited_in_prior_runs == 0newly_mentioned-- analogous for mentionsdropped-- not cited today AND the latest prior run hadcited: true
The report renders one entity-major table per tracked entity (Source | Cited | Mentioned | SoV% | Matched URLs | History), with a one-line interpretive note quoting the surface-form context where the entity was mentioned. When any entity in a (prompt, source) cell is mentioned and include_full_answers == "always", the full LLM answer is embedded verbatim; otherwise short quoted snippets only.
If the user explicitly asks "compare to yesterday only", drop to single-run diff (newly_cited and dropped against the most recent prior run only). Default is history mode.
Scheduling -- via OS-level cron / launchd
TL;DR
Recurrence is driven by the operating system's own scheduler. The skill ships an installer that detects the OS and writes the right schedule entry:
- macOS ->
~/Library/LaunchAgents/com.apify.ai-visibility-tracker.plist - Linux -> a crontab line tagged
# ai-visibility-tracker - Windows -> the installer prints a
schtaskscommand for the user to run
At fire time, the scheduler invokes python3 run_snapshot.py --config <abs path> with the working directory set next to config.json. The runner reads APIFY_TOKEN from a .env file at that location, so no credential ever lands in the schedule entry itself.
Installation
From the user's working directory (containing config.json and .env):
bash ${CLAUDE_PLUGIN_ROOT}/reference/scripts/install_cron.sh --cadence daily --hour 9The installer:
1. Validates that config.json exists. 2. Validates that .env next to config.json contains an APIFY_TOKEN=... line. 3. Resolves absolute paths for python3, run_snapshot.py, config.json. 4. Detects the OS via uname -s and branches:
- macOS -> writes the plist,
chmod 600, thenlaunchctl bootstrap gui/$(id -u) <plist>(falls back tolaunchctl loadon older macOS). - Linux -> appends a single crontab line
0 H * * * cd <config-dir> && python3 <runner> --config <config> >> ~/.ai-visibility-tracker.log 2>&1 # ai-visibility-tracker. Any prior tagged line is removed first so re-installing is idempotent. - Windows -> prints a
schtasks /Createcommand.
5. Shows the user the exact entry it will write, then asks for confirmation (skip with --yes).
Cron expressions by cadence
| Cadence | Cron expression | When |
|---|---|---|
| daily | 0 H * * * | every day at H:00 local |
| weekly | 0 H * * 1 | every Monday at H:00 local |
| monthly | 0 H 1 * * | the 1st of every month at H:00 local |
H is the user's chosen hour (0-23, default 9). On macOS, the same cadence maps to StartCalendarInterval keys (Hour/Minute + optional Weekday/Day).
Credential handling
The schedule entry contains no token. The runner script's load_dotenv_into_env() helper reads .env from the config's directory at run-start and merges it into os.environ before any Apify call. Rotating the token is just echo 'APIFY_TOKEN=new_value' > .env -- no reinstall needed.
Changing cadence
Re-run the installer with the new --cadence / --hour. It removes the previous entry (by Label on macOS, by the marker comment on Linux) and writes a new one.
Verifying the schedule
| OS | Verify it's loaded | Tail its log |
|---|---|---|
| macOS | `launchctl list \ | grep ai-visibility` |
| Linux | crontab -l | tail -f ~/.ai-visibility-tracker.log |
| Windows | schtasks /Query /TN "AI Visibility Tracker" | check Task Scheduler History |
To fire it once now without waiting for the schedule, on macOS:
launchctl kickstart "gui/$(id -u)/com.apify.ai-visibility-tracker"On Linux there's no direct kickstart -- just run the command from the cron line manually, or invoke python3 run_snapshot.py --config ./config.json from the config dir.
Caveats
- Machine must be on at fire time. Both launchd and cron skip a fire if the machine is asleep / off; missed runs are NOT caught up. For a laptop, expect occasional missed mornings.
- DST. Cron and
StartCalendarIntervalinterpret the hour in local time, so they auto-track DST. - No retries. If the actor run fails (Apify outage, network blip), the runner exits non-zero and the next fire is the recovery point. To add retry-on-failure semantics, wrap the runner call in a small bash loop with a sleep between attempts.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
crontab -l shows the entry but no log lines | cron daemon disabled | systemctl status cron (or cronie/crond); start it |
launchctl list shows the label but no fires | machine was asleep at the scheduled hour | Open the lid + power before 09:00, or move to an always-on host |
Log says APIFY_TOKEN not found | .env missing or not readable | Verify cat $CONFIG_DIR/.env and check chmod 600 permissions |
Log says python3: command not found | macOS reinstalled Homebrew, Python path drifted | Re-run install_cron.sh -- it re-resolves python3 |
Reports written to $HOME/reports/ not project folder | cron line missing the leading cd <config-dir> | Reinstall via install_cron.sh (it always injects the cd) |
{
"brand": {
"name": "Apify",
"domain": "apify.com",
"surface_forms": ["Apify", "apify.com", "@apify"]
},
"competitors": [
{
"name": "ScrapingBee",
"domain": "scrapingbee.com",
"surface_forms": ["ScrapingBee", "scrapingbee.com"]
},
{
"name": "Bright Data",
"domain": "brightdata.com",
"surface_forms": ["Bright Data", "brightdata.com", "Luminati"]
}
],
"prompts": [
"best web scraping tool",
"how to scrape Amazon prices",
"Apify vs Scrapy",
"no-code web scraping",
"scraping API for developers"
],
"cadence": "daily",
"run_hour_local": 9,
"include_full_answers": "on_demand",
"ai_sources": {
"ai_overviews": true,
"ai_mode": true,
"chatgpt": true,
"perplexity": true,
"copilot": true,
"gemini": true
},
"apify": {
"actor_id": "apify/google-search-scraper",
"dataset_name": "ai-visibility-apify",
"kv_store_name": "ai-visibility-apify-raw",
"country_code": "us",
"language_code": "en",
"location_uule": null
},
"_comments": {
"brand.domain": "Registrable domain only. blog.<domain> and www.<domain> count as citations.",
"competitors": "Zero competitors is allowed — pass an empty array [].",
"cadence": "daily | weekly | monthly. Drives the cron schedule.",
"run_hour_local": "0-23. Hour of day for daily/weekly/monthly fire.",
"include_full_answers": "'on_demand' (default) or 'always'. On 'on_demand' the report shows only a short interpretive note quoting the surface-form context (~80 chars on either side) for any mentioned entity. On 'always' the report embeds the FULL LLM answer text verbatim whenever any tracked entity is mentioned. Long-form gets unwieldy fast at multiple prompts -- recommend 'on_demand' for daily runs.",
"ai_sources": "Each adds per-result cost; see https://apify.com/apify/google-search-scraper for current pricing.",
"apify.dataset_name": "Named dataset to append to. Created on first run if missing.",
"apify.kv_store_name": "Named key-value store for long-term raw preservation. Each run writes one record per prompt with the full Apify dataset item (organic results, AI answers, citations w/ metadata)."
}
}
#!/usr/bin/env bash
# install_cron.sh -- install a recurring schedule for run_snapshot.py.
#
# Detects OS and installs the appropriate scheduler entry:
# macOS -> ~/Library/LaunchAgents/com.apify.ai-visibility-tracker.plist
# Linux -> crontab line tagged "# ai-visibility-tracker"
# Windows -> prints schtasks instructions (no auto-install)
#
# The runner reads APIFY_TOKEN from a `.env` file next to `config.json`,
# so this script does NOT embed any credentials in the schedule entry.
#
# Usage:
# bash install_cron.sh [--cadence daily|weekly|monthly] \
# [--hour 0-23] \
# [--config /path/to/config.json] \
# [--yes]
set -euo pipefail
CADENCE="daily"
HOUR="9"
CONFIG=""
YES="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--cadence) CADENCE="$2"; shift 2 ;;
--hour) HOUR="$2"; shift 2 ;;
--config) CONFIG="$2"; shift 2 ;;
--yes|-y) YES="1"; shift ;;
-h|--help)
echo "Usage: $0 [--cadence daily|weekly|monthly] [--hour 0-23] [--config /path/to/config.json] [--yes]"
exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
case "$CADENCE" in
daily|weekly|monthly) ;;
*) echo "Error: --cadence must be daily, weekly, or monthly" >&2; exit 2 ;;
esac
if ! [[ "$HOUR" =~ ^[0-9]+$ ]] || (( HOUR < 0 || HOUR > 23 )); then
echo "Error: --hour must be 0-23" >&2; exit 2
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUNNER="$SCRIPT_DIR/run_snapshot.py"
if [[ ! -f "$RUNNER" ]]; then
echo "Error: run_snapshot.py not found at $RUNNER" >&2; exit 2
fi
# Resolve config.json path (default: $PWD/config.json).
if [[ -z "$CONFIG" ]]; then
CONFIG="$PWD/config.json"
fi
if [[ ! -f "$CONFIG" ]]; then
echo "Error: config.json not found at $CONFIG. Create it first (see SKILL.md Step 3)." >&2
exit 2
fi
CONFIG="$(cd "$(dirname "$CONFIG")" && pwd)/$(basename "$CONFIG")"
CONFIG_DIR="$(dirname "$CONFIG")"
# Sanity-check that .env with APIFY_TOKEN exists alongside config.json.
if [[ ! -f "$CONFIG_DIR/.env" ]]; then
cat >&2 <<EOF
Error: $CONFIG_DIR/.env not found.
The runner reads APIFY_TOKEN from a .env file next to config.json. Create it:
echo 'APIFY_TOKEN=your_token_here' > $CONFIG_DIR/.env
chmod 600 $CONFIG_DIR/.env
EOF
exit 2
fi
if ! grep -q '^APIFY_TOKEN=' "$CONFIG_DIR/.env"; then
echo "Error: $CONFIG_DIR/.env exists but has no APIFY_TOKEN line." >&2
exit 2
fi
PYTHON_BIN="$(command -v python3 || true)"
if [[ -z "$PYTHON_BIN" ]]; then
echo "Error: python3 not on PATH" >&2; exit 2
fi
OS="$(uname -s)"
LABEL="com.apify.ai-visibility-tracker"
confirm() {
if [[ "$YES" == "1" ]]; then return 0; fi
read -r -p "$1 [y/N] " ans
[[ "$ans" =~ ^[Yy]$ ]]
}
# ---- macOS: launchd ----
install_macos() {
local plist="$HOME/Library/LaunchAgents/$LABEL.plist"
local logdir="$HOME/Library/Logs"
mkdir -p "$logdir" "$HOME/Library/LaunchAgents"
local cal_block
case "$CADENCE" in
daily)
cal_block="<dict><key>Hour</key><integer>$HOUR</integer><key>Minute</key><integer>0</integer></dict>"
;;
weekly)
cal_block="<dict><key>Hour</key><integer>$HOUR</integer><key>Minute</key><integer>0</integer><key>Weekday</key><integer>1</integer></dict>"
;;
monthly)
cal_block="<dict><key>Hour</key><integer>$HOUR</integer><key>Minute</key><integer>0</integer><key>Day</key><integer>1</integer></dict>"
;;
esac
cat <<EOF
Will write the following launchd plist to:
$plist
Schedule: $CADENCE at ${HOUR}:00 local time
Runner: $PYTHON_BIN $RUNNER --config $CONFIG
Working dir: $CONFIG_DIR
APIFY_TOKEN src: $CONFIG_DIR/.env (read by the runner; NOT embedded in plist)
Logs: $logdir/ai-visibility-tracker.log
EOF
if ! confirm "Proceed with install?"; then echo "Aborted."; exit 1; fi
cat > "$plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$PYTHON_BIN</string>
<string>$RUNNER</string>
<string>--config</string>
<string>$CONFIG</string>
</array>
<key>WorkingDirectory</key><string>$CONFIG_DIR</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
</dict>
<key>StartCalendarInterval</key>
$cal_block
<key>StandardOutPath</key><string>$logdir/ai-visibility-tracker.log</string>
<key>StandardErrorPath</key><string>$logdir/ai-visibility-tracker.log</string>
<key>RunAtLoad</key><false/>
</dict>
</plist>
EOF
chmod 600 "$plist"
# Unload any previous version first; then bootstrap (modern) or fall back to load.
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
if ! launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null; then
launchctl load "$plist"
fi
cat <<EOF
Installed. Verify with:
launchctl list | grep ai-visibility
tail -f $logdir/ai-visibility-tracker.log
To fire it once now (burns one actor run):
launchctl kickstart "gui/\$(id -u)/$LABEL"
EOF
}
# ---- Linux: crontab ----
install_linux() {
local schedule
case "$CADENCE" in
daily) schedule="0 $HOUR * * *" ;;
weekly) schedule="0 $HOUR * * 1" ;;
monthly) schedule="0 $HOUR 1 * *" ;;
esac
local logfile="$HOME/.ai-visibility-tracker.log"
local line="$schedule cd $CONFIG_DIR && $PYTHON_BIN $RUNNER --config $CONFIG >> $logfile 2>&1 # ai-visibility-tracker"
cat <<EOF
Will append the following line to your crontab:
$line
The runner reads APIFY_TOKEN from $CONFIG_DIR/.env -- NOT embedded in the cron line.
Logs: $logfile
EOF
if ! confirm "Proceed with install?"; then echo "Aborted."; exit 1; fi
# Remove any previous tagged line, then append the new one.
( crontab -l 2>/dev/null | grep -v '# ai-visibility-tracker'; echo "$line" ) | crontab -
cat <<EOF
Installed. Verify with:
crontab -l
tail -f $logfile
EOF
}
# ---- Windows: print instructions only ----
install_windows() {
cat <<EOF
Windows automated install is not supported by this script.
Run the following in an elevated PowerShell prompt:
schtasks /Create /SC DAILY /ST ${HOUR}:00 /TN "AI Visibility Tracker" ^
/TR "$PYTHON_BIN $RUNNER --config $CONFIG"
For weekly: /SC WEEKLY /D MON
For monthly: /SC MONTHLY /D 1
The runner reads APIFY_TOKEN from $CONFIG_DIR\\.env -- you do NOT need to set
it via setx.
EOF
}
case "$OS" in
Darwin) install_macos ;;
Linux) install_linux ;;
CYGWIN*|MINGW*|MSYS*) install_windows ;;
*) echo "Unsupported OS: $OS" >&2; exit 2 ;;
esac
#!/usr/bin/env python3
"""
AI Search Visibility Tracker — snapshot runner.
Reads config.json + APIFY_TOKEN from env, calls apify/google-search-scraper,
appends parsed rows to a named Apify Dataset, writes a Markdown diff report.
Non-interactive by design -- no stdin reads. Suitable for invocation from
launchd, cron, Task Scheduler, or any other non-interactive caller.
Usage:
APIFY_TOKEN=xxx python3 run_snapshot.py --config /path/to/config.json
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import sys
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
try:
import requests
except ImportError:
sys.stderr.write("Error: 'requests' is required. Install with: pip3 install requests\n")
sys.exit(2)
try:
import tldextract
_HAS_TLDEXTRACT = True
except ImportError:
_HAS_TLDEXTRACT = False
USER_AGENT = "apify-awesome-skills/ai-search-visibility-tracker-1.0.0"
APIFY_API = "https://api.apify.com/v2"
SOURCE_FIELDS = {
"ai_overviews": ("aiOverview", "ai_overview"),
"ai_mode": ("aiModeResult", "ai_mode_result"),
"chatgpt": ("chatGptSearchResult", "chat_gpt_search_result"),
"perplexity": ("perplexitySearchResult", "perplexity_search_result"),
"copilot": ("copilotSearchResult", "copilot_search_result"),
"gemini": ("geminiSearchResult", "gemini_search_result"),
}
SOURCE_TOGGLES = {
"ai_mode": ("aiModeSearch", "enableAiMode"),
"chatgpt": ("chatGptSearch", "enableChatGpt"),
"perplexity": ("perplexitySearch", "enablePerplexity"),
"copilot": ("copilotSearch", "enableCopilot"),
"gemini": ("geminiSearch", "enableGemini"),
}
# ---------- helpers ----------
def registrable_domain(url: str) -> str:
"""Return the registrable domain (e.g. apify.com) for a URL."""
if not url:
return ""
if "://" not in url:
url = "https://" + url
if _HAS_TLDEXTRACT:
ext = tldextract.extract(url)
return f"{ext.domain}.{ext.suffix}".lower().strip(".")
host = (urlparse(url).hostname or "").lower()
parts = host.split(".")
return ".".join(parts[-2:]) if len(parts) >= 2 else host
def mentions(text: str, surface_forms: list[str]) -> bool:
if not text:
return False
for form in surface_forms:
if not form:
continue
pattern = r"\b" + re.escape(form) + r"\b"
if re.search(pattern, text, flags=re.IGNORECASE):
return True
return False
def load_dotenv_into_env(config_dir: Path) -> None:
"""Best-effort .env loader (no python-dotenv dependency)."""
env_path = config_dir / ".env"
if not env_path.exists():
return
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip("'\""))
def log(msg: str) -> None:
print(f"[{dt.datetime.utcnow().isoformat()}Z] {msg}", flush=True)
# ---------- Apify API ----------
def api_headers(token: str, suffix: str) -> dict[str, str]:
return {"User-Agent": f"{USER_AGENT}/{suffix}", "Authorization": f"Bearer {token}"}
def get_or_create_dataset(token: str, name: str) -> str:
"""Return the dataset ID for a named dataset; create it if missing."""
resp = requests.get(
f"{APIFY_API}/datasets",
params={"unnamed": "false", "limit": 1000},
headers=api_headers(token, "list_datasets"),
timeout=30,
)
resp.raise_for_status()
for item in resp.json().get("data", {}).get("items", []):
if item.get("name") == name:
return item["id"]
resp = requests.post(
f"{APIFY_API}/datasets",
params={"name": name},
headers=api_headers(token, "create_dataset"),
timeout=30,
)
resp.raise_for_status()
return resp.json()["data"]["id"]
def start_actor(token: str, actor_id: str, actor_input: dict) -> tuple[str, str]:
api_actor = actor_id.replace("/", "~")
resp = requests.post(
f"{APIFY_API}/acts/{api_actor}/runs",
headers={**api_headers(token, "start_actor"), "Content-Type": "application/json"},
json=actor_input,
timeout=30,
)
resp.raise_for_status()
data = resp.json()["data"]
return data["id"], data["defaultDatasetId"]
def poll_until_done(token: str, run_id: str, timeout_s: int = 1800, interval_s: int = 5) -> str:
deadline = time.time() + timeout_s
last_status = None
while True:
resp = requests.get(
f"{APIFY_API}/actor-runs/{run_id}",
headers=api_headers(token, "poll_run"),
timeout=30,
)
resp.raise_for_status()
status = resp.json()["data"]["status"]
if status != last_status:
log(f"Run status: {status}")
last_status = status
if status in {"SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"}:
return status
if time.time() > deadline:
return "TIMED-OUT"
time.sleep(interval_s)
def fetch_dataset_items(token: str, dataset_id: str) -> list[dict]:
resp = requests.get(
f"{APIFY_API}/datasets/{dataset_id}/items",
params={"format": "json", "clean": "true"},
headers=api_headers(token, "fetch_items"),
timeout=120,
)
resp.raise_for_status()
return resp.json()
def get_or_create_kv_store(token: str, name: str) -> str:
"""Return the key-value-store ID for a named store; create it if missing."""
resp = requests.get(
f"{APIFY_API}/key-value-stores",
params={"unnamed": "false", "limit": 1000},
headers=api_headers(token, "list_kv_stores"),
timeout=30,
)
resp.raise_for_status()
for item in resp.json().get("data", {}).get("items", []):
if item.get("name") == name:
return item["id"]
resp = requests.post(
f"{APIFY_API}/key-value-stores",
params={"name": name},
headers=api_headers(token, "create_kv_store"),
timeout=30,
)
resp.raise_for_status()
return resp.json()["data"]["id"]
def put_kv_record(token: str, store_id: str, key: str, value: dict) -> None:
resp = requests.put(
f"{APIFY_API}/key-value-stores/{store_id}/records/{key}",
headers={**api_headers(token, "put_kv_record"), "Content-Type": "application/json"},
data=json.dumps(value, ensure_ascii=False).encode("utf-8"),
timeout=60,
)
resp.raise_for_status()
def push_dataset_items(token: str, dataset_id: str, items: list[dict]) -> None:
if not items:
return
for chunk_start in range(0, len(items), 200):
chunk = items[chunk_start:chunk_start + 200]
resp = requests.post(
f"{APIFY_API}/datasets/{dataset_id}/items",
headers={**api_headers(token, "push_items"), "Content-Type": "application/json"},
json=chunk,
timeout=60,
)
resp.raise_for_status()
# ---------- parsing ----------
def _extract_block(item: dict, primary_key: str) -> dict | None:
"""Return the source block dict from a result item, trying common shapes."""
block = item.get(primary_key)
if isinstance(block, dict):
return block
snake = re.sub(r"(?<!^)(?=[A-Z])", "_", primary_key).lower()
block = item.get(snake)
return block if isinstance(block, dict) else None
def _block_text(block: dict | None) -> str:
if not block:
return ""
for key in ("content", "text", "answer", "markdown", "response"):
v = block.get(key)
if isinstance(v, str) and v.strip():
return v
return ""
def _block_citations(block: dict | None) -> list[dict]:
"""Return citations as a list of {url, title, description} dicts."""
if not block:
return []
for key in ("sources", "citations", "references", "links"):
v = block.get(key)
if not isinstance(v, list):
continue
out = []
for entry in v:
if isinstance(entry, str):
out.append({"url": entry, "title": "", "description": ""})
elif isinstance(entry, dict):
url = entry.get("url") or entry.get("link") or entry.get("href") or ""
title = entry.get("title") or entry.get("name") or ""
desc = entry.get("description") or entry.get("snippet") or ""
if url:
out.append({"url": url, "title": title, "description": desc})
if out:
return out
# Fallback: Perplexity exposes plain "citationUrls" alongside structured sources.
if isinstance(block.get("citationUrls"), list):
return [{"url": u, "title": "", "description": ""}
for u in block["citationUrls"] if isinstance(u, str)]
return []
def parse_run_into_rows(
raw_items: list[dict],
config: dict,
run_id: str,
run_timestamp: str,
) -> list[dict]:
"""Turn the Actor's per-query items into one row per (prompt, source, entity)."""
enabled = config["ai_sources"]
brand = config["brand"]
competitors = config.get("competitors", [])
entities = [("brand", brand)] + [("competitor", c) for c in competitors]
rows: list[dict] = []
# Index items by prompt for stable lookup.
by_prompt: dict[str, dict] = {}
for item in raw_items:
prompt = (
(item.get("searchQuery") or {}).get("term")
or item.get("query")
or item.get("keyword")
or ""
)
if prompt:
by_prompt.setdefault(prompt, item)
for prompt in config["prompts"]:
item = by_prompt.get(prompt) or {}
for source_key, (primary_field, _) in SOURCE_FIELDS.items():
if not enabled.get(source_key, False):
continue
block = _extract_block(item, primary_field)
answer = _block_text(block)
citations = _block_citations(block) # list of {url, title, description}
if not answer and not citations:
answer_text = "[no answer returned]"
else:
answer_text = answer or ""
citation_urls = [c["url"] for c in citations]
cited_domains = [registrable_domain(u) for u in citation_urls]
total_citations = len(citation_urls)
for entity_kind, ent in entities:
ent_domain = ent["domain"].lower()
matched_urls = [
citation_urls[i]
for i, d in enumerate(cited_domains)
if d == ent_domain
]
cited = len(matched_urls) > 0
mentioned_flag = mentions(answer, ent.get("surface_forms") or [ent["name"]])
sov = (len(matched_urls) / total_citations * 100.0) if total_citations else 0.0
rows.append({
"run_timestamp": run_timestamp,
"run_id": run_id,
"prompt": prompt,
"source": source_key,
"entity": entity_kind,
"entity_name": ent["name"],
"entity_domain": ent_domain,
"cited": cited,
"mentioned": mentioned_flag,
"matched_citation_urls": matched_urls,
"citation_urls": citation_urls,
"citations": citations,
"answer_text": answer_text,
"share_of_voice_pct": round(sov, 1),
})
return rows
# ---------- history ----------
def build_history(current: list[dict], all_prior: list[dict]) -> dict:
"""
Build per-(prompt, source, entity_name) history across ALL prior runs.
Returns:
{
"by_cell": {(prompt, source, entity_name): {
"total_prior_runs": int,
"cited_in_prior_runs": int,
"mentioned_in_prior_runs": int,
"first_cited_at": str | None, # earliest prior run_timestamp where cited
"last_cited_at": str | None, # most recent prior run_timestamp where cited
"last_cited_url_examples": [str],
"newly_cited": bool, # cited now, never cited before
"newly_mentioned": bool, # mentioned now, never mentioned before
"dropped": bool, # not cited now, but cited in the latest prior run
}},
"prior_run_count": int, # distinct prior runs seen
"prior_run_ids": [str], # newest-first
}
"""
# Group prior rows by cell, sorted by timestamp asc.
by_cell: dict[tuple, list[dict]] = {}
prior_run_ids: list[tuple[str, str]] = [] # (timestamp, run_id) deduped
seen_runs = set()
for r in all_prior:
key = (r["prompt"], r["source"], r["entity_name"])
by_cell.setdefault(key, []).append(r)
rid = r.get("run_id")
if rid and rid not in seen_runs:
seen_runs.add(rid)
prior_run_ids.append((r.get("run_timestamp", ""), rid))
for k in by_cell:
by_cell[k].sort(key=lambda r: r.get("run_timestamp", ""))
prior_run_ids.sort(reverse=True)
cur_idx = {(r["prompt"], r["source"], r["entity_name"]): r for r in current}
out: dict[tuple, dict] = {}
for key, cur in cur_idx.items():
history = by_cell.get(key, [])
total = len(history)
cited_n = sum(1 for h in history if h.get("cited"))
ment_n = sum(1 for h in history if h.get("mentioned"))
first_cited = next((h["run_timestamp"] for h in history if h.get("cited")), None)
last_cited_row = next((h for h in reversed(history) if h.get("cited")), None)
last_cited = last_cited_row["run_timestamp"] if last_cited_row else None
last_cited_urls = (last_cited_row or {}).get("matched_citation_urls", []) if last_cited_row else []
# Was the entity cited in the LATEST prior run for this cell?
latest_prior_cited = history[-1]["cited"] if history else False
out[key] = {
"total_prior_runs": total,
"cited_in_prior_runs": cited_n,
"mentioned_in_prior_runs": ment_n,
"first_cited_at": first_cited,
"last_cited_at": last_cited,
"last_cited_url_examples": last_cited_urls[:5],
"newly_cited": cur["cited"] and cited_n == 0,
"newly_mentioned": cur["mentioned"] and ment_n == 0,
"dropped": (not cur["cited"]) and latest_prior_cited,
}
return {
"by_cell": out,
"prior_run_count": len(prior_run_ids),
"prior_run_ids": [rid for _, rid in prior_run_ids],
}
# ---------- report ----------
def _quote_snippet(text: str, surface_forms: list[str], window: int = 60) -> str | None:
"""Return ~window chars on either side of the first surface-form match, single-lined."""
if not text:
return None
for form in surface_forms:
if not form:
continue
m = re.search(r"\b" + re.escape(form) + r"\b", text, flags=re.IGNORECASE)
if not m:
continue
start = max(0, m.start() - window)
end = min(len(text), m.end() + window)
snippet = text[start:end].replace("\n", " ").strip()
if start > 0:
snippet = "..." + snippet
if end < len(text):
snippet = snippet + "..."
return snippet
return None
def render_report(
current: list[dict],
prior: list[dict] | None,
history: dict | None,
config: dict,
run_id: str,
run_timestamp: str,
) -> str:
iso_date = run_timestamp[:10]
enabled_sources = [k for k, v in config["ai_sources"].items() if v]
entities_full = [("brand", config["brand"])] + [
("competitor", c) for c in config.get("competitors", [])
]
entity_names = [e[1]["name"] for e in entities_full]
by_cell = (history or {}).get("by_cell", {})
prior_count = (history or {}).get("prior_run_count", 0)
include_full_answers = (config.get("include_full_answers") or "on_demand").lower() == "always"
def cell_history(prompt: str, source: str, entity_name: str) -> dict:
return by_cell.get((prompt, source, entity_name), {})
lines = []
lines.append(f"# AI Visibility Snapshot -- {iso_date}")
lines.append("")
lines.append(f"- Run ID: `{run_id}` ([console](https://console.apify.com/actors/runs/{run_id}))")
lines.append(f"- Prompts: {len(config['prompts'])}")
lines.append(f"- Sources enabled: {', '.join(enabled_sources)}")
lines.append(f"- Entities: {', '.join(entity_names)}")
if prior_count:
lines.append(f"- Prior runs compared against: **{prior_count}**")
else:
lines.append("- Prior runs compared against: **0** (first run)")
lines.append(f"- Verbosity: `include_full_answers = {'always' if include_full_answers else 'on_demand'}`")
lines.append("")
# Summary -- highlight what FLIPPED today vs. all prior history.
lines.append("## Summary")
if prior_count == 0:
lines.append("_First run -- nothing to compare against yet._")
else:
newly_cited = [k for k, h in by_cell.items() if h.get("newly_cited")]
newly_mentioned = [k for k, h in by_cell.items() if h.get("newly_mentioned")]
dropped = [k for k, h in by_cell.items() if h.get("dropped")]
lines.append(f"- **First-ever citations today:** {len(newly_cited)} (entity x source combinations cited for the first time)")
for prompt, source, ent in newly_cited:
lines.append(f" - {source} / \"{prompt}\" -> **{ent}**")
lines.append(f"- **First-ever mentions today:** {len(newly_mentioned)}")
for prompt, source, ent in newly_mentioned:
lines.append(f" - {source} / \"{prompt}\" -> **{ent}**")
lines.append(f"- **Drops today** (cited in last run, not today): {len(dropped)}")
for prompt, source, ent in dropped:
lines.append(f" - {source} / \"{prompt}\" -> **{ent}**")
lines.append("")
# Per-entity tables (entity-major: one table per entity, sources as rows).
# This is the mandatory primary output format -- see SKILL.md "Output format".
lines.append("## Per-entity scorecards")
for kind, ent in entities_full:
ent_name = ent["name"]
ent_domain = ent["domain"]
surface_forms = ent.get("surface_forms") or [ent_name]
lines.append("")
lines.append(f"### {ent_name} ({kind})")
lines.append("")
lines.append("| Source | Cited | Mentioned | SoV% | Matched URLs | History |")
lines.append("|--------|-------|-----------|------|--------------|---------|")
for source in enabled_sources:
cell_rows = [r for r in current if r["source"] == source and r["entity_name"] == ent_name]
if not cell_rows:
continue
# Aggregate across the (possibly multiple) prompts for this (source, entity).
cited_any = any(r["cited"] for r in cell_rows)
ment_any = any(r["mentioned"] for r in cell_rows)
sov_vals = [r["share_of_voice_pct"] for r in cell_rows]
sov = round(sum(sov_vals) / len(sov_vals), 1) if sov_vals else 0.0
matched_urls = []
for r in cell_rows:
for u in r.get("matched_citation_urls") or []:
if u not in matched_urls:
matched_urls.append(u)
urls_cell = "<br>".join(matched_urls) if matched_urls else "--"
# History across all prior runs of this (source, entity).
cited_prior = sum(
cell_history(r["prompt"], source, ent_name).get("cited_in_prior_runs", 0)
for r in cell_rows
)
total_prior = sum(
cell_history(r["prompt"], source, ent_name).get("total_prior_runs", 0)
for r in cell_rows
)
history_cell = f"cited {cited_prior}/{total_prior} prior runs" if total_prior else "first run"
# Flag any newly-cited / dropped status.
flags = []
for r in cell_rows:
h = cell_history(r["prompt"], source, ent_name)
if h.get("newly_cited"): flags.append("first-ever cited")
if h.get("newly_mentioned"): flags.append("first-ever mentioned")
if h.get("dropped"): flags.append("dropped vs last")
if flags:
history_cell += " (" + ", ".join(sorted(set(flags))) + ")"
lines.append(
f"| {source} | {'yes' if cited_any else 'no'} | {'yes' if ment_any else 'no'} "
f"| {sov} | {urls_cell} | {history_cell} |"
)
# Interpretive note under the table -- single sentence summarising
# citations + mentions with quoted phrases. This is the compact
# default; full LLM answers are gated below on include_full_answers.
cited_in_sources = []
mentioned_in_sources = []
for source in enabled_sources:
cell_rows = [r for r in current if r["source"] == source and r["entity_name"] == ent_name]
if any(r["cited"] for r in cell_rows):
cited_in_sources.append(source)
if any(r["mentioned"] for r in cell_rows):
# Pull a quoted snippet from one of the matching answer texts.
snippet = None
for r in cell_rows:
if r["mentioned"]:
snippet = _quote_snippet(r["answer_text"], surface_forms)
if snippet:
break
mentioned_in_sources.append((source, snippet))
bits = []
if cited_in_sources:
bits.append(f"**cited** in {', '.join(cited_in_sources)}")
if mentioned_in_sources:
ms = ", ".join(
f'{s} ("{snip}")' if snip else s for s, snip in mentioned_in_sources
)
bits.append(f"**mentioned by name** in {ms}")
if not bits:
note = f"{ent_name} has zero citations and zero mentions across all enabled sources."
else:
note = f"{ent_name} is " + "; ".join(bits) + "."
lines.append("")
lines.append(note)
# Top citation URLs (today only).
lines.append("")
lines.append("## Top 10 most-cited URLs (this run)")
for source in enabled_sources:
counts: dict[str, int] = {}
seen_cells: set[str] = set()
for r in current:
if r["source"] != source:
continue
cell_key = f"{r['prompt']}|{r['source']}"
if cell_key in seen_cells:
continue
seen_cells.add(cell_key)
for url in r["citation_urls"]:
counts[url] = counts.get(url, 0) + 1
top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:10]
lines.append("")
lines.append(f"### {source}")
if not top:
lines.append("_No citations returned._")
else:
for url, n in top:
lines.append(f"- ({n}x) {url}")
# Per-prompt detail with FULL answer text when any entity is mentioned in
# the cell, otherwise an 800-char preview.
lines.append("")
lines.append("## Per-prompt detail")
for prompt in config["prompts"]:
lines.append("")
lines.append(f"### Prompt: \"{prompt}\"")
for source in enabled_sources:
cell_rows = [r for r in current if r["prompt"] == prompt and r["source"] == source]
if not cell_rows:
continue
cell = cell_rows[0]
any_mentioned = any(r.get("mentioned") for r in cell_rows)
lines.append("")
lines.append(f"#### {source}")
for ent_name in entity_names:
ent_row = next((r for r in cell_rows if r["entity_name"] == ent_name), None)
if not ent_row:
continue
h = cell_history(prompt, source, ent_name)
history_bits = []
if h.get("total_prior_runs"):
history_bits.append(
f"cited in {h['cited_in_prior_runs']}/{h['total_prior_runs']} prior runs"
)
if h.get("last_cited_at") and not ent_row["cited"]:
history_bits.append(f"last cited {h['last_cited_at'][:10]}")
if h.get("newly_cited"):
history_bits.append("**first-ever citation today**")
if h.get("newly_mentioned"):
history_bits.append("**first-ever mention today**")
if h.get("dropped"):
history_bits.append("**dropped vs last run**")
history_str = " -- " + ", ".join(history_bits) if history_bits else ""
lines.append(
f"- **{ent_name}** -- cited: {ent_row['cited']}, "
f"mentioned: {ent_row['mentioned']}, SoV: {ent_row['share_of_voice_pct']}%"
f"{history_str}"
)
# Per-entity matched citation URLs (the EXACT URL that triggered "yes").
for ent_name in entity_names:
ent_row = next((r for r in cell_rows if r["entity_name"] == ent_name), None)
if ent_row and ent_row.get("matched_citation_urls"):
lines.append(f"- **{ent_name}** matched citation URLs:")
for url in ent_row["matched_citation_urls"]:
lines.append(f" - {url}")
if cell["citation_urls"]:
lines.append("- All citation URLs:")
for url in cell["citation_urls"][:20]:
lines.append(f" - {url}")
# Default: short quoted snippet around the surface-form match for
# each mentioned entity. Full answer text only when config sets
# include_full_answers="always".
if any_mentioned and include_full_answers:
lines.append("- Answer (full text, for mention context):")
lines.append("")
lines.append("```")
lines.append(cell["answer_text"].strip())
lines.append("```")
elif any_mentioned:
lines.append("- Mention context (quoted snippets):")
for ent_name in entity_names:
ent_row = next((r for r in cell_rows if r["entity_name"] == ent_name), None)
if not ent_row or not ent_row["mentioned"]:
continue
ent_full = next(e for k, e in entities_full if e["name"] == ent_name)
snippet = _quote_snippet(
cell["answer_text"],
ent_full.get("surface_forms") or [ent_name],
)
if snippet:
lines.append(f" - **{ent_name}**: \"{snippet}\"")
else:
lines.append("- Answer (first 400 chars):")
excerpt = cell["answer_text"].replace("\n", " ").strip()[:400]
lines.append(f" > {excerpt}")
return "\n".join(lines) + "\n"
# ---------- main ----------
def build_actor_input(config: dict) -> dict:
apify_cfg = config["apify"]
ai = config["ai_sources"]
inp: dict[str, Any] = {
"queries": "\n".join(config["prompts"]),
"maxPagesPerQuery": 1,
"resultsPerPage": 10,
"countryCode": apify_cfg.get("country_code", "us"),
"languageCode": apify_cfg.get("language_code", "en"),
}
for source_key, (obj_key, flag_key) in SOURCE_TOGGLES.items():
inp[obj_key] = {flag_key: bool(ai.get(source_key, False))}
return inp
def main() -> int:
ap = argparse.ArgumentParser(description="AI visibility snapshot runner.")
ap.add_argument("--config", required=True, help="Path to config.json")
args = ap.parse_args()
config_path = Path(args.config).resolve()
if not config_path.exists():
sys.stderr.write(f"Error: config.json not found at {config_path}\n")
return 2
config_dir = config_path.parent
load_dotenv_into_env(config_dir)
token = os.environ.get("APIFY_TOKEN")
if not token:
sys.stderr.write(
"Error: APIFY_TOKEN not found in environment.\n"
"Export it (`export APIFY_TOKEN=...`) or add it to a .env file next to config.json.\n"
)
return 2
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
sys.stderr.write(f"Error: config.json is not valid JSON: {e}\n")
return 2
actor_id = config["apify"]["actor_id"]
dataset_name = config["apify"]["dataset_name"]
log(f"Loaded config: {config_path}")
log(f"Resolving named dataset: {dataset_name}")
dataset_id = get_or_create_dataset(token, dataset_name)
log(f"Dataset ID: {dataset_id}")
actor_input = build_actor_input(config)
log(f"Starting actor: {actor_id}")
run_id, run_default_dataset_id = start_actor(token, actor_id, actor_input)
run_timestamp = dt.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
log(f"Run ID: {run_id}")
status = poll_until_done(token, run_id)
if status != "SUCCEEDED":
sys.stderr.write(
f"Error: Actor run finished with status {status}. "
f"Inspect: https://console.apify.com/actors/runs/{run_id}\n"
)
return 1
raw_items = fetch_dataset_items(token, run_default_dataset_id)
log(f"Fetched {len(raw_items)} raw result(s).")
rows = parse_run_into_rows(raw_items, config, run_id, run_timestamp)
log(f"Parsed {len(rows)} snapshot row(s).")
# Load ALL prior rows BEFORE pushing, so the history view sees every previous
# run and the current run is not double-counted.
all_prior_rows = fetch_dataset_items(token, dataset_id)
all_prior_rows = [r for r in all_prior_rows if r.get("run_id") != run_id]
distinct_prior_runs = len({r.get("run_id") for r in all_prior_rows if r.get("run_id")})
if distinct_prior_runs:
log(f"Loaded {len(all_prior_rows)} rows across {distinct_prior_runs} prior runs.")
else:
log("No previous runs found -- first snapshot.")
push_dataset_items(token, dataset_id, rows)
log(f"Appended {len(rows)} rows to dataset '{dataset_name}'.")
# Long-term raw preservation: write the full Apify dataset item to a named
# KV store, keyed by run_timestamp + prompt slug. Lets the user open any
# historical snapshot and inspect organicResults, peopleAlsoAsk, the full
# AI answer text, and every citation with title + description -- even
# months later, after the original actor run's retention window expires.
kv_store_name = config["apify"].get("kv_store_name") or f"{dataset_name}-raw"
try:
kv_store_id = get_or_create_kv_store(token, kv_store_name)
log(f"KV store: {kv_store_name} ({kv_store_id})")
for item in raw_items:
prompt_text = (item.get("searchQuery") or {}).get("term") or "unknown"
slug = re.sub(r"[^a-z0-9]+", "-", prompt_text.lower()).strip("-")[:80]
key = f"{run_timestamp.replace(':', '-')}__{slug}"
put_kv_record(token, kv_store_id, key, item)
log(f"Wrote {len(raw_items)} raw record(s) to KV store '{kv_store_name}'.")
except Exception as e:
sys.stderr.write(f"Warning: KV store write failed ({e}); dataset rows still appended.\n")
history = build_history(rows, all_prior_rows) if all_prior_rows else None
report = render_report(rows, all_prior_rows or None, history, config, run_id, run_timestamp)
reports_dir = config_dir / "reports"
reports_dir.mkdir(parents=True, exist_ok=True)
iso_date = run_timestamp[:10]
report_path = reports_dir / f"snapshot-{iso_date}.md"
report_path.write_text(report, encoding="utf-8")
log(f"Wrote report: {report_path}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env bash
# uninstall_cron.sh -- remove the schedule installed by install_cron.sh.
#
# Usage: bash uninstall_cron.sh
set -euo pipefail
OS="$(uname -s)"
LABEL="com.apify.ai-visibility-tracker"
case "$OS" in
Darwin)
plist="$HOME/Library/LaunchAgents/$LABEL.plist"
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null \
|| launchctl unload "$plist" 2>/dev/null \
|| true
if [[ -f "$plist" ]]; then
rm "$plist"
echo "Removed: $plist"
else
echo "No plist found at $plist"
fi
;;
Linux)
if crontab -l 2>/dev/null | grep -q '# ai-visibility-tracker'; then
crontab -l 2>/dev/null | grep -v '# ai-visibility-tracker' | crontab -
echo "Removed crontab entry tagged '# ai-visibility-tracker'."
else
echo "No matching crontab entry found."
fi
;;
CYGWIN*|MINGW*|MSYS*)
echo "On Windows, remove via Task Scheduler:"
echo " schtasks /Delete /TN \"AI Visibility Tracker\" /F"
;;
*)
echo "Unsupported OS: $OS" >&2; exit 2 ;;
esac
Troubleshooting
Setup / first run
`APIFY_TOKEN not found` Put the token in a .env file in the same directory as config.json (echo 'APIFY_TOKEN=...' > .env && chmod 600 .env). The runner auto-loads it on every fire -- whether invoked manually, from launchd, or from cron. Rotate the token by overwriting the same .env file -- no schedule reinstall needed.
`config.json not found` Run Step 3 of SKILL.md -- copy reference/scripts/config.example.json to ./config.json and fill in the seven required fields. The runner refuses to start without it; this is deliberate (no interactive fallback, because launchd / cron has no stdin).
`ModuleNotFoundError: No module named 'requests'` The runner needs requests. Install with pip3 install requests. Optional but recommended: pip3 install tldextract for accurate registrable-domain matching on multi-part TLDs.
Actor runs
Actor run status: `FAILED` The runner prints the console URL -- open it. Common causes:
- Invalid
countryCode/languageCode(use ISO 3166-1 alpha-2 / ISO 639-1). - Apify account out of credits.
- Actor build temporarily broken -- retry in 5 minutes or pin to an older build (
--build).
Dataset not appearing in console Named datasets are scoped to the token's account. Verify by hitting https://api.apify.com/v2/datasets?token=$APIFY_TOKEN&unnamed=false. If the runner's first call to "get-or-create dataset by name" fails, it falls back to writing items to the run's default dataset and prints the run-default dataset URL; the next run will retry the named dataset.
Citation and mention matching
Brand looks uncited but I see it in the answer text Two checks: 1. Is the URL on the same registrable domain? github.com/apify/... is a citation for GitHub, not Apify. 2. Did the surface form match? \bApify\b will not match Apify's if your form is Apify's (the trailing s is a word character). Add Apify's as a separate surface form, or rely on Apify (which matches the Apify part of Apify's via \b...\b).
Brand showing as mentioned in unrelated text The brand surface form is too generic (e.g., Apify as a verb in some other context). Use a more specific form (apify.com, or the brand plus a qualifier).
Schedule (launchd / cron) issues
macOS `launchctl list | grep ai-visibility` shows the label but the dataset has no new rows
- Machine was asleep at the scheduled hour. launchd skips fires while asleep and does NOT catch up. Open the lid before the next scheduled hour, or host the runner on an always-on machine.
- The runner errored. Tail
~/Library/Logs/ai-visibility-tracker.logand look for the lastError:line.
Linux `crontab -l` shows the line but no log lines appear
- cron daemon disabled. On Debian/Ubuntu:
systemctl status cron(orcronie/crondon RHEL/Fedora). Container / WSL environments often have no daemon at all. - Permissions on
~/.ai-visibility-tracker.log: if the file is root-owned from an earlier test,rmit and let cron recreate it.
`APIFY_TOKEN not found` in the log
.envmissing or unreadable. Verify:cat $CONFIG_DIR/.envshould show theAPIFY_TOKEN=line.- Permissions: should be
chmod 600 .env. - Token rotated:
echo 'APIFY_TOKEN=new_value' > .env-- no reinstall needed.
`python3: command not found` in the log
- The plist / crontab captured an absolute path at install time. If you've reinstalled Homebrew or moved Python since, the path is stale. Re-run
install_cron.sh-- it re-resolvespython3.
Reports land in `$HOME/reports/` instead of the project folder
- The launchd plist uses
WorkingDirectory; cron lines start withcd $CONFIG_DIR &&. If you've edited the crontab by hand and dropped thecd, reports go to whatever cron's defaultpwdis ($HOME). Reinstall viainstall_cron.shto fix.
Diff and reports
Report says `_First run -- no diff available._` but I know it's not the first run The runner finds the previous run by querying the dataset for rows with a different run_timestamp for the same prompt. If the dataset name was changed in config.json, the diff has nothing to compare to -- it's looking at an empty dataset. Either restore the old dataset name, or accept the missing diff for this transition run.
Share-of-voice deltas look bigger than expected SoV is sensitive to the citation count. A query with 4 citations vs. one with 14 -- the same brand cited once swings SoV from 25% to 7%. Always look at Delta Cited (raw count) alongside Delta SoV. Both are in the per-source scorecard.
Config corruption
Runner exits with `json.JSONDecodeError` on config.json Restore from the example: cp reference/scripts/config.example.json ./config.json.new, copy your values over by hand. Schema lives in output-schema.md for the dataset; config schema is in config.example.json itself.
Related skills
FAQ
Which AI engines does it track?
Google AI Overviews, Google AI Mode, ChatGPT Search, Perplexity, Microsoft Copilot, and Google Gemini.
What are the four workflows?
Competitor prompt discovery, citation opportunity finder, GEO website audit, and scheduled tracking over time.