{"workflow":{"id":14118,"name":"Generate roofing contractor leads from Google Maps with ScrapeOps, Sheets and Slack","views":20,"recentViews":1,"totalViews":20,"createdAt":"2026-03-17T12:16:59.855Z","description":"## Overview\nThis n8n template automates finding roofing contractors in any city using Google Maps. It deep-scrapes listings via ScrapeOps Proxy, deduplicates results against Google Sheets, saves fresh leads, and sends alerts via Gmail and Slack - all triggered from a simple web form.\n\n## Who is this for?\n- Roofing companies building local lead lists without manual research\n- Sales teams prospecting contractors in new cities or regions\n- Agencies running lead generation campaigns for home services clients\n- Anyone who needs structured local business data from Google Maps\n\n## What problem does it solve?\nManually searching Google Maps, copying business details, and checking for duplicates is slow and error-prone. This workflow automates the entire process - from search to deduplication to saved leads - so you get fresh, structured contractor 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 roofing contractors 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/16oOK5vqHRua4e0tSaywCjwVA0tXQN61Tl2PkfQ487pU/edit?gid=0#gid=0) 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](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/16oOK5vqHRua4e0tSaywCjwVA0tXQN61Tl2PkfQ487pU/edit?gid=0#gid=0) with correct column headers\n- Gmail credentials for alert emails\n- Slack credentials for channel notifications\n\n## Disclaimer\nThis template uses [ScrapeOps n8n integration](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":"eBK9KVREjS88K3Hx","meta":{"instanceId":"c2ff056313a72210aa803da7c5191a260dbed0dab6ae2b8e39a8dd21701bf0ab","templateCredsSetupCompleted":true},"name":"Roofing Contractor Finder with ScrapeOps & Google Maps","tags":[{"id":"ilsCBifE7E9BRMFG","name":"Roofing Contractor","createdAt":"2026-03-14T10:05:38.482Z","updatedAt":"2026-03-14T10:05:38.482Z"},{"id":"lZKSh2IoxHklnOUw","name":"ScrapeOps","createdAt":"2025-10-20T20:27:13.410Z","updatedAt":"2025-10-20T20:27:13.410Z"},{"id":"mbbMg1D5U6dWea6M","name":"Lead Generation","createdAt":"2026-03-14T10:06:02.543Z","updatedAt":"2026-03-14T10:06:02.543Z"},{"id":"yzylwxvLF3YwGBRm","name":"Google Sheets Automation","createdAt":"2026-03-10T07:03:25.329Z","updatedAt":"2026-03-10T07:03:25.329Z"},{"id":"zP3TyKTSGRfSVdoP","name":"Google Maps Scraper","createdAt":"2026-03-14T10:05:50.254Z","updatedAt":"2026-03-14T10:05:50.254Z"}],"nodes":[{"id":"d10f00e5-5e3d-4f76-9ef4-cb7ac83ce059","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":"roofing contractor"},{"name":"baseUrl","stringValue":"https://www.google.com/maps/search/"},{"name":"searchUrl","stringValue":"={{ 'https://www.google.com/maps/search/roofing+contractor+in+' + String($json.city || $json['City Name'] || '').replace(/\\s+/g, '+') }}"}]},"options":{}},"typeVersion":3.2},{"id":"9e2ad7d4-3230-480e-9f7f-a42cb5eb84e0","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: 'roofing contractor', 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: 'roofing contractor', 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  // FIX: Do NOT initialize rating/totalReviews from originalData\n  // originalData comes from search results which has wrong counts\n  // Always extract fresh from detail page HTML only\n  let rating = '';\n  let totalReviews = '';\n  let address = originalData.address || '';\n  let city = originalData.city || '';\n  let category = originalData.category || 'roofing contractor';\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 RATING - from detail page HTML only\n    // ============================================================\n    // Pattern 1: F7nice div contains aria-hidden=\"true\">4.8</span>\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    // Pattern 2: aria-label=\"4.8 stars\"\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    // Pattern 3: MW4etd class\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    // Validate\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 - from detail page HTML only\n    // STRICT: only match \"NUMBER reviews\" — no other words after\n    // This prevents matching \"Reviews for Business Name\" tab buttons\n    // ============================================================\n    \n    // Pattern 1: Find the F7nice block first, then extract review count from it\n    // This is the most targeted approach - only looks inside the rating/review widget\n    if (f7niceBlock && f7niceBlock[1]) {\n      const reviewsInBlock = f7niceBlock[1].match(/aria-label=(?:\\\\?\"|\\\")([0-9,]+)\\s+reviews(?:\\\\?\"|\\\")/i);\n      if (reviewsInBlock && reviewsInBlock[1]) {\n        totalReviews = reviewsInBlock[1].replace(/,/g, '');\n      }\n      // Also try (76) pattern inside the block\n      if (!totalReviews) {\n        const parenInBlock = f7niceBlock[1].match(/\\(([0-9,]+)\\)/);\n        if (parenInBlock && parenInBlock[1]) totalReviews = parenInBlock[1].replace(/,/g, '');\n      }\n    }\n    \n    // Pattern 2: role=\"img\" with ONLY number + reviews (strict - no other text after)\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    \n    // Pattern 3: Strict aria-label - number + reviews + closing quote immediately\n    if (!totalReviews) {\n      // Must end with reviews\" or reviews' — no space after, no extra words\n      const strictMatch = htmlContent.match(/aria-label=[\"\\\\]+([0-9,]+)\\s+reviews[\"\\\\]+/i);\n      if (strictMatch && strictMatch[1]) totalReviews = strictMatch[1].replace(/,/g, '');\n    }\n    \n    // Pattern 4: UY7F9 class\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    \n    // Validate: must be 1 to 999999\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      // Remove \"New\" or \"new\" at the very start - status field bleed\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(service|work|great|good|excellent|professional|recommend|quality|job|team|crew|price|fast|clean|roof|install|repair|company|contractor|shingle|gutter|leak|estimate|quote)\\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 || 'roofing contractor',\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":"cef1a10f-75d5-4ad9-a96e-d73abbdb7289","name":"Read Previous Entries from Sheet","type":"n8n-nodes-base.googleSheets","position":[2096,320],"parameters":{"options":{},"sheetName":{"__rl":true,"mode":"list","value":"gid=0","cachedResultUrl":"https://docs.google.com/spreadsheets/d/16oOK5vqHRua4e0tSaywCjwVA0tXQN61Tl2PkfQ487pU/edit#gid=0","cachedResultName":"Roofing Contractor"},"documentId":{"__rl":true,"mode":"url","value":"https://docs.google.com/spreadsheets/d/16oOK5vqHRua4e0tSaywCjwVA0tXQN61Tl2PkfQ487pU/edit?gid=0#gid=0"}},"credentials":{"googleSheetsOAuth2Api":{"id":"ScA4DXJowherOrNG","name":"Google Sheets account 4"}},"typeVersion":4.4,"continueOnFail":true,"alwaysOutputData":true},{"id":"0ee445ff-72a2-4ee8-be55-d8cdaded9ea3","name":"Sticky Note","type":"n8n-nodes-base.stickyNote","position":[16,-32],"parameters":{"width":672,"height":864,"content":"# 🏠 Roofing Contractor Finder → Google Sheets + Alerts\n\nThis workflow automates finding roofing contractors in any city by scraping Google Maps. It performs a deep scrape to extract business 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 listings for roofing contractors 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 businesses already saved in the sheet.\n9. 🔀 **Filter New Leads Only** keeps only fresh, unseen contractors.\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/16oOK5vqHRua4e0tSaywCjwVA0tXQN61Tl2PkfQ487pU/edit?gid=0#gid=0) and connect it to the Sheet nodes.\n- Configure Gmail and Slack credentials for alert nodes.\n- Open the form URL and enter a city to run.\n\n### Customization\n- Change the `keyword` in **Set Google Maps Configuration** to find plumbers, electricians, HVAC contractors, etc.\n- Replace the Form Trigger with a Schedule Trigger to run automatically on a schedule.   "},"typeVersion":1},{"id":"598821b3-8df7-4336-ab70-663771cac7de","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":"61d38d8c-e0ae-4654-8aa0-c50c1e1648a8","name":"Sticky Note4","type":"n8n-nodes-base.stickyNote","position":[2720,144],"parameters":{"color":7,"width":640,"height":336,"content":"## 4. Save & Alert\nAppend new roofing contractor leads to Google Sheets and send notifications via Gmail and Slack."},"typeVersion":1},{"id":"c8b660d6-5bc8-4ae2-9690-847e29b08e85","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 roofing contractors via [ScrapeOps Proxy](https://scrapeops.io/docs/n8n/proxy-api/), then deep-scrape each listing for phone, website, reviews, and address."},"typeVersion":1},{"id":"8ddc7ea6-2101-4a6e-abbc-100536c7ccee","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 leads not previously saved."},"typeVersion":1},{"id":"c715d05f-7000-42a5-9e8f-261e6830a2d3","name":" Send Slack Alert","type":"n8n-nodes-base.slack","position":[3216,320],"webhookId":"323c630e-b557-4829-a95a-247ec6ad4948","parameters":{"text":"New Roofing Contractor Found: https://docs.google.com/spreadsheets/d/16oOK5vqHRua4e0tSaywCjwVA0tXQN61Tl2PkfQ487pU/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},{"id":"bc9699c1-f40f-4e74-a14c-88041a668cc9","name":"Send Gmail Alert","type":"n8n-nodes-base.gmail","position":[2992,320],"webhookId":"9732cfe4-2152-4355-9e01-7f9eae551054","parameters":{"sendTo":"example@.com","message":"={{ 'New Roofing Contractor Found\\n\\n' + 'Sheet URL: ' + 'https://docs.google.com/spreadsheets/d/16oOK5vqHRua4e0tSaywCjwVA0tXQN61Tl2PkfQ487pU/edit?gid=0#gid=0' }}","options":{"appendAttribution":false},"subject":"={{ '📍 New Businesses Found in ' + $('Set Google Maps Configuration').first().json.city + ' (Roofing Contractor)' }}","emailType":"text"},"credentials":{"gmailOAuth2":{"id":"d8zb1gCoME4ylSXg","name":"Gmail connection"}},"executeOnce":true,"typeVersion":2.1},{"id":"44241ac6-5351-47f9-939c-7e42a14090b1","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 }}"},"schema":[{"id":"businessName","type":"string","display":true,"required":false,"displayName":"businessName","defaultMatch":false,"canBeUsedToMatch":true},{"id":"phone","type":"string","display":true,"required":false,"displayName":"phone","defaultMatch":false,"canBeUsedToMatch":true},{"id":"website","type":"string","display":true,"required":false,"displayName":"website","defaultMatch":false,"canBeUsedToMatch":true},{"id":"rating","type":"string","display":true,"required":false,"displayName":"rating","defaultMatch":false,"canBeUsedToMatch":true},{"id":"totalReviews","type":"string","display":true,"required":false,"displayName":"totalReviews","defaultMatch":false,"canBeUsedToMatch":true},{"id":"address","type":"string","display":true,"required":false,"displayName":"address","defaultMatch":false,"canBeUsedToMatch":true},{"id":"city","type":"string","display":true,"required":false,"displayName":"city","defaultMatch":false,"canBeUsedToMatch":true},{"id":"category","type":"string","display":true,"required":false,"displayName":"category","defaultMatch":false,"canBeUsedToMatch":true},{"id":"mapUrl","type":"string","display":true,"required":false,"displayName":"mapUrl","defaultMatch":false,"canBeUsedToMatch":true},{"id":"checkedAt","type":"string","display":true,"required":false,"displayName":"checkedAt","defaultMatch":false,"canBeUsedToMatch":true},{"id":"status","type":"string","display":true,"required":false,"displayName":"status","defaultMatch":false,"canBeUsedToMatch":true},{"id":"review1","type":"string","display":true,"required":false,"displayName":"review1","defaultMatch":false,"canBeUsedToMatch":true},{"id":"review2","type":"string","display":true,"required":false,"displayName":"review2","defaultMatch":false,"canBeUsedToMatch":true},{"id":"review3","type":"string","display":true,"required":false,"displayName":"review3","defaultMatch":false,"canBeUsedToMatch":true}],"mappingMode":"defineBelow","matchingColumns":[],"attemptToConvertTypes":false,"convertFieldsToString":false},"options":{},"operation":"append","sheetName":{"__rl":true,"mode":"list","value":"gid=0","cachedResultUrl":"hhttps://docs.google.com/spreadsheets/d/16oOK5vqHRua4e0tSaywCjwVA0tXQN61Tl2PkfQ487pU/edit?gid=0#gid=0","cachedResultName":"Roofing Contractor"},"documentId":{"__rl":true,"mode":"url","value":"https://docs.google.com/spreadsheets/d/16oOK5vqHRua4e0tSaywCjwVA0tXQN61Tl2PkfQ487pU/edit?gid=0#gid=0"}},"credentials":{"googleSheetsOAuth2Api":{"id":"ScA4DXJowherOrNG","name":"Google Sheets account 4"}},"executeOnce":false,"typeVersion":4.4,"continueOnFail":true},{"id":"ee667420-0bae-4faf-bb0c-9203007e33da","name":"Filter New Leads Only","type":"n8n-nodes-base.if","position":[2544,320],"parameters":{"conditions":{"string":[{"value1":"={{ $json.status }}","value2":"new"}]}},"typeVersion":1},{"id":"f0568726-7977-4655-b1ea-6d2b21b51886","name":"Form: Enter City to Search","type":"n8n-nodes-base.formTrigger","position":[752,320],"webhookId":"c47ecc4f-c321-4ee1-80ef-4b822ff6d7d6","parameters":{"path":"c47ecc4f-c321-4ee1-80ef-4b822ff6d7d6","options":{},"formTitle":"Tell Which City","formFields":{"values":[{"fieldLabel":"City Name","requiredField":true}]}},"typeVersion":1},{"id":"40a29649-9feb-42b1-ae79-0ece3d41b200","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":"da6fa74e-1350-4d74-8988-9fb96eb19248","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 || 'roofing contractor',\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 || 'roofing contractor',\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":"3a507d51-97eb-45eb-bb07-a41c64bb11f4","name":" ScrapeOps: Fetch Business Details","type":"@scrapeops/n8n-nodes-scrapeops.ScrapeOps","position":[1648,320],"parameters":{"url":"={{ $json.mapUrl }}","returnType":"htmlResponse","advancedOptions":{"wait":"=18000","render_js":true,"residential_proxy":false}},"credentials":{"scrapeOpsApi":{"id":"4LFpuzt1jWwhSJBj","name":"ScrapeOps account"}},"typeVersion":1},{"id":"f91eeb42-0d7e-41fb-ad9d-0fcd1d9c66dc","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  // Remove all non-digit characters except keep the + if it's at the start\n  let normalized = String(phone).replace(/[^0-9+]/g, '');\n  // If it starts with +, keep it, otherwise remove any + in the middle\n  if (normalized.startsWith('+')) {\n    normalized = '+' + normalized.substring(1).replace(/[^0-9]/g, '');\n  } else {\n    normalized = normalized.replace(/[^0-9]/g, '');\n  }\n  return normalized;\n}\n// Helper function to normalize business names (trim, lowercase, remove extra spaces)\nfunction normalizeBusinessName(name) {\n  if (!name) return '';\n  return String(name).trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n// Get current items from Parse Full Business Info node\nlet currentItems = [];\ntry {\n  const parseNode = $('Parse Full Business Info');\n  if (parseNode && parseNode.all) {\n    const parseData = parseNode.all() || [];\n    currentItems = parseData.map(i => i.json || i).filter(item => \n      item && item.businessName && \n      String(item.businessName) !== 'No businesses found' && \n      String(item.businessName) !== 'No HTML content' &&\n      String(item.businessName) !== 'MATCHING_ERROR'\n    );\n  }\n} catch (error) {\n  return [{\n    json: {\n      businessName: 'Error accessing Parse node',\n      phone: '',\n      website: '',\n      rating: '',\n      totalReviews: '',\n      address: '',\n      city: '',\n      mapUrl: '',\n      category: 'roofing contractor',\n      status: 'old',\n      checkedAt: new Date().toISOString(),\n      error: String(error.message || 'Unknown error')\n    }\n  }];\n}\nif (currentItems.length === 0) {\n  return [{\n    json: {\n      businessName: 'No businesses found',\n      phone: '',\n      website: '',\n      rating: '',\n      totalReviews: '',\n      address: '',\n      city: '',\n      mapUrl: '',\n      category: 'roofing contractor',\n      status: 'old',\n      checkedAt: new Date().toISOString()\n    }\n  }];\n}\n// Get previous entries from Google Sheets\nlet previousItems = [];\ntry {\n  const readNode = $('Read Previous Entries from Sheet');\n  if (readNode && readNode.all) {\n    const prevData = readNode.all() || [];\n    previousItems = prevData.map(item => {\n      const json = item.json || item;\n      return {\n        businessName: String(json.businessName || json.BusinessName || json['Business Name'] || ''),\n        phone: String(json.phone || json.Phone || json['Phone'] || ''),\n        website: String(json.website || json.Website || json['Website'] || ''),\n        rating: String(json.rating || json.Rating || json['Rating'] || ''),\n        totalReviews: String(json.totalReviews || json.TotalReviews || json['Total Reviews'] || ''),\n        address: String(json.address || json.Address || json['Address'] || ''),\n        city: String(json.city || json.City || json['City'] || ''),\n        mapUrl: String(json.mapUrl || json.MapUrl || json['Map URL'] || ''),\n        category: String(json.category || json.Category || json['Category'] || ''),\n        status: String(json.status || json.Status || json['Status'] || '')\n      };\n    }).filter(prev => prev.businessName && String(prev.businessName).length > 0);\n  }\n} catch (error) {\n  previousItems = [];\n}\n// Create a set of normalized previous business+phone combinations\nconst prevKeys = new Set();\npreviousItems.forEach(prev => {\n  const normalizedName = normalizeBusinessName(prev.businessName);\n  const normalizedPhone = normalizePhone(prev.phone);\n  if (normalizedName && normalizedName.length > 0) {\n    // Create key with normalized values\n    const key = `${normalizedName}||${normalizedPhone}`;\n    prevKeys.add(key);\n    // Also add a key with just business name (in case phone is missing)\n    if (normalizedPhone && normalizedPhone.length > 0) {\n      prevKeys.add(normalizedName);\n    }\n  }\n});\n// Process current items and mark as new or old\nconst results = [];\nconst seen = new Set();\ncurrentItems.forEach(item => {\n  const originalBusinessName = String(item.businessName || '').trim();\n  const originalPhone = String(item.phone || '').trim();\n  \n  // Normalize for comparison\n  const normalizedName = normalizeBusinessName(originalBusinessName);\n  const normalizedPhone = normalizePhone(originalPhone);\n  \n  // Create key for duplicate detection in current run\n  const currentKey = `${normalizedName}||${normalizedPhone}`;\n  \n  // Skip duplicates in current run\n  if (seen.has(currentKey)) {\n    return;\n  }\n  seen.add(currentKey);\n  \n  // Check if this business exists in previous entries\n  // Try exact match first (name + phone)\n  let isExisting = false;\n  if (normalizedPhone && normalizedPhone.length > 0) {\n    const exactKey = `${normalizedName}||${normalizedPhone}`;\n    isExisting = prevKeys.has(exactKey);\n  }\n  \n  // If not found with phone, try matching by business name only\n  if (!isExisting && normalizedName && normalizedName.length > 0) {\n    isExisting = prevKeys.has(normalizedName);\n  }\n  \n  // Also do a direct comparison with previous items for more flexible matching\n  if (!isExisting) {\n    for (const prev of previousItems) {\n      const prevNormalizedName = normalizeBusinessName(prev.businessName);\n      const prevNormalizedPhone = normalizePhone(prev.phone);\n      \n      // Match if business name matches exactly\n      if (normalizedName === prevNormalizedName) {\n        // If both have phones, they must match\n        if (normalizedPhone && normalizedPhone.length > 0 && prevNormalizedPhone && prevNormalizedPhone.length > 0) {\n          if (normalizedPhone === prevNormalizedPhone) {\n            isExisting = true;\n            break;\n          }\n        } else {\n          // If one or both phones are missing, match by name only\n          isExisting = true;\n          break;\n        }\n      }\n    }\n  }\n  \n  // Determine status: 'old' if exists in sheet, 'new' if it's a new business\n  const status = isExisting ? 'old' : 'new';\n  \n  results.push({\n    json: {\n      businessName: originalBusinessName,\n      phone: originalPhone,\n      website: String(item.website || ''),\n      rating: String(item.rating || ''),\n      totalReviews: String(item.totalReviews || ''),\n      address: String(item.address || ''),\n      city: String(item.city || ''),\n      mapUrl: String(item.mapUrl || ''),\n      category: String(item.category || 'roofing contractor'),\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});\nreturn results.length > 0 ? results : [{\n  json: {\n    businessName: 'No data',\n    phone: '',\n    website: '',\n    rating: '',\n    totalReviews: '',\n    address: '',\n    city: '',\n    category: 'roofing contractor',\n    status: 'old',\n    checkedAt: new Date().toISOString()\n  }\n}];"},"typeVersion":2}],"active":false,"pinData":{},"settings":{"executionOrder":"v1"},"versionId":"5f3c504c-11e2-4a14-8978-f634c6aac379","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}]]},"Save New Leads to Sheet":{"main":[[{"node":"Send Gmail Alert","type":"main","index":0}]]}," Parse Business Listings":{"main":[[{"node":" ScrapeOps: Fetch Business Details","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":[]}}