{"workflow":{"id":14111,"name":"Scrape Google Maps realtor leads with ScrapeOps, Google Sheets, Gmail and Slack","views":16,"recentViews":1,"totalViews":16,"createdAt":"2026-03-17T11:53:01.143Z","description":"## Overview\nThis 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.\n\n## Who is this for?\n- Real estate marketing agencies building targeted agent lead lists\n- PropTech companies prospecting realtors in new cities or regions\n- Sales teams running outreach campaigns targeting real estate agencies\n- Anyone who needs structured local real estate agent data from Google Maps\n\n## What problem does it solve?\nManually searching Google Maps for real estate agents, copying contact details, and checking for duplicates is slow and inconsistent. This workflow automates the entire process — from search to deduplication to saved leads — so you get fresh, structured agent data with zero manual effort.\n\n## How it works\n1. A web form captures the target city.\n2. ScrapeOps Proxy scrapes Google Maps for real estate agents in that city.\n3. Each listing is deep-scraped for full details: phone, website, rating, reviews, and address.\n4. Results are compared against existing Google Sheet entries to remove duplicates.\n5. Only new leads are saved to the sheet.\n6. Gmail and Slack alerts notify you of new leads instantly.\n\n## Set up steps (~10–15 minutes)\n1. Register for a free ScrapeOps API key: [https://scrapeops.io/app/register/n8n](https://scrapeops.io/app/register/n8n)\n2. Add ScrapeOps credentials in n8n. Docs: [https://scrapeops.io/docs/n8n/overview/](https://scrapeops.io/docs/n8n/overview/)\n3. Duplicate the [Google Sheet template](https://docs.google.com/spreadsheets/d/1C7OAR6d6bngkrCw-On7zoIYY0QjobLVdDaP_d7u-hKU/) and connect it to the **Read Previous Entries** and **Save New Leads** nodes.\n4. Configure Gmail credentials in the **Send Gmail Alert** node and set your recipient.\n5. Configure Slack credentials in the **Send Slack Alert** node and set your channel.\n6. Open the form URL, enter a city, and run.\n\n## Pre-conditions\n- Active ScrapeOps account (free tier available): https://scrapeops.io/app/register/n8n\n- ScrapeOps community node installed in n8n: https://scrapeops.io/docs/n8n/overview/\n- Google Sheets credentials configured in n8n\n- Duplicated [Google Sheet template](https://docs.google.com/spreadsheets/d/1C7OAR6d6bngkrCw-On7zoIYY0QjobLVdDaP_d7u-hKU/) with correct column headers\n- Gmail credentials for alert emails\n- Slack credentials for channel notifications\n\n## Disclaimer\nThis template uses [ScrapeOps](https://n8n.io/integrations/scrapeops/) as a community node. You are responsible for complying with Google's Terms of Use, robots.txt directives, and applicable laws in your jurisdiction. Scraping targets may change at any time; adjust render, scroll, and wait settings and parsers as needed. Use responsibly and only for legitimate business purposes.","workflow":{"id":"qT0dWRnlfvEroIK5","meta":{"instanceId":"c2ff056313a72210aa803da7c5191a260dbed0dab6ae2b8e39a8dd21701bf0ab","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(/[★⭐☆✩✫✬✭✮✯✰]/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*[·•]\\s*\\d+\\s+reviews?\\s*[·•]?\\s*(\\d+\\s+photos?)?/gi, '');\n      text = text.replace(/\\d+\\s+reviews?\\s*[·•]\\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":{"id":"ScA4DXJowherOrNG","name":"Google Sheets account 4"}},"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":"# 🏘️ Real Estate Agent Finder → 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 — cross-references results with Google Sheets to remove duplicates — then saves fresh leads and sends alerts via Gmail and Slack.\n\n### How it works\n1. 📝 **Form: Enter City to Search** captures the target city from a simple web form.\n2. ⚙️ **Set Google Maps Configuration** sets the search keyword and location parameters.\n3. 🌐 **ScrapeOps: Search Google Maps** scrapes Google Maps for real estate agents via [ScrapeOps Proxy](https://scrapeops.io/docs/n8n/proxy-api/).\n4. 🔍 **Parse Business Listings** extracts name, address, rating, and Maps URL from results.\n5. 📡 **ScrapeOps: Fetch Business Details** deep-scrapes each listing for phone, website, reviews, and more.\n6. 🗺️ **Parse Full Business Info** normalizes all fields into a clean structured record.\n7. 📂 **Read Previous Entries from Sheet** loads existing leads to check for duplicates.\n8. 🧹 **Compare & Deduplicate Leads** filters out agents already saved in the sheet.\n9. 🔀 **Filter New Leads Only** keeps only fresh, unseen results.\n10. 💾 **Save New Leads to Sheet** appends new leads to Google Sheets.\n11. 📧 **Send Gmail Alert** notifies you of new leads by email.\n12. 📣 **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],"webhookId":"3cc8189c-deb7-496c-9962-7ba89dd92a94","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":{"id":"4LFpuzt1jWwhSJBj","name":"ScrapeOps account"}},"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":{"id":"4LFpuzt1jWwhSJBj","name":"ScrapeOps account"}},"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    }).filter(prev => prev.businessName && String(prev.businessName).length > 0);\n  }\n} catch (error) {\n  previousItems = [];\n}\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    const key = `${normalizedName}||${normalizedPhone}`;\n    prevKeys.add(key);\n    if (normalizedPhone && normalizedPhone.length > 0) {\n      prevKeys.add(normalizedName);\n    }\n  }\n});\n\n// Process current items and mark as new or old\nconst results = [];\nconst seen = new Set();\n\ncurrentItems.forEach(item => {\n  const originalBusinessName = String(item.businessName || '').trim();\n  const originalPhone = String(item.phone || '').trim();\n  \n  const normalizedName = normalizeBusinessName(originalBusinessName);\n  const normalizedPhone = normalizePhone(originalPhone);\n  \n  const currentKey = `${normalizedName}||${normalizedPhone}`;\n  \n  if (seen.has(currentKey)) {\n    return;\n  }\n  seen.add(currentKey);\n  \n  let isExisting = false;\n  if (normalizedPhone && normalizedPhone.length > 0) {\n    const exactKey = `${normalizedName}||${normalizedPhone}`;\n    isExisting = prevKeys.has(exactKey);\n  }\n  \n  if (!isExisting && normalizedName && normalizedName.length > 0) {\n    isExisting = prevKeys.has(normalizedName);\n  }\n  \n  if (!isExisting) {\n    for (const prev of previousItems) {\n      const prevNormalizedName = normalizeBusinessName(prev.businessName);\n      const prevNormalizedPhone = normalizePhone(prev.phone);\n      \n      if (normalizedName === prevNormalizedName) {\n        if (normalizedPhone && normalizedPhone.length > 0 && prevNormalizedPhone && prevNormalizedPhone.length > 0) {\n          if (normalizedPhone === prevNormalizedPhone) {\n            isExisting = true;\n            break;\n          }\n        } else {\n          isExisting = true;\n          break;\n        }\n      }\n    }\n  }\n  \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 || 'real estate agent'),\n      lgbtqFriendly: item.lgbtqFriendly || false,\n      status: status,\n      checkedAt: String(item.checkedAt || new Date().toISOString()),\n      review1: String(item.review1 || ''),\n      review2: String(item.review2 || ''),\n      review3: String(item.review3 || '')\n    }\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    mapUrl: '',\n    category: 'real estate agent',\n    lgbtqFriendly: false,\n    status: 'old',\n    checkedAt: new Date().toISOString()\n  }\n}];"},"typeVersion":2},{"id":"c7f93646-8a4c-4afe-a613-4495eee1f218","name":"Filter New Leads Only","type":"n8n-nodes-base.if","position":[2544,320],"parameters":{"conditions":{"string":[{"value1":"={{ $json.status }}","value2":"new"}]}},"typeVersion":1},{"id":"2b922787-6e81-4c99-8a4c-51b8cfd17fe4","name":"Save New Leads to Sheet","type":"n8n-nodes-base.googleSheets","position":[2768,320],"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":"lgbtqFriendly","type":"string","display":true,"removed":false,"required":false,"displayName":"lgbtqFriendly","defaultMatch":false,"canBeUsedToMatch":true},{"id":"status","type":"string","display":true,"required":false,"displayName":"status","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":1703015671,"cachedResultUrl":"https://docs.google.com/spreadsheets/d/1C7OAR6d6bngkrCw-On7zoIYY0QjobLVdDaP_d7u-hKU/edit?gid=1703015671#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":{"id":"ScA4DXJowherOrNG","name":"Google Sheets account 4"}},"executeOnce":false,"typeVersion":4.4,"continueOnFail":true},{"id":"b803208f-0943-450b-b971-3efaaa0a09a9","name":" Send Gmail Alert","type":"n8n-nodes-base.gmail","position":[2992,320],"webhookId":"c77008bc-a12c-4d59-b796-8e9e5371b273","parameters":{"sendTo":"example@.com","message":"={{ 'New Real Estate Agent Found\\n\\n' + 'Sheet URL: ' + 'https://docs.google.com/spreadsheets/d/1C7OAR6d6bngkrCw-On7zoIYY0QjobLVdDaP_d7u-hKU/edit?gid=0#gid=0' }}","options":{"appendAttribution":false},"subject":"={{ '📍 New Businesses Found in ' + $('Set Google Maps Configuration').first().json.city + ' (Real Estate Agent)' }}","emailType":"text"},"credentials":{"gmailOAuth2":{"id":"d8zb1gCoME4ylSXg","name":"Gmail connection"}},"executeOnce":true,"typeVersion":2.1},{"id":"f1f3e2d0-48a9-4439-978c-271b61fb0995","name":"Send Slack Alert","type":"n8n-nodes-base.slack","position":[3216,320],"webhookId":"be63c5e2-c3d0-48c4-9027-9bd032a84e15","parameters":{"text":"New Real Estate Agent Found: https://docs.google.com/spreadsheets/d/1C7OAR6d6bngkrCw-On7zoIYY0QjobLVdDaP_d7u-hKU/edit?gid=0#gid=0","user":{"__rl":true,"mode":"id","value":"U08P301T9HA"},"select":"user","otherOptions":{"includeLinkToWorkflow":true}},"credentials":{"slackApi":{"id":"EwbHeK0JfdT5thgy","name":"Slack account ScrapeOpsApp"}},"executeOnce":true,"typeVersion":2.3}],"active":false,"pinData":{},"settings":{"executionOrder":"v1"},"versionId":"1faca02b-eecf-42c0-a39f-ac0f03016c60","connections":{" Send Gmail Alert":{"main":[[{"node":"Send Slack Alert","type":"main","index":0}]]},"Filter New Leads Only":{"main":[[{"node":"Save New Leads to Sheet","type":"main","index":0}]]},"Parse Business Listings":{"main":[[{"node":"ScrapeOps: Fetch Business Details","type":"main","index":0}]]},"Save New Leads to Sheet":{"main":[[{"node":" Send Gmail Alert","type":"main","index":0}]]},"Parse Full Business Info":{"main":[[{"node":"Read Previous Entries from Sheet","type":"main","index":0}]]},"Form: Enter City to Search":{"main":[[{"node":"Set Google Maps Configuration","type":"main","index":0}]]},"Compare & Deduplicate Leads":{"main":[[{"node":"Filter New Leads Only","type":"main","index":0}]]},"ScrapeOps: Search Google Maps":{"main":[[{"node":"Parse Business Listings","type":"main","index":0}]]},"Set Google Maps Configuration":{"main":[[{"node":"ScrapeOps: Search Google Maps","type":"main","index":0}]]},"Read Previous Entries from Sheet":{"main":[[{"node":"Compare & Deduplicate Leads","type":"main","index":0}]]},"ScrapeOps: Fetch Business Details":{"main":[[{"node":"Parse Full Business Info","type":"main","index":0}]]}}},"lastUpdatedBy":1,"workflowInfo":{"nodeCount":17,"nodeTypes":{"n8n-nodes-base.if":{"count":1},"n8n-nodes-base.set":{"count":1},"n8n-nodes-base.code":{"count":3},"n8n-nodes-base.gmail":{"count":1},"n8n-nodes-base.slack":{"count":1},"n8n-nodes-base.stickyNote":{"count":5},"n8n-nodes-base.formTrigger":{"count":1},"n8n-nodes-base.googleSheets":{"count":2},"@scrapeops/n8n-nodes-scrapeops.ScrapeOps":{"count":2}}},"status":"published","readyToDemo":null,"user":{"name":"Ian Kerins","username":"iankerins","bio":"","verified":true,"links":["x.com/ianjkerins"],"avatar":"https://gravatar.com/avatar/890cb31fc440a02555fafa9eb072fb2282af0d51f9239c04e356c5eeda68be76?r=pg&d=retro&size=200"},"nodes":[{"id":18,"icon":"file:googleSheets.svg","name":"n8n-nodes-base.googleSheets","codex":{"data":{"alias":["CSV","Sheet","Spreadsheet","GS"],"resources":{"generic":[{"url":"https://n8n.io/blog/love-at-first-sight-ricardos-n8n-journey/","icon":"❤️","label":"Love at first sight: Ricardo’s n8n journey"},{"url":"https://n8n.io/blog/why-business-process-automation-with-n8n-can-change-your-daily-life/","icon":"🧬","label":"Why business process automation with n8n can change your daily life"},{"url":"https://n8n.io/blog/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n/","icon":"🧾","label":"Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n"},{"url":"https://n8n.io/blog/supercharging-your-conference-registration-process-with-n8n/","icon":"🎫","label":"Supercharging your conference registration process with n8n"},{"url":"https://n8n.io/blog/creating-triggers-for-n8n-workflows-using-polling/","icon":"⏲","label":"Creating triggers for n8n workflows using polling"},{"url":"https://n8n.io/blog/no-code-ecommerce-workflow-automations/","icon":"store","label":"6 e-commerce workflows to power up your Shopify s"},{"url":"https://n8n.io/blog/migrating-community-metrics-to-orbit-using-n8n/","icon":"📈","label":"Migrating Community Metrics to Orbit using n8n"},{"url":"https://n8n.io/blog/automate-google-apps-for-productivity/","icon":"💡","label":"15 Google apps you can combine and automate to increase productivity"},{"url":"https://n8n.io/blog/your-business-doesnt-need-you-to-operate/","icon":" 🖥️","label":"Hey founders! Your business doesn't need you to operate"},{"url":"https://n8n.io/blog/how-honest-burgers-use-automation-to-save-100k-per-year/","icon":"🍔","label":"How Honest Burgers Use Automation to Save $100k per year"},{"url":"https://n8n.io/blog/how-a-digital-strategist-uses-n8n-for-online-marketing/","icon":"💻","label":"How a digital strategist uses n8n for online marketing"},{"url":"https://n8n.io/blog/why-this-product-manager-loves-workflow-automation-with-n8n/","icon":"🧠","label":"Why this Product Manager loves workflow automation with n8n"},{"url":"https://n8n.io/blog/sending-automated-congratulations-with-google-sheets-twilio-and-n8n/","icon":"🙌","label":"Sending Automated Congratulations with Google Sheets, Twilio, and n8n "},{"url":"https://n8n.io/blog/how-a-membership-development-manager-automates-his-work-and-investments/","icon":"📈","label":"How a Membership Development Manager automates his work and investments"},{"url":"https://n8n.io/blog/aws-workflow-automation/","label":"7 no-code workflow automations for Amazon Web Services"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlesheets/"}],"credentialDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"}]},"categories":["Data & Storage","Productivity"],"nodeVersion":"1.0","codexVersion":"1.0"}},"group":"[\"input\",\"output\"]","defaults":{"name":"Google Sheets"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGZpbGw9IiMyOEI0NDYiIGQ9Ik0zNS42OSAxIDUyIDE3LjIyNXYzOS4wODdhMy42NyAzLjY3IDAgMCAxLTEuMDg0IDIuNjFBMy43IDMuNyAwIDAgMSA0OC4yOTMgNjBIMTIuNzA3YTMuNyAzLjcgMCAwIDEtMi42MjMtMS4wNzhBMy42NyAzLjY3IDAgMCAxIDkgNTYuMzEyVjQuNjg4YTMuNjcgMy42NyAwIDAgMSAxLjA4NC0yLjYxQTMuNyAzLjcgMCAwIDEgMTIuNzA3IDF6Ii8+PHBhdGggZmlsbD0iIzZBQ0U3QyIgZD0iTTM1LjY5IDEgNTIgMTcuMjI1SDM5LjM5N2MtMi4wNTQgMC0zLjcwNy0xLjgyOS0zLjcwNy0zLjg3MnoiLz48cGF0aCBmaWxsPSIjMjE5QjM4IiBkPSJNMzkuMjExIDE3LjIyNSA1MiAyMi40OHYtNS4yNTV6Ii8+PHBhdGggZmlsbD0iI0ZGRiIgZD0iTTIwLjEyIDMxLjk3NWMwLS44MTcuNjYyLTEuNDc1IDEuNDgzLTEuNDc1aDE3Ljc5NGMuODIxIDAgMS40ODIuNjU4IDEuNDgyIDEuNDc1djE1LjQ4N2MwIC44MTgtLjY2MSAxLjQ3NS0xLjQ4MiAxLjQ3NUgyMS42MDNhMS40NzYgMS40NzYgMCAwIDEtMS40ODItMS40NzRWMzEuOTc0em0yLjIyNSAxLjQ3NWg2LjY3MnYyLjIxMmgtNi42NzJ6bTAgNS4xNjJoNi42NzJ2Mi4yMTNoLTYuNjcyem0wIDUuMTYzaDYuNjcydjIuMjEyaC02LjY3MnptOS42MzgtMTAuMzI1aDYuNjcydjIuMjEyaC02LjY3MnptMCA1LjE2Mmg2LjY3MnYyLjIxM2gtNi42NzJ6bTAgNS4xNjNoNi42NzJ2Mi4yMTJoLTYuNjcyeiIvPjxwYXRoIGZpbGw9IiMyOEI0NDYiIGQ9Ik0zNC42OSAwIDUxIDE2LjIyNXYzOS4wODdhMy42NyAzLjY3IDAgMCAxLTEuMDg0IDIuNjFBMy43IDMuNyAwIDAgMSA0Ny4yOTMgNTlIMTEuNzA3YTMuNyAzLjcgMCAwIDEtMi42MjMtMS4wNzhBMy42NyAzLjY3IDAgMCAxIDggNTUuMzEyVjMuNjg4YTMuNjcgMy42NyAwIDAgMSAxLjA4NC0yLjYxQTMuNyAzLjcgMCAwIDEgMTEuNzA3IDB6Ii8+PHBhdGggZmlsbD0iIzZBQ0U3QyIgZD0iTTM0LjY5IDAgNTEgMTYuMjI1SDM4LjM5N2MtMi4wNTQgMC0zLjcwNy0xLjgyOS0zLjcwNy0zLjg3MnoiLz48cGF0aCBmaWxsPSIjMjE5QjM4IiBkPSJNMzguMjExIDE2LjIyNSA1MSAyMS40OHYtNS4yNTV6Ii8+PHBhdGggZmlsbD0iI0ZGRiIgZD0iTTE5LjEyIDMwLjk3NWMwLS44MTcuNjYyLTEuNDc1IDEuNDgzLTEuNDc1aDE3Ljc5NGMuODIxIDAgMS40ODIuNjU4IDEuNDgyIDEuNDc1djE1LjQ4N2MwIC44MTgtLjY2MSAxLjQ3NS0xLjQ4MiAxLjQ3NUgyMC42MDNhMS40NzYgMS40NzYgMCAwIDEtMS40ODItMS40NzRWMzAuOTc0em0yLjIyNSAxLjQ3NWg2LjY3MnYyLjIxMmgtNi42NzJ6bTAgNS4xNjJoNi42NzJ2Mi4yMTNoLTYuNjcyem0wIDUuMTYzaDYuNjcydjIuMjEyaC02LjY3MnptOS42MzgtMTAuMzI1aDYuNjcydjIuMjEyaC02LjY3MnptMCA1LjE2Mmg2LjY3MnYyLjIxM2gtNi42NzJ6bTAgNS4xNjNoNi42NzJ2Mi4yMTJoLTYuNjcyeiIvPjwvZz48L3N2Zz4="},"displayName":"Google Sheets","typeVersion":5,"nodeCategories":[{"id":3,"name":"Data & Storage"},{"id":4,"name":"Productivity"}]},{"id":20,"icon":"fa:map-signs","name":"n8n-nodes-base.if","codex":{"data":{"alias":["Router","Filter","Condition","Logic","Boolean","Branch"],"details":"The IF node can be used to implement binary conditional logic in your workflow. You can set up one-to-many conditions to evaluate each item of data being inputted into the node. That data will either evaluate to TRUE or FALSE and route out of the node accordingly.\n\nThis node has multiple types of conditions: Bool, String, Number, and Date & Time.","resources":{"generic":[{"url":"https://n8n.io/blog/learn-to-automate-your-factorys-incident-reporting-a-step-by-step-guide/","icon":"🏭","label":"Learn to Automate Your Factory's Incident Reporting: A Step by Step Guide"},{"url":"https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/","icon":"☀️","label":"2021: The Year to Automate the New You with n8n"},{"url":"https://n8n.io/blog/why-business-process-automation-with-n8n-can-change-your-daily-life/","icon":"🧬","label":"Why business process automation with n8n can change your daily life"},{"url":"https://n8n.io/blog/create-a-toxic-language-detector-for-telegram/","icon":"🤬","label":"Create a toxic language detector for Telegram in 4 step"},{"url":"https://n8n.io/blog/no-code-ecommerce-workflow-automations/","icon":"store","label":"6 e-commerce workflows to power up your Shopify s"},{"url":"https://n8n.io/blog/how-to-build-a-low-code-self-hosted-url-shortener/","icon":"🔗","label":"How to build a low-code, self-hosted URL shortener in 3 steps"},{"url":"https://n8n.io/blog/automate-your-data-processing-pipeline-in-9-steps-with-n8n/","icon":"⚙️","label":"Automate your data processing pipeline in 9 steps"},{"url":"https://n8n.io/blog/how-to-get-started-with-crm-automation-and-no-code-workflow-ideas/","icon":"👥","label":"How to get started with CRM automation (with 3 no-code workflow ideas"},{"url":"https://n8n.io/blog/5-tasks-you-can-automate-with-notion-api/","icon":"⚡️","label":"5 tasks you can automate with the new Notion API "},{"url":"https://n8n.io/blog/automate-google-apps-for-productivity/","icon":"💡","label":"15 Google apps you can combine and automate to increase productivity"},{"url":"https://n8n.io/blog/automation-for-maintainers-of-open-source-projects/","icon":"🏷️","label":"How to automatically manage contributions to open-source projects"},{"url":"https://n8n.io/blog/how-uproc-scraped-a-multi-page-website-with-a-low-code-workflow/","icon":" 🕸️","label":"How uProc scraped a multi-page website with a low-code workflow"},{"url":"https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/","icon":"🤖","label":"5 workflow automations for Mattermost that we love at n8n"},{"url":"https://n8n.io/blog/why-this-product-manager-loves-workflow-automation-with-n8n/","icon":"🧠","label":"Why this Product Manager loves workflow automation with n8n"},{"url":"https://n8n.io/blog/sending-automated-congratulations-with-google-sheets-twilio-and-n8n/","icon":"🙌","label":"Sending Automated Congratulations with Google Sheets, Twilio, and n8n "},{"url":"https://n8n.io/blog/how-to-set-up-a-ci-cd-pipeline-with-no-code/","icon":"🎡","label":"How to set up a no-code CI/CD pipeline with GitHub and TravisCI"},{"url":"https://n8n.io/blog/benefits-of-automation-and-n8n-an-interview-with-hubspots-hugh-durkin/","icon":"🎖","label":"Benefits of automation and n8n: An interview with HubSpot's Hugh Durkin"},{"url":"https://n8n.io/blog/aws-workflow-automation/","label":"7 no-code workflow automations for Amazon Web Services"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.if/"}]},"categories":["Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Flow"]}}},"group":"[\"transform\"]","defaults":{"name":"If","color":"#408000"},"iconData":{"icon":"map-signs","type":"icon"},"displayName":"If","typeVersion":2,"nodeCategories":[{"id":9,"name":"Core Nodes"}]},{"id":38,"icon":"fa:pen","name":"n8n-nodes-base.set","codex":{"data":{"alias":["Set","JS","JSON","Filter","Transform","Map"],"resources":{"generic":[{"url":"https://n8n.io/blog/learn-to-automate-your-factorys-incident-reporting-a-step-by-step-guide/","icon":"🏭","label":"Learn to Automate Your Factory's Incident Reporting: A Step by Step Guide"},{"url":"https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/","icon":"☀️","label":"2021: The Year to Automate the New You with n8n"},{"url":"https://n8n.io/blog/automatically-pulling-and-visualizing-data-with-n8n/","icon":"📈","label":"Automatically pulling and visualizing data with n8n"},{"url":"https://n8n.io/blog/database-monitoring-and-alerting-with-n8n/","icon":"📡","label":"Database Monitoring and Alerting with n8n"},{"url":"https://n8n.io/blog/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n/","icon":"🧾","label":"Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n"},{"url":"https://n8n.io/blog/no-code-ecommerce-workflow-automations/","icon":"store","label":"6 e-commerce workflows to power up your Shopify s"},{"url":"https://n8n.io/blog/how-to-build-a-low-code-self-hosted-url-shortener/","icon":"🔗","label":"How to build a low-code, self-hosted URL shortener in 3 steps"},{"url":"https://n8n.io/blog/automate-your-data-processing-pipeline-in-9-steps-with-n8n/","icon":"⚙️","label":"Automate your data processing pipeline in 9 steps"},{"url":"https://n8n.io/blog/how-to-get-started-with-crm-automation-and-no-code-workflow-ideas/","icon":"👥","label":"How to get started with CRM automation (with 3 no-code workflow ideas"},{"url":"https://n8n.io/blog/5-tasks-you-can-automate-with-notion-api/","icon":"⚡️","label":"5 tasks you can automate with the new Notion API "},{"url":"https://n8n.io/blog/automate-google-apps-for-productivity/","icon":"💡","label":"15 Google apps you can combine and automate to increase productivity"},{"url":"https://n8n.io/blog/how-uproc-scraped-a-multi-page-website-with-a-low-code-workflow/","icon":" 🕸️","label":"How uProc scraped a multi-page website with a low-code workflow"},{"url":"https://n8n.io/blog/building-an-expense-tracking-app-in-10-minutes/","icon":"📱","label":"Building an expense tracking app in 10 minutes"},{"url":"https://n8n.io/blog/the-ultimate-guide-to-automate-your-video-collaboration-with-whereby-mattermost-and-n8n/","icon":"📹","label":"The ultimate guide to automate your video collaboration with Whereby, Mattermost, and n8n"},{"url":"https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/","icon":"🤖","label":"5 workflow automations for Mattermost that we love at n8n"},{"url":"https://n8n.io/blog/learn-to-build-powerful-api-endpoints-using-webhooks/","icon":"🧰","label":"Learn to Build Powerful API Endpoints Using Webhooks"},{"url":"https://n8n.io/blog/how-a-membership-development-manager-automates-his-work-and-investments/","icon":"📈","label":"How a Membership Development Manager automates his work and investments"},{"url":"https://n8n.io/blog/a-low-code-bitcoin-ticker-built-with-questdb-and-n8n-io/","icon":"📈","label":"A low-code bitcoin ticker built with QuestDB and n8n.io"},{"url":"https://n8n.io/blog/how-to-set-up-a-ci-cd-pipeline-with-no-code/","icon":"🎡","label":"How to set up a no-code CI/CD pipeline with GitHub and TravisCI"},{"url":"https://n8n.io/blog/benefits-of-automation-and-n8n-an-interview-with-hubspots-hugh-durkin/","icon":"🎖","label":"Benefits of automation and n8n: An interview with HubSpot's Hugh Durkin"},{"url":"https://n8n.io/blog/how-goomer-automated-their-operations-with-over-200-n8n-workflows/","icon":"🛵","label":"How Goomer automated their operations with over 200 n8n workflows"},{"url":"https://n8n.io/blog/aws-workflow-automation/","label":"7 no-code workflow automations for Amazon Web Services"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.set/"}]},"categories":["Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Data Transformation"]}}},"group":"[\"input\"]","defaults":{"name":"Edit Fields"},"iconData":{"icon":"pen","type":"icon"},"displayName":"Edit Fields (Set)","typeVersion":3,"nodeCategories":[{"id":9,"name":"Core Nodes"}]},{"id":40,"icon":"file:slack.svg","name":"n8n-nodes-base.slack","codex":{"data":{"alias":["human","form","wait","hitl","approval"],"resources":{"generic":[{"url":"https://n8n.io/blog/no-code-ecommerce-workflow-automations/","icon":"store","label":"6 e-commerce workflows to power up your Shopify s"},{"url":"https://n8n.io/blog/automate-your-data-processing-pipeline-in-9-steps-with-n8n/","icon":"⚙️","label":"Automate your data processing pipeline in 9 steps"},{"url":"https://n8n.io/blog/how-to-get-started-with-crm-automation-and-no-code-workflow-ideas/","icon":"👥","label":"How to get started with CRM automation (with 3 no-code workflow ideas"},{"url":"https://n8n.io/blog/5-tasks-you-can-automate-with-notion-api/","icon":"⚡️","label":"5 tasks you can automate with the new Notion API "},{"url":"https://n8n.io/blog/build-your-own-virtual-assistant-with-n8n-a-step-by-step-guide/","icon":"👦","label":"Build your own virtual assistant with n8n: A step by step guide"},{"url":"https://n8n.io/blog/how-to-automatically-give-kudos-to-contributors-with-github-slack-and-n8n/","icon":"👏","label":"How to automatically give kudos to contributors with GitHub, Slack, and n8n"},{"url":"https://n8n.io/blog/automations-for-activists/","icon":"✨","label":"How Common Knowledge use workflow automation for activism"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.slack/"}],"credentialDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/credentials/slack/"}]},"categories":["Communication","HITL"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"HITL":["Human in the Loop"]}}},"group":"[\"output\"]","defaults":{"name":"Slack"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB2aWV3Qm94PSIwIDAgMTUwLjg1MiAxNTAuODUyIj48dXNlIHhsaW5rOmhyZWY9IiNhIiB4PSIuOTI2IiB5PSIuOTI2Ii8+PHN5bWJvbCBpZD0iYSIgb3ZlcmZsb3c9InZpc2libGUiPjxnIHN0cm9rZS13aWR0aD0iMS44NTIiPjxwYXRoIGZpbGw9IiNlMDFlNWEiIHN0cm9rZT0iI2UwMWU1YSIgZD0iTTQwLjc0MSA5My41NWMwLTguNzM1IDYuNjA3LTE1Ljc3MiAxNC44MTUtMTUuNzcyczE0LjgxNSA3LjAzNyAxNC44MTUgMTUuNzcydjM4LjgyNGMwIDguNzM3LTYuNjA3IDE1Ljc3NC0xNC44MTUgMTUuNzc0cy0xNC44MTUtNy4wMzctMTQuODE1LTE1Ljc3MnoiLz48cGF0aCBmaWxsPSIjZWNiMjJkIiBzdHJva2U9IiNlY2IyMmQiIGQ9Ik05My41NSAxMDcuNDA4Yy04LjczNSAwLTE1Ljc3Mi02LjYwNy0xNS43NzItMTQuODE1czcuMDM3LTE0LjgxNSAxNS43NzItMTQuODE1aDM4LjgyNmM4LjczNSAwIDE1Ljc3MiA2LjYwNyAxNS43NzIgMTQuODE1cy03LjAzNyAxNC44MTUtMTUuNzcyIDE0LjgxNXoiLz48cGF0aCBmaWxsPSIjMmZiNjdjIiBzdHJva2U9IiMyZmI2N2MiIGQ9Ik03Ny43NzggMTUuNzcyQzc3Ljc3OCA3LjAzNyA4NC4zODUgMCA5Mi41OTMgMHMxNC44MTUgNy4wMzcgMTQuODE1IDE1Ljc3MnYzOC44MjZjMCA4LjczNS02LjYwNyAxNS43NzItMTQuODE1IDE1Ljc3MnMtMTQuODE1LTcuMDM3LTE0LjgxNS0xNS43NzJ6Ii8+PHBhdGggZmlsbD0iIzM2YzVmMSIgc3Ryb2tlPSIjMzZjNWYxIiBkPSJNMTUuNzcyIDcwLjM3MUM3LjAzNyA3MC4zNzEgMCA2My43NjMgMCA1NS41NTZzNy4wMzctMTQuODE1IDE1Ljc3Mi0xNC44MTVoMzguODI2YzguNzM1IDAgMTUuNzcyIDYuNjA3IDE1Ljc3MiAxNC44MTVzLTcuMDM3IDE0LjgxNS0xNS43NzIgMTQuODE1eiIvPjxnIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiPjxwYXRoIGZpbGw9IiNlY2IyMmQiIHN0cm9rZT0iI2VjYjIyZCIgZD0iTTc3Ljc3OCAxMzMuMzMzYzAgOC4yMDggNi42MDcgMTQuODE1IDE0LjgxNSAxNC44MTVzMTQuODE1LTYuNjA3IDE0LjgxNS0xNC44MTUtNi42MDctMTQuODE1LTE0LjgxNS0xNC44MTVINzcuNzc4eiIvPjxwYXRoIGZpbGw9IiMyZmI2N2MiIHN0cm9rZT0iIzJmYjY3YyIgZD0iTTEzMy4zMzQgNzAuMzcxaC0xNC44MTVWNTUuNTU2YzAtOC4yMDcgNi42MDctMTQuODE1IDE0LjgxNS0xNC44MTVzMTQuODE1IDYuNjA3IDE0LjgxNSAxNC44MTUtNi42MDcgMTQuODE1LTE0LjgxNSAxNC44MTV6Ii8+PHBhdGggZmlsbD0iI2UwMWU1YSIgc3Ryb2tlPSIjZTAxZTVhIiBkPSJNMTQuODE1IDc3Ljc3OEgyOS42M3YxNC44MTVjMCA4LjIwNy02LjYwNyAxNC44MTUtMTQuODE1IDE0LjgxNVMwIDEwMC44IDAgOTIuNTkzczYuNjA3LTE0LjgxNSAxNC44MTUtMTQuODE1eiIvPjxwYXRoIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIgZD0iTTcwLjM3MSAxNC44MTVWMjkuNjNINTUuNTU2Yy04LjIwNyAwLTE0LjgxNS02LjYwNy0xNC44MTUtMTQuODE1UzQ3LjM0OCAwIDU1LjU1NiAwczE0LjgxNSA2LjYwNyAxNC44MTUgMTQuODE1eiIvPjwvZz48L2c+PC9zeW1ib2w+PC9zdmc+"},"displayName":"Slack","typeVersion":2,"nodeCategories":[{"id":6,"name":"Communication"},{"id":28,"name":"HITL"}]},{"id":356,"icon":"file:gmail.svg","name":"n8n-nodes-base.gmail","codex":{"data":{"alias":["email","human","form","wait","hitl","approval"],"resources":{"generic":[{"url":"https://n8n.io/blog/why-business-process-automation-with-n8n-can-change-your-daily-life/","icon":"🧬","label":"Why business process automation with n8n can change your daily life"},{"url":"https://n8n.io/blog/supercharging-your-conference-registration-process-with-n8n/","icon":"🎫","label":"Supercharging your conference registration process with n8n"},{"url":"https://n8n.io/blog/no-code-ecommerce-workflow-automations/","icon":"store","label":"6 e-commerce workflows to power up your Shopify s"},{"url":"https://n8n.io/blog/how-to-get-started-with-crm-automation-and-no-code-workflow-ideas/","icon":"👥","label":"How to get started with CRM automation (with 3 no-code workflow ideas"},{"url":"https://n8n.io/blog/automate-google-apps-for-productivity/","icon":"💡","label":"15 Google apps you can combine and automate to increase productivity"},{"url":"https://n8n.io/blog/your-business-doesnt-need-you-to-operate/","icon":" 🖥️","label":"Hey founders! Your business doesn't need you to operate"},{"url":"https://n8n.io/blog/using-automation-to-boost-productivity-in-the-workplace/","icon":"💪","label":"Using Automation to Boost Productivity in the Workplace"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.gmail/"}],"credentialDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/"}]},"categories":["Communication","HITL"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"HITL":["Human in the Loop"]}}},"group":"[\"transform\"]","defaults":{"name":"Gmail"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNTYiIGhlaWdodD0iMTkzIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZmlsbD0iIzQyODVGNCIgZD0iTTU4LjE4MiAxOTIuMDVWOTMuMTRMMjcuNTA3IDY1LjA3NyAwIDQ5LjUwNHYxMjUuMDkxYzAgOS42NTggNy44MjUgMTcuNDU1IDE3LjQ1NSAxNy40NTV6Ii8+PHBhdGggZmlsbD0iIzM0QTg1MyIgZD0iTTE5Ny44MTggMTkyLjA1aDQwLjcyN2M5LjY1OSAwIDE3LjQ1NS03LjgyNiAxNy40NTUtMTcuNDU1VjQ5LjUwNWwtMzEuMTU2IDE3LjgzNy0yNy4wMjYgMjUuNzk4eiIvPjxwYXRoIGZpbGw9IiNFQTQzMzUiIGQ9Im01OC4xODIgOTMuMTQtNC4xNzQtMzguNjQ3IDQuMTc0LTM2Ljk4OUwxMjggNjkuODY4bDY5LjgxOC01Mi4zNjQgNC42NyAzNC45OTItNC42NyA0MC42NDRMMTI4IDE0NS41MDR6Ii8+PHBhdGggZmlsbD0iI0ZCQkMwNCIgZD0iTTE5Ny44MTggMTcuNTA0VjkzLjE0TDI1NiA0OS41MDRWMjYuMjMxYzAtMjEuNTg1LTI0LjY0LTMzLjg5LTQxLjg5LTIwLjk0NXoiLz48cGF0aCBmaWxsPSIjQzUyMjFGIiBkPSJtMCA0OS41MDQgMjYuNzU5IDIwLjA3TDU4LjE4MiA5My4xNFYxNy41MDRMNDEuODkgNS4yODZDMjQuNjEtNy42NiAwIDQuNjQ2IDAgMjYuMjN6Ii8+PC9zdmc+"},"displayName":"Gmail","typeVersion":2,"nodeCategories":[{"id":6,"name":"Communication"},{"id":28,"name":"HITL"}]},{"id":565,"icon":"fa:sticky-note","name":"n8n-nodes-base.stickyNote","codex":{"data":{"alias":["Comments","Notes","Sticky"],"categories":["Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Helpers"]}}},"group":"[\"input\"]","defaults":{"name":"Sticky Note","color":"#FFD233"},"iconData":{"icon":"sticky-note","type":"icon"},"displayName":"Sticky Note","typeVersion":1,"nodeCategories":[{"id":9,"name":"Core Nodes"}]},{"id":834,"icon":"file:code.svg","name":"n8n-nodes-base.code","codex":{"data":{"alias":["cpde","Javascript","JS","Python","Script","Custom Code","Function"],"details":"The Code node allows you to execute JavaScript in your workflow.","resources":{"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code/"}]},"categories":["Development","Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Helpers","Data Transformation"]}}},"group":"[\"transform\"]","defaults":{"name":"Code"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTcxXzQ0MSkiPgo8cGF0aCBkPSJNMTcwLjI4MyA0OEgxOTYuNUMyMDMuMTI3IDQ4IDIwOC41IDQyLjYyNzQgMjA4LjUgMzZWMTJDMjA4LjUgNS4zNzI1OCAyMDMuMTI3IDAgMTk2LjUgMEgxNzAuMjgzQzEyNi4xIDAgOTAuMjgzIDM1LjgxNzIgOTAuMjgzIDgwVjE3NkM5MC4yODMgMjA2LjkyOCA2NS4yMTA5IDIzMiAzNC4yODMgMjMySDIzQzE2LjM3MjYgMjMyIDExIDIzNy4zNzIgMTEgMjQ0VjI2OEMxMSAyNzQuNjI3IDE2LjM3MjQgMjgwIDIyLjk5OTYgMjgwTDM0LjI4MyAyODBDNjUuMjEwOSAyODAgOTAuMjgzIDMwNS4wNzIgOTAuMjgzIDMzNlY0NDBDOTAuMjgzIDQ3OS43NjQgMTIyLjUxOCA1MTIgMTYyLjI4MyA1MTJIMTk2LjVDMjAzLjEyNyA1MTIgMjA4LjUgNTA2LjYyNyAyMDguNSA1MDBWNDc2QzIwOC41IDQ2OS4zNzMgMjAzLjEyNyA0NjQgMTk2LjUgNDY0SDE2Mi4yODNDMTQ5LjAyOCA0NjQgMTM4LjI4MyA0NTMuMjU1IDEzOC4yODMgNDQwVjMzNkMxMzguMjgzIDMwOS4wMjIgMTI4LjAxMSAyODQuNDQzIDExMS4xNjQgMjY1Ljk2MUMxMDYuMTA5IDI2MC40MTYgMTA2LjEwOSAyNTEuNTg0IDExMS4xNjQgMjQ2LjAzOUMxMjguMDExIDIyNy41NTcgMTM4LjI4MyAyMDIuOTc4IDEzOC4yODMgMTc2VjgwQzEzOC4yODMgNjIuMzI2OSAxNTIuNjEgNDggMTcwLjI4MyA0OFoiIGZpbGw9IiNGRjk5MjIiLz4KPHBhdGggZD0iTTMwNSAzNkMzMDUgNDIuNjI3NCAzMTAuMzczIDQ4IDMxNyA0OEgzNDIuOTc5QzM2MC42NTIgNDggMzc0Ljk3OCA2Mi4zMjY5IDM3NC45NzggODBWMTc2QzM3NC45NzggMjAyLjk3OCAzODUuMjUxIDIyNy41NTcgNDAyLjA5OCAyNDYuMDM5QzQwNy4xNTMgMjUxLjU4NCA0MDcuMTUzIDI2MC40MTYgNDAyLjA5OCAyNjUuOTYxQzM4NS4yNTEgMjg0LjQ0MyAzNzQuOTc4IDMwOS4wMjIgMzc0Ljk3OCAzMzZWNDMyQzM3NC45NzggNDQ5LjY3MyAzNjAuNjUyIDQ2NCAzNDIuOTc5IDQ2NEgzMTdDMzEwLjM3MyA0NjQgMzA1IDQ2OS4zNzMgMzA1IDQ3NlY1MDBDMzA1IDUwNi42MjcgMzEwLjM3MyA1MTIgMzE3IDUxMkgzNDIuOTc5QzM4Ny4xNjEgNTEyIDQyMi45NzggNDc2LjE4MyA0MjIuOTc4IDQzMlYzMzZDNDIyLjk3OCAzMDUuMDcyIDQ0OC4wNTEgMjgwIDQ3OC45NzkgMjgwSDQ5MEM0OTYuNjI3IDI4MCA1MDIgMjc0LjYyOCA1MDIgMjY4VjI0NEM1MDIgMjM3LjM3MyA0OTYuNjI4IDIzMiA0OTAgMjMyTDQ3OC45NzkgMjMyQzQ0OC4wNTEgMjMyIDQyMi45NzggMjA2LjkyOCA0MjIuOTc4IDE3NlY4MEM0MjIuOTc4IDM1LjgxNzIgMzg3LjE2MSAwIDM0Mi45NzkgMEgzMTdDMzEwLjM3MyAwIDMwNSA1LjM3MjU4IDMwNSAxMlYzNloiIGZpbGw9IiNGRjk5MjIiLz4KPC9nPgo8ZGVmcz4KPGNsaXBQYXRoIGlkPSJjbGlwMF8xMTcxXzQ0MSI+CjxyZWN0IHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo="},"displayName":"Code","typeVersion":2,"nodeCategories":[{"id":5,"name":"Development"},{"id":9,"name":"Core Nodes"}]},{"id":1225,"icon":"file:form.svg","name":"n8n-nodes-base.formTrigger","codex":{"data":{"alias":["table","submit","post"],"resources":{"generic":[],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.formtrigger/"}]},"categories":["Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Other Trigger Nodes"]}}},"group":"[\"trigger\"]","defaults":{"name":"On form submission"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NiIgaGVpZ2h0PSI0MCIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzAwQjdCQyIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzQuOTc4IDM3LjczMmExLjU2IDEuNTYgMCAwIDEtMS41NjIgMS41NjNINi4yNmExLjU2IDEuNTYgMCAwIDEtMS41NjMtMS41NjNWOS42MDdjMC0uNDA1LjE1Ny0uNzk0LjQzOC0xLjA4Nmw2LjMwNC02LjUzMXY1LjM0NEg4LjIxM2ExLjE3MiAxLjE3MiAwIDEgMCAwIDIuMzQzaDQuNDNhMS4xNyAxLjE3IDAgMCAwIDEuMTcxLTEuMTcxVi4yMzJoMTkuNjAyYTEuNTYgMS41NiAwIDAgMSAxLjU2MiAxLjU2M3YxMC4zMjdsLTIuODYgMi44Ni04LjI1MiA4LjI3NmE0MTMuMDA2IDQxMy4wMDYgMCAwIDEtMS42NTQgMS42NjJsLS4zMzcuMzM3YTIgMiAwIDAgMC0uNTU3IDEuMDhMMjAuMyAzMS45MjJjLS4xMDguNjM4LS4yMTUgMS4wNzkuMjExIDEuNDE4LjQwMy4zMi45LjE3NCAxLjU0LjA2Nmw1LjQwOC0uOTI4YTIgMiAwIDAgMCAxLjA4LS41NTZsNi40NC02LjQyOXptLTI0LjAzLTIxLjI2NWExLjE4IDEuMTggMCAwIDAgMS4xNzEgMS4xNzJoMTMuMTYzYTEuMTcyIDEuMTcyIDAgMSAwIDAtMi4zNDRIMTIuMTE5YTEuMTcgMS4xNyAwIDAgMC0xLjE3MiAxLjE3Mm03LjI5NCAxNC43NjZhMS4xNyAxLjE3IDAgMCAwLTEuMTcyLTEuMTcySDEyLjEyYTEuMTcyIDEuMTcyIDAgMSAwIDAgMi4zNDNoNC45NTFhMS4xNyAxLjE3IDAgMCAwIDEuMTcyLTEuMTcybS44Ni03LjM5MWExLjE3IDEuMTcgMCAwIDAtMS4xNzItMS4xNzJoLTUuODExYTEuMTcyIDEuMTcyIDAgMSAwIDAgMi4zNDNoNS44MWExLjE2NCAxLjE2NCAwIDAgMCAxLjE3My0xLjE3MSIgY2xpcC1ydWxlPSJldmVub2RkIi8+PHBhdGggZmlsbD0iIzAwQjdCQyIgZD0ibTMzLjUzMiAxNi4zOTcgNC4yODktNC4yODkgMy43NTggMy43MSAxLjYxNy0xLjYxNiAyLjI1OCAyLjI1N2MuMjE4LjIxOC4zNC41MTMuMzQzLjgyLS4wMDIuMzExLS4xMjUuNjA4LS4zNDQuODNsLTYuODA0IDYuNzk2YTEuMTMgMS4xMyAwIDAgMS0uODI4LjM0MyAxLjE1IDEuMTUgMCAwIDEtLjgyOC0uMzQzIDEuMTggMS4xOCAwIDAgMSAwLTEuNjU3bDUuOTc2LTUuOTY4LTEuMzEyLTEuMzEzLTEuMzgzIDEuNDE0LTEzLjE0OSAxMy4xMjUtNC42MTcuNzgyLjc4Mi00LjYxNy4zMzYtLjMzNyAyLjU2MiAyLjU1NWExLjEgMS4xIDAgMCAwIC44MjguMzQ0Yy4zMTIuMDA1LjYxMi0uMTIuODI4LS4zNDRhMS4xOCAxLjE4IDAgMCAwIDAtMS42NTZsLTIuNTYyLTIuNTYyek00NC43MzYgMTIuMjRjMCAuNDE0LS4xNjMuODEtLjQ1NCAxLjEwMmwtLjkyMi45MTQtMy44NTItMy44MjguOTMtLjkzYTEuNTYzIDEuNTYzIDAgMCAxIDIuMjAzIDBsMS42NCAxLjY0MWMuMjkxLjI5My40NTUuNjkuNDU1IDEuMTAyIi8+PC9zdmc+"},"displayName":"n8n Form Trigger","typeVersion":3,"nodeCategories":[{"id":9,"name":"Core Nodes"}]}],"categories":[{"id":37,"name":"Lead Generation"}],"image":[]}}