AutomationFlowsMarketing & Ads › Scrape & Track Dentist Leads From Google Maps with Scrapeops, Sheets &…

Scrape & Track Dentist Leads From Google Maps with Scrapeops, Sheets &…

Original n8n title: Scrape & Track Dentist Leads From Google Maps with Scrapeops, Sheets & Notifications

ByIan Kerins @iankerins on n8n.io

This n8n template automates the generation of local business leads by scraping Google Maps. It goes beyond basic search results by visiting individual business pages to extract detailed contact information, reviews, and attributes (like LGBTQ+ friendly status). It includes…

Event trigger★★★★☆ complexity17 nodesForm Trigger@Scrapeops/N8N Nodes ScrapeopsGoogle SheetsGmailSlack
Marketing & Ads Trigger: Event Nodes: 17 Complexity: ★★★★☆ Added:
Scrape & Track Dentist Leads From Google Maps with Scrapeops, Sheets &… — n8n workflow card showing Form Trigger, @Scrapeops/N8N Nodes Scrapeops, Google Sheets integration

This workflow corresponds to n8n.io template #11456 — we link there as the canonical source.

This workflow follows the Form Trigger → Gmail recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "dyRCR6bTKB1R1dxd",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Google Maps Dentist Finder",
  "tags": [
    {
      "id": "EjaruU0tFeVUtNR1",
      "name": "#scraping",
      "createdAt": "2025-12-03T11:31:23.564Z",
      "updatedAt": "2025-12-03T11:31:23.564Z"
    },
    {
      "id": "l2YGilkhCC3xKC2G",
      "name": "#google-maps",
      "createdAt": "2025-12-03T11:31:29.830Z",
      "updatedAt": "2025-12-03T11:31:29.830Z"
    },
    {
      "id": "eOWrCN9ADd6YxjOs",
      "name": "#lead-generation",
      "createdAt": "2025-12-03T11:31:34.561Z",
      "updatedAt": "2025-12-03T11:31:34.561Z"
    },
    {
      "id": "EhmkN5O1u9hmkuNw",
      "name": "#google-sheets",
      "createdAt": "2025-12-03T11:31:40.052Z",
      "updatedAt": "2025-12-03T11:31:40.052Z"
    },
    {
      "id": "RHcyji5THnilgzip",
      "name": "#automation",
      "createdAt": "2025-12-03T11:31:46.416Z",
      "updatedAt": "2025-12-03T11:31:46.416Z"
    },
    {
      "id": "0dfFHYGHluLLiSXe",
      "name": "#dentist",
      "createdAt": "2025-12-03T11:31:51.724Z",
      "updatedAt": "2025-12-03T11:31:51.724Z"
    }
  ],
  "nodes": [
    {
      "id": "ebfa2196-c608-41cf-9fb4-b53495e90b25",
      "name": "Form - City Input",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        -2112,
        512
      ],
      "parameters": {
        "path": "form-city-input-webhook",
        "options": {},
        "formTitle": "Tell Which City",
        "formFields": {
          "values": [
            {
              "fieldLabel": "City Name",
              "requiredField": true
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "b0630953-305c-43d7-b930-c3ab9d7a86f0",
      "name": "Set Google Maps Configuration",
      "type": "n8n-nodes-base.set",
      "position": [
        -1888,
        512
      ],
      "parameters": {
        "fields": {
          "values": [
            {
              "name": "city",
              "stringValue": "={{ $json.city || $json['City Name'] || '' }}"
            },
            {
              "name": "keyword",
              "stringValue": "dentist"
            },
            {
              "name": "baseUrl",
              "stringValue": "https://www.google.com/maps/search/"
            },
            {
              "name": "searchUrl",
              "stringValue": "={{ 'https://www.google.com/maps/search/dentist+in+' + String($json.city || $json['City Name'] || '').replace(/\\s+/g, '+') }}"
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "758efa19-791e-4e82-9353-6e426208dab9",
      "name": "ScrapeOps - Google Maps Search",
      "type": "@scrapeops/n8n-nodes-scrapeops.ScrapeOps",
      "position": [
        -1664,
        512
      ],
      "parameters": {
        "url": "={{ $json.searchUrl }}",
        "returnType": "htmlResponse",
        "advancedOptions": {
          "wait": "=12000",
          "render_js": true,
          "residential_proxy": false
        }
      },
      "credentials": {
        "scrapeOpsApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "9c35cd52-dd67-4bef-92d4-413f8003e4a5",
      "name": "Parse Business Details",
      "type": "n8n-nodes-base.code",
      "position": [
        -1440,
        512
      ],
      "parameters": {
        "jsCode": "const item = $input.item;\nconst config = $('Set Google Maps Configuration').item.json;\n\n// City validation: Check if city is valid\nconst cityName = config.city || config['City Name'] || '';\nif (!cityName || cityName.trim().length === 0) {\n  return [{\n    json: {\n      error: true,\n      message: 'Invalid city name or no businesses found. Please enter a valid Google Maps city.'\n    }\n  }];\n}\n\n// Get HTML from ScrapeOps htmlResponse - it's in $json.response\nlet htmlContent = '';\nif (item.json.response && typeof item.json.response === 'string') {\n  htmlContent = item.json.response;\n} else if (typeof item.json === 'string' && item.json.length > 1000) {\n  htmlContent = item.json;\n} else if (item.json.body && typeof item.json.body === 'string') {\n  htmlContent = item.json.body;\n} else if (item.json.data && typeof item.json.data === 'string') {\n  htmlContent = item.json.data;\n}\n\n// Limit HTML size to prevent memory issues\nif (htmlContent.length > 1000000) {\n  htmlContent = htmlContent.substring(0, 1000000);\n}\n\nif (!htmlContent || htmlContent.length < 100) {\n  return [{\n    json: {\n      businessName: 'No HTML content',\n      phone: '',\n      website: '',\n      rating: '',\n      totalReviews: '',\n      address: '',\n      city: config.city || '',\n      mapUrl: '',\n      category: config.keyword || 'dentist',\n      checkedAt: new Date().toISOString(),\n      error: 'No HTML content received'\n    }\n  }];\n}\n\n// Helper function to extract text from HTML tag\nfunction extractText(html, pattern) {\n  const match = html.match(pattern);\n  if (match && match[1]) {\n    return match[1].replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n  }\n  return '';\n}\n\n// Helper function to extract attribute value\nfunction extractAttribute(html, pattern) {\n  const match = html.match(pattern);\n  if (match && match[1]) {\n    return match[1].trim();\n  }\n  return '';\n}\n\n// Helper function to extract all matches\nfunction extractAllMatches(html, pattern) {\n  const matches = [];\n  let match;\n  while ((match = pattern.exec(html)) !== null) {\n    if (match[1]) {\n      matches.push(match[1].trim());\n    }\n  }\n  return matches;\n}\n\nconst results = [];\nconst seenPlaceIds = new Set();\n\n// NEW APPROACH: Extract by mapUrl first (most reliable identifier)\n// Step 1: Find all unique mapUrls with their place IDs - use flexible pattern\nconst mapUrlPatterns = [\n  /href=[\"']([^\"']*maps\\.google\\.com[^\"']*place[^\"']*\\/data=!4m[^\"']*)[\"']/gi,\n  /href=[\"']([^\"']*maps\\.google\\.com[^\"']*place[^\"']*)[\"']/gi,\n  /href=[\"']([^\"']*google\\.com\\/maps\\/place[^\"']*)[\"']/gi\n];\n\nconst mapUrls = [];\nfor (const mapUrlPattern of mapUrlPatterns) {\n  let mapUrlMatch;\n  while ((mapUrlMatch = mapUrlPattern.exec(htmlContent)) !== null && mapUrls.length < 100) {\n    let mapUrl = mapUrlMatch[1];\n    if (!mapUrl.startsWith('http')) {\n      mapUrl = 'https://' + mapUrl;\n    }\n    \n    // Extract place ID to ensure uniqueness\n    const placeIdMatch = mapUrl.match(/1s([^:!\\/]+)/) || mapUrl.match(/place\\/([^\\/\\?]+)/);\n    const placeId = placeIdMatch ? placeIdMatch[1] : mapUrl;\n    \n    if (!seenPlaceIds.has(placeId) && mapUrl.includes('place')) {\n      seenPlaceIds.add(placeId);\n      mapUrls.push({ mapUrl: mapUrl, placeId: placeId });\n    }\n  }\n  if (mapUrls.length > 0) break; // Use first pattern that finds URLs\n}\n\n// If no mapUrls found, try to extract business cards from HTML structure\nif (mapUrls.length === 0) {\n  // Fallback: Extract business cards using HTML structure\n  const cardPatterns = [\n    /<div[^>]*class=[\"'][^\"']*Nv2PK[^\"']*[\"'][^>]*>([\\s\\S]{300,8000})<\\/div>/gi,\n    /<a[^>]*href=[\"'][^\"']*\\/maps\\/place\\/[^\"']*[\"'][^>]*>([\\s\\S]{300,8000})<\\/a>/gi,\n    /<div[^>]*data-result-index[^>]*>([\\s\\S]{300,8000})<\\/div>/gi\n  ];\n  \n  const allCards = [];\n  for (const pattern of cardPatterns) {\n    let match;\n    while ((match = pattern.exec(htmlContent)) !== null && allCards.length < 100) {\n      if (match[1] && match[1].length > 200) {\n        allCards.push(match[1]);\n      }\n    }\n    if (allCards.length > 0) break;\n  }\n  \n  // Process cards and extract mapUrls from them\n  for (const cardHtml of allCards) {\n    const cardMapUrlMatch = cardHtml.match(/href=[\"']([^\"']*maps\\.google\\.com[^\"']*place[^\"']*)[\"']/i) ||\n                              cardHtml.match(/href=[\"']([^\"']*google\\.com\\/maps\\/place[^\"']*)[\"']/i);\n    if (cardMapUrlMatch && cardMapUrlMatch[1]) {\n      let mapUrl = cardMapUrlMatch[1];\n      if (!mapUrl.startsWith('http')) {\n        mapUrl = 'https://' + mapUrl;\n      }\n      const placeIdMatch = mapUrl.match(/1s([^:!\\/]+)/) || mapUrl.match(/place\\/([^\\/\\?]+)/);\n      const placeId = placeIdMatch ? placeIdMatch[1] : mapUrl;\n      if (!seenPlaceIds.has(placeId)) {\n        seenPlaceIds.add(placeId);\n        mapUrls.push({ mapUrl: mapUrl, placeId: placeId, cardHtml: cardHtml });\n      }\n    } else {\n      // Even without mapUrl, try to extract business info from card\n      const businessName = extractText(cardHtml, /<div[^>]*class=[\"'][^\"']*qBF1Pd[^\"']*[\"'][^>]*>([^<]+)<\\/div>/i) ||\n                          extractText(cardHtml, /<h3[^>]*>([^<]+)<\\/h3>/i) ||\n                          extractAttribute(cardHtml, /aria-label=[\"']([^\"']+)[\"']/i) ||\n                          '';\n      if (businessName && businessName.trim().length > 2) {\n        const cardId = 'card_' + allCards.indexOf(cardHtml);\n        if (!seenPlaceIds.has(cardId)) {\n          seenPlaceIds.add(cardId);\n          mapUrls.push({ mapUrl: '', placeId: cardId, cardHtml: cardHtml });\n        }\n      }\n    }\n  }\n}\n\n// Step 2: For each mapUrl, find its card and extract all data\nfor (const { mapUrl: currentMapUrl, placeId, cardHtml: preExtractedCard } of mapUrls) {\n  // Use pre-extracted card if available, otherwise find the card\n  let cardHtml = preExtractedCard || '';\n  let mapUrlIndex = -1;\n  \n  if (!cardHtml) {\n    // Find the card that contains this mapUrl\n    mapUrlIndex = htmlContent.indexOf(currentMapUrl);\n    if (mapUrlIndex === -1) continue;\n    \n    // Extract a card context around this mapUrl (3000 chars before and after)\n    const cardStart = Math.max(0, mapUrlIndex - 3000);\n    const cardEnd = Math.min(htmlContent.length, mapUrlIndex + currentMapUrl.length + 3000);\n    cardHtml = htmlContent.substring(cardStart, cardEnd);\n  } else {\n    mapUrlIndex = cardHtml.indexOf(currentMapUrl);\n    if (mapUrlIndex === -1) mapUrlIndex = 0; // Use start if not found\n  }\n  \n  // Try to find the actual card boundaries (only if not pre-extracted)\n  if (!preExtractedCard && mapUrlIndex >= 0) {\n    const cardStartPattern = /<div[^>]*(?:class=[\"'][^\"']*(?:Nv2PK|qBF1Pd|result)[^\"']*[\"']|data-result-index)[^>]*>/i;\n    \n    // Find the start of the card\n    const cardStart = Math.max(0, mapUrlIndex - 3000);\n    let actualStart = cardStart;\n    for (let i = mapUrlIndex; i >= Math.max(0, mapUrlIndex - 3000); i -= 100) {\n      const checkHtml = htmlContent.substring(Math.max(0, i - 500), mapUrlIndex);\n      const startMatch = checkHtml.match(cardStartPattern);\n      if (startMatch) {\n        actualStart = Math.max(0, i - 500) + checkHtml.lastIndexOf(startMatch[0]);\n        break;\n      }\n    }\n    \n    // Find the end of the card\n    const cardEnd = Math.min(htmlContent.length, mapUrlIndex + currentMapUrl.length + 3000);\n    let actualEnd = cardEnd;\n    let divCount = 0;\n    let foundStart = false;\n    for (let i = actualStart; i < Math.min(htmlContent.length, actualStart + 5000); i++) {\n      if (htmlContent.substring(i, i + 4) === '<div') {\n        divCount++;\n        foundStart = true;\n      } else if (htmlContent.substring(i, i + 6) === '</div>') {\n        divCount--;\n        if (foundStart && divCount === 0) {\n          actualEnd = i + 6;\n          break;\n        }\n      }\n    }\n    \n    cardHtml = htmlContent.substring(actualStart, actualEnd);\n    mapUrlIndex = cardHtml.indexOf(currentMapUrl);\n    if (mapUrlIndex === -1) mapUrlIndex = 0;\n  }\n  \n  if (cardHtml.length < 200) continue;\n  \n  // STEP 1: Extract business name and address from mapUrl (most reliable)\n  let businessName = '';\n  let address = '';\n  \n  // Try to extract mapUrl from card if not provided\n  if (!currentMapUrl || currentMapUrl.length === 0) {\n    const cardMapUrlMatch = cardHtml.match(/href=[\"']([^\"']*maps\\.google\\.com[^\"']*place[^\"']*)[\"']/i) ||\n                              cardHtml.match(/href=[\"']([^\"']*google\\.com\\/maps\\/place[^\"']*)[\"']/i);\n    if (cardMapUrlMatch && cardMapUrlMatch[1]) {\n      currentMapUrl = cardMapUrlMatch[1];\n      if (!currentMapUrl.startsWith('http')) {\n        currentMapUrl = 'https://' + currentMapUrl;\n      }\n      mapUrlIndex = cardHtml.indexOf(currentMapUrl);\n      if (mapUrlIndex === -1) mapUrlIndex = 0;\n    }\n  }\n  \n  const placeMatch = currentMapUrl ? currentMapUrl.match(/\\/place\\/([^\\/]+)/) : null;\n  if (placeMatch && placeMatch[1]) {\n    let placePath = decodeURIComponent(placeMatch[1]);\n    placePath = placePath.replace(/\\+/g, ' ').trim();\n    \n    // Split by ' - ' or ' | ' - business name is usually first, address is after\n    const parts = placePath.split(/\\s*[-|]\\s*/);\n    if (parts.length >= 2) {\n      businessName = parts[0].trim();\n      // Address is everything after the first part\n      address = parts.slice(1).join(', ').trim();\n    } else if (parts.length === 1) {\n      businessName = parts[0].trim();\n    }\n    \n    // Clean business name - remove pipe separators and trailing location info\n    businessName = businessName\n      .replace(/\\s*\\|.*$/i, '')\n      .trim();\n    \n    // Clean address - remove business name words if they appear\n    if (address && businessName) {\n      const nameWords = businessName.split(/\\s+/).filter(w => w.length > 4);\n      let cleanedAddress = address;\n      for (const word of nameWords) {\n        const wordRegex = new RegExp(word.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'gi');\n        cleanedAddress = cleanedAddress.replace(wordRegex, '');\n      }\n      address = cleanedAddress.replace(/^[-|,\\s]+|[-|,\\s]+$/g, '').trim();\n      \n      // If address is too short or looks like business name, clear it\n      if (address.length < 5 || address.toLowerCase().includes(businessName.toLowerCase().substring(0, 15))) {\n        address = '';\n      }\n    }\n  }\n  \n  // If no business name from mapUrl, try HTML extraction with multiple patterns\n  if (!businessName || businessName.length < 3) {\n    const namePatterns = [\n      /<div[^>]*class=[\"'][^\"']*qBF1Pd[^\"']*[\"'][^>]*>([^<]+)<\\/div>/i,\n      /<h3[^>]*>([^<]+)<\\/h3>/i,\n      /<a[^>]*href=[\"'][^\"']*\\/maps\\/place[^\"']*[\"'][^>]*>([^<]+)<\\/a>/i,\n      /<span[^>]*class=[\"'][^\"']*qBF1Pd[^\"']*[\"'][^>]*>([^<]+)<\\/span>/i,\n      /<div[^>]*class=[\"'][^\"']*fontHeadlineSmall[^\"']*[\"'][^>]*>([^<]+)<\\/div>/i,\n      /<div[^>]*role=[\"']heading[\"'][^>]*>([^<]+)<\\/div>/i,\n      /aria-label=[\"']([^\"']+)[\"']/i\n    ];\n    \n    for (const pattern of namePatterns) {\n      businessName = extractText(cardHtml, pattern) || extractAttribute(cardHtml, pattern) || '';\n      if (businessName && businessName.length >= 3) break;\n    }\n    \n    businessName = businessName.replace(/\\s+/g, ' ').trim();\n  }\n  \n  // Filter invalid names\n  if (!businessName || businessName.length < 3 || businessName.length > 150 ||\n      /^(directions|save|share|more|view|see|click|here|search|map)$/i.test(businessName)) {\n    continue;\n  }\n  \n  // STEP 2: Extract rating and reviews from card\n  let rating = '';\n  rating = extractText(cardHtml, /<span[^>]*class=[\"'][^\"']*MW4etd[^\"']*[\"'][^>]*>([^<]+)<\\/span>/i) ||\n           extractText(cardHtml, /(\\d+\\.?\\d*)\\s*(?:star)/i) ||\n           '';\n  rating = rating.replace(/[^0-9.]/g, '').trim();\n  \n  let totalReviews = '';\n  totalReviews = extractText(cardHtml, /<span[^>]*class=[\"'][^\"']*UY7F9[^\"']*[\"'][^>]*>([^<]+)<\\/span>/i) ||\n                 extractText(cardHtml, /(\\d+(?:,\\d+)?)\\s*(?:review)/i) ||\n                 extractText(cardHtml, /\\((\\d+(?:,\\d+)?)\\)/i) ||\n                 '';\n  totalReviews = totalReviews.replace(/[^0-9,]/g, '').trim();\n  \n  // STEP 3: Extract address from Google Maps HTML\n  // Note: mapUrl path usually only contains business name, not full address\n  // So we must extract address from HTML elements\n  if (!address || address.length < 5) {\n    // Method 1: Extract from data-item-id=\"address\" (MOST RELIABLE)\n    const addressSectionMatch = cardHtml.match(/<[^>]*data-item-id=[\"']address[\"'][^>]*>([\\s\\S]{0,500})<\\/button>/i) ||\n                                cardHtml.match(/<[^>]*data-item-id=[\"']address[\"'][^>]*>([\\s\\S]{0,500})<\\/div>/i);\n    if (addressSectionMatch && addressSectionMatch[1]) {\n      const addressTextMatch = addressSectionMatch[1].match(/<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/i);\n      if (addressTextMatch && addressTextMatch[1]) {\n        address = addressTextMatch[1].replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n        if (address.length > 10 && address.length < 300) {\n          // Found address from data-item-id\n        } else {\n          address = '';\n        }\n      }\n    }\n    \n    // Method 2: Extract from aria-label=\"Address:\"\n    if (!address || address.length < 5) {\n      const addressAriaMatch = cardHtml.match(/aria-label=[\"']Address:[^\"']*[\"'][^>]*>([\\s\\S]{0,500})<\\/button>/i);\n      if (addressAriaMatch && addressAriaMatch[1]) {\n        const addressTextMatch = addressAriaMatch[1].match(/<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/i);\n        if (addressTextMatch && addressTextMatch[1]) {\n          address = addressTextMatch[1].replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n          if (address.length > 10 && address.length < 300) {\n            // Found address from aria-label\n          } else {\n            address = '';\n          }\n        }\n      }\n    }\n    \n    // Method 3: Fallback - extract from Io6YTe elements and filter\n    if (!address || address.length < 5) {\n      const addressPatterns = [\n        /<div[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/div>/gi,\n        /<span[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/span>/gi\n      ];\n      \n      const allAddressCandidates = [];\n      for (const pattern of addressPatterns) {\n        const matches = extractAllMatches(cardHtml, pattern);\n        for (const match of matches) {\n          const cleanMatch = match.replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n          if (cleanMatch.length > 10 && cleanMatch.length < 300) {\n            allAddressCandidates.push(cleanMatch);\n          }\n        }\n      }\n      \n      // Filter and select the best address candidate\n      for (const candidate of allAddressCandidates) {\n        // Skip if it's a phone number\n        if (candidate.match(/^\\+?[\\d\\s\\-\\(\\)]{10,20}$/) && \n            (candidate.match(/\\+/) || candidate.match(/\\(?\\d{3}\\)?[\\s.-]?\\d{3}[\\s.-]?\\d{4}/))) {\n          continue;\n        }\n        // Skip common non-address text\n        if (candidate.match(/^(Open|Closed|Call|Website|Directions|Save|Share|Hours|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday|nexhealth|souldentalnyc|P2Q2|Your Maps activity|LGBTQ|women-owned)$/i)) {\n          continue;\n        }\n        // Skip if it's just the business name\n        if (businessName && candidate.toLowerCase().includes(businessName.toLowerCase().substring(0, 20))) {\n          continue;\n        }\n        // Prefer candidates that look like addresses (contain comma, numbers, or street indicators)\n        if (candidate.includes(',') || candidate.match(/\\d/) || \n            candidate.match(/\\b(street|st|avenue|ave|road|rd|boulevard|blvd|drive|dr|lane|ln|way|circle|ct|court|plaza|pl|suite|ste|unit|apt|apartment|floor|fl|w|e|n|s|north|south|east|west)\\b/i)) {\n          address = candidate;\n          break;\n        }\n      }\n    }\n  }\n  \n  // STEP 4: Extract phone from Google Maps HTML\n  let phone = '';\n  \n  // Method 1: Extract from data-item-id=\"phone:tel:\" attribute (MOST RELIABLE)\n  const phoneDataIdMatch = cardHtml.match(/data-item-id=[\"']phone:tel:([^\"']+)[\"']/i);\n  if (phoneDataIdMatch && phoneDataIdMatch[1]) {\n    phone = phoneDataIdMatch[1].replace(/[^0-9+]/g, '');\n    if (!phone.startsWith('+')) {\n      phone = '+' + phone;\n    }\n  }\n  \n  // Method 2: Extract from tel: href links\n  if (!phone || phone.length < 7) {\n    const telLinks = extractAllMatches(cardHtml, /href=[\"']tel:([^\"']+)[\"']/gi);\n    if (telLinks.length > 0) {\n      let rawPhone = telLinks[0];\n      phone = rawPhone.replace(/[^0-9+]/g, '');\n      if (!phone.startsWith('+') && phone.length >= 10) {\n        phone = '+' + phone;\n      }\n    }\n  }\n  \n  // Method 3: Extract from Io6YTe element with phone pattern (near data-item-id=\"phone\")\n  if (!phone || phone.length < 7) {\n    // Find the phone section by looking for data-item-id containing \"phone\"\n    const phoneSectionMatch = cardHtml.match(/<[^>]*data-item-id=[\"'][^\"']*phone[^\"']*[\"'][^>]*>([\\s\\S]{0,500})<\\/button>/i) ||\n                              cardHtml.match(/<[^>]*data-item-id=[\"'][^\"']*phone[^\"']*[\"'][^>]*>([\\s\\S]{0,500})<\\/a>/i);\n    if (phoneSectionMatch && phoneSectionMatch[1]) {\n      const phoneTextMatch = phoneSectionMatch[1].match(/<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/i);\n      if (phoneTextMatch && phoneTextMatch[1]) {\n        phone = phoneTextMatch[1].replace(/[^0-9+()-]/g, '').trim();\n        if (phone.length >= 7 && phone.length <= 15) {\n          // Format phone - ensure + prefix if it looks international\n          if (!phone.startsWith('+') && phone.length >= 10) {\n            phone = '+' + phone;\n          }\n        } else {\n          phone = '';\n        }\n      }\n    }\n  }\n  \n  // Method 4: Extract from visible text patterns in Io6YTe elements (fallback)\n  if (!phone || phone.length < 7) {\n    const phoneElements = extractAllMatches(cardHtml, /<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/gi);\n    for (const element of phoneElements) {\n      const cleanElement = element.replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n      // Check if this looks like a phone number\n      if (cleanElement.match(/^\\+?[\\d\\s\\-\\(\\)]{10,20}$/) && \n          (cleanElement.match(/\\+/) || cleanElement.match(/\\(?\\d{3}\\)?[\\s.-]?\\d{3}[\\s.-]?\\d{4}/))) {\n        phone = cleanElement.replace(/[^0-9+()-]/g, '').trim();\n        if (phone.length >= 7 && phone.length <= 15) {\n          if (!phone.startsWith('+') && phone.length >= 10) {\n            phone = '+' + phone;\n          }\n          break;\n        }\n      }\n    }\n  }\n  \n  // STEP 6: Extract website from Google Maps HTML\n  let website = '';\n  \n  // Method 1: Extract from data-item-id=\"authority\" (MOST RELIABLE - Google Maps uses this)\n  const websiteSectionMatch = cardHtml.match(/<[^>]*data-item-id=[\"']authority[\"'][^>]*href=[\"']([^\"']+)[\"'][^>]*>/i);\n  if (websiteSectionMatch && websiteSectionMatch[1]) {\n    website = websiteSectionMatch[1];\n    if (website.startsWith('www.')) {\n      website = 'http://' + website;\n    }\n    // Keep query parameters (like utm_campaign) but clean up\n    website = website.split('#')[0].trim();\n  }\n  \n  // Method 2: Extract from aria-label=\"Website:\" attribute\n  if (!website) {\n    const websiteAriaMatch = cardHtml.match(/aria-label=[\"']Website:[^\"']*[\"'][^>]*href=[\"']([^\"']+)[\"']/i);\n    if (websiteAriaMatch && websiteAriaMatch[1]) {\n      website = websiteAriaMatch[1];\n      if (website.startsWith('www.')) {\n        website = 'http://' + website;\n      }\n      website = website.split('#')[0].trim();\n    }\n  }\n  \n  // Method 3: Extract from href links near data-item-id=\"authority\"\n  if (!website) {\n    const authoritySectionMatch = cardHtml.match(/<[^>]*data-item-id=[\"']authority[\"'][^>]*>([\\s\\S]{0,500})<\\/a>/i);\n    if (authoritySectionMatch && authoritySectionMatch[1]) {\n      const hrefMatch = authoritySectionMatch[1].match(/href=[\"']([^\"']+)[\"']/i);\n      if (hrefMatch && hrefMatch[1]) {\n        website = hrefMatch[1];\n        if (website.startsWith('www.')) {\n          website = 'http://' + website;\n        }\n        if (!website.includes('google.com') && !website.includes('maps.google.com') &&\n            !website.includes('googleapis.com') && !website.includes('gstatic.com') &&\n            (website.startsWith('http://') || website.startsWith('https://'))) {\n          website = website.split('#')[0].trim();\n        } else {\n          website = '';\n        }\n      }\n    }\n  }\n  \n  // Method 4: Fallback - find website links in the card (excluding Google domains)\n  if (!website) {\n    const cardLinks = extractAllMatches(cardHtml, /href=[\"']([^\"']+)[\"']/gi);\n    for (const link of cardLinks) {\n      if (link && !link.includes('google.com/maps') && !link.includes('maps.google.com') && \n          !link.includes('googleapis.com') && !link.includes('gstatic.com') &&\n          !link.includes('nexhealth.com') && !link.includes('zocdoc.com') &&\n          !link.startsWith('tel:') && !link.startsWith('mailto:') && !link.startsWith('#') &&\n          (link.startsWith('http://') || link.startsWith('https://') || link.startsWith('www.'))) {\n        website = link;\n        if (website.startsWith('www.')) {\n          website = 'http://' + website;\n        }\n        website = website.split('#')[0].trim();\n        break;\n      }\n    }\n  }\n  \n  website = website.trim();\n  \n  // Only add if we have at least a business name\n  if (businessName && businessName.length > 2) {\n    results.push({\n      json: {\n        businessName: businessName,\n        phone: phone || '',\n        website: website || '',\n        rating: rating || '',\n        totalReviews: totalReviews || '',\n        address: address || '',\n        city: config.city || '',\n        mapUrl: currentMapUrl || '',\n        category: config.keyword || 'dentist',\n        checkedAt: new Date().toISOString()\n      }\n    });\n  }\n}\n\n// City validation: Return error if no businesses found\nif (results.length === 0) {\n  return [{\n    json: {\n      error: true,\n      message: 'Invalid city name or no businesses found. Please enter a valid Google Maps city.'\n    }\n  }];\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "b458d142-3f93-429f-81c3-8f55bc7331ac",
      "name": "ScrapeOps - Business Details",
      "type": "@scrapeops/n8n-nodes-scrapeops.ScrapeOps",
      "position": [
        -1216,
        512
      ],
      "parameters": {
        "url": "={{ $json.mapUrl }}",
        "returnType": "htmlResponse",
        "advancedOptions": {
          "wait": "=12000",
          "render_js": true,
          "residential_proxy": false
        }
      },
      "credentials": {
        "scrapeOpsApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "7761bb98-37bb-4e29-99cd-6d939ddc4e3e",
      "name": "Parse Full Business Info",
      "type": "n8n-nodes-base.code",
      "position": [
        -992,
        512
      ],
      "parameters": {
        "jsCode": "// Process ALL input items - n8n processes items individually, so we need to handle all\nconst inputItems = $input.all() || [$input.item];\nconst output = [];\n\n// Process each item individually\nfor (const inputItem of inputItems) {\n  let originalData = {};\n  let currentMapUrl = '';\n  \n  // Get HTML from ScrapeOps detail page first\n  let htmlContent = '';\n  if (inputItem.json.response && typeof inputItem.json.response === 'string') {\n    htmlContent = inputItem.json.response;\n  } else if (typeof inputItem.json === 'string' && inputItem.json.length > 1000) {\n    htmlContent = inputItem.json;\n  } else if (inputItem.json.body && typeof inputItem.json.body === 'string') {\n    htmlContent = inputItem.json.body;\n  } else if (inputItem.json.data && typeof inputItem.json.data === 'string') {\n    htmlContent = inputItem.json.data;\n  }\n  \n  // Extract mapUrl from HTML - this is the KEY to matching correctly\n  // Google Maps detail pages contain the place URL in the HTML\n  let extractedMapUrl = '';\n  if (htmlContent && typeof htmlContent === 'string' && htmlContent.length > 100) {\n    // Try multiple patterns to extract the full mapUrl from HTML\n    const mapUrlPatterns = [\n      /https?:\\/\\/[^\"'<>\\s]*maps\\.google\\.com[^\"'<>\\s]*place[^\"'<>\\s]*/i,\n      /maps\\.google\\.com[^\"'<>\\s]*place[^\"'<>\\s]*/i,\n      /\\/maps\\/place\\/[^\"'<>\\s\\?]+/i\n    ];\n    \n    for (const pattern of mapUrlPatterns) {\n      const match = htmlContent.match(pattern);\n      if (match && match[0]) {\n        extractedMapUrl = match[0];\n        if (!extractedMapUrl.startsWith('http')) {\n          extractedMapUrl = 'https://' + extractedMapUrl.split(/[\"'<>\\s\\?]/)[0];\n        } else {\n          extractedMapUrl = extractedMapUrl.split(/[\"'<>\\s\\?]/)[0];\n        }\n        break;\n      }\n    }\n    \n    // If no full URL found, try to extract place ID and construct URL\n    if (!extractedMapUrl) {\n      const placeIdPatterns = [\n        /1s([^:!\\/\"'<>\\s]+)/,\n        /place\\/([^\\/\\?\"'<>\\s]+)/\n      ];\n      \n      for (const pattern of placeIdPatterns) {\n        const match = htmlContent.match(pattern);\n        if (match && match[1]) {\n          // Try to find the business name from HTML to construct URL\n          const nameMatch = htmlContent.match(/<h1[^>]*>([^<]+)<\\/h1>/i) || \n                           htmlContent.match(/<div[^>]*class=[\"'][^\"']*qBF1Pd[^\"']*[\"'][^>]*>([^<]+)<\\/div>/i);\n          if (nameMatch && nameMatch[1]) {\n            const businessName = nameMatch[1].trim().replace(/\\s+/g, '+');\n            extractedMapUrl = `https://www.google.com/maps/place/${businessName}/data=!4m7!3m6!1s${match[1]}`;\n            break;\n          }\n        }\n      }\n    }\n  }\n  \n  // Now match with Parse Business Details using the extracted mapUrl\n  try {\n    const parseNode = $('Parse Business Details');\n    if (parseNode && parseNode.all) {\n      const parseData = parseNode.all() || [];\n      \n      // Method 1: Match by extracted mapUrl (most reliable)\n      if (extractedMapUrl) {\n        // Extract place ID from extracted mapUrl for comparison\n        const getPlaceId = (url) => {\n          if (!url) return '';\n          const match1 = url.match(/1s([^:!\\/]+)/);\n          const match2 = url.match(/place\\/([^\\/\\?]+)/);\n          return match1 ? match1[1] : (match2 ? match2[1] : '');\n        };\n        \n        const extractedPlaceId = getPlaceId(extractedMapUrl);\n        \n        // Find matching item by comparing place IDs\n        for (let i = 0; i < parseData.length; i++) {\n          const itemData = parseData[i].json || parseData[i];\n          const itemMapUrl = itemData.mapUrl || '';\n          \n          if (itemMapUrl) {\n            const itemPlaceId = getPlaceId(itemMapUrl);\n            \n            // Exact place ID match\n            if (itemPlaceId && extractedPlaceId && itemPlaceId === extractedPlaceId) {\n              originalData = itemData;\n              currentMapUrl = itemMapUrl;\n              break;\n            }\n            \n            // Partial match (one contains the other)\n            if (itemPlaceId && extractedPlaceId && \n                (itemPlaceId.includes(extractedPlaceId) || extractedPlaceId.includes(itemPlaceId))) {\n              originalData = itemData;\n              currentMapUrl = itemMapUrl;\n              break;\n            }\n            \n            // URL similarity match\n            if (itemMapUrl.includes(extractedPlaceId) || extractedMapUrl.includes(itemPlaceId)) {\n              originalData = itemData;\n              currentMapUrl = itemMapUrl;\n              break;\n            }\n          }\n        }\n      }\n      \n      // Method 2: If mapUrl matching failed, try index-based matching\n      // This works because n8n processes items in order\n      if (!originalData || Object.keys(originalData).length === 0) {\n        const scrapeOpsNode = $('ScrapeOps - Business Details');\n        if (scrapeOpsNode && scrapeOpsNode.all) {\n          const scrapeOpsData = scrapeOpsNode.all() || [];\n          \n          // Find this item's position in ScrapeOps output\n          let currentIndex = -1;\n          for (let i = 0; i < scrapeOpsData.length; i++) {\n            const itemHtml = scrapeOpsData[i].json.response || scrapeOpsData[i].json.body || scrapeOpsData[i].json.data || '';\n            if (itemHtml === htmlContent || \n                (itemHtml && htmlContent && itemHtml.substring(0, 500) === htmlContent.substring(0, 500))) {\n              currentIndex = i;\n              break;\n            }\n          }\n          \n          // Use index to match with Parse Business Details\n          if (currentIndex >= 0 && currentIndex < parseData.length) {\n            originalData = parseData[currentIndex].json || parseData[currentIndex];\n            currentMapUrl = originalData.mapUrl || '';\n          } else if (inputItem.index !== undefined && inputItem.index !== null && \n                     inputItem.index < parseData.length) {\n            // Fallback to input item index\n            originalData = parseData[inputItem.index].json || parseData[inputItem.index];\n            currentMapUrl = originalData.mapUrl || '';\n          }\n        } else if (inputItem.index !== undefined && inputItem.index !== null && \n                   inputItem.index < parseData.length) {\n          // Direct index matching if ScrapeOps not accessible\n          originalData = parseData[inputItem.index].json || parseData[inputItem.index];\n          currentMapUrl = originalData.mapUrl || '';\n        }\n      }\n      \n      // If STILL no match, this is an error - don't default to first item\n      // Push error data to output and continue with next item\n      if (!originalData || Object.keys(originalData).length === 0) {\n        output.push({\n          json: {\n            businessName: 'MATCHING_ERROR',\n            phone: '',\n            website: '',\n            rating: '',\n            totalReviews: '',\n            address: '',\n            city: '',\n            mapUrl: extractedMapUrl || '',\n            category: 'dentist',\n            checkedAt: new Date().toISOString(),\n            error: 'Could not match HTML to original business data. Extracted mapUrl: ' + (extractedMapUrl || 'none')\n          }\n        });\n        continue; // Skip to next item\n      }\n    }\n  } catch (error) {\n    // If error, push error data to output and continue with next item\n    output.push({\n      json: {\n        businessName: 'MATCHING_ERROR',\n        phone: '',\n        website: '',\n        rating: '',\n        totalReviews: '',\n        address: '',\n        city: '',\n        mapUrl: extractedMapUrl || '',\n        category: 'dentist',\n        checkedAt: new Date().toISOString(),\n        error: 'Error matching data: ' + String(error.message || 'Unknown error')\n      }\n    });\n    continue; // Skip to next item\n  }\n  \n  // Ensure we have mapUrl from originalData\n  if (!originalData.mapUrl && currentMapUrl) {\n    originalData.mapUrl = currentMapUrl;\n  } else if (!originalData.mapUrl && extractedMapUrl) {\n    originalData.mapUrl = extractedMapUrl;\n  }\n  \n  // Limit HTML size\n  if (htmlContent.length > 2000000) {\n    htmlContent = htmlContent.substring(0, 2000000);\n  }\n  \n  // Helper functions (defined inside loop but reusable)\n  function extractText(html, pattern) {\n    const match = html.match(pattern);\n    if (match && match[1]) {\n      return match[1].replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n    }\n    return '';\n  }\n  \n  function extractAttribute(html, pattern) {\n    const match = html.match(pattern);\n    if (match && match[1]) {\n      return match[1].trim();\n    }\n    return '';\n  }\n  \n  function extractAllMatches(html, pattern) {\n    const matches = [];\n    let match;\n    while ((match = pattern.exec(html)) !== null) {\n      if (match[1]) {\n        matches.push(match[1].trim());\n      }\n    }\n    return matches;\n  }\n  \n  // Initialize with original data - CRITICAL: Preserve mapUrl from originalData\n  let businessName = originalData.businessName || '';\n  let phone = originalData.phone || '';\n  let website = originalData.website || '';\n  let rating = originalData.rating || '';\n  let totalReviews = originalData.totalReviews || '';\n  let address = originalData.address || '';\n  let city = originalData.city || '';\n  // ALWAYS use mapUrl from originalData - this is the correct one from Parse Business Details\n  let mapUrl = originalData.mapUrl || currentMapUrl || '';\n  let category = originalData.category || 'dentist';\n  let lgbtqFriendly = originalData.lgbtqFriendly || false;\n  let review1 = originalData.review1 || '';\n  let review2 = originalData.review2 || '';\n  let review3 = originalData.review3 || '';\n  \n  // Only parse if we have HTML content\n  if (htmlContent && htmlContent.length > 100) {\n    // EXTRACT PHONE from detail page\n    if (!phone || phone.length < 7) {\n      // Method 1: data-item-id=\"phone:tel:\"\n      const phoneDataIdMatch = htmlContent.match(/data-item-id=[\"']phone:tel:([^\"']+)[\"']/i);\n      if (phoneDataIdMatch && phoneDataIdMatch[1]) {\n        phone = phoneDataIdMatch[1].replace(/[^0-9+]/g, '');\n        if (!phone.startsWith('+')) {\n          phone = '+' + phone;\n        }\n      }\n      \n      // Method 2: tel: href links\n      if (!phone || phone.length < 7) {\n        const telLinks = extractAllMatches(htmlContent, /href=[\"']tel:([^\"']+)[\"']/gi);\n        if (telLinks.length > 0) {\n          let rawPhone = telLinks[0];\n          phone = rawPhone.replace(/[^0-9+]/g, '');\n          if (!phone.startsWith('+') && phone.length >= 10) {\n            phone = '+' + phone;\n          }\n        }\n      }\n      \n      // Method 3: Io6YTe element near data-item-id=\"phone\"\n      if (!phone || phone.length < 7) {\n        const phoneSectionMatch = htmlContent.match(/<[^>]*data-item-id=[\"'][^\"']*phone[^\"']*[\"'][^>]*>([\\s\\S]{0,500})<\\/button>/i) ||\n                                htmlContent.match(/<[^>]*data-item-id=[\"'][^\"']*phone[^\"']*[\"'][^>]*>([\\s\\S]{0,500})<\\/a>/i);\n        if (phoneSectionMatch && phoneSectionMatch[1]) {\n          const phoneTextMatch = phoneSectionMatch[1].match(/<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/i);\n          if (phoneTextMatch && phoneTextMatch[1]) {\n            phone = phoneTextMatch[1].replace(/[^0-9+()-]/g, '').trim();\n            if (phone.length >= 7 && phone.length <= 15) {\n              if (!phone.startsWith('+') && phone.length >= 10) {\n                phone = '+' + phone;\n              }\n            } else {\n              phone = originalData.phone || '';\n            }\n          }\n        }\n      }\n    }\n    \n    // EXTRACT WEBSITE from detail page\n    if (!website || website.length < 5) {\n      // Method 1: data-item-id=\"authority\"\n      const websiteSectionMatch = htmlContent.match(/<[^>]*data-item-id=[\"']authority[\"'][^>]*href=[\"']([^\"']+)[\"'][^>]*>/i);\n      if (websiteSectionMatch && websiteSectionMatch[1]) {\n        website = websiteSectionMatch[1];\n        if (website.startsWith('www.')) {\n          website = 'http://' + website;\n        }\n        website = website.split('#')[0].trim();\n        // Filter out Google and booking sites\n        if (website.includes('google.com') || website.includes('maps.google.com') ||\n            website.includes('googleapis.com') || website.includes('gstatic.com') ||\n            website.includes('nexhealth.com') || website.includes('zocdoc.com')) {\n          website = originalData.website || '';\n        }\n      }\n      \n      // Method 2: aria-label=\"Website:\"\n      if (!website || website.length < 5) {\n        const websiteAriaMatch = htmlContent.match(/aria-label=[\"']Website:[^\"']*[\"'][^>]*href=[\"']([^\"']+)[\"']/i);\n        if (websiteAriaMatch && websiteAriaMatch[1]) {\n          website = websiteAriaMatch[1];\n          if (website.startsWith('www.')) {\n            website = 'http://' + website;\n          }\n          website = website.split('#')[0].trim();\n          if (website.includes('google.com') || website.includes('maps.google.com') ||\n              website.includes('googleapis.com') || website.includes('gstatic.com') ||\n              website.includes('nexhealth.com') || website.includes('zocdoc.com')) {\n            website = originalData.website || '';\n          }\n        }\n      }\n      \n      // Method 3: Extract from section with data-item-id=\"authority\"\n      if (!website || website.length < 5) {\n        const authoritySectionMatch = htmlContent.match(/<[^>]*data-item-id=[\"']authority[\"'][^>]*>([\\s\\S]{0,500})<\\/a>/i);\n        if (authoritySectionMatch && authoritySectionMatch[1]) {\n          const hrefMatch = authoritySectionMatch[1].match(/href=[\"']([^\"']+)[\"']/i);\n          if (hrefMatch && hrefMatch[1]) {\n            website = hrefMatch[1];\n            if (website.startsWith('www.')) {\n              website = 'http://' + website;\n            }\n            if (!website.includes('google.com') && !website.includes('maps.google.com') &&\n                !website.includes('googleapis.com') && !website.includes('gstatic.com') &&\n                !website.includes('nexhealth.com') && !website.includes('zocdoc.com') &&\n                (website.startsWith('http://') || website.startsWith('https://'))) {\n              website = website.split('#')[0].trim();\n            } else {\n              website = originalData.website || '';\n            }\n          }\n        }\n      }\n    }\n    \n    // EXTRACT ADDRESS from detail page\n    if (!address || address.length < 5) {\n      // Method 1: data-item-id=\"address\"\n      const addressSectionMatch = htmlContent.match(/<[^>]*data-item-id=[\"']address[\"'][^>]*>([\\s\\S]{0,500})<\\/button>/i) ||\n                                htmlContent.match(/<[^>]*data-item-id=[\"']address[\"'][^>]*>([\\s\\S]{0,500})<\\/div>/i);\n      if (addressSectionMatch && addressSectionMatch[1]) {\n        const addressTextMatch = addressSectionMatch[1].match(/<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/i);\n        if (addressTextMatch && addressTextMatch[1]) {\n          address = addressTextMatch[1].replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n          if (address.length < 10 || address.length > 300) {\n            address = originalData.address || '';\n          }\n        }\n      }\n      \n      // Method 2: aria-label=\"Address:\"\n      if (!address || address.length < 5) {\n        const addressAriaMatch = htmlContent.match(/aria-label=[\"']Address:[^\"']*[\"'][^>]*>([\\s\\S]{0,500})<\\/button>/i);\n        if (addressAriaMatch && addressAriaMatch[1]) {\n          const addressTextMatch = addressAriaMatch[1].match(/<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/i);\n          if (addressTextMatch && addressTextMatch[1]) {\n            address = addressTextMatch[1].replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n            if (address.length < 10 || address.length > 300) {\n              address = originalData.address || '';\n            }\n          }\n        }\n      }\n    }\n    \n    // EXTRACT RATING from detail page (if missing)\n    if (!rating || rating.length === 0) {\n      rating = extractText(htmlContent, /<span[^>]*class=[\"'][^\"']*MW4etd[^\"']*[\"'][^>]*>([^<]+)<\\/span>/i) ||\n               extractText(htmlContent, /(\\d+\\.?\\d*)\\s*(?:star)/i) ||\n               '';\n      rating = rating.replace(/[^0-9.]/g, '').trim();\n      if (!rating) {\n        rating = originalData.rating || '';\n      }\n    }\n    \n    // EXTRACT TOTAL REVIEWS from detail page (if missing)\n    if (!totalReviews || totalReviews.length === 0) {\n      totalReviews = extractText(htmlContent, /<span[^>]*class=[\"'][^\"']*UY7F9[^\"']*[\"'][^>]*>([^<]+)<\\/span>/i) ||\n                     extractText(htmlContent, /(\\d+(?:,\\d+)?)\\s*(?:review)/i) ||\n                     extractText(htmlContent, /\\((\\d+(?:,\\d+)?)\\)/i) ||\n                     '';\n      totalReviews = totalReviews.replace(/[^0-9,]/g, '').trim();\n      if (!totalReviews) {\n        totalReviews = originalData.totalReviews || '';\n      }\n    }\n    \n    // EXTRACT BUSINESS NAME from detail page (fallback if missing)\n    if (!businessName || businessName.length < 3) {\n      const namePatterns = [\n        /<div[^>]*class=[\"'][^\"']*qBF1Pd[^\"']*[\"'][^>]*>([^<]+)<\\/div>/i,\n        /<h1[^>]*>([^<]+)<\\/h1>/i,\n        /<h2[^>]*>([^<]+)<\\/h2>/i,\n        /<h3[^>]*>([^<]+)<\\/h3>/i,\n        /aria-label=[\"']([^\"']+)[\"']/i\n      ];\n      \n      for (const pattern of namePatterns) {\n        businessName = extractText(htmlContent, pattern) || extractAttribute(htmlContent, pattern) || '';\n        if (businessName && businessName.length >= 3) break;\n      }\n      \n      businessName = businessName.replace(/\\s+/g, ' ').trim();\n      if (!businessName || businessName.length < 3) {\n        businessName = originalData.businessName || '';\n      }\n    }\n    \n    // EXTRACT LGBTQ+ FRIENDLY STATUS\n    if (htmlContent.match(/LGBTQ\\+\\s*friendly/i) || htmlContent.match(/LGBTQ\\+ friendly/i)) {\n      lgbtqFriendly = true;\n    }\n    \n    // EXTRACT REVIEWS (top 3) - ONLY review text, no names/stars/dates\n    let reviews = [];\n    \n    // Helper function to clean review text\n    function cleanReviewText(text) {\n      if (!text) return '';\n      \n      // Remove HTML tags\n      text = text.replace(/<[^>]*>/g, '');\n      \n      // Remove reviewer names (common patterns)\n      text = text.replace(/^[A-Z][a-z]+\\s+[A-Z][a-z]+(\\s+[A-Z][a-z]+)?\\s*[-\u2013\u2014]?\\s*/i, '');\n      text = text.replace(/^Local\\s+guide\\s*/i, '');\n      text = text.replace(/\\d+\\s+reviews?\\s*\u00b7?\\s*\\d+\\s+photos?/gi, '');\n      \n      // Remove rating stars (\u2605, \u2b50, etc.)\n      text = text.replace(/[\u2605\u2b50\u2606]\\s*/g, '');\n      text = text.replace(/\\d+\\s*stars?/gi, '');\n      \n      // Remove dates (2 months ago, a month ago, etc.)\n      text = text.replace(/\\d+\\s+(days?|weeks?|months?|years?)\\s+ago/gi, '');\n      text = text.replace(/(a|an)\\s+(day|week|month|year)\\s+ago/gi, '');\n      text = text.replace(/\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}/g, '');\n      \n      // Remove emojis and special characters (like \ue5d4, \ue838)\n      text = text.replace(/[\\uE000-\\uF8FF]/g, ''); // Private use area\n      text = text.replace(/[\\u2000-\\u206F]/g, ''); // General punctuation\n      text = text.replace(/[\\u20A0-\\u20CF]/g, ''); // Currency symbols\n      \n      // Remove line breaks and normalize whitespace\n      text = text.replace(/[\\r\\n]+/g, ' ');\n      text = text.replace(/\\s+/g, ' ');\n      \n      // Remove trailing junk (icons, punctuation only)\n      text = text.replace(/[\\s\\.,;:!?\\-]+$/g, '');\n      \n      return text.trim();\n    }\n    \n    // Priority 1: div.section-review-text\n    const sectionReviewPattern = /<div[^>]*class=[\"'][^\"']*section-review-text[^\"']*[\"'][^>]*>([\\s\\S]{0,3000})<\\/div>/gi;\n    let match;\n    while ((match = sectionReviewPattern.exec(htmlContent)) !== null && reviews.length < 3) {\n      if (match[1]) {\n        // Extract text from wiI7pd class within this section\n        const wiI7pdMatch = match[1].match(/<[^>]*class=[\"'][^\"']*wiI7pd[^\"']*[\"'][^>]*>([\\s\\S]{0,2000})<\\/[^>]*>/i);\n        if (wiI7pdMatch && wiI7pdMatch[1]) {\n          let cleaned = cleanReviewText(wiI7pdMatch[1]);\n          if (cleaned && cleaned.length >= 10 && cleaned.length < 2000 && !reviews.includes(cleaned)) {\n            reviews.push(cleaned);\n          }\n        }\n      }\n    }\n    \n    // Priority 2: div[jsname=\"bN97Pc\"]\n    if (reviews.length < 3) {\n      const bN97PcPattern = /<div[^>]*jsname=[\"']bN97Pc[\"'][^>]*>([\\s\\S]{0,3000})<\\/div>/gi;\n      while ((match = bN97PcPattern.exec(htmlContent)) !== null && reviews.length < 3) {\n        if (match[1]) {\n          const wiI7pdMatch = match[1].match(/<[^>]*class=[\"'][^\"']*wiI7pd[^\"']*[\"'][^>]*>([\\s\\S]{0,2000})<\\/[^>]*>/i);\n          if (wiI7pdMatch && wiI7pdMatch[1]) {\n            let cleaned = cleanReviewText(wiI7pdMatch[1]);\n            if (cleaned && cleaned.length >= 10 && cleaned.length < 2000 && !reviews.includes(cleaned)) {\n              reviews.push(cleaned);\n            }\n          }\n        }\n      }\n    }\n    \n    // Priority 3: span[class*=\"wiI7pd\"]\n    if (reviews.length < 3) {\n      const wiI7pdSpanPattern = /<span[^>]*class=[\"'][^\"']*wiI7pd[^\"']*[\"'][^>]*>([\\s\\S]{0,2000})<\\/span>/gi;\n      while ((match = wiI7pdSpanPattern.exec(htmlContent)) !== null && reviews.length < 3) {\n        if (match[1]) {\n          let cleaned = cleanReviewText(match[1]);\n          if (cleaned && cleaned.length >= 10 && cleaned.length < 2000 && !reviews.includes(cleaned)) {\n            reviews.push(cleaned);\n          }\n        }\n      }\n    }\n    \n    // Priority 4: div[data-review-id] .wiI7pd\n    if (reviews.length < 3) {\n      const dataReviewPattern = /<div[^>]*data-review-id=[\"'][^\"']*[\"'][^>]*>([\\s\\S]{0,5000})<\\/div>/gi;\n      while ((match = dataReviewPattern.exec(htmlContent)) !== null && reviews.length < 3) {\n        if (match[1]) {\n          const wiI7pdMatch = match[1].match(/<[^>]*class=[\"'][^\"']*wiI7pd[^\"']*[\"'][^>]*>([\\s\\S]{0,2000})<\\/[^>]*>/i);\n          if (wiI7pdMatch && wiI7pdMatch[1]) {\n            let cleaned = cleanReviewText(wiI7pdMatch[1]);\n            if (cleaned && cleaned.length >= 10 && cleaned.length < 2000 && !reviews.includes(cleaned)) {\n              reviews.push(cleaned);\n            }\n          }\n        }\n      }\n    }\n    \n    // Priority 5: [aria-label=\"Review\"] p\n    if (reviews.length < 3) {\n      const ariaReviewPattern = /<[^>]*aria-label=[\"']Review[\"'][^>]*>([\\s\\S]{0,3000})<\\/[^>]*>/gi;\n      while ((match = ariaReviewPattern.exec(htmlContent)) !== null && reviews.length < 3) {\n        if (match[1]) {\n          const pMatch = match[1].match(/<p[^>]*>([\\s\\S]{0,2000})<\\/p>/i);\n          if (pMatch && pMatch[1]) {\n            let cleaned = cleanReviewText(pMatch[1]);\n            if (cleaned && cleaned.length >= 10 && cleaned.length < 2000 && !reviews.includes(cleaned)) {\n              reviews.push(cleaned);\n            }\n          }\n        }\n      }\n    }\n    \n    // Priority 6: meta[itemprop=\"reviewBody\"]\n    if (reviews.length < 3) {\n      const metaReviewPattern = /<meta[^>]*itemprop=[\"']reviewBody[\"'][^>]*content=[\"']([^\"']+)[\"'][^>]*>/gi;\n      while ((match = metaReviewPattern.exec(htmlContent)) !== null && reviews.length < 3) {\n        if (match[1]) {\n          let cleaned = cleanReviewText(match[1]);\n          if (cleaned && cleaned.length >= 10 && cleaned.length < 2000 && !reviews.includes(cleaned)) {\n            reviews.push(cleaned);\n          }\n        }\n      }\n    }\n    \n    // Limit to first 3 reviews and assign\n    reviews = reviews.slice(0, 3);\n    review1 = reviews[0] || '';\n    review2 = reviews[1] || '';\n    review3 = reviews[2] || '';\n  }\n  \n  // Return merged data (detail page data overrides search result data)\n  // CRITICAL: ALWAYS use originalData.mapUrl - it's the correct one from Parse Business Details\n  output.push({\n    json: {\n      businessName: businessName || originalData.businessName || '',\n      phone: phone || originalData.phone || '',\n      website: website || originalData.website || '',\n      rating: rating || originalData.rating || '',\n      totalReviews: totalReviews || originalData.totalReviews || '',\n      address: address || originalData.address || '',\n      city: city || originalData.city || '',\n      mapUrl: originalData.mapUrl || '',\n      category: category || originalData.category || 'dentist',\n      checkedAt: originalData.checkedAt || new Date().toISOString(),\n      lgbtqFriendly: lgbtqFriendly,\n      review1: review1 || '',\n      review2: review2 || '',\n      review3: review3 || ''\n    }\n  });\n}\n\n// Return all processed items as an array\nreturn output;"
      },
      "typeVersion": 2
    },
    {
      "id": "467b3a16-25a3-4f3a-bfc7-0b889b2353d1",
      "name": "Read Previous Entries from Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -768,
        512
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1HYO6pw9PigmNKzrnmE9s9JcNVSvZAcIHGF1ZJQ9kfks/edit#gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "https://docs.google.com/spreadsheets/d/1HYO6pw9PigmNKzrnmE9s9JcNVSvZAcIHGF1ZJQ9kfks/edit?gid=0#gid=0"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4,
      "continueOnFail": true,
      "alwaysOutputData": true
    },
    {
      "id": "4d6c1cc4-633b-46af-9778-792cf93db254",
      "name": "Compare With Previous Run",
      "type": "n8n-nodes-base.code",
      "position": [
        -544,
        512
      ],
      "parameters": {
        "jsCode": "// Helper function to normalize phone numbers (remove all non-digit characters)\nfunction normalizePhone(phone) {\n  if (!phone) return '';\n  // Remove all non-digit characters except keep the + if it's at the start\n  let normalized = String(phone).replace(/[^0-9+]/g, '');\n  // If it starts with +, keep it, otherwise remove any + in the middle\n  if (normalized.startsWith('+')) {\n    normalized = '+' + normalized.substring(1).replace(/[^0-9]/g, '');\n  } else {\n    normalized = normalized.replace(/[^0-9]/g, '');\n  }\n  return normalized;\n}\n// Helper function to normalize business names (trim, lowercase, remove extra spaces)\nfunction normalizeBusinessName(name) {\n  if (!name) return '';\n  return String(name).trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n// Get current items from Parse Full Business Info node\nlet currentItems = [];\ntry {\n  const parseNode = $('Parse Full Business Info');\n  if (parseNode && parseNode.all) {\n    const parseData = parseNode.all() || [];\n    currentItems = parseData.map(i => i.json || i).filter(item => \n      item && item.businessName && \n      String(item.businessName) !== 'No businesses found' && \n      String(item.businessName) !== 'No HTML content' &&\n      String(item.businessName) !== 'MATCHING_ERROR'\n    );\n  }\n} catch (error) {\n  return [{\n    json: {\n      businessName: 'Error accessing Parse node',\n      phone: '',\n      website: '',\n      rating: '',\n      totalReviews: '',\n      address: '',\n      city: '',\n      mapUrl: '',\n      category: 'dentist',\n      status: 'old',\n      checkedAt: new Date().toISOString(),\n      error: String(error.message || 'Unknown error')\n    }\n  }];\n}\nif (currentItems.length === 0) {\n  return [{\n    json: {\n      businessName: 'No businesses found',\n      phone: '',\n      website: '',\n      rating: '',\n      totalReviews: '',\n      address: '',\n      city: '',\n      mapUrl: '',\n      category: 'dentist',\n      status: 'old',\n      checkedAt: new Date().toISOString()\n    }\n  }];\n}\n// Get previous entries from Google Sheets\nlet previousItems = [];\ntry {\n  const readNode = $('Read Previous Entries from Sheet');\n  if (readNode && readNode.all) {\n    const prevData = readNode.all() || [];\n    previousItems = prevData.map(item => {\n      const json = item.json || item;\n      return {\n        businessName: String(json.businessName || json.BusinessName || json['Business Name'] || ''),\n        phone: String(json.phone || json.Phone || json['Phone'] || ''),\n        website: String(json.website || json.Website || json['Website'] || ''),\n        rating: String(json.rating || json.Rating || json['Rating'] || ''),\n        totalReviews: String(json.totalReviews || json.TotalReviews || json['Total Reviews'] || ''),\n        address: String(json.address || json.Address || json['Address'] || ''),\n        city: String(json.city || json.City || json['City'] || ''),\n        mapUrl: String(json.mapUrl || json.MapUrl || json['Map URL'] || ''),\n        category: String(json.category || json.Category || json['Category'] || ''),\n        status: String(json.status || json.Status || json['Status'] || '')\n      };\n    }).filter(prev => prev.businessName && String(prev.businessName).length > 0);\n  }\n} catch (error) {\n  previousItems = [];\n}\n// Create a set of normalized previous business+phone combinations\nconst prevKeys = new Set();\npreviousItems.forEach(prev => {\n  const normalizedName = normalizeBusinessName(prev.businessName);\n  const normalizedPhone = normalizePhone(prev.phone);\n  if (normalizedName && normalizedName.length > 0) {\n    // Create key with normalized values\n    const key = `${normalizedName}||${normalizedPhone}`;\n    prevKeys.add(key);\n    // Also add a key with just business name (in case phone is missing)\n    if (normalizedPhone && normalizedPhone.length > 0) {\n      prevKeys.add(normalizedName);\n    }\n  }\n});\n// Process current items and mark as new or old\nconst results = [];\nconst seen = new Set();\ncurrentItems.forEach(item => {\n  const originalBusinessName = String(item.businessName || '').trim();\n  const originalPhone = String(item.phone || '').trim();\n  \n  // Normalize for comparison\n  const normalizedName = normalizeBusinessName(originalBusinessName);\n  const normalizedPhone = normalizePhone(originalPhone);\n  \n  // Create key for duplicate detection in current run\n  const currentKey = `${normalizedName}||${normalizedPhone}`;\n  \n  // Skip duplicates in current run\n  if (seen.has(currentKey)) {\n    return;\n  }\n  seen.add(currentKey);\n  \n  // Check if this business exists in previous entries\n  // Try exact match first (name + phone)\n  let isExisting = false;\n  if (normalizedPhone && normalizedPhone.length > 0) {\n    const exactKey = `${normalizedName}||${normalizedPhone}`;\n    isExisting = prevKeys.has(exactKey);\n  }\n  \n  // If not found with phone, try matching by business name only\n  if (!isExisting && normalizedName && normalizedName.length > 0) {\n    isExisting = prevKeys.has(normalizedName);\n  }\n  \n  // Also do a direct comparison with previous items for more flexible matching\n  if (!isExisting) {\n    for (const prev of previousItems) {\n      const prevNormalizedName = normalizeBusinessName(prev.businessName);\n      const prevNormalizedPhone = normalizePhone(prev.phone);\n      \n      // Match if business name matches exactly\n      if (normalizedName === prevNormalizedName) {\n        // If both have phones, they must match\n        if (normalizedPhone && normalizedPhone.length > 0 && prevNormalizedPhone && prevNormalizedPhone.length > 0) {\n          if (normalizedPhone === prevNormalizedPhone) {\n            isExisting = true;\n            break;\n          }\n        } else {\n          // If one or both phones are missing, match by name only\n          isExisting = true;\n          break;\n        }\n      }\n    }\n  }\n  \n  // Determine status: 'old' if exists in sheet, 'new' if it's a new business\n  const status = isExisting ? 'old' : 'new';\n  \n  results.push({\n    json: {\n      businessName: originalBusinessName,\n      phone: originalPhone,\n      website: String(item.website || ''),\n      rating: String(item.rating || ''),\n      totalReviews: String(item.totalReviews || ''),\n      address: String(item.add

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This n8n template automates the generation of local business leads by scraping Google Maps. It goes beyond basic search results by visiting individual business pages to extract detailed contact information, reviews, and attributes (like LGBTQ+ friendly status). It includes…

Source: https://n8n.io/workflows/11456/ — original creator credit. Request a take-down →

More Marketing & Ads workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Marketing & Ads

This n8n template automates finding roofing contractors in any city using Google Maps. It deep-scrapes listings via ScrapeOps Proxy, deduplicates results against Google Sheets, saves fresh leads, and

Google Sheets, Slack, Gmail +2
Marketing & Ads

This repository contains an SLA-based lead routing workflow built in n8n, designed to ensure fast lead response, fair sales distribution, and controlled escalation without relying on a full CRM system

Form Trigger, Google Sheets, Slack +1
Marketing & Ads

Find companies similar to your best clients using PredictLeads, enrich each with news, hiring, and tech signals, then score them 0–100 for outreach priority.

Google Sheets, @Predictleads/N8N Nodes Predictleads, Slack +2
Marketing & Ads

This workflow allows users to extract potential leads from their inboxes. The idea of a reverse outreach is based on the notion that the next big client/customer/partner might be sitting in your inbox

Gmail Trigger, Gmail, Slack +1
Marketing & Ads

Automatically qualify, score, and route inbound B2B leads using GPT-4o-mini — no manual review needed.

HTTP Request, Slack, Gmail +1