{
  "name": "Audit Google Maps listings against approved branch details with HasData",
  "nodes": [
    {
      "id": "run-manually",
      "name": "Run manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        260
      ],
      "parameters": {}
    },
    {
      "id": "settings",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        240,
        260
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "spreadsheet_id",
              "name": "spreadsheet_id",
              "value": "REPLACE_WITH_SPREADSHEET_ID",
              "type": "string"
            },
            {
              "id": "max_items",
              "name": "max_items",
              "value": 5,
              "type": "number"
            },
            {
              "id": "country_calling_code",
              "name": "country_calling_code",
              "value": "1",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "validate-settings",
      "name": "Validate settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function config(input, kind) {\n  const c = { ...input, kind };\n  if (!/^[\\w-]{20,}$/.test(c.spreadsheet_id || '') || /REPLACE/.test(c.spreadsheet_id)) throw new Error('Set spreadsheet_id and create the input and Results tabs using the canvas headers.');\n  c.max_items = bound(c.max_items, 1, kind === 'cluster' ? 30 : 10, 'max_items');\n  c.run_id = new Date().toISOString();\n  if (kind !== 'maps') { c.location = clean(c.location); c.gl = clean(c.gl).toLowerCase(); if (c.gl && !/^[a-z]{2}$/.test(c.gl)) throw new Error('gl must be blank or a two-letter country code.'); }\n  if (kind === 'cluster') { c.min_results = bound(c.min_results, 3, 10, 'min_results'); c.shared_urls = bound(c.shared_urls, 2, 10, 'shared_urls'); if (c.shared_urls > c.min_results) throw new Error('shared_urls cannot exceed min_results'); }\n  if (kind === 'publishers' || kind === 'markets') {\n    c.max_pages = bound(c.max_pages, 1, 10, 'max_pages');\n    c.content_selector = clean(c.content_selector) || 'main, article, [role=\"main\"]';\n    if (typeof c.js_rendering !== 'boolean') throw new Error('js_rendering must be true or false. Rendering can increase API cost.');\n  }\n  if (kind === 'publishers') {\n    c.own_domain = host('https://' + clean(c.own_domain));\n    c.brand_aliases = lines(c.brand_aliases); c.competitor_names = lines(c.competitor_names);\n    c.excluded_domains = lines(c.excluded_domains).map(d => host('https://' + d));\n    if (!c.own_domain || !c.brand_aliases.length || !c.competitor_names.length || c.excluded_domains.some(d => !d)) throw new Error('Set your bare domain, brand aliases, competitor names and valid excluded domains.');\n    if ([...c.brand_aliases, ...c.competitor_names].some(s => s.length < 3 || s.length > 80)) throw new Error('Brand names must contain 3–80 characters.');\n    c.content_selector = clean(c.content_selector) || 'main';\n  }\n  if (kind === 'markets') {\n    c.location_b = clean(c.location_b); c.gl_b = clean(c.gl_b).toLowerCase();\n    if (!c.location || !c.location_b || !/^[a-z]{2}$/.test(c.gl) || !/^[a-z]{2}$/.test(c.gl_b) || c.gl === c.gl_b) throw new Error('Set two canonical locations and different two-letter country codes. Both markets must use English queries.');\n  }\n  if (kind === 'maps') c.country_calling_code = String(bound(c.country_calling_code, 1, 999, 'country_calling_code'));\n  return c;\n}\n\nfunction host(v) { return url(v)?.split('/')[2] || ''; }\n\nfunction lines(v) { return [...new Set(String(v || '').split('\\n').map(clean).filter(Boolean))]; }\n\nfunction markets(c, searches, pages) {\n  const ids = [...new Set(searches.map(s => s.topic_id))];\n  return ids.map(id => {\n    const a = searches.find(s => s.topic_id === id && s.market === 'A'), b = searches.find(s => s.topic_id === id && s.market === 'B');\n    const good = a?.status === 'observed' && b?.status === 'observed' && a.returned >= 5 && b.returned >= 5;\n    const urls = good ? overlap(a, b) : [];\n    const domains = good ? [...new Set(a.results.map(r => host(r.url)))].filter(d => b.results.some(r => host(r.url) === d)) : [];\n    const ownPages = pages.filter(p => [a, b].some(s => s?.results.some(r => r.url === p.url)));\n    const bothRead = [a, b].every(s => s?.results.some(r => ownPages.some(p => p.url === r.url && p.page_status === 'read')));\n    return row(c, key([id, a?.context, b?.context]), id, !good ? 'insufficient_serp_evidence' : bothRead ? 'ready_for_localization_review' : 'serp_only_review',\n      good ? 'Compare intent and page formats before choosing one adapted page or separate market pages. URL overlap alone cannot decide.' : 'Failed or sparse market results; do not infer an intent difference.',\n      { market_a: a, market_b: b, shared_urls: urls, shared_domains: domains, pages: ownPages.map(p => ({ url: p.url, status: p.page_status, reason: p.reason, title: p.title, h1: p.h1, excerpt: p.text.slice(0, 2500), excerpt_truncated: p.text.length > 2500 || p.truncated, metadata: p.page_metadata })),\n        pages_not_read: [...new Set([...(a?.results || []), ...(b?.results || [])].map(r => r.url))].filter(u => !ownPages.some(p => p.url === u && p.page_status === 'read')),\n        editorial_questions: ['Do both queries describe the same customer need?', 'Which local entities, terminology, currencies and page formats appear in the evidence?', 'Would one adapted page serve both markets, or does intent justify separate pages?', 'Which unvisited or truncated sources need checking?'],\n        limits: 'English-language comparison, one response per query and market. This does not measure search demand, prove intent, or recommend hreflang implementation.' });\n  });\n}\n\nfunction metadata(r) { const m = r?.requestMetadata || {}; return { id: clean(m.id), status: clean(m.status), html: clean(m.html), json: clean(m.json), search_url: clean(m.url) }; }\n\nfunction overlap(a, b) { const s = new Set(b.results.map(r => r.url)); return a.results.map(r => r.url).filter(u => s.has(u)); }\n\nfunction publishers(c, sources, pages, searches) {\n  const result = searches.map(s => row(c, key(['prompt', c.own_domain, s.context]), s.query, s.status, 'AI Mode citation inventory; citations are not brand mentions.', s));\n  for (const s of sources) {\n    const p = pages.find(x => x.url === s.url);\n    const known = p?.page_status === 'read';\n    const own = known ? mentions(p.text, c.brand_aliases) : [], competitors = known ? mentions(p.text, c.competitor_names) : [];\n    const ownLinks = known ? p.links.filter(u => domainMatches(host(u), c.own_domain)) : [];\n    const status = s.status !== 'fetch' ? s.status : !known ? 'page_unavailable' : own.length || ownLinks.length ? 'brand_already_observed' : competitors.length ? 'review_publisher_fit' : 'no_competitor_evidence';\n    result.push(row(c, key(['source', c.own_domain, s.url, c.location, c.gl]), s.url, status,\n      status === 'review_publisher_fit' ? 'Competitors appear in the inspected text; verify publisher ownership, topical fit and full-page coverage before outreach.' : 'Review retained citation and page evidence. No contact discovery or messages are sent.',\n      { source: s, page_status: p?.page_status || 'not_fetched', page_reason: p?.reason || '', title: p?.title || '', h1: p?.h1 || '', page_metadata: p?.page_metadata || {}, text_truncated: !!p?.truncated, own_mentions: own, competitor_mentions: competitors, own_links: ownLinks, links_truncated: !!p?.links_truncated, absence_note: 'No match in extracted content is not proof that the full page or publisher omits your brand. Known-domain exclusions do not establish independent ownership.', configured_brand_aliases: c.brand_aliases, configured_competitors: c.competitor_names }));\n  }\n  return result;\n}\n\nfunction row(c, id, topic, status, summary, evidence) {\n  const extra = c.kind === 'cluster' ? {\n    group_keywords: (evidence.cluster || []).join('\\n'), returned_results: evidence.search?.returned || 0,\n    cross_group_matches: (evidence.pairs || []).filter(p => !p.same_group && p.shared_urls.length >= c.shared_urls).map(p => p.query).join('\\n'),\n  } : c.kind === 'publishers' ? {\n    citing_prompts: (evidence.source?.prompts || [evidence.query]).filter(Boolean).join('\\n'),\n    competitors_observed: (evidence.competitor_mentions || []).map(m => m.name + ' — ' + m.excerpt).join('\\n'),\n    brand_evidence: (evidence.own_mentions || []).map(m => m.excerpt).concat(evidence.own_links || []).join('\\n'),\n    source_url: evidence.source?.url || '',\n  } : c.kind === 'maps' ? {\n    differences: (evidence.fields || []).filter(f => f.status === 'review_difference').map(f => f.field + ': expected ' + f.expected + ' | observed ' + f.observed).join('\\n'),\n    unknown_fields: (evidence.fields || []).filter(f => f.status === 'unknown').map(f => f.field).join(', '),\n    resolved_fields: (evidence.transitions || []).filter(f => f.resolved).map(f => f.field).join(', '), place_id: evidence.requested_place_id,\n  } : {\n    market_a_query: evidence.market_a?.query || '', market_b_query: evidence.market_b?.query || '',\n    shared_url_count: status === 'insufficient_serp_evidence' ? '' : evidence.shared_urls.length,\n    shared_domains: (evidence.shared_domains || []).join('\\n'),\n    page_samples: (evidence.pages || []).map(p => p.url + '\\n' + p.status + ' | ' + p.title + '\\n' + p.excerpt).join('\\n\\n'),\n  };\n  return { result_key: key([c.kind, id]), checked_at: c.run_id, topic, status, summary, ...extra, evidence_json: cell(evidence) };\n}\n\nfunction url(v) {\n  const m = /^https?:\\/\\/([a-z0-9.-]+)(?::(443|80))?(\\/[^\\s#]*)?(?:#.*)?$/i.exec(clean(v));\n  if (!m || !m[1].includes('.') || /^[\\d.]+$/.test(m[1]) || /(^|\\.)(localhost|local|internal|invalid|test)$/.test(m[1])) return '';\n  const [path, query] = (m[3] || '/').split('?');\n  const params = (query || '').split('&').filter(p => p && !/^(utm_[^=]*|gclid|fbclid)=/i.test(p)).sort();\n  return 'https://' + m[1].toLowerCase().replace(/^www\\./, '') + (path === '/' ? '/' : path.replace(/\\/$/, '')) + (params.length ? '?' + params.join('&') : '');\n}\n\nfunction bound(v, min, max, name) { const n = Number(v); if (!Number.isInteger(n) || n < min || n > max) throw new Error(name + ' must be an integer from ' + min + ' to ' + max); return n; }\n\nfunction cell(v) { const s = JSON.stringify(v); if (s.length > 44000) throw new Error('Evidence exceeds the Sheets cell limit. Reduce the batch size.'); return s; }\n\nfunction citations(p, r) {\n  const base = { ...p, metadata: metadata(r), references: [] };\n  if (r.error || r.requestMetadata?.status !== 'ok' || !Array.isArray(r.references)) return { ...base, status: 'unknown' };\n  base.references = r.references.map(x => ({ url: url(x.link || x.url), source_url: clean(x.link || x.url), title: clean(x.title) })).filter(x => x.url);\n  return { ...base, status: base.references.length ? 'observed' : 'no_references_returned' };\n}\n\nfunction clean(v) { return typeof v === 'string' ? v.replace(/\\s+/g, ' ').trim() : ''; }\n\nfunction domainMatches(a, b) { return !!a && !!b && (a === b || a.endsWith('.' + b)); }\n\nfunction key(parts) { return JSON.stringify(parts); }\n\nfunction mentions(text, names) {\n  return names.flatMap(name => {\n    const escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n    const m = new RegExp('(?:^|[^\\\\p{L}\\\\p{N}])(' + escaped + ')(?=$|[^\\\\p{L}\\\\p{N}])', 'iu').exec(text);\n    return m ? [{ name, excerpt: text.slice(Math.max(0, m.index - 100), m.index + name.length + 180) }] : [];\n  });\n}\n\nreturn [{json:config($input.first().json,'maps')}];"
      }
    },
    {
      "id": "read-input",
      "name": "Read input",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        720,
        260
      ],
      "parameters": {
        "resource": "sheet",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Validate settings').first().json.spreadsheet_id }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Branches"
        },
        "options": {}
      },
      "alwaysOutputData": true
    },
    {
      "id": "validate-input-batch",
      "name": "Validate input batch",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        960,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function plan(c, rows) {\n  const required = c.kind === 'maps' ? ['branch_id', 'place_id', 'expected_name', 'expected_address'] : c.kind === 'markets' ? ['topic_id', 'query_a', 'query_b'] : ['query'];\n  const active = readRows(rows, required, required[0]);\n  if (active.length > c.max_items) throw new Error('Input exceeds max_items. Select a smaller batch; rows are never silently dropped.');\n  if (c.kind === 'maps' && new Set(active.map(r => clean(r.place_id))).size !== active.length) throw new Error('A place_id may only belong to one branch.');\n  return active.flatMap(r => {\n    if (c.kind === 'maps') {\n      if (!/^[\\w-]{10,}$/.test(clean(r.place_id))) throw new Error('Use the Google place ID, not a Maps URL or data ID.');\n      return [{ ...r, context: key(['maps', clean(r.branch_id), clean(r.place_id), c.country_calling_code]) }];\n    }\n    const queries = c.kind === 'markets' ? [r.query_a, r.query_b] : [r.query];\n    return queries.map((q, i) => {\n      const query = clean(q); if (!query || query.length > 250) throw new Error('Queries must contain 1–250 characters.');\n      const location = i ? c.location_b : c.location, gl = i ? c.gl_b : c.gl;\n      const context = key([query, location, gl, 'desktop', c.kind === 'markets' ? 'en' : '']);\n      return { query, topic_id: clean(r.topic_id), market: i ? 'B' : 'A', location, gl, context,\n        fields: { deviceType: 'desktop', ...(location ? { location } : {}), ...(gl ? { gl } : {}), ...(c.kind === 'markets' ? { hl: 'en' } : {}) } };\n    });\n  });\n}\n\nfunction readRows(rows, required, idField) {\n  const active = rows.filter(r => required.some(k => clean(String(r[k] ?? ''))));\n  if (!active.length) throw new Error('No input rows. Required headers: ' + required.join(', '));\n  const seen = new Set();\n  for (const r of active) {\n    if (required.some(k => !clean(String(r[k] ?? '')))) throw new Error('Incomplete input row. Required: ' + required.join(', '));\n    const id = clean(String(r[idField])).normalize('NFKC').toLowerCase();\n    if (seen.has(id)) throw new Error('Duplicate input identifier: ' + id); seen.add(id);\n  }\n  return active;\n}\n\nfunction row(c, id, topic, status, summary, evidence) {\n  const extra = c.kind === 'cluster' ? {\n    group_keywords: (evidence.cluster || []).join('\\n'), returned_results: evidence.search?.returned || 0,\n    cross_group_matches: (evidence.pairs || []).filter(p => !p.same_group && p.shared_urls.length >= c.shared_urls).map(p => p.query).join('\\n'),\n  } : c.kind === 'publishers' ? {\n    citing_prompts: (evidence.source?.prompts || [evidence.query]).filter(Boolean).join('\\n'),\n    competitors_observed: (evidence.competitor_mentions || []).map(m => m.name + ' — ' + m.excerpt).join('\\n'),\n    brand_evidence: (evidence.own_mentions || []).map(m => m.excerpt).concat(evidence.own_links || []).join('\\n'),\n    source_url: evidence.source?.url || '',\n  } : c.kind === 'maps' ? {\n    differences: (evidence.fields || []).filter(f => f.status === 'review_difference').map(f => f.field + ': expected ' + f.expected + ' | observed ' + f.observed).join('\\n'),\n    unknown_fields: (evidence.fields || []).filter(f => f.status === 'unknown').map(f => f.field).join(', '),\n    resolved_fields: (evidence.transitions || []).filter(f => f.resolved).map(f => f.field).join(', '), place_id: evidence.requested_place_id,\n  } : {\n    market_a_query: evidence.market_a?.query || '', market_b_query: evidence.market_b?.query || '',\n    shared_url_count: status === 'insufficient_serp_evidence' ? '' : evidence.shared_urls.length,\n    shared_domains: (evidence.shared_domains || []).join('\\n'),\n    page_samples: (evidence.pages || []).map(p => p.url + '\\n' + p.status + ' | ' + p.title + '\\n' + p.excerpt).join('\\n\\n'),\n  };\n  return { result_key: key([c.kind, id]), checked_at: c.run_id, topic, status, summary, ...extra, evidence_json: cell(evidence) };\n}\n\nfunction url(v) {\n  const m = /^https?:\\/\\/([a-z0-9.-]+)(?::(443|80))?(\\/[^\\s#]*)?(?:#.*)?$/i.exec(clean(v));\n  if (!m || !m[1].includes('.') || /^[\\d.]+$/.test(m[1]) || /(^|\\.)(localhost|local|internal|invalid|test)$/.test(m[1])) return '';\n  const [path, query] = (m[3] || '/').split('?');\n  const params = (query || '').split('&').filter(p => p && !/^(utm_[^=]*|gclid|fbclid)=/i.test(p)).sort();\n  return 'https://' + m[1].toLowerCase().replace(/^www\\./, '') + (path === '/' ? '/' : path.replace(/\\/$/, '')) + (params.length ? '?' + params.join('&') : '');\n}\n\nfunction cell(v) { const s = JSON.stringify(v); if (s.length > 44000) throw new Error('Evidence exceeds the Sheets cell limit. Reduce the batch size.'); return s; }\n\nfunction clean(v) { return typeof v === 'string' ? v.replace(/\\s+/g, ' ').trim() : ''; }\n\nfunction key(parts) { return JSON.stringify(parts); }\n\nfunction markets(c, searches, pages) {\n  const ids = [...new Set(searches.map(s => s.topic_id))];\n  return ids.map(id => {\n    const a = searches.find(s => s.topic_id === id && s.market === 'A'), b = searches.find(s => s.topic_id === id && s.market === 'B');\n    const good = a?.status === 'observed' && b?.status === 'observed' && a.returned >= 5 && b.returned >= 5;\n    const urls = good ? overlap(a, b) : [];\n    const domains = good ? [...new Set(a.results.map(r => host(r.url)))].filter(d => b.results.some(r => host(r.url) === d)) : [];\n    const ownPages = pages.filter(p => [a, b].some(s => s?.results.some(r => r.url === p.url)));\n    const bothRead = [a, b].every(s => s?.results.some(r => ownPages.some(p => p.url === r.url && p.page_status === 'read')));\n    return row(c, key([id, a?.context, b?.context]), id, !good ? 'insufficient_serp_evidence' : bothRead ? 'ready_for_localization_review' : 'serp_only_review',\n      good ? 'Compare intent and page formats before choosing one adapted page or separate market pages. URL overlap alone cannot decide.' : 'Failed or sparse market results; do not infer an intent difference.',\n      { market_a: a, market_b: b, shared_urls: urls, shared_domains: domains, pages: ownPages.map(p => ({ url: p.url, status: p.page_status, reason: p.reason, title: p.title, h1: p.h1, excerpt: p.text.slice(0, 2500), excerpt_truncated: p.text.length > 2500 || p.truncated, metadata: p.page_metadata })),\n        pages_not_read: [...new Set([...(a?.results || []), ...(b?.results || [])].map(r => r.url))].filter(u => !ownPages.some(p => p.url === u && p.page_status === 'read')),\n        editorial_questions: ['Do both queries describe the same customer need?', 'Which local entities, terminology, currencies and page formats appear in the evidence?', 'Would one adapted page serve both markets, or does intent justify separate pages?', 'Which unvisited or truncated sources need checking?'],\n        limits: 'English-language comparison, one response per query and market. This does not measure search demand, prove intent, or recommend hreflang implementation.' });\n  });\n}\n\nfunction metadata(r) { const m = r?.requestMetadata || {}; return { id: clean(m.id), status: clean(m.status), html: clean(m.html), json: clean(m.json), search_url: clean(m.url) }; }\n\nfunction overlap(a, b) { const s = new Set(b.results.map(r => r.url)); return a.results.map(r => r.url).filter(u => s.has(u)); }\n\nfunction publishers(c, sources, pages, searches) {\n  const result = searches.map(s => row(c, key(['prompt', c.own_domain, s.context]), s.query, s.status, 'AI Mode citation inventory; citations are not brand mentions.', s));\n  for (const s of sources) {\n    const p = pages.find(x => x.url === s.url);\n    const known = p?.page_status === 'read';\n    const own = known ? mentions(p.text, c.brand_aliases) : [], competitors = known ? mentions(p.text, c.competitor_names) : [];\n    const ownLinks = known ? p.links.filter(u => domainMatches(host(u), c.own_domain)) : [];\n    const status = s.status !== 'fetch' ? s.status : !known ? 'page_unavailable' : own.length || ownLinks.length ? 'brand_already_observed' : competitors.length ? 'review_publisher_fit' : 'no_competitor_evidence';\n    result.push(row(c, key(['source', c.own_domain, s.url, c.location, c.gl]), s.url, status,\n      status === 'review_publisher_fit' ? 'Competitors appear in the inspected text; verify publisher ownership, topical fit and full-page coverage before outreach.' : 'Review retained citation and page evidence. No contact discovery or messages are sent.',\n      { source: s, page_status: p?.page_status || 'not_fetched', page_reason: p?.reason || '', title: p?.title || '', h1: p?.h1 || '', page_metadata: p?.page_metadata || {}, text_truncated: !!p?.truncated, own_mentions: own, competitor_mentions: competitors, own_links: ownLinks, links_truncated: !!p?.links_truncated, absence_note: 'No match in extracted content is not proof that the full page or publisher omits your brand. Known-domain exclusions do not establish independent ownership.', configured_brand_aliases: c.brand_aliases, configured_competitors: c.competitor_names }));\n  }\n  return result;\n}\n\nfunction citations(p, r) {\n  const base = { ...p, metadata: metadata(r), references: [] };\n  if (r.error || r.requestMetadata?.status !== 'ok' || !Array.isArray(r.references)) return { ...base, status: 'unknown' };\n  base.references = r.references.map(x => ({ url: url(x.link || x.url), source_url: clean(x.link || x.url), title: clean(x.title) })).filter(x => x.url);\n  return { ...base, status: base.references.length ? 'observed' : 'no_references_returned' };\n}\n\nfunction domainMatches(a, b) { return !!a && !!b && (a === b || a.endsWith('.' + b)); }\n\nfunction host(v) { return url(v)?.split('/')[2] || ''; }\n\nfunction mentions(text, names) {\n  return names.flatMap(name => {\n    const escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n    const m = new RegExp('(?:^|[^\\\\p{L}\\\\p{N}])(' + escaped + ')(?=$|[^\\\\p{L}\\\\p{N}])', 'iu').exec(text);\n    return m ? [{ name, excerpt: text.slice(Math.max(0, m.index - 100), m.index + name.length + 180) }] : [];\n  });\n}\n\nreturn plan($('Validate settings').first().json,$input.all().map(x=>x.json)).map(json=>({json}));"
      }
    },
    {
      "id": "prepare-queue-read",
      "name": "Prepare queue read",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "\n\nreturn [{json:{ready:true}}];"
      }
    },
    {
      "id": "read-results",
      "name": "Read Results",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1440,
        260
      ],
      "parameters": {
        "resource": "sheet",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Validate settings').first().json.spreadsheet_id }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Results"
        },
        "options": {}
      },
      "alwaysOutputData": true
    },
    {
      "id": "check-saved-keys",
      "name": "Check saved keys",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1680,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function previousRows(rows) {\n  const seen = new Set();\n  for (const r of rows.filter(r => r.result_key)) { if (seen.has(r.result_key)) throw new Error('Duplicate result_key in Results. Resolve before running.'); seen.add(r.result_key); }\n  return rows;\n}\n\npreviousRows($input.all().map(x=>x.json)); return $('Validate input batch').all().map(x=>({json:x.json}));"
      }
    },
    {
      "id": "fetch-search-evidence",
      "name": "Fetch search evidence",
      "type": "@hasdata/n8n-nodes-hasdata.hasData",
      "typeVersion": 1,
      "position": [
        1920,
        260
      ],
      "parameters": {
        "resource": "google_maps",
        "operation": "place",
        "placeId": "={{ $json.place_id }}",
        "additionalFields": {}
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "attach-search-context",
      "name": "Attach search context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2160,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function mapsAudit(c, p, response, prior) {\n  const place = response.placeResults, valid = !response.error && response.requestMetadata?.status === 'ok' && place?.placeId === clean(p.place_id);\n  const fields = ['name', 'address', 'phone', 'website'];\n  const values = { name: place?.title, address: place?.address, phone: place?.phone, website: place?.website };\n  const comparisons = fields.map(field => {\n    const expected = clean(String(p['expected_' + field] || '')), observed = clean(values[field]);\n    const normalize = field === 'website' ? host : field === 'phone' ? v => phone(v, c.country_calling_code) : normalized;\n    const alternatives = [expected, ...lines(p[field + '_aliases'])].filter(Boolean);\n    const comparable = valid && expected && observed && normalize(observed) && normalize(expected);\n    return { field, expected, observed: valid ? observed : '', alternatives,\n      status: !expected ? 'not_configured' : !comparable ? 'unknown' : alternatives.some(v => normalize(v) === normalize(observed)) ? 'matches' : 'review_difference' };\n  });\n  const id = key([c.kind, p.context]); const old = prior.find(r => r.result_key === id);\n  let before; if (old) { try { before = JSON.parse(old.evidence_json); } catch { throw new Error('Invalid saved listing evidence. Restore or clear that Results row.'); } }\n  const transitions = comparisons.map(f => { const prev = before?.fields?.find(x => x.field === f.field); return { field: f.field, before: prev?.status || 'no_baseline', now: f.status, resolved: prev?.status === 'review_difference' && f.status === 'matches' && prev.expected === f.expected }; });\n  const status = !valid ? 'identity_or_fetch_unknown' : comparisons.some(f => f.status === 'review_difference') ? 'differences_to_review' : comparisons.some(f => f.status === 'unknown') ? 'incomplete_listing' : 'configured_fields_match';\n  return row(c, p.context, clean(p.branch_id), status, 'Compare owner-approved values with the exact place ID. Review differences; no Google Business Profile edits are made.',\n    { requested_place_id: p.place_id, returned_place_id: place?.placeId || '', metadata: metadata(response), fields: comparisons, previous_checked_at: old?.checked_at || '', transitions,\n      previous_fields: before?.fields || [], working_hours: place?.workingHours || null, hours_note: 'Hours are retained for manual review, not compared: holiday dates and time zones need owner confirmation.', website_note: 'Website comparison uses hostname only. Review paths, redirects, shared franchise domains and tracking URLs manually.' });\n}\n\nfunction metadata(r) { const m = r?.requestMetadata || {}; return { id: clean(m.id), status: clean(m.status), html: clean(m.html), json: clean(m.json), search_url: clean(m.url) }; }\n\nfunction normalized(v) { return clean(v).normalize('NFKC').toLowerCase().replace(/[.,]/g, '').replace(/\\s+/g, ' '); }\n\nfunction phone(v, calling) { const raw = clean(String(v || '')); if (!raw || /(?:ext\\.?|x|#)\\s*\\d/i.test(raw)) return ''; const digits = raw.replace(/\\D/g, ''); if (digits.length < 7 || digits.length > 15) return ''; return raw.startsWith('+') ? digits : digits.startsWith('00') ? digits.slice(2) : digits.startsWith(calling) && digits.length > 10 ? digits : calling + digits.replace(/^0/, ''); }\n\nfunction row(c, id, topic, status, summary, evidence) {\n  const extra = c.kind === 'cluster' ? {\n    group_keywords: (evidence.cluster || []).join('\\n'), returned_results: evidence.search?.returned || 0,\n    cross_group_matches: (evidence.pairs || []).filter(p => !p.same_group && p.shared_urls.length >= c.shared_urls).map(p => p.query).join('\\n'),\n  } : c.kind === 'publishers' ? {\n    citing_prompts: (evidence.source?.prompts || [evidence.query]).filter(Boolean).join('\\n'),\n    competitors_observed: (evidence.competitor_mentions || []).map(m => m.name + ' — ' + m.excerpt).join('\\n'),\n    brand_evidence: (evidence.own_mentions || []).map(m => m.excerpt).concat(evidence.own_links || []).join('\\n'),\n    source_url: evidence.source?.url || '',\n  } : c.kind === 'maps' ? {\n    differences: (evidence.fields || []).filter(f => f.status === 'review_difference').map(f => f.field + ': expected ' + f.expected + ' | observed ' + f.observed).join('\\n'),\n    unknown_fields: (evidence.fields || []).filter(f => f.status === 'unknown').map(f => f.field).join(', '),\n    resolved_fields: (evidence.transitions || []).filter(f => f.resolved).map(f => f.field).join(', '), place_id: evidence.requested_place_id,\n  } : {\n    market_a_query: evidence.market_a?.query || '', market_b_query: evidence.market_b?.query || '',\n    shared_url_count: status === 'insufficient_serp_evidence' ? '' : evidence.shared_urls.length,\n    shared_domains: (evidence.shared_domains || []).join('\\n'),\n    page_samples: (evidence.pages || []).map(p => p.url + '\\n' + p.status + ' | ' + p.title + '\\n' + p.excerpt).join('\\n\\n'),\n  };\n  return { result_key: key([c.kind, id]), checked_at: c.run_id, topic, status, summary, ...extra, evidence_json: cell(evidence) };\n}\n\nfunction url(v) {\n  const m = /^https?:\\/\\/([a-z0-9.-]+)(?::(443|80))?(\\/[^\\s#]*)?(?:#.*)?$/i.exec(clean(v));\n  if (!m || !m[1].includes('.') || /^[\\d.]+$/.test(m[1]) || /(^|\\.)(localhost|local|internal|invalid|test)$/.test(m[1])) return '';\n  const [path, query] = (m[3] || '/').split('?');\n  const params = (query || '').split('&').filter(p => p && !/^(utm_[^=]*|gclid|fbclid)=/i.test(p)).sort();\n  return 'https://' + m[1].toLowerCase().replace(/^www\\./, '') + (path === '/' ? '/' : path.replace(/\\/$/, '')) + (params.length ? '?' + params.join('&') : '');\n}\n\nfunction cell(v) { const s = JSON.stringify(v); if (s.length > 44000) throw new Error('Evidence exceeds the Sheets cell limit. Reduce the batch size.'); return s; }\n\nfunction clean(v) { return typeof v === 'string' ? v.replace(/\\s+/g, ' ').trim() : ''; }\n\nfunction host(v) { return url(v)?.split('/')[2] || ''; }\n\nfunction key(parts) { return JSON.stringify(parts); }\n\nfunction lines(v) { return [...new Set(String(v || '').split('\\n').map(clean).filter(Boolean))]; }\n\nfunction publishers(c, sources, pages, searches) {\n  const result = searches.map(s => row(c, key(['prompt', c.own_domain, s.context]), s.query, s.status, 'AI Mode citation inventory; citations are not brand mentions.', s));\n  for (const s of sources) {\n    const p = pages.find(x => x.url === s.url);\n    const known = p?.page_status === 'read';\n    const own = known ? mentions(p.text, c.brand_aliases) : [], competitors = known ? mentions(p.text, c.competitor_names) : [];\n    const ownLinks = known ? p.links.filter(u => domainMatches(host(u), c.own_domain)) : [];\n    const status = s.status !== 'fetch' ? s.status : !known ? 'page_unavailable' : own.length || ownLinks.length ? 'brand_already_observed' : competitors.length ? 'review_publisher_fit' : 'no_competitor_evidence';\n    result.push(row(c, key(['source', c.own_domain, s.url, c.location, c.gl]), s.url, status,\n      status === 'review_publisher_fit' ? 'Competitors appear in the inspected text; verify publisher ownership, topical fit and full-page coverage before outreach.' : 'Review retained citation and page evidence. No contact discovery or messages are sent.',\n      { source: s, page_status: p?.page_status || 'not_fetched', page_reason: p?.reason || '', title: p?.title || '', h1: p?.h1 || '', page_metadata: p?.page_metadata || {}, text_truncated: !!p?.truncated, own_mentions: own, competitor_mentions: competitors, own_links: ownLinks, links_truncated: !!p?.links_truncated, absence_note: 'No match in extracted content is not proof that the full page or publisher omits your brand. Known-domain exclusions do not establish independent ownership.', configured_brand_aliases: c.brand_aliases, configured_competitors: c.competitor_names }));\n  }\n  return result;\n}\n\nfunction citations(p, r) {\n  const base = { ...p, metadata: metadata(r), references: [] };\n  if (r.error || r.requestMetadata?.status !== 'ok' || !Array.isArray(r.references)) return { ...base, status: 'unknown' };\n  base.references = r.references.map(x => ({ url: url(x.link || x.url), source_url: clean(x.link || x.url), title: clean(x.title) })).filter(x => x.url);\n  return { ...base, status: base.references.length ? 'observed' : 'no_references_returned' };\n}\n\nfunction domainMatches(a, b) { return !!a && !!b && (a === b || a.endsWith('.' + b)); }\n\nfunction mentions(text, names) {\n  return names.flatMap(name => {\n    const escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n    const m = new RegExp('(?:^|[^\\\\p{L}\\\\p{N}])(' + escaped + ')(?=$|[^\\\\p{L}\\\\p{N}])', 'iu').exec(text);\n    return m ? [{ name, excerpt: text.slice(Math.max(0, m.index - 100), m.index + name.length + 180) }] : [];\n  });\n}\n\nconst plans=$('Check saved keys').all(); const items=$input.all(); const seen=new Set(); const paired=items.map(item=>{const pair=Array.isArray(item.pairedItem)?item.pairedItem[0]:item.pairedItem; const i=pair?.item; if(!Number.isInteger(i)||!plans[i]||seen.has(i))throw new Error('Missing or ambiguous API item pairing. Nothing saved.'); seen.add(i); return {p:plans[i].json,r:item.json};}); if(seen.size!==plans.length)throw new Error('Missing API responses. Nothing saved.');return paired.map(({p,r})=>({json:mapsAudit($('Validate settings').first().json,p,r,$('Read Results').all().map(x=>x.json))}));"
      }
    },
    {
      "id": "build-review-rows",
      "name": "Build review rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2400,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "\n\nreturn $input.all();"
      }
    },
    {
      "id": "save-review-queue",
      "name": "Save review queue",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2640,
        260
      ],
      "parameters": {
        "resource": "sheet",
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Validate settings').first().json.spreadsheet_id }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Results"
        },
        "options": {
          "cellFormat": "RAW"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "result_key": "={{ $json.result_key }}",
            "checked_at": "={{ $json.checked_at }}",
            "topic": "={{ $json.topic }}",
            "status": "={{ $json.status }}",
            "summary": "={{ $json.summary }}",
            "differences": "={{ $json.differences }}",
            "unknown_fields": "={{ $json.unknown_fields }}",
            "resolved_fields": "={{ $json.resolved_fields }}",
            "place_id": "={{ $json.place_id }}",
            "evidence_json": "={{ $json.evidence_json }}"
          },
          "matchingColumns": [
            "result_key"
          ],
          "schema": [
            {
              "id": "result_key",
              "displayName": "result_key",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "checked_at",
              "displayName": "checked_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "topic",
              "displayName": "topic",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "summary",
              "displayName": "summary",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "differences",
              "displayName": "differences",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "unknown_fields",
              "displayName": "unknown_fields",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "resolved_fields",
              "displayName": "resolved_fields",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "place_id",
              "displayName": "place_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "evidence_json",
              "displayName": "evidence_json",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      }
    },
    {
      "id": "section-1",
      "name": "Section 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -30,
        60
      ],
      "parameters": {
        "content": "## Configure and read inputs\nCreate Branches with headers: `branch_id`, `place_id`, `expected_name`, `expected_address`, `expected_phone`, `expected_website`, `name_aliases`, `address_aliases`, `phone_aliases`, `website_aliases`.",
        "width": 940,
        "height": 440,
        "color": 7
      }
    },
    {
      "id": "section-2",
      "name": "Section 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        930,
        60
      ],
      "parameters": {
        "content": "## Validate before spending credits\nKeep source IDs and observed values. Failed requests stay unknown. Review evidence before acting; automatic paid retries are disabled.",
        "width": 940,
        "height": 440,
        "color": 7
      }
    },
    {
      "id": "section-3",
      "name": "Section 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1890,
        60
      ],
      "parameters": {
        "content": "## Save computed fields only\nCreate Results with headers: `result_key`, `checked_at`, `topic`, `status`, `summary`, `differences`, `unknown_fields`, `resolved_fields`, `place_id`, `evidence_json`. Add optional review_status and editor_notes columns; this workflow never writes them.",
        "width": 940,
        "height": 440,
        "color": 7
      }
    },
    {
      "id": "overview",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -650,
        -140
      ],
      "parameters": {
        "content": "# Audit Google Maps listings against approved branch details with HasData\n\n### How it works\nCompare owner-approved branch details with the exact Google Maps place ID, rather than trusting a fuzzy business search. Review name, address, phone and website-host differences with the observed values and request metadata. On later runs, retain the preceding field comparison and show which differences now match. Missing fields and identity mismatches stay unknown instead of resolving an issue.\n\nA difference is a review task, not proof that Google is wrong. Approved aliases handle alternate address spellings. Website checks compare hostnames, not paths. Opening hours are retained for manual review because dates, holidays and time zones complicate automated comparisons. No listing edits are made.\n\n### Setup\nInstall the verified HasData community node. Connect HasData credentials to every API request and Google Sheets credentials to every Sheets node. Create Branches and Results tabs with the headers shown on the canvas. Set spreadsheet_id in Settings. Fill Branches with unique branch IDs and Google place IDs. Name and address are required; phone and website are optional. Use full HTTPS website URLs and international phone numbers where possible. Put approved aliases on separate lines in a cell. Use one country calling code per workflow.\n\n### Customization\nOne Maps Place request per branch, at most ten. No pagination, model calls or automatic paid retries. Run one execution at a time. Keep result_key and saved evidence unchanged. Results contains the latest review, not a permanent archive. Export the sheet if you need historical versions.",
        "width": 570,
        "height": 1130,
        "color": 1
      }
    }
  ],
  "connections": {
    "Run manually": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Validate settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate settings": {
      "main": [
        [
          {
            "node": "Read input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read input": {
      "main": [
        [
          {
            "node": "Validate input batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate input batch": {
      "main": [
        [
          {
            "node": "Prepare queue read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare queue read": {
      "main": [
        [
          {
            "node": "Read Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Results": {
      "main": [
        [
          {
            "node": "Check saved keys",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check saved keys": {
      "main": [
        [
          {
            "node": "Fetch search evidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch search evidence": {
      "main": [
        [
          {
            "node": "Attach search context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach search context": {
      "main": [
        [
          {
            "node": "Build review rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build review rows": {
      "main": [
        [
          {
            "node": "Save review queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false,
  "pinData": {},
  "tags": []
}
