{
  "name": "Find publisher opportunities in Google AI Mode citations 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": "location",
              "name": "location",
              "value": "",
              "type": "string"
            },
            {
              "id": "gl",
              "name": "gl",
              "value": "",
              "type": "string"
            },
            {
              "id": "max_items",
              "name": "max_items",
              "value": 5,
              "type": "number"
            },
            {
              "id": "max_pages",
              "name": "max_pages",
              "value": 5,
              "type": "number"
            },
            {
              "id": "own_domain",
              "name": "own_domain",
              "value": "hasdata.com",
              "type": "string"
            },
            {
              "id": "brand_aliases",
              "name": "brand_aliases",
              "value": "HasData\nHas Data",
              "type": "string"
            },
            {
              "id": "competitor_names",
              "name": "competitor_names",
              "value": "ScrapingBee\nFirecrawl",
              "type": "string"
            },
            {
              "id": "excluded_domains",
              "name": "excluded_domains",
              "value": "scrapingbee.com\nfirecrawl.dev",
              "type": "string"
            },
            {
              "id": "content_selector",
              "name": "content_selector",
              "value": "main, article, [role=\"main\"]",
              "type": "string"
            },
            {
              "id": "js_rendering",
              "name": "js_rendering",
              "value": false,
              "type": "boolean"
            }
          ]
        },
        "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,'publishers')}];"
      }
    },
    {
      "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": "Prompts"
        },
        "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_serp",
        "operation": "ai_mode",
        "q": "={{ $json.query }}",
        "additionalFields": "={{ $json.fields }}"
      },
      "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 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 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 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\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:citations(p,r)}));"
      }
    },
    {
      "id": "plan-page-sample",
      "name": "Plan page sample",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2400,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function publisherPlan(c, evidence) {\n  const sources = new Map();\n  for (const e of evidence) for (const ref of e.references) {\n    if (!sources.has(ref.url)) sources.set(ref.url, { ...ref, prompts: [], request_ids: [] });\n    const s = sources.get(ref.url); if (!s.prompts.includes(e.query)) s.prompts.push(e.query); if (!s.request_ids.includes(e.metadata.id)) s.request_ids.push(e.metadata.id);\n  }\n  let count = 0;\n  return [...sources.values()].sort((a, b) => b.prompts.length - a.prompts.length || a.url.localeCompare(b.url)).map(s => {\n    const d = host(s.url);\n    const excluded = [c.own_domain, ...c.excluded_domains, 'youtube.com', 'youtu.be', 'reddit.com', 'facebook.com', 'instagram.com'].some(x => domainMatches(d, x));\n    const status = excluded ? 'excluded_domain' : count++ < c.max_pages ? 'fetch' : 'page_budget_exceeded';\n    return { ...s, status };\n  });\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 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 host(v) { return url(v)?.split('/')[2] || ''; }\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\nconst c=$('Validate settings').first().json; const searches=$input.all().map(x=>x.json); const sources=publisherPlan(c,searches); return [{json:{searches,sources,pages:sources.filter(x=>x.status==='fetch')}}];"
      }
    },
    {
      "id": "pages-to-inspect",
      "name": "Pages to inspect",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2640,
        260
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "pages",
              "leftValue": "={{ $json.pages.length > 0 }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "split-page-requests",
      "name": "Split page requests",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2880,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "\n\nreturn $input.first().json.pages.map(json=>({json}));"
      }
    },
    {
      "id": "read-source-pages",
      "name": "Read source pages",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        260
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.hasdata.com/scrape/web",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hasDataApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ {url:$json.source_url || $json.url,outputFormat:[\"json\",\"html\"],jsRendering:$(\"Validate settings\").first().json.js_rendering} }}",
        "options": {
          "timeout": 330000,
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 250
            }
          }
        }
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "pair-source-pages",
      "name": "Pair source pages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3360,
        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\nconst plans=$('Split page requests').all();const seen=new Set();return $input.all().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('Ambiguous page response pairing');seen.add(i);return {json:{plan:plans[i].json,response:item.json,content:typeof item.json.content==='string'?item.json.content:''}};});"
      }
    },
    {
      "id": "extract-page-evidence",
      "name": "Extract page evidence",
      "type": "n8n-nodes-base.html",
      "typeVersion": 1.2,
      "position": [
        3600,
        260
      ],
      "parameters": {
        "operation": "extractHtmlContent",
        "sourceData": "json",
        "dataPropertyName": "content",
        "extractionValues": {
          "values": [
            {
              "key": "text",
              "cssSelector": "={{ $('Validate settings').first().json.content_selector }}",
              "returnValue": "text",
              "skipSelectors": "script, style, nav, header, footer, noscript",
              "returnArray": false
            },
            {
              "key": "title",
              "cssSelector": "title",
              "returnValue": "text",
              "returnArray": false
            },
            {
              "key": "h1",
              "cssSelector": "h1",
              "returnValue": "text",
              "returnArray": false
            },
            {
              "key": "links",
              "cssSelector": "a[href]",
              "returnValue": "attribute",
              "attribute": "href",
              "returnArray": true
            }
          ]
        },
        "options": {
          "trimValues": true,
          "cleanUpText": true
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "collect-page-evidence",
      "name": "Collect page evidence",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3840,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function pageEvidence(p, response, extracted) {\n  const text = clean(extracted.text), title = clean(extracted.title), h1 = clean(extracted.h1);\n  const links = Array.isArray(extracted.links) ? extracted.links.filter(x => typeof x === 'string').map(url).filter(Boolean) : [];\n  const ok = !response.error && response.requestMetadata?.status === 'ok' && (!response.statusCode || (response.statusCode >= 200 && response.statusCode < 300)) && !extracted.error && text.length >= 150;\n  const reason = ok ? '' : response.error ? 'Page request failed.' : extracted.error ? 'HTML extraction failed: ' + clean(extracted.error).slice(0, 200) : response.statusCode && (response.statusCode < 200 || response.statusCode >= 300) ? 'Source HTTP ' + response.statusCode : 'No usable main-content text. Check the selector, source page and rendering setting.';\n  return { ...p, page_status: ok ? 'read' : 'unknown', reason, page_metadata: metadata(response), text: ok ? text.slice(0, 30000) : '', truncated: text.length > 30000, title, h1, links: [...new Set(links)].slice(0, 500), links_truncated: links.length > 500 };\n}\n\nfunction 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\nconst sources=$('Pair source pages').all();const seen=new Set();const pages=$input.all().map(item=>{const pair=Array.isArray(item.pairedItem)?item.pairedItem[0]:item.pairedItem;const i=pair?.item;if(!Number.isInteger(i)||!sources[i]||seen.has(i))throw new Error('Ambiguous extraction pairing');seen.add(i);const s=sources[i].json;return pageEvidence(s.plan,s.response,item.json);});if(pages.length!==$('Split page requests').all().length)throw new Error('Missing page responses');return [{json:{pages}}];"
      }
    },
    {
      "id": "no-eligible-pages",
      "name": "No eligible pages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2880,
        800
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "\n\nreturn [{json:{pages:[]}}];"
      }
    },
    {
      "id": "keep-empty-page-inventory",
      "name": "Keep empty page inventory",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        800
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "\n\nreturn $input.all();"
      }
    },
    {
      "id": "build-review-rows",
      "name": "Build review rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4560,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function 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 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 host(v) { return url(v)?.split('/')[2] || ''; }\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\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\nconst c=$('Validate settings').first().json;const p=$('Plan page sample').first().json;const pages=$input.first().json.pages;return publishers(c,p.sources,pages,p.searches).map(json=>({json}));"
      }
    },
    {
      "id": "empty-sample",
      "name": "Empty sample",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2850,
        630
      ],
      "parameters": {
        "content": "## Retain results without page reads\nExcluded sources, missing citations and unavailable searches still reach Results. An empty sample makes no Web Scraping requests.",
        "width": 480,
        "height": 380,
        "color": 7
      }
    },
    {
      "id": "save-review-queue",
      "name": "Save review queue",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        5040,
        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 }}",
            "citing_prompts": "={{ $json.citing_prompts }}",
            "competitors_observed": "={{ $json.competitors_observed }}",
            "brand_evidence": "={{ $json.brand_evidence }}",
            "source_url": "={{ $json.source_url }}",
            "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": "citing_prompts",
              "displayName": "citing_prompts",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "competitors_observed",
              "displayName": "competitors_observed",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "brand_evidence",
              "displayName": "brand_evidence",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "source_url",
              "displayName": "source_url",
              "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 Prompts with headers: `query`.",
        "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": "## Collect and attach source evidence\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-4",
      "name": "Section 4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2850,
        60
      ],
      "parameters": {
        "content": "## Inspect page content\nCheck content_selector when text is missing. Enable js_rendering only if the page needs it; this can increase request cost. Failed reads stay unknown.",
        "width": 940,
        "height": 440,
        "color": 7
      }
    },
    {
      "id": "section-5",
      "name": "Section 5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3810,
        60
      ],
      "parameters": {
        "content": "## Save computed fields only\nCreate Results with headers: `result_key`, `checked_at`, `topic`, `status`, `summary`, `citing_prompts`, `competitors_observed`, `brand_evidence`, `source_url`, `evidence_json`. Add optional review_status and editor_notes columns; this workflow never writes them.",
        "width": 1420,
        "height": 440,
        "color": 7
      }
    },
    {
      "id": "overview",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -650,
        -140
      ],
      "parameters": {
        "content": "# Find publisher opportunities in Google AI Mode citations with HasData\n\n### How it works\nCollect citations from several commercial research prompts, deduplicate source URLs and prioritize pages cited across prompts. Exclude your own site, configured competitor domains and selected social platforms before fetching pages. Inspect their main text for named competitors, your brand aliases and links to your website. Save a source review queue with exact matching excerpts and the prompts that cited each page.\n\nA citation is not a brand mention or endorsement. Missing text matches do not prove absence from the full page. Publisher ownership and topical fit require human review. This workflow does not invent contacts, draft pitches or send outreach.\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 Prompts and Results tabs with the headers shown on the canvas. Set spreadsheet_id in Settings. Add queries to Prompts. Replace the example brand, aliases, competitor names and excluded domains. Enter one value per line in list settings. Choose a main-content CSS selector and a page budget. Add competitor-owned publishers to exclusions when you discover them.\n\n### Customization\nOne AI Mode request per prompt, at most ten, plus bounded Web Scraping page requests. No 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": "Plan page sample",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Plan page sample": {
      "main": [
        [
          {
            "node": "Pages to inspect",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pages to inspect": {
      "main": [
        [
          {
            "node": "Split page requests",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No eligible pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split page requests": {
      "main": [
        [
          {
            "node": "Read source pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read source pages": {
      "main": [
        [
          {
            "node": "Pair source pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pair source pages": {
      "main": [
        [
          {
            "node": "Extract page evidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract page evidence": {
      "main": [
        [
          {
            "node": "Collect page evidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect page evidence": {
      "main": [
        [
          {
            "node": "Build review rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "No eligible pages": {
      "main": [
        [
          {
            "node": "Keep empty page inventory",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep empty page inventory": {
      "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": []
}
