{"id":57978,"date":"2025-09-26T12:41:06","date_gmt":"2025-09-26T12:41:06","guid":{"rendered":"https:\/\/nuoptima.com\/?post_type=ai-search-guide&#038;p=57978"},"modified":"2026-03-09T15:07:02","modified_gmt":"2026-03-09T15:07:02","slug":"ai-tracking","status":"publish","type":"ai-search-guide","link":"https:\/\/nuoptima.com\/ai-search-guide\/ai-tracking","title":{"rendered":"How to Track Your Brand in AI Search"},"content":{"rendered":"<p class=\"wp-block-paragraph \" style=\"\">Traditional SEO reporting used to be simple: you tracked rankings, checked impressions and clicks in Search Console, and mapped those numbers to business impact. AI-driven search has reshaped that flow. Platforms like Google AI Overviews, AI Mode, Perplexity, and Bing Copilot no longer just list results &#8211; they generate answers and choose which sources to reference. That shift means the key question is no longer &ldquo;do we rank?&rdquo; but &ldquo;are we being cited in the generative layer?&rdquo;<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">The challenge is that this answer layer doesn&rsquo;t sit neatly inside existing analytics tools, and it isn&rsquo;t stable. A query that cites your brand today might not include you tomorrow, even if nothing on your site changed. GEO analytics is about shining light on this hidden layer, tracking how it moves, and connecting those appearances to measurable results.<\/p>\n\n\n<h2 class=\"wp-block-heading\" id=\"active-vs-passive-ways-to-spot-visibility\">Active vs Passive Ways to Spot Visibility<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">A reliable GEO measurement setup blends two approaches: actively checking where you show up and passively watching how search systems interact with your site.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Active detection means building your own monitoring agents that query AI search engines, capture the responses, and check for mentions of your domain. This could be as lightweight as a browser automation script that runs a set of queries and saves screenshots, or as advanced as a full pipeline that stores structured citation data from HTML or JSON outputs. The important part is repetition &mdash; AI answers are regenerated each time, so you need to run these agents often to capture how results shift. That variation is useful data in itself, especially when you compare it against changes in your own content or the wider competitive set.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Passive detection comes from your server logs. By tracking requests from AI crawlers, you can see when these systems fetch your pages. For example, if you notice PerplexityBot hitting certain URLs more frequently and then spot an uptick in citations for those same pages, you&rsquo;ve uncovered a clear connection between retrieval and exposure. With more advanced parsing, you can group these bot visits by page and understand which parts of your site are being prioritized.<\/p>\n\n\n<h2 class=\"wp-block-heading\" id=\"tracking-ai-search-bots\">Tracking AI Search Bots<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">One of the most direct ways to understand how your content feeds into generative answers is to keep a close eye on the bots that crawl your site. These user agents represent the retrieval layer for platforms such as ChatGPT, Claude, Perplexity, Bing Copilot, and others. By monitoring them in your server logs, you get an early signal of when and how your content is being collected for potential inclusion in AI-generated responses.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">This visibility matters because changes in crawl activity often align with shifts in your presence across AI search features. If certain bots increase their visits, it can indicate a stronger chance of your pages being retrieved and cited. Building and maintaining an up-to-date list of these crawlers, then matching their activity to your generative visibility, gives you one of the few tangible ways to measure a process that otherwise operates in the dark.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Vendor<\/strong><\/td><td><strong>User Agent \/ Token<\/strong><\/td><td><strong>Purpose<\/strong><\/td><td><strong>How to Manage<\/strong><\/td><td><strong>Extra Notes<\/strong><\/td><\/tr><tr><td><strong>OpenAI<\/strong><\/td><td>GPTBot<\/td><td>Collects content for OpenAI model training<\/td><td>Block or allow in robots.txt (User-agent: GPTBot)<\/td><td>Documented on OpenAI\u2019s official site<\/td><\/tr><tr><td><\/td><td>OAI-SearchBot<\/td><td>Fetches pages for ChatGPT\u2019s search results, not for training<\/td><td>User-agent: OAI-SearchBot<\/td><td>Used specifically for retrieval<\/td><\/tr><tr><td><strong>Anthropic<\/strong><\/td><td>ClaudeBot<\/td><td>Broad crawler for Claude model training<\/td><td>Control with robots.txt<\/td><td>Publicly explained in Anthropic\u2019s help docs<\/td><\/tr><tr><td><\/td><td>Claude-User<\/td><td>Fetcher triggered during live user queries<\/td><td>User-agent: Claude-User<\/td><td>Distinct from training bot<\/td><\/tr><tr><td><strong>Perplexity<\/strong><\/td><td>PerplexityBot<\/td><td>Main crawler that builds Perplexity\u2019s index<\/td><td>User-agent: PerplexityBot<\/td><td>Publishes IP ranges and bot details<\/td><\/tr><tr><td><\/td><td>Perplexity-User<\/td><td>Request-time fetcher when a user asks a question<\/td><td>User-agent: Perplexity-User<\/td><td>Not used for long-term indexing<\/td><\/tr><tr><td><strong>Google<\/strong><\/td><td>Googlebot Family<\/td><td>Standard crawlers for web, images, video, etc.<\/td><td>Controlled in robots.txt<\/td><td>Feeds both Search and generative features<\/td><\/tr><tr><td><\/td><td>Google-Extended<\/td><td>Token that governs use of your content in AI features<\/td><td>Add in robots.txt as User-agent: Google-Extended<\/td><td>Not a crawler itself, just a control switch<\/td><\/tr><tr><td><strong>Microsoft<\/strong><\/td><td>bingbot<\/td><td>Bing\u2019s primary crawler, also used for Copilot<\/td><td>User-agent: bingbot<\/td><td>Behaves like Googlebot with AI integration<\/td><\/tr><tr><td><strong>Apple<\/strong><\/td><td>Applebot<\/td><td>Crawler for Siri, Spotlight, and Apple services<\/td><td>User-agent: Applebot<\/td><td>Standard Apple crawler<\/td><\/tr><tr><td><\/td><td>Applebot-Extended<\/td><td>Token for opting out of Apple\u2019s AI training use<\/td><td>Control in robots.txt<\/td><td>Only affects model training, not crawling<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n<h2 class=\"wp-block-heading\" id=\"shaping-visibility-with-nuoptima\">Shaping Visibility With NUOPTIMA<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">At <a href=\"https:\/\/nuoptima.com\/\">NUOPTIMA<\/a>, we see visibility as more than search rankings. In AI-driven results, the question is whether your brand is recognized, cited, and trusted in the answer itself. That&rsquo;s why we align classic SEO with Generative Engine Optimization &#8211; helping brands earn presence where it matters most.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">We combine strategy, data, and execution to make sure your content is both retrievable by AI systems and impactful for real users. Our work spans technical SEO, international optimization, and content built to drive measurable growth. The result is stronger visibility that translates directly into business outcomes.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"why-brands-choose-us\">Why Brands Choose Us:<\/h3>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>Proven results with 70+ industry leaders across multiple sectors<\/li>\n\n\n\n<li>3x average return on ad spend across organic and paid campaigns<\/li>\n\n\n\n<li>Expertise in scaling SaaS, eCommerce, healthcare, and more<\/li>\n\n\n\n<li>A balance of AI-driven insights with human-led strategy<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Our approach isn&rsquo;t about chasing vanity metrics. It&rsquo;s about building visibility that sticks &mdash; presence in AI answers, credibility with users, and lasting gains in traffic and revenue. By focusing on how AI systems retrieve and cite information, we help brands secure a position in the conversations that drive decisions.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">We also understand that every business is different. That&rsquo;s why we tailor strategies to match your industry, your audience, and your growth goals. Whether you&rsquo;re a startup looking for traction or an established company scaling globally, we apply the same data-driven mindset to deliver results you can measure.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">At the end of the day, our mission is simple: make sure your brand doesn&rsquo;t just appear in search, but that it stands out in the AI-powered future of discovery.<\/p>\n\n\n<h2 class=\"wp-block-heading\" id=\"detecting-ai-overviews-and-ai-mode\">Detecting AI Overviews and AI Mode<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Google runs both AI Overviews and AI Mode on top of its generative systems, but the two behave differently, which means you need separate ways of measuring them.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"ai-overviews\">AI Overviews<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Overviews appear directly within the search results page, usually when Google decides a quick, synthesized summary will improve the experience. Spotting them requires more than glancing at rankings. You need to capture the full SERP, scan for the Overview block, and check whether your site is being referenced in the answer or linked as a source.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">The challenge is that Overviews aren&rsquo;t fixed. A query might trigger one result in the morning and a completely different set in the afternoon. Sometimes the block disappears altogether. That instability makes ongoing tracking essential &#8211; one screenshot tells you almost nothing, but repeated checks reveal the real pattern of your presence.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"ai-mode\">AI Mode<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">AI Mode works differently. It sits in its own tab and produces longer, conversational responses. Because it aims to sustain a dialogue, the sources it cites often diverge from those shown in Overviews. Measuring visibility here means capturing the full conversation output and extracting every link included.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">By comparing the two: Overviews and AI Mode &#8211; you can see how Google favors different sites depending on context. This side-by-side view often uncovers platform biases and shows where your brand is strong in one environment but missing in the other.<\/p>\n\n\n<h2 class=\"wp-block-heading\" id=\"tracking-ai-overviews-and-ai-mode-with-fetchserp\">Tracking AI Overviews and AI Mode With FetchSERP<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">One of the simplest ways to monitor your visibility in Google&rsquo;s AI surfaces is through FetchSERP. The platform provides a set of API endpoints that can return both traditional SERP data and generative results. For GEO tracking, the two endpoints that matter most are:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>\/serp_ai &ndash; combines AI Overview and AI Mode in one payload whenever they&rsquo;re available.<\/li>\n\n\n\n<li>\/serp_ai_mode &ndash; a faster, US-only endpoint that focuses specifically on AI Mode results.<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Both require an API token in the header, and each request needs your search query plus an optional country parameter (default is US).<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"how-it-maps-into-sheets\">How It Maps Into Sheets<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">To keep everything organized, you&rsquo;ll want to connect these API calls to a Google Sheet. Here&rsquo;s the structure that works best:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>\n<strong>Keywords tab<\/strong>: Your query list (column A should be &ldquo;keyword,&rdquo; followed by one query per row).<\/li>\n\n\n\n<li>\n<strong>AIO_Results<\/strong>: Automatically populated with flags and source details whenever an AI Overview shows your brand.<\/li>\n\n\n\n<li>\n<strong>AI_Mode_Results<\/strong>: Similar output for AI Mode queries.<\/li>\n\n\n\n<li>\n<strong>GEO_AISummary<\/strong>: A running summary with charts that show how many queries triggered each AI surface.<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Every time the script runs, it appends new rows with a timestamp. This means you can build a historical log that shows not just if you&rsquo;re cited, but how that visibility changes over time.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"why-this-matters\">Why This Matters<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Running the script multiple times per day helps you capture short-term swings, while daily runs build a reliable long-term view. Over time, you can:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>Track frequency and consistency of citations<\/li>\n\n\n\n<li>Compare citation order and positioning<\/li>\n\n\n\n<li>Spot patterns in volatility versus stability<\/li>\n<\/ul>\n\n\n<h3 class=\"wp-block-heading\" id=\"setting-up-the-script\">Setting Up the Script<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">In Google Sheets, you&rsquo;ll add the script through Extensions &rarr; Apps Script. Before the first run, set your API key under Project Settings with the property name FETCHSERP_API_TOKEN. Once saved, paste in the script, reload the Sheet, and you&rsquo;ll see a new menu option: GEO (FetchSERP) &rarr; Fetch AIO &amp; AI Mode.<\/p>\n\n\n<pre class=\"wp-block-code\"><div class=\"copy-to-clipboard\">\n<span>Copied!<\/span><button class=\"click-to-copy-button\" title=\"Copy to clipboard\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" viewbox=\"0 0 32 32\" stroke=\"currentcolor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"24\" height=\"24\" fill=\"none\">\n  <path d=\"M12.9975 10.7499L11.7475 10.7499C10.6429 10.7499 9.74747 11.6453 9.74747 12.7499L9.74747 21.2499C9.74747 22.3544 10.6429 23.2499 11.7475 23.2499L20.2475 23.2499C21.352 23.2499 22.2475 22.3544 22.2475 21.2499L22.2475 12.7499C22.2475 11.6453 21.352 10.7499 20.2475 10.7499L18.9975 10.7499Z\"><\/path>\n  <path d=\"M17.9975 12.2499L13.9975 12.2499C13.4452 12.2499 12.9975 11.8022 12.9975 11.2499L12.9975 9.74988C12.9975 9.19759 13.4452 8.74988 13.9975 8.74988L17.9975 8.74988C18.5498 8.74988 18.9975 9.19759 18.9975 9.74988L18.9975 11.2499C18.9975 11.8022 18.5498 12.2499 17.9975 12.2499Z\"><\/path>\n  <path d=\"M13.7475 16.2499L18.2475 16.2499\"><\/path>\n  <path d=\"M13.7475 19.2499L18.2475 19.2499\"><\/path>\n<\/svg><\/button><textarea>\/**\n * FetchSERP &rarr; Google Sheets tracker for AI Overviews (AIO) and AI Mode\n * with rank-like signals and brand presence pivot.\n *\n * Tabs expected\/created:\n *  - Keywords: column A header \"keyword\", then one query per row.\n *  - Brands: column A header \"domain\" (your brand domains, no www).\n *  - AIO_Results: appended per run (presence + top domains).\n *  - AI_Mode_Results: appended per run (presence + top domains).\n *  - AI_Sources: appended per run (1 row per citation with rank and metadata).\n *  - GEO_AISummary: summary counts + chart (AIO vs AI Mode triggers).\n *  - GEO_BrandPresence: pivot for latest run + chart of rank-1 shares.\n *\/\n\nconst FETCHSERP_BASE = 'https:\/\/www.fetchserp.com\/api\/v1';\nconst DEFAULT_COUNTRY = 'us';\n\nfunction onOpen() {\n  SpreadsheetApp.getUi()\n    .createMenu('GEO (FetchSERP)')\n    .addItem('Fetch AIO &amp; AI Mode', 'runAIOTracking')\n    .addToUi();\n}\n\nfunction runAIOTracking() {\n  const ss = SpreadsheetApp.getActiveSpreadsheet();\n  const token = getApiToken_();\n  const keywords = getKeywords_(ss);\n  ensureBrandsSheet_(ss); \/\/ make sure Brands exists (empty is fine)\n\n  const aioSheet = ensureSheet_(ss, 'AIO_Results', [\n    'timestamp', 'keyword', 'country',\n    'has_ai_overview', 'source_count', 'top_source_domain', 'all_sources'\n  ]);\n\n  const aimodeSheet = ensureSheet_(ss, 'AI_Mode_Results', [\n    'timestamp', 'keyword', 'country',\n    'has_ai_mode', 'source_count', 'top_source_domain', 'all_sources'\n  ]);\n\n  const srcSheet = ensureSheet_(ss, 'AI_Sources', [\n    'timestamp', 'keyword', 'country', 'surface', \/\/ AIO or AI_MODE\n    'rank', 'url', 'domain', 'title', 'site_name'\n  ]);\n\n  const now = new Date();\n  const aioRows = [];\n  const aimodeRows = [];\n  const sourceRows = [];\n\n  for (const kw of keywords) {\n    const country = DEFAULT_COUNTRY;\n\n    \/\/ Primary combined endpoint\n    const aiData = callFetchSerp_('serp_ai', { query: kw, country }, token);\n\n    \/\/ Optional AI Mode accelerator (US-only, cached)\n    let aiModeData = null;\n    try {\n      aiModeData = callFetchSerp_('serp_ai_mode', { query: kw }, token);\n    } catch (e) {\n      \/\/ OK to ignore; not always necessary\n    }\n\n    \/\/ ---- AI OVERVIEW ----\n    const aioBlock = getBlock_(aiData, 'ai_overview');\n    const aioSources = normalizeSources_(aioBlock &amp;&amp; aioBlock.sources);\n    const aioTop = aioSources.length ? aioSources[0] : null;\n    aioRows.push([\n      now, kw, country,\n      !!aioBlock,\n      aioSources.length,\n      aioTop ? domainOnly_(aioTop.url || aioTop.site_name || '') : '',\n      aioSources.map(s =&gt; domainOnly_(s.url || s.site_name || '')).join(' | ')\n    ]);\n\n    \/\/ push detailed sources with rank\n    aioSources.forEach((s, i) =&gt; {\n      sourceRows.push([\n        now, kw, country, 'AIO',\n        i + 1,\n        s.url || '',\n        domainOnly_(s.url || s.site_name || ''),\n        s.title || '',\n        s.site_name || ''\n      ]);\n    });\n\n    \/\/ ---- AI MODE ----\n    const aiModeBlock = extractAiMode_(aiModeData) || extractAiMode_(aiData);\n    const aimSources = normalizeSources_(aiModeBlock &amp;&amp; aiModeBlock.sources);\n    const aimTop = aimSources.length ? aimSources[0] : null;\n    aimodeRows.push([\n      now, kw, country,\n      !!aiModeBlock,\n      aimSources.length,\n      aimTop ? domainOnly_(aimTop.url || aimTop.site_name || '') : '',\n      aimSources.map(s =&gt; domainOnly_(s.url || s.site_name || '')).join(' | ')\n    ]);\n\n    aimSources.forEach((s, i) =&gt; {\n      sourceRows.push([\n        now, kw, country, 'AI_MODE',\n        i + 1,\n        s.url || '',\n        domainOnly_(s.url || s.site_name || ''),\n        s.title || '',\n        s.site_name || ''\n      ]);\n    });\n\n    Utilities.sleep(400); \/\/ rate-friendly\n  }\n\n  if (aioRows.length) appendRows_(aioSheet, aioRows);\n  if (aimodeRows.length) appendRows_(aimodeSheet, aimodeRows);\n  if (sourceRows.length) appendRows_(srcSheet, sourceRows);\n\n  buildSummaryAndChart_();\n  buildBrandPresencePivotAndChart_(); \/\/ NEW\n}\n\n\/* ----------------------- Helpers &amp; Builders ----------------------- *\/\n\nfunction getApiToken_() {\n  const props = PropertiesService.getScriptProperties();\n  const token = props.getProperty('FETCHSERP_API_TOKEN') || '';\n  if (!token) {\n    throw new Error('Missing FETCHSERP_API_TOKEN in Script properties. Set it in Project Settings.');\n  }\n  return token;\n}\n\nfunction getKeywords_(ss) {\n  const sh = ss.getSheetByName('Keywords');\n  if (!sh) throw new Error('Missing \"Keywords\" sheet with header \"keyword\" in A1.');\n  const values = sh.getRange(2, 1, Math.max(0, sh.getLastRow() - 1), 1)\n    .getValues().flat().map(String).map(s =&gt; s.trim()).filter(Boolean);\n  if (!values.length) throw new Error('No keywords found under header \"keyword\" (A2:A).');\n  return values;\n}\n\nfunction ensureSheet_(ss, name, headers) {\n  let sh = ss.getSheetByName(name);\n  if (!sh) sh = ss.insertSheet(name);\n  if (sh.getLastRow() === 0) {\n    sh.getRange(1, 1, 1, headers.length).setValues([headers]);\n    sh.setFrozenRows(1);\n  }\n  return sh;\n}\n\nfunction ensureBrandsSheet_(ss) {\n  let sh = ss.getSheetByName('Brands');\n  if (!sh) {\n    sh = ss.insertSheet('Brands');\n    sh.getRange(1, 1).setValue('domain');\n    sh.setFrozenRows(1);\n  }\n  return sh;\n}\n\nfunction callFetchSerp_(path, params, token) {\n  const url = `${FETCHSERP_BASE}\/${path}?` + Object.keys(params)\n    .map(k =&gt; `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join('&amp;');\n\n  const res = UrlFetchApp.fetch(url, {\n    method: 'get',\n    headers: { 'accept': 'application\/json', 'authorization': `Bearer ${token}` },\n    muteHttpExceptions: true\n  });\n\n  const code = res.getResponseCode();\n  const text = res.getContentText();\n  if (code = 300) throw new Error(`FetchSERP ${path} error ${code}: ${text}`);\n\n  try { return JSON.parse(text); }\n  catch (e) { throw new Error(`FetchSERP ${path} invalid JSON: ${text.slice(0, 300)}&hellip;`); }\n}\n\nfunction getBlock_(payload, key) {\n  if (!payload) return null;\n  const d = payload.data || payload;\n  if (d.results &amp;&amp; d.results[key]) return d.results[key];\n  if (d[key]) return d[key];\n  return null;\n}\n\nfunction extractAiMode_(payload) { \/\/ tolerate different shapes\n  if (!payload) return null;\n  const d = payload.data || payload;\n  if (d.results &amp;&amp; d.results.ai_mode) return d.results.ai_mode;\n  if (d.ai_mode) return d.ai_mode;\n  return null;\n}\n\nfunction normalizeSources_(sources) {\n  if (!Array.isArray(sources)) return [];\n  return sources\n    .map(s =&gt; s || {})\n    .map(s =&gt; ({\n      url: s.url || '',\n      title: s.title || '',\n      site_name: s.site_name || ''\n    }))\n    .filter(s =&gt; s.url || s.site_name);\n}\n\nfunction domainOnly_(u) {\n  try {\n    const host = (new URL(u)).hostname || '';\n    return host.replace(\/^www\\.\/i, '');\n  } catch (e) {\n    return (u || '').replace(\/^www\\.\/i, '');\n  }\n}\n\nfunction appendRows_(sheet, rows) {\n  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);\n}\n\n\/** Create or refresh a historical summary tab with counts &amp; share of voice, plus trend chart. *\/\n\/** Create or refresh a historical summary tab with counts, SOV, and 7-day rolling averages, plus a trend chart. *\/\nfunction buildSummaryAndChart_() {\n  const ss = SpreadsheetApp.getActiveSpreadsheet();\n  const sum = ensureSheet_(ss, 'GEO_AISummary', [\n    'date',\n    'AIO_count', 'AIO_share_of_voice',\n    'AI_Mode_count', 'AI_Mode_share_of_voice',\n    'keywords_tracked',\n    'AIO_count_7dma', 'AI_Mode_count_7dma',\n    'AIO_sov_7dma', 'AI_Mode_sov_7dma'\n  ]);\n\n  \/\/ Clear old rows (keep header)\n  if (sum.getLastRow() &gt; 1) {\n    sum.getRange(2, 1, sum.getLastRow() - 1, 11).clearContent();\n  }\n\n  const aio = ss.getSheetByName('AIO_Results');\n  const aim = ss.getSheetByName('AI_Mode_Results');\n  const kwSheet = ss.getSheetByName('Keywords');\n  if (!aio || !aim || !kwSheet) return;\n\n  const keywordsTracked = Math.max(0, kwSheet.getLastRow() - 1);\n\n  \/\/ Build daily counts maps\n  const aioCounts = countByDate_(aio, 1, 4); \/\/ timestamp col 1, has_ai_overview col 4\n  const aimCounts = countByDate_(aim, 1, 4); \/\/ timestamp col 1, has_ai_mode col 4\n\n  const allDates = Array.from(new Set([...Object.keys(aioCounts), ...Object.keys(aimCounts)]))\n    .sort((a, b) =&gt; new Date(a) - new Date(b));\n\n  \/\/ Build rows with daily values first\n  const rows = allDates.map(date =&gt; {\n    const aCount = aioCounts[date] || 0;\n    const mCount = aimCounts[date] || 0;\n    const aSOV = keywordsTracked ? aCount \/ keywordsTracked : 0;\n    const mSOV = keywordsTracked ? mCount \/ keywordsTracked : 0;\n    return [\n      date,\n      aCount, aSOV,\n      mCount, mSOV,\n      keywordsTracked,\n      null, null, \/\/ AIO_count_7dma, AI_Mode_count_7dma (fill after)\n      null, null  \/\/ AIO_sov_7dma, AI_Mode_sov_7dma (fill after)\n    ];\n  });\n\n  \/\/ Compute 7-day rolling averages (centered on the last 7 days ending at index i)\n  const aCountSeries = rows.map(r =&gt; r[1]);\n  const mCountSeries = rows.map(r =&gt; r[3]);\n  const aSovSeries   = rows.map(r =&gt; r[2]);\n  const mSovSeries   = rows.map(r =&gt; r[4]);\n\n  const aCount7 = rollingMean_(aCountSeries, 7);\n  const mCount7 = rollingMean_(mCountSeries, 7);\n  const aSov7   = rollingMean_(aSovSeries,   7);\n  const mSov7   = rollingMean_(mSovSeries,   7);\n\n  \/\/ Fill the rolling columns\n  for (let i = 0; i  sum.removeChart(c));\n\n  const dataHeight = rows.length + 1; \/\/ include header\n  const chart = sum.newChart()\n    .setChartType(Charts.ChartType.LINE)\n    .addRange(sum.getRange(1, 1, dataHeight, 10)) \/\/ includes counts, SOV, and 7d SOV\n    .setPosition(5, 1, 0, 0)\n    .setOption('title', 'AIO &amp; AI Mode &mdash; Daily Counts and 7-Day SOV Averages')\n    .setOption('hAxis', { title: 'Date' })\n    .setOption('vAxes', {\n      0: { title: 'Keyword Count' },\n      1: { title: 'Share of Voice (7-day avg)', format: 'percent' }\n    })\n    \/\/ Series mapping: 0=AIO_count, 1=AIO_SOV, 2=AI_Mode_count, 3=AI_Mode_SOV, 4=keywords_tracked,\n    \/\/                 5=AIO_count_7dma, 6=AI_Mode_count_7dma, 7=AIO_sov_7dma, 8=AI_Mode_sov_7dma\n    \/\/ We'll show daily counts (0,2) on axis 0, hide raw daily SOV (1,3) to reduce noise,\n    \/\/ show smoothed SOV (7,8) on axis 1, and hide keywords_tracked (4) + count_7dma (5,6) from display.\n    .setOption('series', {\n      0: { targetAxisIndex: 0 }, \/\/ AIO_count (line)\n      1: { targetAxisIndex: 1, visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ raw AIO SOV (hidden)\n      2: { targetAxisIndex: 0 }, \/\/ AI_Mode_count (line)\n      3: { targetAxisIndex: 1, visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ raw AI Mode SOV (hidden)\n      4: { visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ keywords_tracked (hidden)\n      5: { visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ AIO_count_7dma (hidden to avoid clutter)\n      6: { visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ AI_Mode_count_7dma (hidden)\n      7: { targetAxisIndex: 1 }, \/\/ AIO_sov_7dma (smooth)\n      8: { targetAxisIndex: 1 }  \/\/ AI_Mode_sov_7dma (smooth)\n    })\n    .setOption('legend', { position: 'bottom' })\n    .build();\n\n  sum.insertChart(chart);\n}\n\n\/** Simple trailing rolling mean with window W; returns array aligned to input length (nulls until window is filled). *\/\nfunction rollingMean_(arr, W) {\n  const out = new Array(arr.length).fill(null);\n  let sum = 0;\n  for (let i = 0; i = W) sum -= (typeof arr[i - W] === 'number' ? arr[i - W] : 0);\n    if (i &gt;= W - 1) out[i] = sum \/ W;\n  }\n  return out;\n}\n\n\n\/** Helper: count how many rows have TRUE in booleanCol, grouped by date from dateCol. *\/\nfunction countByDate_(sheet, dateCol, booleanCol) {\n  const rows = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).getValues();\n  const counts = {};\n  rows.forEach(row =&gt; {\n    const ts = row[dateCol - 1];\n    const has = row[booleanCol - 1];\n    if (ts &amp;&amp; (has === true || has === 'TRUE')) {\n      const d = new Date(ts);\n      const dateStr = Utilities.formatDate(d, Session.getScriptTimeZone(), 'yyyy-MM-dd');\n      counts[dateStr] = (counts[dateStr] || 0) + 1;\n    }\n  });\n  return counts;\n}\n\n\n\nfunction countTrue_(sheet, col) {\n  if (!sheet || sheet.getLastRow()  v === true || v === 'TRUE').length;\n}\n\n\/**\n * Build a pivot for BRAND presence (latest run only).\n * For each surface (AIO \/ AI_MODE), we compute:\n *  - queries_with_surface: number of keywords where that surface triggered\n *  - queries_brand_cited: number of those keywords where brand domain appears in any citation\n *  - presence_rate = brand_cited \/ queries_with_surface\n *  - queries_brand_rank1: number where brand is rank 1 citation\n *  - rank1_rate = brand_rank1 \/ queries_with_surface\n *\/\nfunction buildBrandPresencePivotAndChart_() {\n  const ss = SpreadsheetApp.getActiveSpreadsheet();\n  const brands = readBrands_(ss); \/\/ array of domains (no www)\n\n  const aioSheet = ss.getSheetByName('AIO_Results');\n  const aimSheet = ss.getSheetByName('AI_Mode_Results');\n  const srcSheet = ss.getSheetByName('AI_Sources');\n\n  if (!brands.length || !srcSheet || srcSheet.getLastRow()  Set(keywords)\n  const surfaceBrandRank1 = { AIO: new Map(), AI_MODE: new Map() }; \/\/ brand -&gt; Set(keywords)\n\n  \/\/ Iterate source rows for latest timestamp only\n  const srcVals = srcSheet.getRange(2, 1, srcSheet.getLastRow() - 1, 9).getValues();\n  for (const row of srcVals) {\n    const [ts, kw, country, surface, rank, url, domain\/*clean*\/, title, site_name] = row;\n    if (!sameDay_(ts, latest)) continue; \/\/ group by day\/run timestamp granularity\n\n    const dom = String(domain || '').toLowerCase();\n    if (!dom) continue;\n\n    \/\/ For presence calculations, only consider keywords that triggered that surface\n    if (surface === 'AIO' &amp;&amp; !latestAioKeywords.has(kw)) continue;\n    if (surface === 'AI_MODE' &amp;&amp; !latestAimKeywords.has(kw)) continue;\n\n    \/\/ For each brand, check match\n    for (const b of brands) {\n      if (dom.endsWith(b)) {\n        \/\/ any-cited\n        if (!surfaceBrandAny[surface].has(b)) surfaceBrandAny[surface].set(b, new Set());\n        surfaceBrandAny[surface].get(b).add(kw);\n\n        \/\/ rank1\n        if (rank === 1) {\n          if (!surfaceBrandRank1[surface].has(b)) surfaceBrandRank1[surface].set(b, new Set());\n          surfaceBrandRank1[surface].get(b).add(kw);\n        }\n      }\n    }\n  }\n\n  \/\/ Prepare output rows\n  const out = [];\n  const surfaces = ['AIO', 'AI_MODE'];\n  for (const s of surfaces) {\n    const queriesWithSurface = (s === 'AIO') ? latestAioKeywords.size : latestAimKeywords.size;\n    for (const b of brands) {\n      const cited = surfaceBrandAny[s].get(b)?.size || 0;\n      const r1 = surfaceBrandRank1[s].get(b)?.size || 0;\n      const presenceRate = queriesWithSurface ? (cited \/ queriesWithSurface) : 0;\n      const rank1Rate = queriesWithSurface ? (r1 \/ queriesWithSurface) : 0;\n      out.push([\n        b, s, queriesWithSurface, cited, presenceRate, r1, rank1Rate\n      ]);\n    }\n  }\n\n  const pivot = ensureSheet_(ss, 'GEO_BrandPresence', ['brand_domain', 'surface', 'queries_with_surface', 'queries_brand_cited', 'presence_rate', 'queries_brand_rank1', 'rank1_rate']);\n  \/\/ Clear old data\n  if (pivot.getLastRow() &gt; 1) pivot.getRange(2, 1, pivot.getLastRow() - 1, 7).clearContent();\n  if (out.length) pivot.getRange(2, 1, out.length, 7).setValues(out);\n\n  \/\/ Build\/refresh a chart for Rank-1 share per brand per surface\n  const charts = pivot.getCharts();\n  charts.forEach(c =&gt; pivot.removeChart(c));\n\n  \/\/ Simple approach: chart all rows, data has both surfaces; users can filter in Sheets UI.\n  const chart = pivot.newChart()\n    .setChartType(Charts.ChartType.COLUMN)\n    .addRange(pivot.getRange(1, 1, Math.max(2, pivot.getLastRow()), 7))\n    .setPosition(5, 1, 0, 0)\n    .setOption('title', 'Brand Rank-1 Share (latest run)')\n    .setOption('series', {\n      0: { targetAxisIndex: 0 }, \/\/ presence metrics\n      1: { targetAxisIndex: 0 },\n      2: { targetAxisIndex: 1 }  \/\/ rate on secondary axis if desired\n    })\n    .setOption('legend', { position: 'right' })\n    .build();\n  pivot.insertChart(chart);\n}\n\n\/* ---------- Pivot helpers ---------- *\/\n\nfunction readBrands_(ss) {\n  const sh = ss.getSheetByName('Brands');\n  if (!sh || sh.getLastRow()  v.trim().toLowerCase().replace(\/^www\\.\/, ''))\n    .filter(Boolean);\n  return Array.from(new Set(vals));\n}\n\nfunction latestTimestamp_(sheets) {\n  let latest = null;\n  for (const sh of sheets) {\n    if (!sh || sh.getLastRow()  latest) latest = d;\n      }\n    }\n  }\n  return latest;\n}\n\nfunction sameDay_(a, b) {\n  if (!(a instanceof Date)) a = new Date(a);\n  if (!(b instanceof Date)) b = new Date(b);\n  return a.getFullYear() === b.getFullYear() &amp;&amp;\n         a.getMonth() === b.getMonth() &amp;&amp;\n         a.getDate() === b.getDate();\n}\n\nfunction filterKeywordsByTimestampAndBool_(sheet, latestTs, boolColIndex) {\n  if (!sheet || sheet.getLastRow() <\/textarea>\n<\/div><code>\/**\n * FetchSERP &rarr; Google Sheets tracker for AI Overviews (AIO) and AI Mode\n * with rank-like signals and brand presence pivot.\n *\n * Tabs expected\/created:\n *  - Keywords: column A header \"keyword\", then one query per row.\n *  - Brands: column A header \"domain\" (your brand domains, no www).\n *  - AIO_Results: appended per run (presence + top domains).\n *  - AI_Mode_Results: appended per run (presence + top domains).\n *  - AI_Sources: appended per run (1 row per citation with rank and metadata).\n *  - GEO_AISummary: summary counts + chart (AIO vs AI Mode triggers).\n *  - GEO_BrandPresence: pivot for latest run + chart of rank-1 shares.\n *\/\n\nconst FETCHSERP_BASE = 'https:\/\/www.fetchserp.com\/api\/v1';\nconst DEFAULT_COUNTRY = 'us';\n\nfunction onOpen() {\n  SpreadsheetApp.getUi()\n    .createMenu('GEO (FetchSERP)')\n    .addItem('Fetch AIO &amp; AI Mode', 'runAIOTracking')\n    .addToUi();\n}\n\nfunction runAIOTracking() {\n  const ss = SpreadsheetApp.getActiveSpreadsheet();\n  const token = getApiToken_();\n  const keywords = getKeywords_(ss);\n  ensureBrandsSheet_(ss); \/\/ make sure Brands exists (empty is fine)\n\n  const aioSheet = ensureSheet_(ss, 'AIO_Results', [\n    'timestamp', 'keyword', 'country',\n    'has_ai_overview', 'source_count', 'top_source_domain', 'all_sources'\n  ]);\n\n  const aimodeSheet = ensureSheet_(ss, 'AI_Mode_Results', [\n    'timestamp', 'keyword', 'country',\n    'has_ai_mode', 'source_count', 'top_source_domain', 'all_sources'\n  ]);\n\n  const srcSheet = ensureSheet_(ss, 'AI_Sources', [\n    'timestamp', 'keyword', 'country', 'surface', \/\/ AIO or AI_MODE\n    'rank', 'url', 'domain', 'title', 'site_name'\n  ]);\n\n  const now = new Date();\n  const aioRows = [];\n  const aimodeRows = [];\n  const sourceRows = [];\n\n  for (const kw of keywords) {\n    const country = DEFAULT_COUNTRY;\n\n    \/\/ Primary combined endpoint\n    const aiData = callFetchSerp_('serp_ai', { query: kw, country }, token);\n\n    \/\/ Optional AI Mode accelerator (US-only, cached)\n    let aiModeData = null;\n    try {\n      aiModeData = callFetchSerp_('serp_ai_mode', { query: kw }, token);\n    } catch (e) {\n      \/\/ OK to ignore; not always necessary\n    }\n\n    \/\/ ---- AI OVERVIEW ----\n    const aioBlock = getBlock_(aiData, 'ai_overview');\n    const aioSources = normalizeSources_(aioBlock &amp;&amp; aioBlock.sources);\n    const aioTop = aioSources.length ? aioSources[0] : null;\n    aioRows.push([\n      now, kw, country,\n      !!aioBlock,\n      aioSources.length,\n      aioTop ? domainOnly_(aioTop.url || aioTop.site_name || '') : '',\n      aioSources.map(s =&gt; domainOnly_(s.url || s.site_name || '')).join(' | ')\n    ]);\n\n    \/\/ push detailed sources with rank\n    aioSources.forEach((s, i) =&gt; {\n      sourceRows.push([\n        now, kw, country, 'AIO',\n        i + 1,\n        s.url || '',\n        domainOnly_(s.url || s.site_name || ''),\n        s.title || '',\n        s.site_name || ''\n      ]);\n    });\n\n    \/\/ ---- AI MODE ----\n    const aiModeBlock = extractAiMode_(aiModeData) || extractAiMode_(aiData);\n    const aimSources = normalizeSources_(aiModeBlock &amp;&amp; aiModeBlock.sources);\n    const aimTop = aimSources.length ? aimSources[0] : null;\n    aimodeRows.push([\n      now, kw, country,\n      !!aiModeBlock,\n      aimSources.length,\n      aimTop ? domainOnly_(aimTop.url || aimTop.site_name || '') : '',\n      aimSources.map(s =&gt; domainOnly_(s.url || s.site_name || '')).join(' | ')\n    ]);\n\n    aimSources.forEach((s, i) =&gt; {\n      sourceRows.push([\n        now, kw, country, 'AI_MODE',\n        i + 1,\n        s.url || '',\n        domainOnly_(s.url || s.site_name || ''),\n        s.title || '',\n        s.site_name || ''\n      ]);\n    });\n\n    Utilities.sleep(400); \/\/ rate-friendly\n  }\n\n  if (aioRows.length) appendRows_(aioSheet, aioRows);\n  if (aimodeRows.length) appendRows_(aimodeSheet, aimodeRows);\n  if (sourceRows.length) appendRows_(srcSheet, sourceRows);\n\n  buildSummaryAndChart_();\n  buildBrandPresencePivotAndChart_(); \/\/ NEW\n}\n\n\/* ----------------------- Helpers &amp; Builders ----------------------- *\/\n\nfunction getApiToken_() {\n  const props = PropertiesService.getScriptProperties();\n  const token = props.getProperty('FETCHSERP_API_TOKEN') || '';\n  if (!token) {\n    throw new Error('Missing FETCHSERP_API_TOKEN in Script properties. Set it in Project Settings.');\n  }\n  return token;\n}\n\nfunction getKeywords_(ss) {\n  const sh = ss.getSheetByName('Keywords');\n  if (!sh) throw new Error('Missing \"Keywords\" sheet with header \"keyword\" in A1.');\n  const values = sh.getRange(2, 1, Math.max(0, sh.getLastRow() - 1), 1)\n    .getValues().flat().map(String).map(s =&gt; s.trim()).filter(Boolean);\n  if (!values.length) throw new Error('No keywords found under header \"keyword\" (A2:A).');\n  return values;\n}\n\nfunction ensureSheet_(ss, name, headers) {\n  let sh = ss.getSheetByName(name);\n  if (!sh) sh = ss.insertSheet(name);\n  if (sh.getLastRow() === 0) {\n    sh.getRange(1, 1, 1, headers.length).setValues([headers]);\n    sh.setFrozenRows(1);\n  }\n  return sh;\n}\n\nfunction ensureBrandsSheet_(ss) {\n  let sh = ss.getSheetByName('Brands');\n  if (!sh) {\n    sh = ss.insertSheet('Brands');\n    sh.getRange(1, 1).setValue('domain');\n    sh.setFrozenRows(1);\n  }\n  return sh;\n}\n\nfunction callFetchSerp_(path, params, token) {\n  const url = `${FETCHSERP_BASE}\/${path}?` + Object.keys(params)\n    .map(k =&gt; `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join('&amp;');\n\n  const res = UrlFetchApp.fetch(url, {\n    method: 'get',\n    headers: { 'accept': 'application\/json', 'authorization': `Bearer ${token}` },\n    muteHttpExceptions: true\n  });\n\n  const code = res.getResponseCode();\n  const text = res.getContentText();\n  if (code &lt; 200 || code &gt;= 300) throw new Error(`FetchSERP ${path} error ${code}: ${text}`);\n\n  try { return JSON.parse(text); }\n  catch (e) { throw new Error(`FetchSERP ${path} invalid JSON: ${text.slice(0, 300)}&hellip;`); }\n}\n\nfunction getBlock_(payload, key) {\n  if (!payload) return null;\n  const d = payload.data || payload;\n  if (d.results &amp;&amp; d.results[key]) return d.results[key];\n  if (d[key]) return d[key];\n  return null;\n}\n\nfunction extractAiMode_(payload) { \/\/ tolerate different shapes\n  if (!payload) return null;\n  const d = payload.data || payload;\n  if (d.results &amp;&amp; d.results.ai_mode) return d.results.ai_mode;\n  if (d.ai_mode) return d.ai_mode;\n  return null;\n}\n\nfunction normalizeSources_(sources) {\n  if (!Array.isArray(sources)) return [];\n  return sources\n    .map(s =&gt; s || Array)\n    .map(s =&gt; ({\n      url: s.url || '',\n      title: s.title || '',\n      site_name: s.site_name || ''\n    }))\n    .filter(s =&gt; s.url || s.site_name);\n}\n\nfunction domainOnly_(u) {\n  try {\n    const host = (new URL(u)).hostname || '';\n    return host.replace(\/^www\\.\/i, '');\n  } catch (e) {\n    return (u || '').replace(\/^www\\.\/i, '');\n  }\n}\n\nfunction appendRows_(sheet, rows) {\n  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);\n}\n\n\/** Create or refresh a historical summary tab with counts &amp; share of voice, plus trend chart. *\/\n\/** Create or refresh a historical summary tab with counts, SOV, and 7-day rolling averages, plus a trend chart. *\/\nfunction buildSummaryAndChart_() {\n  const ss = SpreadsheetApp.getActiveSpreadsheet();\n  const sum = ensureSheet_(ss, 'GEO_AISummary', [\n    'date',\n    'AIO_count', 'AIO_share_of_voice',\n    'AI_Mode_count', 'AI_Mode_share_of_voice',\n    'keywords_tracked',\n    'AIO_count_7dma', 'AI_Mode_count_7dma',\n    'AIO_sov_7dma', 'AI_Mode_sov_7dma'\n  ]);\n\n  \/\/ Clear old rows (keep header)\n  if (sum.getLastRow() &gt; 1) {\n    sum.getRange(2, 1, sum.getLastRow() - 1, 11).clearContent();\n  }\n\n  const aio = ss.getSheetByName('AIO_Results');\n  const aim = ss.getSheetByName('AI_Mode_Results');\n  const kwSheet = ss.getSheetByName('Keywords');\n  if (!aio || !aim || !kwSheet) return;\n\n  const keywordsTracked = Math.max(0, kwSheet.getLastRow() - 1);\n\n  \/\/ Build daily counts maps\n  const aioCounts = countByDate_(aio, 1, 4); \/\/ timestamp col 1, has_ai_overview col 4\n  const aimCounts = countByDate_(aim, 1, 4); \/\/ timestamp col 1, has_ai_mode col 4\n\n  const allDates = Array.from(new Set([...Object.keys(aioCounts), ...Object.keys(aimCounts)]))\n    .sort((a, b) =&gt; new Date(a) - new Date(b));\n\n  \/\/ Build rows with daily values first\n  const rows = allDates.map(date =&gt; {\n    const aCount = aioCounts[date] || 0;\n    const mCount = aimCounts[date] || 0;\n    const aSOV = keywordsTracked ? aCount \/ keywordsTracked : 0;\n    const mSOV = keywordsTracked ? mCount \/ keywordsTracked : 0;\n    return [\n      date,\n      aCount, aSOV,\n      mCount, mSOV,\n      keywordsTracked,\n      null, null, \/\/ AIO_count_7dma, AI_Mode_count_7dma (fill after)\n      null, null  \/\/ AIO_sov_7dma, AI_Mode_sov_7dma (fill after)\n    ];\n  });\n\n  \/\/ Compute 7-day rolling averages (centered on the last 7 days ending at index i)\n  const aCountSeries = rows.map(r =&gt; r[1]);\n  const mCountSeries = rows.map(r =&gt; r[3]);\n  const aSovSeries   = rows.map(r =&gt; r[2]);\n  const mSovSeries   = rows.map(r =&gt; r[4]);\n\n  const aCount7 = rollingMean_(aCountSeries, 7);\n  const mCount7 = rollingMean_(mCountSeries, 7);\n  const aSov7   = rollingMean_(aSovSeries,   7);\n  const mSov7   = rollingMean_(mSovSeries,   7);\n\n  \/\/ Fill the rolling columns\n  for (let i = 0; i &lt; rows.length; i++) {\n    rows[i][6]  = aCount7[i]; \/\/ AIO_count_7dma\n    rows[i][7]  = mCount7[i]; \/\/ AI_Mode_count_7dma\n    rows[i][8]  = aSov7[i];   \/\/ AIO_sov_7dma\n    rows[i][9]  = mSov7[i];   \/\/ AI_Mode_sov_7dma\n  }\n\n  if (rows.length) {\n    sum.getRange(2, 1, rows.length, rows[0].length).setValues(rows);\n  }\n\n  \/\/ Rebuild chart: daily counts + smoothed SOV on dual axes\n  const charts = sum.getCharts();\n  charts.forEach(c =&gt; sum.removeChart(c));\n\n  const dataHeight = rows.length + 1; \/\/ include header\n  const chart = sum.newChart()\n    .setChartType(Charts.ChartType.LINE)\n    .addRange(sum.getRange(1, 1, dataHeight, 10)) \/\/ includes counts, SOV, and 7d SOV\n    .setPosition(5, 1, 0, 0)\n    .setOption('title', 'AIO &amp; AI Mode &mdash; Daily Counts and 7-Day SOV Averages')\n    .setOption('hAxis', { title: 'Date' })\n    .setOption('vAxes', {\n      0: { title: 'Keyword Count' },\n      1: { title: 'Share of Voice (7-day avg)', format: 'percent' }\n    })\n    \/\/ Series mapping: 0=AIO_count, 1=AIO_SOV, 2=AI_Mode_count, 3=AI_Mode_SOV, 4=keywords_tracked,\n    \/\/                 5=AIO_count_7dma, 6=AI_Mode_count_7dma, 7=AIO_sov_7dma, 8=AI_Mode_sov_7dma\n    \/\/ We'll show daily counts (0,2) on axis 0, hide raw daily SOV (1,3) to reduce noise,\n    \/\/ show smoothed SOV (7,8) on axis 1, and hide keywords_tracked (4) + count_7dma (5,6) from display.\n    .setOption('series', {\n      0: { targetAxisIndex: 0 }, \/\/ AIO_count (line)\n      1: { targetAxisIndex: 1, visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ raw AIO SOV (hidden)\n      2: { targetAxisIndex: 0 }, \/\/ AI_Mode_count (line)\n      3: { targetAxisIndex: 1, visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ raw AI Mode SOV (hidden)\n      4: { visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ keywords_tracked (hidden)\n      5: { visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ AIO_count_7dma (hidden to avoid clutter)\n      6: { visibleInLegend: false, lineWidth: 0, pointsVisible: false }, \/\/ AI_Mode_count_7dma (hidden)\n      7: { targetAxisIndex: 1 }, \/\/ AIO_sov_7dma (smooth)\n      8: { targetAxisIndex: 1 }  \/\/ AI_Mode_sov_7dma (smooth)\n    })\n    .setOption('legend', { position: 'bottom' })\n    .build();\n\n  sum.insertChart(chart);\n}\n\n\/** Simple trailing rolling mean with window W; returns array aligned to input length (nulls until window is filled). *\/\nfunction rollingMean_(arr, W) {\n  const out = new Array(arr.length).fill(null);\n  let sum = 0;\n  for (let i = 0; i &lt; arr.length; i++) {\n    sum += (typeof arr[i] === 'number' ? arr[i] : 0);\n    if (i &gt;= W) sum -= (typeof arr[i - W] === 'number' ? arr[i - W] : 0);\n    if (i &gt;= W - 1) out[i] = sum \/ W;\n  }\n  return out;\n}\n\n\n\/** Helper: count how many rows have TRUE in booleanCol, grouped by date from dateCol. *\/\nfunction countByDate_(sheet, dateCol, booleanCol) {\n  const rows = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).getValues();\n  const counts = Array;\n  rows.forEach(row =&gt; {\n    const ts = row[dateCol - 1];\n    const has = row[booleanCol - 1];\n    if (ts &amp;&amp; (has === true || has === 'TRUE')) {\n      const d = new Date(ts);\n      const dateStr = Utilities.formatDate(d, Session.getScriptTimeZone(), 'yyyy-MM-dd');\n      counts[dateStr] = (counts[dateStr] || 0) + 1;\n    }\n  });\n  return counts;\n}\n\n\n\nfunction countTrue_(sheet, col) {\n  if (!sheet || sheet.getLastRow() &lt; 2) return 0;\n  const vals = sheet.getRange(2, col, sheet.getLastRow() - 1, 1).getValues().flat();\n  return vals.filter(v =&gt; v === true || v === 'TRUE').length;\n}\n\n\/**\n * Build a pivot for BRAND presence (latest run only).\n * For each surface (AIO \/ AI_MODE), we compute:\n *  - queries_with_surface: number of keywords where that surface triggered\n *  - queries_brand_cited: number of those keywords where brand domain appears in any citation\n *  - presence_rate = brand_cited \/ queries_with_surface\n *  - queries_brand_rank1: number where brand is rank 1 citation\n *  - rank1_rate = brand_rank1 \/ queries_with_surface\n *\/\nfunction buildBrandPresencePivotAndChart_() {\n  const ss = SpreadsheetApp.getActiveSpreadsheet();\n  const brands = readBrands_(ss); \/\/ array of domains (no www)\n\n  const aioSheet = ss.getSheetByName('AIO_Results');\n  const aimSheet = ss.getSheetByName('AI_Mode_Results');\n  const srcSheet = ss.getSheetByName('AI_Sources');\n\n  if (!brands.length || !srcSheet || srcSheet.getLastRow() &lt; 2) {\n    ensureSheet_(ss, 'GEO_BrandPresence', ['brand_domain', 'surface', 'queries_with_surface', 'queries_brand_cited', 'presence_rate', 'queries_brand_rank1', 'rank1_rate']);\n    return;\n  }\n\n  \/\/ Determine latest run timestamp (max timestamp across results)\n  const latest = latestTimestamp_([aioSheet, aimSheet, srcSheet].filter(Boolean));\n  if (!latest) return;\n\n  \/\/ Build per-surface sets of keywords that triggered surface in the latest run\n  const latestAioKeywords = new Set(filterKeywordsByTimestampAndBool_(aioSheet, latest, 4)); \/\/ has_ai_overview\n  const latestAimKeywords = new Set(filterKeywordsByTimestampAndBool_(aimSheet, latest, 4)); \/\/ has_ai_mode\n\n  \/\/ Build maps for brand presence by keyword and rank1 by keyword (latest run only)\n  const surfaceBrandAny = { AIO: new Map(), AI_MODE: new Map() }; \/\/ brand -&gt; Set(keywords)\n  const surfaceBrandRank1 = { AIO: new Map(), AI_MODE: new Map() }; \/\/ brand -&gt; Set(keywords)\n\n  \/\/ Iterate source rows for latest timestamp only\n  const srcVals = srcSheet.getRange(2, 1, srcSheet.getLastRow() - 1, 9).getValues();\n  for (const row of srcVals) {\n    const [ts, kw, country, surface, rank, url, domain\/*clean*\/, title, site_name] = row;\n    if (!sameDay_(ts, latest)) continue; \/\/ group by day\/run timestamp granularity\n\n    const dom = String(domain || '').toLowerCase();\n    if (!dom) continue;\n\n    \/\/ For presence calculations, only consider keywords that triggered that surface\n    if (surface === 'AIO' &amp;&amp; !latestAioKeywords.has(kw)) continue;\n    if (surface === 'AI_MODE' &amp;&amp; !latestAimKeywords.has(kw)) continue;\n\n    \/\/ For each brand, check match\n    for (const b of brands) {\n      if (dom.endsWith(b)) {\n        \/\/ any-cited\n        if (!surfaceBrandAny[surface].has(b)) surfaceBrandAny[surface].set(b, new Set());\n        surfaceBrandAny[surface].get(b).add(kw);\n\n        \/\/ rank1\n        if (rank === 1) {\n          if (!surfaceBrandRank1[surface].has(b)) surfaceBrandRank1[surface].set(b, new Set());\n          surfaceBrandRank1[surface].get(b).add(kw);\n        }\n      }\n    }\n  }\n\n  \/\/ Prepare output rows\n  const out = [];\n  const surfaces = ['AIO', 'AI_MODE'];\n  for (const s of surfaces) {\n    const queriesWithSurface = (s === 'AIO') ? latestAioKeywords.size : latestAimKeywords.size;\n    for (const b of brands) {\n      const cited = surfaceBrandAny[s].get(b)?.size || 0;\n      const r1 = surfaceBrandRank1[s].get(b)?.size || 0;\n      const presenceRate = queriesWithSurface ? (cited \/ queriesWithSurface) : 0;\n      const rank1Rate = queriesWithSurface ? (r1 \/ queriesWithSurface) : 0;\n      out.push([\n        b, s, queriesWithSurface, cited, presenceRate, r1, rank1Rate\n      ]);\n    }\n  }\n\n  const pivot = ensureSheet_(ss, 'GEO_BrandPresence', ['brand_domain', 'surface', 'queries_with_surface', 'queries_brand_cited', 'presence_rate', 'queries_brand_rank1', 'rank1_rate']);\n  \/\/ Clear old data\n  if (pivot.getLastRow() &gt; 1) pivot.getRange(2, 1, pivot.getLastRow() - 1, 7).clearContent();\n  if (out.length) pivot.getRange(2, 1, out.length, 7).setValues(out);\n\n  \/\/ Build\/refresh a chart for Rank-1 share per brand per surface\n  const charts = pivot.getCharts();\n  charts.forEach(c =&gt; pivot.removeChart(c));\n\n  \/\/ Simple approach: chart all rows, data has both surfaces; users can filter in Sheets UI.\n  const chart = pivot.newChart()\n    .setChartType(Charts.ChartType.COLUMN)\n    .addRange(pivot.getRange(1, 1, Math.max(2, pivot.getLastRow()), 7))\n    .setPosition(5, 1, 0, 0)\n    .setOption('title', 'Brand Rank-1 Share (latest run)')\n    .setOption('series', {\n      0: { targetAxisIndex: 0 }, \/\/ presence metrics\n      1: { targetAxisIndex: 0 },\n      2: { targetAxisIndex: 1 }  \/\/ rate on secondary axis if desired\n    })\n    .setOption('legend', { position: 'right' })\n    .build();\n  pivot.insertChart(chart);\n}\n\n\/* ---------- Pivot helpers ---------- *\/\n\nfunction readBrands_(ss) {\n  const sh = ss.getSheetByName('Brands');\n  if (!sh || sh.getLastRow() &lt; 2) return [];\n  const vals = sh.getRange(2, 1, sh.getLastRow() - 1, 1).getValues().flat()\n    .map(String).map(v =&gt; v.trim().toLowerCase().replace(\/^www\\.\/, ''))\n    .filter(Boolean);\n  return Array.from(new Set(vals));\n}\n\nfunction latestTimestamp_(sheets) {\n  let latest = null;\n  for (const sh of sheets) {\n    if (!sh || sh.getLastRow() &lt; 2) continue;\n    const tsCol = 1; \/\/ first col in our schemas\n    const vals = sh.getRange(2, tsCol, sh.getLastRow() - 1, 1).getValues().flat();\n    for (const v of vals) {\n      const d = (v instanceof Date) ? v : new Date(v);\n      if (!isNaN(+d)) {\n        if (!latest || d &gt; latest) latest = d;\n      }\n    }\n  }\n  return latest;\n}\n\nfunction sameDay_(a, b) {\n  if (!(a instanceof Date)) a = new Date(a);\n  if (!(b instanceof Date)) b = new Date(b);\n  return a.getFullYear() === b.getFullYear() &amp;&amp;\n         a.getMonth() === b.getMonth() &amp;&amp;\n         a.getDate() === b.getDate();\n}\n\nfunction filterKeywordsByTimestampAndBool_(sheet, latestTs, boolColIndex) {\n  if (!sheet || sheet.getLastRow() &lt; 2) return [];\n  const rows = sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).getValues();\n  const out = [];\n  for (const r of rows) {\n    const ts = r[0];\n    const kw = r[1];\n    const val = r[boolColIndex - 1];\n    if (sameDay_(ts, latestTs) &amp;&amp; (val === true || val === 'TRUE')) out.push(kw);\n  }\n  return out;\n}\n<\/code><\/pre>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">From there, each run queries your keyword list, calls the endpoints, normalizes the output, and adds timestamped results into the proper tabs. Over time, this becomes a full dataset you can pivot, chart, and analyze &#8211; essentially a visibility dashboard you can control.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"how-fetchserp-fits-into-the-workflow\">How FetchSERP Fits Into the Workflow<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">The script starts by calling \/api\/v1\/serp_ai, which provides a combined snapshot of AI Overview and AI Mode results whenever they&rsquo;re present. If you&rsquo;re running queries from the US, the script also checks \/api\/v1\/serp_ai_mode, a cached endpoint that delivers AI Mode data faster. When both are available, the cached AI Mode is preferred.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Inside the payload, you&rsquo;ll usually find:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>AI Overview results stored under data.results.ai_overview<\/li>\n\n\n\n<li>AI Mode results under data.results.ai_mode<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Each comes with an array of sources, which are then parsed into a clean list of domains. Those domains are what you&rsquo;ll eventually use for pivoting and visibility charts.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"writing-results-into-sheets\">Writing Results Into Sheets<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">The Google Apps Script connects directly to Sheets in a straightforward way:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>It checks that the Keywords tab is set up properly.<\/li>\n\n\n\n<li>If missing, it automatically creates the AIO_Results and AI_Mode_Results tabs.<\/li>\n\n\n\n<li>For every query run, it appends a new row that includes the timestamp, presence flags, number of sources, the top cited domain, and a list of all domains in that answer set.<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Because new rows are added instead of overwritten, you gradually build a historical record. Over time, you can chart metrics like:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>The percentage of your tracked keywords that trigger an AI Overview<\/li>\n\n\n\n<li>The most frequently cited domains in AI Mode by week or month<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">This historical view is what turns one-off checks into actionable insights.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"best-practices-for-using-fetchserp\">Best Practices for Using FetchSERP<\/h3>\n\n\n<ol class=\"wp-block-list wp-block-list\">\n<li>\n<strong>Plan for volatility<\/strong>: AI answers are not fixed. Repeated sampling and timestamps are the only way to make sense of changes.<\/li>\n\n\n\n<li>\n<strong>Avoid hitting limits: <\/strong>If you&rsquo;re tracking large keyword sets, add pauses (e.g., Utilities.sleep(400)) or split them into batches.<\/li>\n\n\n\n<li>\n<strong>Stay region-specific<\/strong>: Always pass a country code for queries, otherwise your data will mix locations.<\/li>\n\n\n\n<li>\n<strong>Capture more detail<\/strong>: If you want deeper analysis, extend the script to also store full URLs, page titles, and publisher names.<\/li>\n<\/ol>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">While enterprise SaaS tools can give you polished dashboards, building your own pipeline with FetchSERP gives you flexibility and control. For many teams, it&rsquo;s the quickest way to start treating AI visibility as a measurable, repeatable process.<\/p>\n\n\n<h2 class=\"wp-block-heading\" id=\"how-to-monitor-perplexity-and-copilot\">How to Monitor Perplexity and Copilot<\/h2>\n\n\n<h3 class=\"wp-block-heading\" id=\"perplexity\">Perplexity<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Perplexity is one of the easier platforms to measure because it displays its citations directly alongside the answer. Detection tools can grab those references as soon as they render. The catch is that Perplexity rarely sticks to the query you type. It quietly reformulates the question before pulling in results, which means part of the diagnostic process is capturing those reformulated queries. Without them, it&rsquo;s hard to know why your content did or didn&rsquo;t make the cut.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">If you&rsquo;re scraping Perplexity for tracking, these regions are most reliable:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>Answer text: usually appears in main [data-testid=&#8221;answer&#8221;], main article, or main .prose. As a fallback, look for classes containing prose or markdown.<\/li>\n\n\n\n<li>Citations list: often found in aside [data-testid=&#8221;source&#8221;] or nav[aria-label*=&#8221;Sources&#8221;] a[href]. Inline citations may appear as sup a[href] or a[data-source-id] inside the answer itself.<\/li>\n\n\n\n<li>Bonus method: Perplexity sometimes embeds a JSON state in script[type=&#8221;application\/json&#8221;] or via a window.__&#8230; hydration object. Parsing this can give you a more stable list of sources &#8211; complete with titles, URLs, and authors, instead of relying only on the visible DOM.<\/li>\n<\/ul>\n\n\n<h3 class=\"wp-block-heading\" id=\"bing-copilot\">Bing Copilot<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Copilot introduces another set of challenges. It leans heavily on Bing&rsquo;s index, so your baseline Bing visibility strongly affects whether you appear in its generative answers. Unlike Perplexity, Copilot tends to tuck citations at the end of its response, which reduces their visibility. Tracking requires capturing the entire AI block and parsing every link. By comparing Copilot&rsquo;s citations against Bing&rsquo;s standard rankings for the same query, you can identify whether rankings alone drive inclusion or if other authority signals are at play.<\/p>\n\n\n<h2 class=\"wp-block-heading\" id=\"turning-ai-search-data-into-a-dashboard\">Turning AI Search Data Into a Dashboard<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">The end goal of all your monitoring is a single place where you can see how your brand performs across AI surfaces. A good dashboard pulls together both the active checks you run and the passive crawl data from your logs, then turns it into a clear view of where you stand.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"what-the-dashboard-should-show\">What the Dashboard Should Show<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Your dashboard shouldn&rsquo;t just tell you if you&rsquo;re cited &#8211; it should explain the context. For each keyword you track, you want to know:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>Did an AI Overview or AI Mode trigger?<\/li>\n\n\n\n<li>Was your content referenced, and how prominent was it?<\/li>\n\n\n\n<li>How often have you been cited in the last week or month?<\/li>\n\n\n\n<li>Do changes in bot crawl patterns line up with those citations?<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">With this setup, you can stop guessing about visibility and start spotting actual trends.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"key-metrics-to-track\">Key Metrics to Track<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">The most useful dashboards highlight:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>\n<strong>Daily Counts<\/strong>: Number of keywords that triggered AI Overviews or AI Mode.<\/li>\n\n\n\n<li>\n<strong>Share of Voice (SOV)<\/strong>: Percentage of your keywords that show up in each feature.<\/li>\n\n\n\n<li>\n<strong>Rolling Averages<\/strong>: Seven-day trends that smooth out daily volatility and show the real trajectory.<\/li>\n\n\n\n<li>\n<strong>Brand Presence<\/strong>: Where your domain appears, and whether you&rsquo;re cited first or buried down the list.<\/li>\n<\/ul>\n\n\n<h3 class=\"wp-block-heading\" id=\"turning-data-into-insight\">Turning Data Into Insight<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">By layering visibility data with server log activity, you can uncover which content updates, technical fixes, or competitive shifts are moving the needle. Over time, this cause-and-effect view helps you see not just whether you&rsquo;re present in AI answers, but why. That&rsquo;s the insight you need to guide your next round of optimizations.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"breakdown-of-geoaisummary-data\">Breakdown of GEO_AISummary Data<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Column<\/strong><\/td><td><strong>Field<\/strong><\/td><td><strong>What It Shows<\/strong><\/td><\/tr><tr><td>A<\/td><td>Date<\/td><td>The calendar date of the run, pulled from the timestamp<\/td><\/tr><tr><td>B<\/td><td>AIO_Count<\/td><td>Number of tracked keywords that triggered an AI Overview on that day<\/td><\/tr><tr><td>C<\/td><td>AIO_SOV<\/td><td>Share of voice for AI Overviews (AIO_Count \u00f7 total keywords)<\/td><\/tr><tr><td>D<\/td><td>AIMode_Count<\/td><td>Number of tracked keywords that triggered AI Mode<\/td><\/tr><tr><td>E<\/td><td>AIMode_SOV<\/td><td>Share of voice for AI Mode (AIMode_Count \u00f7 total keywords)<\/td><\/tr><tr><td>F<\/td><td>Keywords_Tracked<\/td><td>Total number of queries being monitored in your list<\/td><\/tr><tr><td>G<\/td><td>AIO_Count_7DayAvg<\/td><td>Seven-day rolling average of AI Overview counts<\/td><\/tr><tr><td>H<\/td><td>AIMode_Count_7DayAvg<\/td><td>Seven-day rolling average of AI Mode counts<\/td><\/tr><tr><td>I<\/td><td>AIO_SOV_7DayAvg<\/td><td>Seven-day rolling average of AI Overview share of voice<\/td><\/tr><tr><td>J<\/td><td>AIMode_SOV_7DayAvg<\/td><td>Seven-day rolling average of AI Mode share of voice<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n<h2 class=\"wp-block-heading\" id=\"setting-up-a-geo-dashboard-in-google-sheets\">Setting Up a GEO Dashboard in Google Sheets<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">The script drops in a basic chart, but building your own dashboard inside Google Sheets gives you more control over how you view the data. With a few tweaks, you can turn a list of numbers into a clear picture of how your AI visibility is trending.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-organize-the-data\">Step 1: Organize the Data<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Open the GEO_AISummary tab. Make sure each column is labeled correctly, then sort by date from oldest to newest. This keeps the timeline straight before you start charting.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-plot-daily-activity\">Step 2: Plot Daily Activity<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Select columns A, B, and D (Date, AIO_Count, AIMode_Count). Insert a chart &#8211; a line or column chart works best here.<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>X-axis: Date<\/li>\n\n\n\n<li>Series 1: AIO_Count<\/li>\n\n\n\n<li>Series 2: AIMode_Count<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Give it a title such as &lsquo;&rsquo;Daily AI Overview and AI Mode Activity&rsquo;&rsquo;.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-3-visualize-share-of-voice\">Step 3: Visualize Share of Voice<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Highlight columns A, C, E, I, and J (Date, AIO_SOV, AIMode_SOV, plus the 7-day averages). Insert another line chart.<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>Plot the raw daily percentages if you want to see short-term swings.<\/li>\n\n\n\n<li>Plot the rolling averages to reveal longer-term movement.<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Label it something like &lsquo;&rsquo;Share of Voice Trends&rsquo;&rsquo;.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-4-build-the-dashboard-view\">Step 4: Build the Dashboard View<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Create a new sheet called &lsquo;Dashboard&rsquo;. Add both charts you just built, then create a small summary section at the top showing:<\/p>\n\n\n<ul class=\"wp-block-list wp-block-list\">\n<li>Current counts and SOV for today<\/li>\n\n\n\n<li>Comparison to last week&rsquo;s rolling average<\/li>\n<\/ul>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-5-track-over-time\">Step 5: Track Over Time<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Run the script each day (or more often if you need higher resolution). Because the data appends rather than overwrites, you&rsquo;ll automatically create a running history. Over weeks and months, patterns will emerge &#8211; seasonal fluctuations, the impact of algorithm shifts, or the results of your optimization efforts.<\/p>\n\n\n<h2 class=\"wp-block-heading\" id=\"practical-tips-for-reliable-geo-tracking\">Practical Tips for Reliable GEO Tracking<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Working with AI search visibility is messy by nature. Systems are probabilistic, results shift constantly, and the same query can behave differently from one run to the next. These practices can help you get cleaner, more useful data.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"expect-volatility\">Expect Volatility<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Don&rsquo;t assume stability. AI Overviews and AI Mode can change citations from one moment to the next. That&rsquo;s why running checks regularly, and logging timestamps, is essential. The noise is part of the signal.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"manage-query-volume\">Manage Query Volume<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">If you&rsquo;re monitoring hundreds of keywords, pace your requests. Add delays between runs or break large lists into smaller batches. This prevents hitting rate limits and keeps your data collection consistent.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"be-region-specific\">Be Region-Specific<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Always include a country code when running queries. Otherwise, your results might mix geographies, making it harder to interpret changes over time.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"track-more-than-domains\">Track More Than Domains<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Domains are a solid starting point, but sometimes you need deeper detail. Capture full URLs, page titles, and publisher names when it matters. This helps you see exactly which assets are earning visibility.<\/p>\n\n\n<h3 class=\"wp-block-heading\" id=\"keep-raw-data\">Keep Raw Data<\/h3>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">AI platforms tweak their outputs often. Holding onto raw HTML or JSON responses means you can reprocess older data if parsers break or if you need to extract new fields later.<\/p>\n\n\n<h2 class=\"wp-block-heading\" id=\"conclusion\">Conclusion<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Generative search has rewritten the playbook for measuring visibility. Rankings and clicks alone no longer capture the full story &#8211; the real question is whether your brand is cited in the answers users actually see. By combining active detection with server log analysis, keeping tabs on AI crawlers, and building a dashboard that tracks both daily counts and long-term trends, you can turn an opaque system into measurable insights.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">The work isn&rsquo;t just about watching numbers climb. It&rsquo;s about connecting those citations to real business outcomes: conversions, revenue, and growth. That&rsquo;s what transforms visibility into value. With a consistent tracking framework in place, you&rsquo;re no longer guessing about your presence in AI search. You&rsquo;re making informed decisions that push your brand into the conversations that matter.<\/p>\n\n\n<h2 class=\"wp-block-heading\" id=\"frequently-asked-questions\">Frequently Asked Questions<\/h2>\n\n\n<p class=\"wp-block-paragraph \" style=\"\"><strong>1. Why is tracking AI search visibility so difficult?<\/strong><\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Because AI answers regenerate each time, results can shift from one run to the next. Unlike traditional rankings, there&rsquo;s no fixed position to rely on, which makes ongoing monitoring essential.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\"><strong>2. How often should I run tracking scripts?<\/strong><\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">For a small keyword set, multiple runs per day capture volatility. For larger lists, daily runs are usually enough to build a reliable trendline.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\"><strong>3. What&rsquo;s the role of server logs in GEO analytics?<\/strong><\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Server logs reveal when AI bots crawl your pages. Correlating crawl activity with citation data helps you understand how retrieval connects to visibility.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\"><strong>4. Do I need to track both AI Overviews and AI Mode?<\/strong><\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">Yes. They behave differently, and your brand might show up in one but not the other. Tracking both surfaces gives a complete picture of your Google AI presence.<\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\"><strong>5. Is Perplexity easier to measure than Google?<\/strong><\/p>\n\n\n<p class=\"wp-block-paragraph \" style=\"\">In some ways, yes. Perplexity shows citations transparently, but it also rewrites queries behind the scenes, so capturing reformulated questions is just as important as logging visible sources.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Traditional SEO reporting used to be simple: you tracked rankings, checked impressions and clicks in Search Console, and mapped those numbers to business impact. AI-driven search has reshaped that flow. Platforms like Google AI Overviews, AI Mode, Perplexity, and Bing Copilot no longer just list results &#8211; they generate answers and choose which sources to &hellip;<\/p>\n","protected":false},"featured_media":58257,"template":"","class_list":["post-57978","ai-search-guide","type-ai-search-guide","status-publish","has-post-thumbnail","hentry"],"acf":[],"_links":{"self":[{"href":"https:\/\/nuoptima.com\/wp-json\/wp\/v2\/ai-search-guide\/57978","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nuoptima.com\/wp-json\/wp\/v2\/ai-search-guide"}],"about":[{"href":"https:\/\/nuoptima.com\/wp-json\/wp\/v2\/types\/ai-search-guide"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/nuoptima.com\/wp-json\/wp\/v2\/media\/58257"}],"wp:attachment":[{"href":"https:\/\/nuoptima.com\/wp-json\/wp\/v2\/media?parent=57978"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}