{
  "nodes": [
    {
      "id": "41259296-ec07-4167-a1c2-d7307fc4499c",
      "name": "Loop over queries",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        912,
        688
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "67ca1049-b28e-41ec-bb86-d23ab4bec479",
      "name": "Search Google Maps with query",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueErrorOutput",
      "maxTries": 2,
      "position": [
        1232,
        704
      ],
      "parameters": {
        "url": "=https://places.googleapis.com/v1/places:searchText",
        "method": "POST",
        "options": {
          "allowUnauthorizedCerts": false
        },
        "jsonBody": "={{ { \"textQuery\": $json.query || $('Loop over queries').item.json.query, ...($json.pageToken ? { \"pageToken\": $json.pageToken } : {}) } }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "X-Goog-FieldMask",
              "value": "places.displayName,places.formattedAddress,places.rating,places.nationalPhoneNumber,places.regularOpeningHours,places.websiteUri,places.businessStatus,nextPageToken"
            }
          ]
        }
      },
      "executeOnce": false,
      "retryOnFail": true,
      "typeVersion": 4.2,
      "alwaysOutputData": true
    },
    {
      "id": "0cddbb70-a8eb-4e97-8ebd-101e745517e4",
      "name": "Run workflow",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        688,
        688
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "473f6344-a1bc-4721-a9f4-4ffee8a5bc5f",
      "name": "Extract Places Data",
      "type": "n8n-nodes-base.set",
      "position": [
        2112,
        752
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "id-1",
              "name": "name",
              "type": "string",
              "value": "={{ $json.places.displayName.text }}"
            },
            {
              "id": "id-2",
              "name": "address",
              "type": "string",
              "value": "={{ $json.places.formattedAddress }}"
            },
            {
              "id": "id-3",
              "name": "rating",
              "type": "number",
              "value": "={{ $json.places.rating }}"
            },
            {
              "id": "id-4",
              "name": "phone",
              "type": "string",
              "value": "={{ $json.places.nationalPhoneNumber }}"
            },
            {
              "id": "id-5",
              "name": "openingHours",
              "type": "object",
              "value": "={{ $json.places.regularOpeningHours }}"
            },
            {
              "id": "id-6",
              "name": "website",
              "type": "string",
              "value": "={{ $json.places.websiteUri }}"
            },
            {
              "id": "id-7",
              "name": "businessStatus",
              "type": "string",
              "value": "={{ $json.places.businessStatus }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "ea0dbc01-474d-42ee-917a-b13a533d4b6b",
      "name": "Check if Website Exists",
      "type": "n8n-nodes-base.if",
      "position": [
        2736,
        944
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "id-1",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.places.websiteUri }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "c861ccaa-2318-46ee-b830-4e6035624c8d",
      "name": "Check for Next Page Token",
      "type": "n8n-nodes-base.code",
      "position": [
        1552,
        688
      ],
      "parameters": {
        "jsCode": "// Get the response from the Google Maps API\nconst response = $input.first().json;\n\n// Get the query from the input\nconst query = $input.first().json.query || $('Search Google Maps with query').first().json.query;\n\n// Extract nextPageToken if it exists\nconst nextPageToken = response.nextPageToken || null;\n\n// Extract places array from the response\nconst places = response.places || [];\n\n// Initialize or get accumulated places from execution custom data\nif (!$execution.customData.accumulatedPlaces) {\n  $execution.customData.accumulatedPlaces = [];\n}\n\n// Accumulate the places\n$execution.customData.accumulatedPlaces = $execution.customData.accumulatedPlaces.concat(places);\n\n// Return the nextPageToken, query, and accumulated places\nreturn [\n  {\n    json: {\n      query: query,\n      nextPageToken: nextPageToken,\n      pageToken: nextPageToken,\n      places: $execution.customData.accumulatedPlaces,\n      currentPagePlaces: places.length,\n      totalAccumulatedPlaces: $execution.customData.accumulatedPlaces.length\n    }\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "f2929d01-3781-4ba8-a759-5f0754000c6a",
      "name": "Has Next Page?",
      "type": "n8n-nodes-base.if",
      "position": [
        1856,
        736
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "id-1",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.nextPageToken }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "70c3a947-403f-4698-989d-a2f376c39ac6",
      "name": "Merge Emails with Business Data",
      "type": "n8n-nodes-base.merge",
      "position": [
        6128,
        640
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineByPosition"
      },
      "typeVersion": 3.2
    },
    {
      "id": "2ff082fb-27f6-4e6e-919c-e5a24f570ec7",
      "name": "Merge All Businesses",
      "type": "n8n-nodes-base.merge",
      "position": [
        6352,
        960
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "185fa4e8-5162-4d8a-80e6-9e62e6d7a8b0",
      "name": "Split Out Places",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        2352,
        752
      ],
      "parameters": {
        "include": "allOtherFields",
        "options": {},
        "fieldToSplitOut": "places"
      },
      "typeVersion": 1
    },
    {
      "id": "9c0020e6-dd29-49bf-b801-71794fe40386",
      "name": "Filter Operational Businesses",
      "type": "n8n-nodes-base.filter",
      "position": [
        2592,
        752
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "id-1",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.places.businessStatus }}",
              "rightValue": "OPERATIONAL"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "d599c3a4-28f0-4a65-bdec-1664952e088f",
      "name": "Fetch Website HTML",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        2928,
        816
      ],
      "parameters": {
        "url": "={{ $json.places.websiteUri }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "0d0a8bf0-b3b3-413a-8f7a-7ee4b0a4db9f",
      "name": "Extract Emails from HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        4384,
        784
      ],
      "parameters": {
        "jsCode": "// 1. Identify the field containing the HTML (Mapped here)\nconst html = $input.item.json.combinedHtml || $input.item.json.data || $input.item.json.body || $input.item.json.html || '';\n\n// 2. Clone the business data (Name, Phone, etc.)\nconst businessData = { ...$input.item.json };\n\n// 3. Clean up business data so we don't pass the HTML text to Google Sheets\ndelete businessData.data;\ndelete businessData.body;\ndelete businessData.html;\ndelete businessData.error;\ndelete businessData.combinedHtml;\n\n// 4. Extract Emails\nconst emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/g;\nconst emailMatches = html.match(emailRegex) || [];\n\n// 5. Remove duplicates and filter out image-based emails\nconst uniqueEmails = [...new Set(emailMatches)].filter(email => {\n  return !/\\.(png|jpg|jpeg|gif|svg|webp|ico)$/i.test(email);\n});\n\n// 6. Return the data\nif (uniqueEmails.length === 0) {\n  // If no email found, return the business info with a blank email field\n  return { json: { ...businessData, email: '' } };\n} \n\n// If multiple emails found, n8n allows you to return an array \n// of objects, which will automatically split into separate rows.\nreturn uniqueEmails.map(email => ({\n  json: { ...businessData, email: email }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "f326a102-87c7-4c22-a476-0b0f720605cb",
      "name": "Filter Valid Emails",
      "type": "n8n-nodes-base.filter",
      "position": [
        4640,
        784
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "id-1",
              "operator": {
                "type": "string",
                "operation": "notRegex"
              },
              "leftValue": "={{ $json.email }}",
              "rightValue": "(google|gstatic|ggpht|schema\\.org|example\\.com|png|jpg|gif|jpeg|wixpress|sentry|imli|noreply|no-reply|donotreply|mailer-daemon|postmaster)"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "79fc536e-709a-492a-b494-86dd62d4085a",
      "name": "Append to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        6912,
        960
      ],
      "parameters": {
        "columns": {
          "value": {
            "name": "={{ $json.places.displayName.text }}",
            "email": "={{ $json.email }}",
            "phone": "={{ $json.places.nationalPhoneNumber }}",
            "places": "={{ $json.places.displayName.languageCode }}",
            "address": "={{ $json.places.formattedAddress }}",
            "website": "={{ $json.places.websiteUri }}",
            "businessStatus": "={{ $json.places.businessStatus }}"
          },
          "schema": [
            {
              "id": "name",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "address",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "address",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "rating",
              "type": "string",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "rating",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "website",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "website",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "businessStatus",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "businessStatus",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "places",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "places",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "email",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "email",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 509043379,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1pBTh42nVAp3X1OTgTsdWVAc-YUosIPI4vGRilqWJMYY/edit#gid=509043379",
          "cachedResultName": "B2B Lead Scraper"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1pBTh42nVAp3X1OTgTsdWVAc-YUosIPI4vGRilqWJMYY",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1pBTh42nVAp3X1OTgTsdWVAc-YUosIPI4vGRilqWJMYY/edit?usp=drivesdk",
          "cachedResultName": "Solar leads"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "6ee08a6f-f4ee-40d0-87a1-8b42d82705e8",
      "name": "Extract Contact Links",
      "type": "n8n-nodes-base.code",
      "position": [
        3216,
        880
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Get the HTML from the data field\nconst html = $input.item.json.data || '';\n\n// Clone the business data to preserve it\nconst businessData = { ...$input.item.json };\n\n// Extract the base URL from the website URI\nconst websiteUri = businessData.places?.websiteUri || '';\nlet baseUrl = '';\ntry {\n  const urlObj = new URL(websiteUri);\n  baseUrl = `${urlObj.protocol}//${urlObj.hostname}`;\n} catch (e) {\n  baseUrl = websiteUri;\n}\n\n// Use regex to find all href attributes in anchor tags\nconst hrefRegex = /href=[\"']([^\"']+)[\"']/gi;\nconst matches = [];\nlet match;\n\nwhile ((match = hrefRegex.exec(html)) !== null) {\n  matches.push(match[1]);\n}\n\n// Filter for contact-related links with expanded keywords\nconst contactKeywords = ['contact', 'about', 'reach', 'info', 'get-in-touch', 'touch', 'connect', 'team', 'privacy', 'terms', 'legal', 'imprint', 'impressum'];\nconst contactLinks = matches.filter(link => {\n  const lowerLink = link.toLowerCase();\n  return contactKeywords.some(keyword => lowerLink.includes(keyword));\n});\n\n// Convert all relative URLs to absolute URLs\nconst absoluteContactLinks = contactLinks.map(link => {\n  if (link.startsWith('/')) {\n    return baseUrl + link;\n  } else if (!link.startsWith('http')) {\n    return baseUrl + '/' + link;\n  }\n  return link;\n});\n\n// Return all matching contact links as separate items\nif (absoluteContactLinks.length === 0) {\n  // If no contact links found, return business data with empty contactLink\n  return {\n    json: {\n      ...businessData,\n      contactLink: ''\n    }\n  };\n}\n\n// Return an array of items, one for each contact link\nreturn absoluteContactLinks.map(contactLink => ({\n  json: {\n    ...businessData,\n    contactLink: contactLink\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "305994a9-dc4b-4174-af52-b39037b1b02d",
      "name": "Filter Contact Links",
      "type": "n8n-nodes-base.filter",
      "position": [
        3472,
        880
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "id-1",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.contactLink }}"
            },
            {
              "id": "b6925dc5-3140-4e6d-943b-3654c3bd8203",
              "operator": {
                "type": "string",
                "operation": "notRegex"
              },
              "leftValue": "={{ $json.contactLink }}",
              "rightValue": "\\.(png|jpg|jpeg|ico|css|svg)$"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "a5bb802b-e446-454d-b823-376bab92b602",
      "name": "Fetch Contact Page HTML",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "maxTries": 2,
      "position": [
        3728,
        880
      ],
      "parameters": {
        "url": "={{ $json.contactLink }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          },
          "allowUnauthorizedCerts": true
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
            }
          ]
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2
    },
    {
      "id": "3fc0737f-9453-44ba-8cb7-1ea6f77716b1",
      "name": "Remove Duplicate Businesses",
      "type": "n8n-nodes-base.removeDuplicates",
      "position": [
        6640,
        960
      ],
      "parameters": {
        "compare": "selectedFields",
        "options": {},
        "fieldsToCompare": "places.displayName.text"
      },
      "typeVersion": 2
    },
    {
      "id": "9bff479f-b55f-4f78-877d-cbfa2de9ba40",
      "name": "Combine Homepage and Contact HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        4032,
        784
      ],
      "parameters": {
        "jsCode": "// Get all input items\nconst items = $input.all();\n\nif (items.length === 0) {\n  return [];\n}\n\n// Get the first item to preserve business data\nconst firstItem = items[0].json;\n\n// Extract and combine HTML from all items\nlet combinedHtml = '';\n\nfor (const item of items) {\n  const html = item.json.data || item.json.body || item.json.html || '';\n  if (html) {\n    combinedHtml += html + '\\n\\n';\n  }\n}\n\n// Clone business data from first item\nconst businessData = { ...firstItem };\n\n// Clean up unnecessary fields\ndelete businessData.data;\ndelete businessData.body;\ndelete businessData.html;\ndelete businessData.error;\ndelete businessData.contactLink;\n\n// Return combined data\nreturn {\n  json: {\n    ...businessData,\n    combinedHtml: combinedHtml.trim()\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "340da66a-cf7b-4aae-aa4e-e199bca9b4a1",
      "name": "Remove Duplicate Emails",
      "type": "n8n-nodes-base.removeDuplicates",
      "position": [
        5872,
        784
      ],
      "parameters": {
        "compare": "selectedFields",
        "options": {},
        "fieldsToCompare": "email"
      },
      "typeVersion": 2
    },
    {
      "id": "b510e4cb-fd35-42de-9d71-0732454402b6",
      "name": "Score and Rank Emails",
      "type": "n8n-nodes-base.code",
      "position": [
        5168,
        784
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Get the email and business data\nconst email = $input.item.json.email || '';\nconst website = $input.item.json.website || $input.item.json.places?.websiteUri || '';\nconst businessData = { ...$input.item.json };\n\n// Initialize quality score\nlet qualityScore = 100;\n\n// Extract email parts\nconst emailLower = email.toLowerCase();\nconst emailLocalPart = emailLower.split('@')[0];\nconst emailDomain = emailLower.split('@')[1] || '';\n\n// Extract domain from business website\nlet businessDomain = '';\nif (website) {\n  try {\n    const urlObj = new URL(website);\n    businessDomain = urlObj.hostname.replace('www.', '');\n  } catch (e) {\n    businessDomain = website.replace(/^(https?:\\/\\/)?(www\\.)?/, '').split('/')[0];\n  }\n}\n\n// Penalize generic email prefixes (heavy penalty)\nconst genericPrefixes = ['info', 'contact', 'admin', 'support', 'sales', 'hello', 'noreply', 'no-reply', 'enquiry', 'enquiries', 'inquiry', 'inquiries', 'office', 'general', 'help', 'service', 'team', 'mail'];\nif (genericPrefixes.some(prefix => emailLocalPart === prefix || emailLocalPart.startsWith(prefix + '.'))) {\n  qualityScore -= 40;\n}\n\n// Reward emails that appear to have personal names (contains dots or multiple parts)\nif (emailLocalPart.includes('.') && !genericPrefixes.some(prefix => emailLocalPart.includes(prefix))) {\n  qualityScore += 20;\n}\n\n// Check domain match with business website\nif (businessDomain && emailDomain) {\n  if (emailDomain === businessDomain || emailDomain.includes(businessDomain) || businessDomain.includes(emailDomain)) {\n    qualityScore += 30; // Reward matching domain\n  } else {\n    qualityScore -= 20; // Penalize non-matching domain\n  }\n}\n\n// Penalize free email providers\nconst freeProviders = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'aol.com', 'icloud.com', 'mail.com', 'protonmail.com'];\nif (freeProviders.includes(emailDomain)) {\n  qualityScore -= 25;\n}\n\n// Ensure score stays within 0-100 range\nqualityScore = Math.max(0, Math.min(100, qualityScore));\n\n// Return the business data with quality score\nreturn {\n  json: {\n    ...businessData,\n    emailQualityScore: qualityScore\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "01be1b17-7fc3-48e5-b8c7-82ec4c543fea",
      "name": "Sort by Email Quality",
      "type": "n8n-nodes-base.sort",
      "position": [
        5424,
        784
      ],
      "parameters": {
        "options": {},
        "sortFieldsUi": {
          "sortField": [
            {
              "order": "descending",
              "fieldName": "emailQualityScore"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "60c2b837-1b12-4202-8a88-18fc57777fb4",
      "name": "Keep Best Email Only",
      "type": "n8n-nodes-base.limit",
      "position": [
        5648,
        784
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "b941abc6-cfb5-4698-8396-bdb057a0e8c3",
      "name": "Group by Business",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        4896,
        784
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "31691192-c241-447e-9aad-83dedad59d9e",
      "name": "\ud83d\udccb Overview: B2B Lead Generation Scraper",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        608,
        -384
      ],
      "parameters": {
        "color": "yellow",
        "width": 352,
        "height": 768,
        "content": "## B2B Lead Generation Scraper\n\nThis workflow automates lead discovery and qualification from Google Maps. It searches for businesses, scrapes their websites for contact emails, scores email quality, and exports verified leads to Google Sheets.\n\n### How it works\n1. Input search queries (e.g., \"Dentist New York\", \"Plumber London\")\n2. Searches Google Maps for all matching businesses\n3. Filters for operational businesses only\n4. Visits business websites to find contact emails\n5. Scores emails by quality (domain match, personal names, generic prefixes)\n6. Keeps the best email per business\n7. Exports deduplicated leads to Google Sheets\n\n### Setup steps\n1. Get Google Places API key from Google Cloud Console\n2. Enable Places API (New) in your project\n3. Add credentials to \"Search Google Maps with query\" node\n4. Connect Google Sheets credentials for lead export\n5. Prepare search queries in JSON format (see Query Prompt sticky)\n6. Adjust email scoring thresholds if needed"
      },
      "typeVersion": 1
    },
    {
      "id": "9c7113ab-8389-4266-a74e-269ef72e2937",
      "name": "Stage 1: Google Maps Search",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        528
      ],
      "parameters": {
        "color": 7,
        "width": 832,
        "height": 640,
        "content": "## Stage 1: Google Maps Search\n\nLoops through search queries, fetches all matching businesses from Google Maps, handles pagination automatically, and filters for operational businesses only."
      },
      "typeVersion": 1
    },
    {
      "id": "bf4a9c62-21c2-4f0a-87c5-927d46f43c7a",
      "name": "Stage 2: Website Scraping",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1488,
        528
      ],
      "parameters": {
        "color": 7,
        "width": 2784,
        "height": 640,
        "content": "## Stage 2: Website Scraping\n\nVisits business websites and contact pages to extract all available email addresses. Handles both homepage and dedicated contact page scraping."
      },
      "typeVersion": 1
    },
    {
      "id": "fadc94fd-fc55-4ae7-8978-418d2c743578",
      "name": "Stage 3: Email Quality Control",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4272,
        528
      ],
      "parameters": {
        "color": 7,
        "width": 1744,
        "height": 640,
        "content": "## Stage 3: Email Quality Control\n\nScores emails by quality (+30 domain match, +20 personal names, -40 generic prefixes, -25 free providers). Keeps only the best email per business."
      },
      "typeVersion": 1
    },
    {
      "id": "3cb35ee5-bff7-487e-8fb7-4c6d3d54bac9",
      "name": "Stage 4: Final Output",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        6000,
        528
      ],
      "parameters": {
        "color": 7,
        "width": 1104,
        "height": 640,
        "content": "## Stage 4: Final Output & Deduplication\n\nMerges email data with business info, removes duplicate businesses, and exports clean leads to Google Sheets."
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "Run workflow": {
      "main": [
        [
          {
            "node": "Loop over queries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Next Page?": {
      "main": [
        [
          {
            "node": "Search Google Maps with query",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Extract Places Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Out Places": {
      "main": [
        [
          {
            "node": "Filter Operational Businesses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Group by Business": {
      "main": [
        [
          {
            "node": "Score and Rank Emails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop over queries": {
      "main": [
        [],
        [
          {
            "node": "Search Google Maps with query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Website HTML": {
      "main": [
        [
          {
            "node": "Extract Contact Links",
            "type": "main",
            "index": 0
          },
          {
            "node": "Combine Homepage and Contact HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Places Data": {
      "main": [
        [
          {
            "node": "Split Out Places",
            "type": "main",
            "index": 0
          },
          {
            "node": "Loop over queries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Valid Emails": {
      "main": [
        [
          {
            "node": "Group by Business",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Contact Links": {
      "main": [
        [
          {
            "node": "Fetch Contact Page HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep Best Email Only": {
      "main": [
        [
          {
            "node": "Remove Duplicate Emails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge All Businesses": {
      "main": [
        [
          {
            "node": "Remove Duplicate Businesses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Contact Links": {
      "main": [
        [
          {
            "node": "Filter Contact Links",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score and Rank Emails": {
      "main": [
        [
          {
            "node": "Sort by Email Quality",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sort by Email Quality": {
      "main": [
        [
          {
            "node": "Keep Best Email Only",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append to Google Sheets": {
      "main": [
        [
          {
            "node": "Loop over queries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check if Website Exists": {
      "main": [
        [
          {
            "node": "Fetch Website HTML",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge All Businesses",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Fetch Contact Page HTML": {
      "main": [
        [
          {
            "node": "Combine Homepage and Contact HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Remove Duplicate Emails": {
      "main": [
        [
          {
            "node": "Merge Emails with Business Data",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Extract Emails from HTML": {
      "main": [
        [
          {
            "node": "Filter Valid Emails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for Next Page Token": {
      "main": [
        [
          {
            "node": "Has Next Page?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Remove Duplicate Businesses": {
      "main": [
        [
          {
            "node": "Append to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Operational Businesses": {
      "main": [
        [
          {
            "node": "Check if Website Exists",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge Emails with Business Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search Google Maps with query": {
      "main": [
        [
          {
            "node": "Check for Next Page Token",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Loop over queries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Emails with Business Data": {
      "main": [
        [
          {
            "node": "Merge All Businesses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine Homepage and Contact HTML": {
      "main": [
        [
          {
            "node": "Extract Emails from HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}