{
  "nodes": [
    {
      "id": "46ba4cf8-de50-4cc3-b1dc-fb4148cc88ef",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1680,
        -128
      ],
      "parameters": {
        "width": 480,
        "height": 752,
        "content": "## Collect local business leads from Google Maps to Google Sheets via ScraperAPI\n\n### How it works\n\n1. Initializes the workflow by creating a leads spreadsheet.\n2. Listens for lead search requests to start the search process.\n3. Configures the search query using provided parameters.\n4. Fetches and processes data from Google Maps via ScraperAPI.\n5. Deduces new leads and appends them to the spreadsheet.\n\n### Setup steps\n\n- [ ] Set up Google Sheets API credentials for creating and accessing spreadsheets.\n- [ ] Configure ScraperAPI with an API key.\n- [ ] Ensure form trigger is set up to capture lead search requests.\n\n### Customization\n\nAdjust the query parameters in the 'Configure' node to refine search criteria (e.g., change country code, rating, or max results)."
      },
      "typeVersion": 1
    },
    {
      "id": "ef74b78c-380e-4419-945e-3ae9b487f730",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1120,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 304,
        "content": "## Initialize leads spreadsheet\n\nManually creates an initial Google Sheets document for storing leads."
      },
      "typeVersion": 1
    },
    {
      "id": "d6eadd70-a8d9-4959-ba32-03af96e8d132",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1120,
        352
      ],
      "parameters": {
        "color": 7,
        "width": 928,
        "height": 272,
        "content": "## Lead search trigger and configuration\n\nTriggers the lead search process and configures search criteria."
      },
      "typeVersion": 1
    },
    {
      "id": "eedcb8fb-84c0-471c-ba26-62748e318e8e",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -128,
        352
      ],
      "parameters": {
        "color": 7,
        "width": 976,
        "height": 272,
        "content": "## Fetch and process map data\n\nHandles search query execution, data fetching from Google Maps, and URL extraction."
      },
      "typeVersion": 1
    },
    {
      "id": "ccf85a39-ff80-4c2f-bbfd-f2e2e42d095f",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        896,
        320
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 304,
        "content": "## Deduplicate and append leads\n\nRemoves duplicate leads and appends new ones to the Google Sheets document."
      },
      "typeVersion": 1
    },
    {
      "id": "setup-trigger",
      "name": "Create Leads Sheet (run once)",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -1072,
        0
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "create-spreadsheet",
      "name": "Create Leads Spreadsheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -832,
        0
      ],
      "parameters": {
        "title": "Google Maps Leads",
        "options": {},
        "resource": "spreadsheet",
        "sheetsUi": {
          "sheetValues": [
            {
              "title": "Leads"
            }
          ]
        },
        "operation": "create"
      },
      "typeVersion": 4.5
    },
    {
      "id": "form-trigger",
      "name": "On Lead Search Request",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        -1072,
        460
      ],
      "parameters": {
        "options": {},
        "formTitle": "Find local business leads",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Niche",
              "placeholder": "plumbers",
              "requiredField": true
            },
            {
              "fieldLabel": "Location",
              "placeholder": "Austin, TX",
              "requiredField": true
            },
            {
              "fieldType": "number",
              "fieldLabel": "Max results",
              "placeholder": "20",
              "requiredField": false
            }
          ]
        },
        "formDescription": "Enter a niche and a location to pull matching businesses from Google Maps."
      },
      "typeVersion": 2.2
    },
    {
      "id": "configure",
      "name": "Configure",
      "type": "n8n-nodes-base.set",
      "position": [
        -832,
        460
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "query",
              "name": "query",
              "type": "string",
              "value": "={{ $json.Niche }} in {{ $json.Location }}"
            },
            {
              "id": "country-code",
              "name": "countryCode",
              "type": "string",
              "value": "us"
            },
            {
              "id": "min-rating",
              "name": "minRating",
              "type": "number",
              "value": 0
            },
            {
              "id": "max-results",
              "name": "maxResults",
              "type": "number",
              "value": "={{ Number($json['Max results']) || 20 }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "read-existing-leads",
      "name": "Read Existing Leads",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -580,
        460
      ],
      "parameters": {
        "options": {},
        "operation": "read",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        }
      },
      "typeVersion": 4.5,
      "alwaysOutputData": true
    },
    {
      "id": "build-search",
      "name": "Build Search Query",
      "type": "n8n-nodes-base.code",
      "position": [
        -340,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Re-emit a single search item, decoupled from the rows returned by the\n// existing-leads read, so the ScraperAPI nodes run exactly once no matter\n// how many leads are already stored in the sheet.\n\nconst cfg = $('Configure').first().json;\n\nreturn [{\n  json: {\n    query: cfg.query,\n    countryCode: cfg.countryCode,\n  },\n  pairedItem: { item: 0 },\n}];\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "fetch-maps-metadata",
      "name": "Fetch Maps Metadata via ScraperAPI",
      "type": "n8n-nodes-scraperapi-official.scraperApi",
      "maxTries": 2,
      "position": [
        -80,
        460
      ],
      "parameters": {
        "resource": "sde",
        "sdeQuery": "={{ $json.query }}",
        "operation": "googleMapsSearch",
        "sdePlatform": "google",
        "sdeGoogleMapsSearchOptions": {
          "countryCode": "={{ $json.countryCode }}"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 2000
    },
    {
      "id": "extract-maps-url",
      "name": "Extract Results URL",
      "type": "n8n-nodes-base.code",
      "position": [
        160,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// The Google Maps structured endpoint returns a metadata document, not the\n// businesses: the actual results live behind `next_page_url`, a ready-made\n// ScraperAPI request wrapping the Google Maps results page. Pull out the\n// inner Google URL so the next node can fetch and autoparse it.\n\nconst getBody = (item) => {\n  const resp = item && item.json && item.json.response;\n  if (!resp || resp.body === undefined) return {};\n  if (typeof resp.body === 'string') {\n    try { return JSON.parse(resp.body); } catch (e) { return {}; }\n  }\n  return resp.body;\n};\n\nconst meta = getBody($input.first());\nconst nextPage = meta.next_page_url || meta.nextPageURL || '';\n\n// Pull the `url` query param out by hand \u2014 the n8n Code sandbox does not\n// reliably expose the URL constructor. The inner Google URL is percent-\n// encoded, so it never contains a literal `&` to break the match.\nconst match = /[?&]url=([^&]+)/.exec(nextPage);\nlet targetUrl = '';\nif (match) {\n  try { targetUrl = decodeURIComponent(match[1]); } catch (e) { targetUrl = match[1]; }\n}\n\nreturn [{ json: { targetUrl }, pairedItem: { item: 0 } }];\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "fetch-maps-results",
      "name": "Fetch Maps Results via ScraperAPI",
      "type": "n8n-nodes-scraperapi-official.scraperApi",
      "maxTries": 2,
      "position": [
        400,
        460
      ],
      "parameters": {
        "apiUrl": "={{ $json.targetUrl }}",
        "resource": "api",
        "operation": "apiRequest",
        "apiOptionalParameters": {
          "apiAutoparse": true,
          "apiCountryCode": "={{ $('Configure').first().json.countryCode }}"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 2000
    },
    {
      "id": "extract-businesses",
      "name": "Extract Businesses",
      "type": "n8n-nodes-base.code",
      "position": [
        700,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Parse the autoparsed Google Maps results and flatten each business into a\n// clean lead row. Applies the configured minimum-rating filter (on stars).\n// Result fields: name, address (array), address_line, stars, ratings, type\n// (array), url (website), phone_number, latitude, longitude, district.\n\nconst cfg = $('Configure').first().json;\nconst minRating = Number(cfg.minRating) || 0;\n\nconst getBody = (item) => {\n  const resp = item && item.json && item.json.response;\n  if (!resp || resp.body === undefined) return {};\n  if (typeof resp.body === 'string') {\n    try { return JSON.parse(resp.body); } catch (e) { return {}; }\n  }\n  return resp.body;\n};\n\nconst numOrNull = (v) => {\n  if (v === null || v === undefined || v === '') return null;\n  const s = typeof v === 'string' ? v.replace(/[^0-9.]/g, '') : v;\n  const n = parseFloat(s);\n  return Number.isFinite(n) ? n : null;\n};\n\nconst joinList = (v) => (Array.isArray(v) ? v.filter(Boolean).join(', ') : (v || ''));\n\nconst scrapedAt = new Date().toISOString();\nconst out = [];\n\n$input.all().forEach((item, i) => {\n  const body = getBody(item);\n  const results = body.results || body.local_results || [];\n  if (!Array.isArray(results)) return;\n\n  for (const b of results) {\n    const rating = numOrNull(b.stars ?? b.rating);\n    if (minRating > 0 && (rating === null || rating < minRating)) continue;\n\n    const name = b.name || b.title || '';\n    const address = joinList(b.address) || b.address_line || '';\n    const mapQuery = `${name} ${address}`.trim();\n\n    out.push({\n      json: {\n        name,\n        category: joinList(b.type),\n        address,\n        phone: b.phone_number || b.phone || '',\n        website: b.url || b.website || '',\n        rating,\n        reviews: numOrNull(b.ratings ?? b.reviews),\n        district: b.district || '',\n        latitude: b.latitude ?? null,\n        longitude: b.longitude ?? null,\n        google_maps_url: mapQuery ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(mapQuery)}` : '',\n        search_query: cfg.query,\n        scraped_at: scrapedAt,\n      },\n      pairedItem: { item: i },\n    });\n  }\n});\n\nreturn out;\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "dedupe-new-leads",
      "name": "Dedupe New Leads",
      "type": "n8n-nodes-base.code",
      "position": [
        940,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Drop businesses already in the sheet (matched by name + address) and any\n// duplicates within this batch, then cap to the configured maximum so a broad\n// query can't flood the sheet.\n\nconst maxResults = Number($('Configure').first().json.maxResults) || 20;\n\nconst keyOf = (r) =>\n  `${String(r.name || '').toLowerCase().trim()}|${String(r.address || '').toLowerCase().trim()}`;\n\nconst seen = new Set(\n  $('Read Existing Leads').all()\n    .map((r) => keyOf(r.json || {}))\n    .filter((k) => k && k !== '|')\n);\n\nconst out = [];\nfor (const item of $input.all()) {\n  const lead = item.json;\n  const key = keyOf(lead);\n  if (!key || key === '|' || seen.has(key)) continue;\n  seen.add(key);\n  out.push({ json: lead, pairedItem: item.pairedItem });\n  if (out.length >= maxResults) break;\n}\n\nreturn out;\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "append-leads",
      "name": "Append Leads to Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1180,
        460
      ],
      "parameters": {
        "columns": {
          "value": {
            "name": "={{ $json.name }}",
            "phone": "={{ $json.phone }}",
            "rating": "={{ $json.rating }}",
            "address": "={{ $json.address }}",
            "reviews": "={{ $json.reviews }}",
            "website": "={{ $json.website }}",
            "category": "={{ $json.category }}",
            "district": "={{ $json.district }}",
            "latitude": "={{ $json.latitude }}",
            "longitude": "={{ $json.longitude }}",
            "scraped_at": "={{ $json.scraped_at }}",
            "search_query": "={{ $json.search_query }}",
            "google_maps_url": "={{ $json.google_maps_url }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        }
      },
      "typeVersion": 4.5
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Configure": {
      "main": [
        [
          {
            "node": "Read Existing Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dedupe New Leads": {
      "main": [
        [
          {
            "node": "Append Leads to Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Search Query": {
      "main": [
        [
          {
            "node": "Fetch Maps Metadata via ScraperAPI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Businesses": {
      "main": [
        [
          {
            "node": "Dedupe New Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Results URL": {
      "main": [
        [
          {
            "node": "Fetch Maps Results via ScraperAPI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Existing Leads": {
      "main": [
        [
          {
            "node": "Build Search Query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "On Lead Search Request": {
      "main": [
        [
          {
            "node": "Configure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Leads Sheet (run once)": {
      "main": [
        [
          {
            "node": "Create Leads Spreadsheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Maps Results via ScraperAPI": {
      "main": [
        [
          {
            "node": "Extract Businesses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Maps Metadata via ScraperAPI": {
      "main": [
        [
          {
            "node": "Extract Results URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}