{
  "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.address || ''),\n      city: String(item.city || ''),\n      mapUrl: String(item.mapUrl || ''),\n      category: String(item.category || 'dentist'),\n      status: status,\n      checkedAt: String(item.checkedAt || new Date().toISOString()),\n      lgbtqFriendly: item.lgbtqFriendly !== undefined ? item.lgbtqFriendly : false,\n      review1: String(item.review1 || ''),\n      review2: String(item.review2 || ''),\n      review3: String(item.review3 || '')\n    }\n  });\n});\nreturn results.length > 0 ? results : [{\n  json: {\n    businessName: 'No data',\n    phone: '',\n    website: '',\n    rating: '',\n    totalReviews: '',\n    address: '',\n    city: '',\n    category: 'dentist',\n    status: 'old',\n    checkedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "289b3dac-fb2b-4e51-90e7-79f0044a7fc6",
      "name": "Filter New Leads",
      "type": "n8n-nodes-base.if",
      "position": [
        -320,
        512
      ],
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.status }}",
              "value2": "new"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "027c4530-3942-4eac-a7d8-e29d542cf5fc",
      "name": "Send Gmail Alert for New Leads",
      "type": "n8n-nodes-base.gmail",
      "position": [
        128,
        512
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "={{ 'New Dentist Found\\n\\n' + 'Sheet URL: ' + 'https://docs.google.com/spreadsheets/d/1HYO6pw9PigmNKzrnmE9s9JcNVSvZAcIHGF1ZJQ9kfks/edit?gid=0#gid=0' }}",
        "options": {
          "appendAttribution": false
        },
        "subject": "={{ '\ud83d\udccd New Businesses Found in ' + $('Set Google Maps Configuration').first().json.city + ' (Dentist)' }}",
        "emailType": "text"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "executeOnce": true,
      "typeVersion": 2.1
    },
    {
      "id": "cb5ed271-5953-47a3-8999-9f615b8d62a0",
      "name": "Save to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -96,
        512
      ],
      "parameters": {
        "columns": {
          "value": {
            "city": "={{ $json.city }}",
            "phone": "={{ $json.phone }}",
            "mapUrl": "={{ $json.mapUrl }}",
            "rating": "={{ $json.rating }}",
            "status": "={{ $json.status }}",
            "address": "={{ $json.address }}",
            "review1": "={{ $json.review1 }}",
            "review2": "={{ $json.review2 }}",
            "review3": "={{ $json.review3 }}",
            "website": "={{ $json.website }}",
            "category": "={{ $json.category }}",
            "checkedAt": "={{ $json.checkedAt }}",
            "businessName": "={{ $json.businessName }}",
            "totalReviews": "={{ $json.totalReviews }}",
            "lgbtqFriendly": "={{ $json.lgbtqFriendly }}"
          },
          "schema": [
            {
              "id": "businessName",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "businessName",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "phone",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "website",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "website",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "rating",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "rating",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "totalReviews",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "totalReviews",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "address",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "address",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "city",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "city",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "category",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "category",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "mapUrl",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "mapUrl",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "checkedAt",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "checkedAt",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "lgbtqFriendly",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "lgbtqFriendly",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "review1",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "review1",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "review2",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "review2",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "review3",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "review3",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/YOUR_AWS_SECRET_KEY_HERE_OqtAw/edit#gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "https://docs.google.com/spreadsheets/d/1HYO6pw9PigmNKzrnmE9s9JcNVSvZAcIHGF1ZJQ9kfks/edit?usp=sharing"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "executeOnce": false,
      "typeVersion": 4.4,
      "continueOnFail": true
    },
    {
      "id": "a8f40428-e51a-4eaa-bd79-cda51c7052c2",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2864,
        192
      ],
      "parameters": {
        "width": 672,
        "height": 624,
        "content": "# \ud83d\uddfa\ufe0f Google Maps Dentist Finder\n\n### How it works\nThis workflow automates the process of finding local businesses (like Dentists) in any city by scraping Google Maps. It performs a \"deep scrape\" to extract comprehensive details including phone numbers, websites, reviews, and LGBTQ+ friendly status. It then cross-references these results with a Google Sheet to ensure you only get fresh leads, avoiding duplicates. Finally, it saves the new data and sends alerts via Gmail and Slack.\n\n### Setup steps\n1. **Credentials**: Configure credentials for **[ScrapeOps](https://scrapeops.io/app/register/n8n)** (for scraping), **Google Sheets**, **Gmail**, and **Slack**.\n2. **Google Sheet**: Create a new sheet with these headers: `businessName`, `phone`, `website`, `rating`, `totalReviews`, `address`, `city`, `category`, `mapUrl`, `status`, `checkedAt`, `lgbtqFriendly`, `review1`, `review2`, `review3`.\n   - [Get the Template Sheet here](https://docs.google.com/spreadsheets/d/1HYO6pw9PigmNKzrnmE9s9JcNVSvZAcIHGF1ZJQ9kfks/edit?gid=0#gid=0)\n3. **Configure**:\n   - In the **Set Google Maps Configuration** node, update the `keyword` (default is \"dentist\").\n   - In the **Read Previous Entries** and **Save to Google Sheets** nodes, select your new sheet.\n   - Update the **Send Gmail Alert** and **Send a message** (Slack) nodes with your recipients.\n\n### Customization\n- **Change Business Type**: Update the `keyword` in the configuration node to find \"plumbers\", \"gyms\", etc.\n- **Schedule**: Replace the Form Trigger with a Schedule Trigger to run automatically."
      },
      "typeVersion": 1
    },
    {
      "id": "e0c2adc5-f474-4f1c-b163-5f1ffc87e322",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2152,
        336
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 336,
        "content": "## 1. Input & Config\nCapture city input and set search terms."
      },
      "typeVersion": 1
    },
    {
      "id": "30f283cd-2c7e-4e6a-9108-159f0dcb716a",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -144,
        336
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 336,
        "content": "## 4. Save & Alert\nStore data and notify via Email/Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "b588a9e9-9c83-49b4-b643-80b844a59c77",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1720,
        336
      ],
      "parameters": {
        "color": 7,
        "width": 880,
        "height": 336,
        "content": "## 2. Deep Scraping\nSearch Maps & extract full dentist details."
      },
      "typeVersion": 1
    },
    {
      "id": "8e5a6d8c-e9c6-4246-9a23-199f6d37887d",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -824,
        336
      ],
      "parameters": {
        "color": 7,
        "width": 656,
        "height": 336,
        "content": "## 3. Deduplication\nFilter out existing businesses from Sheet."
      },
      "typeVersion": 1
    },
    {
      "id": "3d8444be-8cef-4c87-83c7-726777dd6643",
      "name": "Send a message",
      "type": "n8n-nodes-base.slack",
      "position": [
        352,
        512
      ],
      "parameters": {
        "text": "New Dentist Found: https://docs.google.com/spreadsheets/d/1HYO6pw9PigmNKzrnmE9s9JcNVSvZAcIHGF1ZJQ9kfks/edit?gid=0#gid=0",
        "user": {
          "__rl": true,
          "mode": "id",
          "value": "U08P301T9HA"
        },
        "select": "user",
        "otherOptions": {
          "includeLinkToWorkflow": true
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "executeOnce": true,
      "typeVersion": 2.3
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "a4290cf2-863a-43bc-a706-cb769246fcb3",
  "connections": {
    "Filter New Leads": {
      "main": [
        [
          {
            "node": "Save to Google Sheets",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Form - City Input": {
      "main": [
        [
          {
            "node": "Set Google Maps Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save to Google Sheets": {
      "main": [
        [
          {
            "node": "Send Gmail Alert for New Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Business Details": {
      "main": [
        [
          {
            "node": "ScrapeOps - Business Details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Full Business Info": {
      "main": [
        [
          {
            "node": "Read Previous Entries from Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare With Previous Run": {
      "main": [
        [
          {
            "node": "Filter New Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ScrapeOps - Business Details": {
      "main": [
        [
          {
            "node": "Parse Full Business Info",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Google Maps Configuration": {
      "main": [
        [
          {
            "node": "ScrapeOps - Google Maps Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ScrapeOps - Google Maps Search": {
      "main": [
        [
          {
            "node": "Parse Business Details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Gmail Alert for New Leads": {
      "main": [
        [
          {
            "node": "Send a message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Previous Entries from Sheet": {
      "main": [
        [
          {
            "node": "Compare With Previous Run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}