AutomationFlowsAI & RAG › Scrape Google Maps Realtor Leads with Scrapeops, Google Sheets, Gmail and Slack

Scrape Google Maps Realtor Leads with Scrapeops, Google Sheets, Gmail and Slack

ByIan Kerins @iankerins on n8n.io

This n8n template automates finding real estate agents in any city using Google Maps. It deep-scrapes listings via ScrapeOps Proxy, deduplicates results against Google Sheets, saves fresh leads, and sends alerts via Gmail and Slack — all triggered from a simple web form. Real…

Event trigger★★★★☆ complexity17 nodesGoogle SheetsForm Trigger@Scrapeops/N8N Nodes ScrapeopsGmailSlack
AI & RAG Trigger: Event Nodes: 17 Complexity: ★★★★☆ Added:
Scrape Google Maps Realtor Leads with Scrapeops, Google Sheets, Gmail and Slack — n8n workflow card showing Google Sheets, Form Trigger, @Scrapeops/N8N Nodes Scrapeops integration

This workflow corresponds to n8n.io template #14111 — 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": "qT0dWRnlfvEroIK5",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Real Estate Agent Finder with ScrapeOps and Google Sheets",
  "tags": [
    {
      "id": "LMn6o05fqdQKEjLc",
      "name": "Real Estate Lead",
      "createdAt": "2026-03-14T10:34:09.520Z",
      "updatedAt": "2026-03-14T10:34:09.520Z"
    },
    {
      "id": "k8saSclPqoEhjFw3",
      "name": "Real Estate Agent Finder",
      "createdAt": "2026-03-14T10:33:56.398Z",
      "updatedAt": "2026-03-14T10:33:56.398Z"
    },
    {
      "id": "l2YGilkhCC3xKC2G",
      "name": "#google-maps",
      "createdAt": "2025-12-03T11:31:29.830Z",
      "updatedAt": "2025-12-03T11:31:29.830Z"
    },
    {
      "id": "lZKSh2IoxHklnOUw",
      "name": "ScrapeOps",
      "createdAt": "2025-10-20T20:27:13.410Z",
      "updatedAt": "2025-10-20T20:27:13.410Z"
    },
    {
      "id": "yzylwxvLF3YwGBRm",
      "name": "Google Sheets Automation",
      "createdAt": "2026-03-10T07:03:25.329Z",
      "updatedAt": "2026-03-10T07:03:25.329Z"
    }
  ],
  "nodes": [
    {
      "id": "1cdf0632-9041-4417-a0b8-852647c21eda",
      "name": "Set Google Maps Configuration",
      "type": "n8n-nodes-base.set",
      "position": [
        976,
        320
      ],
      "parameters": {
        "fields": {
          "values": [
            {
              "name": "city",
              "stringValue": "={{ $json.city || $json['City Name'] || '' }}"
            },
            {
              "name": "keyword",
              "stringValue": "real estate agent"
            },
            {
              "name": "baseUrl",
              "stringValue": "https://www.google.com/maps/search/"
            },
            {
              "name": "searchUrl",
              "stringValue": "={{ 'https://www.google.com/maps/search/real+estate+agent+in+' + String($json.city || $json['City Name'] || '').replace(/\\s+/g, '+') }}"
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "802b7a74-7695-49d9-91db-f03ec621414d",
      "name": "Parse Full Business Info",
      "type": "n8n-nodes-base.code",
      "position": [
        1872,
        320
      ],
      "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\n  let extractedMapUrl = '';\n  if (htmlContent && typeof htmlContent === 'string' && htmlContent.length > 100) {\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    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    if (!extractedMapUrl) {\n      const placeIdPatterns = [/1s([^:!\\/\"'<>\\s]+)/, /place\\/([^\\/\\?\"'<>\\s]+)/];\n      for (const pattern of placeIdPatterns) {\n        const match = htmlContent.match(pattern);\n        if (match && match[1]) {\n          const nameMatch = htmlContent.match(/<h1[^>]*>([^<]+)<\\/h1>/i) || \n                           htmlContent.match(/<div[^>]*class=[\"'][^\"']*qBF1Pd[^\"']*[\"'][^>]*>([^<]+)<\\/div>/i);\n          if (nameMatch && nameMatch[1]) {\n            const bn = nameMatch[1].trim().replace(/\\s+/g, '+');\n            extractedMapUrl = `https://www.google.com/maps/place/${bn}/data=!4m7!3m6!1s${match[1]}`;\n            break;\n          }\n        }\n      }\n    }\n  }\n  \n  // Match with Parse Business Details\n  try {\n    const parseNode = $('Parse Business Listings');\n    if (parseNode && parseNode.all) {\n      const parseData = parseNode.all() || [];\n      if (extractedMapUrl) {\n        const getPlaceId = (url) => {\n          if (!url) return '';\n          const m1 = url.match(/1s([^:!\\/]+)/);\n          const m2 = url.match(/place\\/([^\\/\\?]+)/);\n          return m1 ? m1[1] : (m2 ? m2[1] : '');\n        };\n        const extractedPlaceId = getPlaceId(extractedMapUrl);\n        for (let i = 0; i < parseData.length; i++) {\n          const itemData = parseData[i].json || parseData[i];\n          const itemMapUrl = itemData.mapUrl || '';\n          if (itemMapUrl) {\n            const itemPlaceId = getPlaceId(itemMapUrl);\n            if (itemPlaceId && extractedPlaceId && itemPlaceId === extractedPlaceId) {\n              originalData = itemData; currentMapUrl = itemMapUrl; break;\n            }\n            if (itemPlaceId && extractedPlaceId && \n                (itemPlaceId.includes(extractedPlaceId) || extractedPlaceId.includes(itemPlaceId))) {\n              originalData = itemData; currentMapUrl = itemMapUrl; break;\n            }\n            if (itemMapUrl.includes(extractedPlaceId) || extractedMapUrl.includes(itemPlaceId)) {\n              originalData = itemData; currentMapUrl = itemMapUrl; break;\n            }\n          }\n        }\n      }\n      if (!originalData || Object.keys(originalData).length === 0) {\n        const scrapeOpsNode = $('ScrapeOps: Fetch Business Details');\n        if (scrapeOpsNode && scrapeOpsNode.all) {\n          const scrapeOpsData = scrapeOpsNode.all() || [];\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; break;\n            }\n          }\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 && inputItem.index < parseData.length) {\n            originalData = parseData[inputItem.index].json || parseData[inputItem.index];\n            currentMapUrl = originalData.mapUrl || '';\n          }\n        } else if (inputItem.index !== undefined && inputItem.index !== null && inputItem.index < parseData.length) {\n          originalData = parseData[inputItem.index].json || parseData[inputItem.index];\n          currentMapUrl = originalData.mapUrl || '';\n        }\n      }\n      if (!originalData || Object.keys(originalData).length === 0) {\n        output.push({\n          json: {\n            businessName: 'MATCHING_ERROR', phone: '', website: '', rating: '',\n            totalReviews: '', address: '', city: '', mapUrl: extractedMapUrl || '',\n            category: 'real estate agent', lgbtqFriendly: false,\n            checkedAt: new Date().toISOString(),\n            error: 'Could not match HTML to original business data.'\n          }\n        });\n        continue;\n      }\n    }\n  } catch (error) {\n    output.push({\n      json: {\n        businessName: 'MATCHING_ERROR', phone: '', website: '', rating: '',\n        totalReviews: '', address: '', city: '', mapUrl: extractedMapUrl || '',\n        category: 'real estate agent', lgbtqFriendly: false,\n        checkedAt: new Date().toISOString(),\n        error: 'Error matching data: ' + String(error.message || 'Unknown error')\n      }\n    });\n    continue;\n  }\n  \n  if (!originalData.mapUrl && currentMapUrl) originalData.mapUrl = currentMapUrl;\n  else if (!originalData.mapUrl && extractedMapUrl) originalData.mapUrl = extractedMapUrl;\n  if (htmlContent.length > 2000000) htmlContent = htmlContent.substring(0, 2000000);\n  \n  function extractText(html, pattern) {\n    const match = html.match(pattern);\n    if (match && match[1]) return match[1].replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim();\n    return '';\n  }\n  function extractAllMatches(html, pattern) {\n    const matches = [];\n    let match;\n    while ((match = pattern.exec(html)) !== null) { if (match[1]) matches.push(match[1].trim()); }\n    return matches;\n  }\n  \n  let businessName = originalData.businessName || '';\n  let phone = originalData.phone || '';\n  let website = originalData.website || '';\n  let rating = '';\n  let totalReviews = '';\n  let address = originalData.address || '';\n  let city = originalData.city || '';\n  let category = originalData.category || 'real estate agent';\n  let lgbtqFriendly = false;\n  let review1 = '', review2 = '', review3 = '';\n  \n  if (htmlContent && htmlContent.length > 100) {\n    \n    // EXTRACT PHONE\n    if (!phone || phone.length < 7) {\n      const m = htmlContent.match(/data-item-id=[\"']phone:tel:([^\"']+)[\"']/i);\n      if (m) { phone = m[1].replace(/[^0-9+]/g, ''); if (!phone.startsWith('+')) phone = '+' + phone; }\n    }\n    if (!phone || phone.length < 7) {\n      const telLinks = extractAllMatches(htmlContent, /href=[\"']tel:([^\"']+)[\"']/gi);\n      if (telLinks.length > 0) { phone = telLinks[0].replace(/[^0-9+]/g, ''); if (!phone.startsWith('+') && phone.length >= 10) phone = '+' + phone; }\n    }\n    if (!phone || phone.length < 7) {\n      const m = 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 (m) {\n        const t = m[1].match(/<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/i);\n        if (t) { phone = t[1].replace(/[^0-9+()-]/g, '').trim(); if (phone.length < 7 || phone.length > 15) phone = originalData.phone || ''; else if (!phone.startsWith('+') && phone.length >= 10) phone = '+' + phone; }\n      }\n    }\n    \n    // EXTRACT WEBSITE\n    if (!website || website.length < 5) {\n      const m = htmlContent.match(/<[^>]*data-item-id=[\"']authority[\"'][^>]*href=[\"']([^\"']+)[\"'][^>]*>/i);\n      if (m) { website = m[1]; if (website.startsWith('www.')) website = 'http://' + website; website = website.split('#')[0].trim(); if (website.includes('google.com') || website.includes('googleapis.com') || website.includes('gstatic.com')) website = originalData.website || ''; }\n    }\n    if (!website || website.length < 5) {\n      const m = htmlContent.match(/aria-label=[\"']Website:[^\"']*[\"'][^>]*href=[\"']([^\"']+)[\"']/i);\n      if (m) { website = m[1]; if (website.startsWith('www.')) website = 'http://' + website; website = website.split('#')[0].trim(); if (website.includes('google.com') || website.includes('googleapis.com') || website.includes('gstatic.com')) website = originalData.website || ''; }\n    }\n    \n    // EXTRACT ADDRESS\n    if (!address || address.length < 5) {\n      const m = 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 (m) { const t = m[1].match(/<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/i); if (t) { address = t[1].replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim(); if (address.length < 10 || address.length > 300) address = originalData.address || ''; } }\n    }\n    if (!address || address.length < 5) {\n      const m = htmlContent.match(/aria-label=[\"']Address:[^\"']*[\"'][^>]*>([\\s\\S]{0,500})<\\/button>/i);\n      if (m) { const t = m[1].match(/<[^>]*class=[\"'][^\"']*Io6YTe[^\"']*[\"'][^>]*>([^<]+)<\\/[^>]*>/i); if (t) { address = t[1].replace(/<[^>]*>/g, '').replace(/\\s+/g, ' ').trim(); if (address.length < 10 || address.length > 300) address = originalData.address || ''; } }\n    }\n    \n    // ============================================================\n    // EXTRACT LGBTQ+ FRIENDLY\n    // ============================================================\n    const lgbtqPatterns = [\n      /lgbtq[^\"'<>]{0,50}friendly/i,\n      /lgbt[^\"'<>]{0,50}friendly/i,\n      /identifies as lgbtq\\+?\\s*friendly/i,\n      /lgbtq\\+?\\s*owned/i,\n      /gay.friendly/i,\n      /transgender.friendly/i,\n      /pride[^\"'<>]{0,30}friendly/i\n    ];\n    for (const pattern of lgbtqPatterns) {\n      if (pattern.test(htmlContent)) {\n        lgbtqFriendly = true;\n        break;\n      }\n    }\n    \n    // ============================================================\n    // EXTRACT RATING\n    // ============================================================\n    const f7niceBlock = htmlContent.match(/F7nice[^>]*>([\\s\\S]{0,500}?)<\\/div>/i);\n    if (f7niceBlock && f7niceBlock[1]) {\n      const ariaHidden = f7niceBlock[1].match(/aria-hidden=(?:\\\\?\"|\\\")true(?:\\\\?\\\"|\\\")\\s*>(\\d+\\.?\\d*)<\\/span>/i);\n      if (ariaHidden && ariaHidden[1]) {\n        const n = parseFloat(ariaHidden[1]);\n        if (n >= 1.0 && n <= 5.0) rating = ariaHidden[1];\n      }\n    }\n    if (!rating) {\n      const m = htmlContent.match(/aria-label=(?:\\\\?\"|\\\")(\\d+\\.?\\d*)\\s+stars?\\s*(?:\\\\?\"|\\\")/i);\n      if (m && m[1]) { const n = parseFloat(m[1]); if (n >= 1.0 && n <= 5.0) rating = m[1]; }\n    }\n    if (!rating) {\n      const raw = extractText(htmlContent, /<span[^>]*class=[\"'][^\"']*MW4etd[^\"']*[\"'][^>]*>([^<]+)<\\/span>/i).replace(/[^0-9.]/g, '');\n      const n = parseFloat(raw);\n      if (raw && !isNaN(n) && n >= 1.0 && n <= 5.0) rating = raw;\n    }\n    const ratingNum = parseFloat(rating);\n    if (!rating || isNaN(ratingNum) || ratingNum < 1.0 || ratingNum > 5.0) {\n      rating = originalData.rating || '';\n    }\n    \n    // ============================================================\n    // EXTRACT TOTAL REVIEWS\n    // ============================================================\n    if (f7niceBlock && f7niceBlock[1]) {\n      const reviewsInBlock = f7niceBlock[1].match(/aria-label=(?:\\\\?\"|\\\")([0-9,]+)\\s+reviews(?:\\\\?\"|\\\")/i);\n      if (reviewsInBlock && reviewsInBlock[1]) totalReviews = reviewsInBlock[1].replace(/,/g, '');\n      if (!totalReviews) {\n        const parenInBlock = f7niceBlock[1].match(/\\(([0-9,]+)\\)/);\n        if (parenInBlock && parenInBlock[1]) totalReviews = parenInBlock[1].replace(/,/g, '');\n      }\n    }\n    if (!totalReviews) {\n      const roleImg = htmlContent.match(/role=(?:\\\\?\"|\\\"|')img(?:\\\\?\"|\\\"|')[^>]*aria-label=(?:\\\\?\"|\\\")([0-9,]+)\\s+reviews(?:\\\\?\"|\\\")[\\s>]/i);\n      if (roleImg && roleImg[1]) totalReviews = roleImg[1].replace(/,/g, '');\n    }\n    if (!totalReviews) {\n      const strictMatch = htmlContent.match(/aria-label=[\"\\\\]+([0-9,]+)\\s+reviews[\"\\\\]+/i);\n      if (strictMatch && strictMatch[1]) totalReviews = strictMatch[1].replace(/,/g, '');\n    }\n    if (!totalReviews) {\n      const raw = extractText(htmlContent, /<span[^>]*class=[\"'][^\"']*UY7F9[^\"']*[\"'][^>]*>([^<]+)<\\/span>/i).replace(/[^0-9]/g, '');\n      if (raw && raw.length >= 1 && raw.length <= 6) totalReviews = raw;\n    }\n    const totalNum = parseInt(totalReviews);\n    if (!totalReviews || isNaN(totalNum) || totalNum < 1 || totalNum > 999999) {\n      totalReviews = '';\n    }\n    \n    // EXTRACT BUSINESS NAME\n    if (!businessName || businessName.length < 3) {\n      for (const p of [\n        /<div[^>]*class=[\"'][^\"']*qBF1Pd[^\"']*[\"'][^>]*>([^<]+)<\\/div>/i,\n        /<h1[^>]*>([^<]+)<\\/h1>/i,\n        /<h2[^>]*>([^<]+)<\\/h2>/i\n      ]) {\n        businessName = extractText(htmlContent, p);\n        if (businessName && businessName.length >= 3) break;\n      }\n      businessName = businessName.replace(/\\s+/g, ' ').trim();\n      if (!businessName || businessName.length < 3) businessName = originalData.businessName || '';\n    }\n    \n    // ============================================================\n    // EXTRACT REVIEWS - FULL TEXT\n    // ============================================================\n    \n    function cleanReviewText(text) {\n      if (!text) return '';\n      text = text.replace(/<[^>]*>/g, ' ');\n      text = text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '\"').replace(/&#39;/g, \"'\").replace(/&nbsp;/g, ' ');\n      text = text.replace(/\\\\u0026/g, '&').replace(/\\\\u003c/g, '<').replace(/\\\\u003e/g, '>').replace(/\\\\n/g, ' ').replace(/\\\\\"/g, '\"');\n      text = text.replace(/\\bMore\\b/g, '').replace(/\\bLess\\b/g, '').replace(/\\bTranslate\\b/g, '');\n      text = text.replace(/\\bLike\\b/g, '').replace(/\\bShare\\b/g, '').replace(/\\bReport\\b/g, '');\n      text = text.replace(/Response from the owner[\\s\\S]{0,500}?(?=\\s{2,}|$)/gi, '');\n      text = text.replace(/Owner[''s]*\\s*response[\\s\\S]{0,500}?(?=\\s{2,}|$)/gi, '');\n      text = text.replace(/^\\s*[Nn][Ee][Ww]\\s+/g, '');\n      text = text.replace(/[\u2605\u2b50\u2606\u2729\u272b\u272c\u272d\u272e\u272f\u2730]/g, '');\n      text = text.replace(/\\d+\\s*stars?/gi, '');\n      text = text.replace(/\\d+\\s+(days?|weeks?|months?|years?)\\s+ago/gi, '');\n      text = text.replace(/\\b(a|an)\\s+(day|week|month|year)\\s+ago\\b/gi, '');\n      text = text.replace(/\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}/g, '');\n      text = text.replace(/\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{4}\\b/gi, '');\n      text = text.replace(/Local\\s+Guide\\s*[\u00b7\u2022]\\s*\\d+\\s+reviews?\\s*[\u00b7\u2022]?\\s*(\\d+\\s+photos?)?/gi, '');\n      text = text.replace(/\\d+\\s+reviews?\\s*[\u00b7\u2022]\\s*\\d+\\s+photos?/gi, '');\n      text = text.replace(/[\\uE000-\\uF8FF\\uFFF0-\\uFFFF]/g, '');\n      text = text.replace(/[\\r\\n\\t]+/g, ' ').replace(/\\s{2,}/g, ' ').trim();\n      return text;\n    }\n    \n    function areSimilar(a, b) {\n      if (!a || !b) return false;\n      const shorter = a.length < b.length ? a : b;\n      const longer = a.length < b.length ? b : a;\n      const checkLen = Math.min(shorter.length, 120);\n      if (longer.includes(shorter.substring(0, checkLen))) return true;\n      if (shorter.includes(longer.substring(0, checkLen))) return true;\n      if (a.substring(0, 80) === b.substring(0, 80)) return true;\n      return false;\n    }\n    \n    let allCandidates = [];\n    function addCandidate(text, source) {\n      if (!text) return;\n      const cleaned = cleanReviewText(text);\n      if (!cleaned || cleaned.length < 40) return;\n      if (cleaned.match(/^[\\d\\s\\.,]+$/)) return;\n      allCandidates.push({ text: cleaned, length: cleaned.length, source });\n    }\n    \n    // SOURCE 1: data-expanded-text (char by char)\n    const expandedAttrPattern = /data-expanded-text=/gi;\n    let attrMatch;\n    while ((attrMatch = expandedAttrPattern.exec(htmlContent)) !== null) {\n      const startPos = attrMatch.index + 'data-expanded-text='.length;\n      const quoteChar = htmlContent[startPos];\n      if (quoteChar !== '\"' && quoteChar !== \"'\") continue;\n      let endPos = startPos + 1, fullText = '';\n      while (endPos < htmlContent.length && endPos < startPos + 10000) {\n        if (htmlContent[endPos] === quoteChar && htmlContent[endPos - 1] !== '\\\\') { fullText = htmlContent.substring(startPos + 1, endPos); break; }\n        endPos++;\n      }\n      if (fullText) addCandidate(fullText, 'expanded');\n    }\n    \n    // SOURCE 2: data-original-text (char by char)\n    const originalTextPattern = /data-original-text=/gi;\n    let origMatch;\n    while ((origMatch = originalTextPattern.exec(htmlContent)) !== null) {\n      const startPos = origMatch.index + 'data-original-text='.length;\n      const quoteChar = htmlContent[startPos];\n      if (quoteChar !== '\"' && quoteChar !== \"'\") continue;\n      let endPos = startPos + 1, fullText = '';\n      while (endPos < htmlContent.length && endPos < startPos + 10000) {\n        if (htmlContent[endPos] === quoteChar && htmlContent[endPos - 1] !== '\\\\') { fullText = htmlContent.substring(startPos + 1, endPos); break; }\n        endPos++;\n      }\n      if (fullText) addCandidate(fullText, 'original');\n    }\n    \n    // SOURCE 3: data-review-id blocks\n    const reviewBlockPositions = [];\n    const reviewIdFinder = /data-review-id=\"([^\"]+)\"/gi;\n    let ridMatch;\n    while ((ridMatch = reviewIdFinder.exec(htmlContent)) !== null) reviewBlockPositions.push({ pos: ridMatch.index, id: ridMatch[1] });\n    for (let bi = 0; bi < reviewBlockPositions.length; bi++) {\n      const startPos = reviewBlockPositions[bi].pos;\n      const endPos = bi + 1 < reviewBlockPositions.length ? Math.min(reviewBlockPositions[bi + 1].pos, startPos + 8000) : startPos + 8000;\n      const chunk = htmlContent.substring(startPos, endPos);\n      const spanCandidates = [];\n      const spanPat = /<span[^>]*>([\\s\\S]{40,3000}?)<\\/span>/gi;\n      let spanMatch;\n      while ((spanMatch = spanPat.exec(chunk)) !== null) {\n        const raw = spanMatch[1];\n        if ((raw.match(/<[^>]+>/g) || []).length > 12) continue;\n        const cleaned = cleanReviewText(raw);\n        if (cleaned && cleaned.length >= 40) spanCandidates.push(cleaned);\n      }\n      spanCandidates.sort((a, b) => b.length - a.length);\n      if (spanCandidates.length > 0) addCandidate(spanCandidates[0], 'review_block_' + bi);\n    }\n    \n    // SOURCE 4: wiI7pd class\n    const wiPat = /<span[^>]*class=\"[^\"]*wiI7pd[^\"]*\"[^>]*>([\\s\\S]{40,8000}?)<\\/span>/gi;\n    let wiMatch;\n    while ((wiMatch = wiPat.exec(htmlContent)) !== null) addCandidate(wiMatch[1], 'wiI7pd');\n    \n    // SOURCE 5: MyEned class\n    const myPat = /<span[^>]*class=\"[^\"]*MyEned[^\"]*\"[^>]*>([\\s\\S]{40,8000}?)<\\/span>/gi;\n    let myMatch;\n    while ((myMatch = myPat.exec(htmlContent)) !== null) addCandidate(myMatch[1], 'MyEned');\n    \n    // SOURCE 6: rsqaWe class\n    const rsqaPat = /<span[^>]*class=\"[^\"]*rsqaWe[^\"]*\"[^>]*>([\\s\\S]{40,8000}?)<\\/span>/gi;\n    let rsqaMatch;\n    while ((rsqaMatch = rsqaPat.exec(htmlContent)) !== null) addCandidate(rsqaMatch[1], 'rsqaWe');\n    \n    // SOURCE 7: JSON-LD\n    const jsonPat = /<script[^>]*type=\"application\\/ld\\+json\"[^>]*>([\\s\\S]*?)<\\/script>/gi;\n    let jsonMatch;\n    while ((jsonMatch = jsonPat.exec(htmlContent)) !== null) {\n      try {\n        const obj = JSON.parse(jsonMatch[1]);\n        const walkJson = (o) => {\n          if (!o || typeof o !== 'object') return;\n          if (o['@type'] === 'Review' && o.reviewBody) addCandidate(o.reviewBody, 'jsonld');\n          if (Array.isArray(o)) o.forEach(walkJson);\n          else Object.values(o).forEach(v => { if (typeof v === 'object') walkJson(v); });\n        };\n        walkJson(obj);\n      } catch(e) {}\n    }\n    \n    // SOURCE 8: meta reviewBody\n    const metaPat = /<meta[^>]*itemprop=\"reviewBody\"[^>]*content=\"([^\"]{40,})\"[^>]*>/gi;\n    let metaMatch;\n    while ((metaMatch = metaPat.exec(htmlContent)) !== null) addCandidate(metaMatch[1], 'meta');\n    \n    // DEDUPLICATE AND SELECT BEST 3\n    allCandidates.sort((a, b) => b.length - a.length);\n    let finalReviews = [];\n    for (const candidate of allCandidates) {\n      if (finalReviews.length >= 3) break;\n      if (!finalReviews.some(r => areSimilar(r, candidate.text))) finalReviews.push(candidate.text);\n    }\n    \n    // Fallback\n    if (finalReviews.length === 0) {\n      const broadCandidates = [];\n      const broadPat = /<(?:span|div|p)[^>]*>([\\s\\S]{150,3000}?)<\\/(?:span|div|p)>/gi;\n      let broadMatch;\n      while ((broadMatch = broadPat.exec(htmlContent)) !== null) {\n        const raw = broadMatch[1];\n        if ((raw.match(/<[^>]+>/g) || []).length > 8) continue;\n        const cleaned = cleanReviewText(raw);\n        const reviewWords = /\\b(agent|realtor|property|home|house|listing|sale|purchase|buyer|seller|market|negotiation|closing|mortgage|neighborhood|recommend|professional|excellent|responsive|knowledgeable|helpful|trust|deal|offer|contract)\\b/i;\n        if (cleaned && cleaned.length >= 150 && reviewWords.test(cleaned)) broadCandidates.push(cleaned);\n      }\n      broadCandidates.sort((a, b) => b.length - a.length);\n      for (const text of broadCandidates) {\n        if (finalReviews.length >= 3) break;\n        if (!finalReviews.some(r => areSimilar(r, text))) finalReviews.push(text);\n      }\n    }\n    \n    review1 = finalReviews[0] || '';\n    review2 = finalReviews[1] || '';\n    review3 = finalReviews[2] || '';\n  }\n  \n  output.push({\n    json: {\n      businessName: businessName || originalData.businessName || '',\n      phone: phone || originalData.phone || '',\n      website: website || originalData.website || '',\n      rating: rating || '',\n      totalReviews: totalReviews || '',\n      address: address || originalData.address || '',\n      city: city || originalData.city || '',\n      mapUrl: originalData.mapUrl || '',\n      category: category || originalData.category || 'real estate agent',\n      lgbtqFriendly: lgbtqFriendly,\n      checkedAt: originalData.checkedAt || new Date().toISOString(),\n      review1: review1,\n      review2: review2,\n      review3: review3\n    }\n  });\n}\n\nreturn output;"
      },
      "typeVersion": 2
    },
    {
      "id": "9c09bd5e-dceb-45b4-9986-3e5aac436713",
      "name": "Read Previous Entries from Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2096,
        320
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1703015671,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1C7OAR6d6bngkrCw-On7zoIYY0QjobLVdDaP_d7u-hKU/edit#gid=1703015671",
          "cachedResultName": "Real Estate Agent Finder"
        },
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "https://docs.google.com/spreadsheets/d/1C7OAR6d6bngkrCw-On7zoIYY0QjobLVdDaP_d7u-hKU/edit?gid=0#gid=0"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4,
      "continueOnFail": true,
      "alwaysOutputData": true
    },
    {
      "id": "0e7e72f0-5ae3-45b4-938a-073e5b42fb8d",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        0
      ],
      "parameters": {
        "width": 672,
        "height": 864,
        "content": "# \ud83c\udfd8\ufe0f Real Estate Agent Finder \u2192 Google Sheets + Alerts\n\nThis workflow automates finding real estate agents in any city by scraping Google Maps. It performs a deep scrape to extract agent name, phone, website, rating, reviews, and address \u2014 cross-references results with Google Sheets to remove duplicates \u2014 then saves fresh leads and sends alerts via Gmail and Slack.\n\n### How it works\n1. \ud83d\udcdd **Form: Enter City to Search** captures the target city from a simple web form.\n2. \u2699\ufe0f **Set Google Maps Configuration** sets the search keyword and location parameters.\n3. \ud83c\udf10 **ScrapeOps: Search Google Maps** scrapes Google Maps for real estate agents via [ScrapeOps Proxy](https://scrapeops.io/docs/n8n/proxy-api/).\n4. \ud83d\udd0d **Parse Business Listings** extracts name, address, rating, and Maps URL from results.\n5. \ud83d\udce1 **ScrapeOps: Fetch Business Details** deep-scrapes each listing for phone, website, reviews, and more.\n6. \ud83d\uddfa\ufe0f **Parse Full Business Info** normalizes all fields into a clean structured record.\n7. \ud83d\udcc2 **Read Previous Entries from Sheet** loads existing leads to check for duplicates.\n8. \ud83e\uddf9 **Compare & Deduplicate Leads** filters out agents already saved in the sheet.\n9. \ud83d\udd00 **Filter New Leads Only** keeps only fresh, unseen results.\n10. \ud83d\udcbe **Save New Leads to Sheet** appends new leads to Google Sheets.\n11. \ud83d\udce7 **Send Gmail Alert** notifies you of new leads by email.\n12. \ud83d\udce3 **Send Slack Alert** posts a summary to your Slack channel.\n\n### Setup steps\n- Register for a free ScrapeOps API key: https://scrapeops.io/app/register/n8n\n- Add ScrapeOps credentials in n8n. Docs: https://scrapeops.io/docs/n8n/overview/\n- Duplicate the [Google Sheet template](https://docs.google.com/spreadsheets/d/1C7OAR6d6bngkrCw-On7zoIYY0QjobLVdDaP_d7u-hKU/edit?gid=0#gid=0) and connect it to the Sheet nodes.\n- Configure Gmail and Slack credentials for the alert nodes.\n- Open the form URL, enter a city, and run.\n\n### Customization\n- Change the `keyword` in **Set Google Maps Configuration** to find property managers, mortgage brokers, home inspectors, etc.\n- Replace the Form Trigger with a Schedule Trigger to run automatically on a recurring schedule."
      },
      "typeVersion": 1
    },
    {
      "id": "b2c42457-8b62-4c5e-a456-8b53aee4120a",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        720,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 336,
        "content": "## 1. Input & Configuration\nCapture the target city via form and set the Google Maps search keyword and parameters."
      },
      "typeVersion": 1
    },
    {
      "id": "9954d898-563b-4388-b597-e0a26eac0607",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2720,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 336,
        "content": "## 4. Save & Alert\nAppend new agent leads to Google Sheets and send notifications via Gmail and Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "9f3276ea-ed55-44ff-a0a0-2f4d8b5b8c35",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1152,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 880,
        "height": 336,
        "content": "## 2. Deep Scrape Google Maps\nSearch Maps for real estate agents via [ScrapeOps Proxy](https://scrapeops.io/docs/n8n/proxy-api/), then deep-scrape each listing for phone, website, reviews, and address."
      },
      "typeVersion": 1
    },
    {
      "id": "2823c212-05bd-4d4c-bc29-53c2f3a76456",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2048,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 656,
        "height": 336,
        "content": "## 3. Deduplicate Leads\nLoad existing sheet entries, compare against new results, and keep only agents not previously saved."
      },
      "typeVersion": 1
    },
    {
      "id": "7329e920-df75-4a7b-bc5f-e9102c78c6f5",
      "name": "Form: Enter City to Search",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        752,
        320
      ],
      "parameters": {
        "path": "3cc8189c-deb7-496c-9962-7ba89dd92a94",
        "options": {},
        "formTitle": "Tell Which City",
        "formFields": {
          "values": [
            {
              "fieldLabel": "City Name",
              "requiredField": true
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "676cff78-9de6-4d99-9dcb-424325a590f1",
      "name": "ScrapeOps: Search Google Maps",
      "type": "@scrapeops/n8n-nodes-scrapeops.ScrapeOps",
      "position": [
        1200,
        320
      ],
      "parameters": {
        "url": "={{ $json.searchUrl }}",
        "returnType": "htmlResponse",
        "advancedOptions": {
          "wait": "=12000",
          "render_js": true,
          "residential_proxy": false
        }
      },
      "credentials": {
        "scrapeOpsApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "56d2d3bf-3398-42b5-bd85-f8c07ac2240f",
      "name": "Parse Business Listings",
      "type": "n8n-nodes-base.code",
      "position": [
        1424,
        320
      ],
      "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 || 'real estate agent',\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 || 'real estate agent',\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": "53119f38-dd63-42e3-9062-1b4529956cce",
      "name": "ScrapeOps: Fetch Business Details",
      "type": "@scrapeops/n8n-nodes-scrapeops.ScrapeOps",
      "position": [
        1648,
        320
      ],
      "parameters": {
        "url": "={{ $json.mapUrl }}",
        "returnType": "htmlResponse",
        "advancedOptions": {
          "wait": "=12000",
          "render_js": true,
          "residential_proxy": false
        }
      },
      "credentials": {
        "scrapeOpsApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "218438d3-6f88-49b7-b11d-9b2d92f74454",
      "name": "Compare & Deduplicate Leads",
      "type": "n8n-nodes-base.code",
      "position": [
        2320,
        320
      ],
      "parameters": {
        "jsCode": "// Helper function to normalize phone numbers (remove all non-digit characters)\nfunction normalizePhone(phone) {\n  if (!phone) return '';\n  let normalized = String(phone).replace(/[^0-9+]/g, '');\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\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\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: 'real estate agent',\n      lgbtqFriendly: false,\n      status: 'old',\n      checkedAt: new Date().toISOString(),\n      error: String(error.message || 'Unknown error')\n    }\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: 'real estate agent',\n      lgbtqFriendly: false,\n      status: 'old',\n      checkedAt: new Date().toISOString()\n    }\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        lgbtqFriendly: json.lgbtqFriendly || json.LgbtqFriendly || json['Lgbtq Friendly'] || false,\n        status: String(json.status || json.Status || json['Status'] || '')\n      };\n    }).

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 finding real estate agents in any city using Google Maps. It deep-scrapes listings via ScrapeOps Proxy, deduplicates results against Google Sheets, saves fresh leads, and sends alerts via Gmail and Slack — all triggered from a simple web form. Real…

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

More AI & RAG workflows → · Browse all categories →

Related workflows

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

AI & RAG

Automate your lead intake, scoring, and outreach pipeline. This workflow collects leads from forms, enriches and scores them using Relevance AI, routes them by quality, and triggers the right follow-u

Form Trigger, HTTP Request, Chain Llm +6
AI & RAG

This workflow contains community nodes that are only compatible with the self-hosted version of n8n.

Google Sheets, Form Trigger, Output Parser Structured +7
AI & RAG

Whether you’re a product manager, developer, or simply curious about workflow automation, you’re in the right place. This n8n workflow is designed to help you streamline and automate your social media

Output Parser Structured, OpenAI Chat, LinkedIn +8
AI & RAG

Triggered every 4 hours (or manually) to check all active products in Google Sheets Each product is evaluated for stock level and urgency against its reorder threshold Products with sufficient stock a

Form Trigger, Groq Chat, Gmail +5
AI & RAG

New hire submits an onboarding form with their details Groq AI generates personalised welcome content, Slack messages, and IT access request Welcome email sent to the new hire via Gmail Automated emai

Form Trigger, Groq Chat, Gmail +5