This workflow corresponds to n8n.io template #14118 — we link there as the canonical source.
This workflow follows the Form Trigger → Gmail recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "eBK9KVREjS88K3Hx",
"meta": {
"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\" \u2014 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' \u2014 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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '\"').replace(/'/g, \"'\").replace(/ /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(/[\u2605\u2b50\u2606\u2729\u272b\u272c\u272d\u272e\u272f\u2730]/g, '');\n text = text.replace(/\\d+\\s*stars?/gi, '');\n text = text.replace(/\\d+\\s+(days?|weeks?|months?|years?)\\s+ago/gi, '');\n text = text.replace(/\\b(a|an)\\s+(day|week|month|year)\\s+ago\\b/gi, '');\n text = text.replace(/\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}/g, '');\n text = text.replace(/\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{4}\\b/gi, '');\n text = text.replace(/Local\\s+Guide\\s*[\u00b7\u2022]\\s*\\d+\\s+reviews?\\s*[\u00b7\u2022]?\\s*(\\d+\\s+photos?)?/gi, '');\n text = text.replace(/\\d+\\s+reviews?\\s*[\u00b7\u2022]\\s*\\d+\\s+photos?/gi, '');\n text = text.replace(/[\\uE000-\\uF8FF\\uFFF0-\\uFFFF]/g, '');\n text = text.replace(/[\\r\\n\\t]+/g, ' ').replace(/\\s{2,}/g, ' ').trim();\n return text;\n }\n \n function areSimilar(a, b) {\n if (!a || !b) return false;\n const shorter = a.length < b.length ? a : b;\n const longer = a.length < b.length ? b : a;\n const checkLen = Math.min(shorter.length, 120);\n if (longer.includes(shorter.substring(0, checkLen))) return true;\n if (shorter.includes(longer.substring(0, checkLen))) return true;\n if (a.substring(0, 80) === b.substring(0, 80)) return true;\n return false;\n }\n \n let allCandidates = [];\n function addCandidate(text, source) {\n if (!text) return;\n const cleaned = cleanReviewText(text);\n if (!cleaned || cleaned.length < 40) return;\n if (cleaned.match(/^[\\d\\s\\.,]+$/)) return;\n allCandidates.push({ text: cleaned, length: cleaned.length, source });\n }\n \n // SOURCE 1: data-expanded-text (char by char)\n const expandedAttrPattern = /data-expanded-text=/gi;\n let attrMatch;\n while ((attrMatch = expandedAttrPattern.exec(htmlContent)) !== null) {\n const startPos = attrMatch.index + 'data-expanded-text='.length;\n const quoteChar = htmlContent[startPos];\n if (quoteChar !== '\"' && quoteChar !== \"'\") continue;\n let endPos = startPos + 1, fullText = '';\n while (endPos < htmlContent.length && endPos < startPos + 10000) {\n if (htmlContent[endPos] === quoteChar && htmlContent[endPos - 1] !== '\\\\') { fullText = htmlContent.substring(startPos + 1, endPos); break; }\n endPos++;\n }\n if (fullText) addCandidate(fullText, 'expanded');\n }\n \n // SOURCE 2: data-original-text (char by char)\n const originalTextPattern = /data-original-text=/gi;\n let origMatch;\n while ((origMatch = originalTextPattern.exec(htmlContent)) !== null) {\n const startPos = origMatch.index + 'data-original-text='.length;\n const quoteChar = htmlContent[startPos];\n if (quoteChar !== '\"' && quoteChar !== \"'\") continue;\n let endPos = startPos + 1, fullText = '';\n while (endPos < htmlContent.length && endPos < startPos + 10000) {\n if (htmlContent[endPos] === quoteChar && htmlContent[endPos - 1] !== '\\\\') { fullText = htmlContent.substring(startPos + 1, endPos); break; }\n endPos++;\n }\n if (fullText) addCandidate(fullText, 'original');\n }\n \n // SOURCE 3: data-review-id blocks\n const reviewBlockPositions = [];\n const reviewIdFinder = /data-review-id=\"([^\"]+)\"/gi;\n let ridMatch;\n while ((ridMatch = reviewIdFinder.exec(htmlContent)) !== null) reviewBlockPositions.push({ pos: ridMatch.index, id: ridMatch[1] });\n for (let bi = 0; bi < reviewBlockPositions.length; bi++) {\n const startPos = reviewBlockPositions[bi].pos;\n const endPos = bi + 1 < reviewBlockPositions.length ? Math.min(reviewBlockPositions[bi + 1].pos, startPos + 8000) : startPos + 8000;\n const chunk = htmlContent.substring(startPos, endPos);\n const spanCandidates = [];\n const spanPat = /<span[^>]*>([\\s\\S]{40,3000}?)<\\/span>/gi;\n let spanMatch;\n while ((spanMatch = spanPat.exec(chunk)) !== null) {\n const raw = spanMatch[1];\n if ((raw.match(/<[^>]+>/g) || []).length > 12) continue;\n const cleaned = cleanReviewText(raw);\n if (cleaned && cleaned.length >= 40) spanCandidates.push(cleaned);\n }\n spanCandidates.sort((a, b) => b.length - a.length);\n if (spanCandidates.length > 0) addCandidate(spanCandidates[0], 'review_block_' + bi);\n }\n \n // SOURCE 4: wiI7pd class\n const wiPat = /<span[^>]*class=\"[^\"]*wiI7pd[^\"]*\"[^>]*>([\\s\\S]{40,8000}?)<\\/span>/gi;\n let wiMatch;\n while ((wiMatch = wiPat.exec(htmlContent)) !== null) addCandidate(wiMatch[1], 'wiI7pd');\n \n // SOURCE 5: MyEned class\n const myPat = /<span[^>]*class=\"[^\"]*MyEned[^\"]*\"[^>]*>([\\s\\S]{40,8000}?)<\\/span>/gi;\n let myMatch;\n while ((myMatch = myPat.exec(htmlContent)) !== null) addCandidate(myMatch[1], 'MyEned');\n \n // SOURCE 6: rsqaWe class\n const rsqaPat = /<span[^>]*class=\"[^\"]*rsqaWe[^\"]*\"[^>]*>([\\s\\S]{40,8000}?)<\\/span>/gi;\n let rsqaMatch;\n while ((rsqaMatch = rsqaPat.exec(htmlContent)) !== null) addCandidate(rsqaMatch[1], 'rsqaWe');\n \n // SOURCE 7: JSON-LD\n const jsonPat = /<script[^>]*type=\"application\\/ld\\+json\"[^>]*>([\\s\\S]*?)<\\/script>/gi;\n let jsonMatch;\n while ((jsonMatch = jsonPat.exec(htmlContent)) !== null) {\n try {\n const obj = JSON.parse(jsonMatch[1]);\n const walkJson = (o) => {\n if (!o || typeof o !== 'object') return;\n if (o['@type'] === 'Review' && o.reviewBody) addCandidate(o.reviewBody, 'jsonld');\n if (Array.isArray(o)) o.forEach(walkJson);\n else Object.values(o).forEach(v => { if (typeof v === 'object') walkJson(v); });\n };\n walkJson(obj);\n } catch(e) {}\n }\n \n // SOURCE 8: meta reviewBody\n const metaPat = /<meta[^>]*itemprop=\"reviewBody\"[^>]*content=\"([^\"]{40,})\"[^>]*>/gi;\n let metaMatch;\n while ((metaMatch = metaPat.exec(htmlContent)) !== null) addCandidate(metaMatch[1], 'meta');\n \n // DEDUPLICATE AND SELECT BEST 3\n allCandidates.sort((a, b) => b.length - a.length);\n let finalReviews = [];\n for (const candidate of allCandidates) {\n if (finalReviews.length >= 3) break;\n if (!finalReviews.some(r => areSimilar(r, candidate.text))) finalReviews.push(candidate.text);\n }\n \n // Fallback\n if (finalReviews.length === 0) {\n const broadCandidates = [];\n const broadPat = /<(?:span|div|p)[^>]*>([\\s\\S]{150,3000}?)<\\/(?:span|div|p)>/gi;\n let broadMatch;\n while ((broadMatch = broadPat.exec(htmlContent)) !== null) {\n const raw = broadMatch[1];\n if ((raw.match(/<[^>]+>/g) || []).length > 8) continue;\n const cleaned = cleanReviewText(raw);\n const reviewWords = /\\b(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": {
"name": "<your credential>"
}
},
"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": "# \ud83c\udfe0 Roofing Contractor Finder \u2192 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 \u2014 then saves fresh leads and sends alerts via Gmail and Slack.\n\n### How it works\n1. \ud83d\udcdd **Form: Enter City to Search** captures the target city from a simple web form.\n2. \u2699\ufe0f **Set Google Maps Configuration** sets the search keyword and location parameters.\n3. \ud83c\udf10 **ScrapeOps: Search Google Maps** scrapes Google Maps listings for roofing contractors via [ScrapeOps Proxy](https://scrapeops.io/docs/n8n/proxy-api/).\n4. \ud83d\udd0d **Parse Business Listings** extracts name, address, rating, and Maps URL from results.\n5. \ud83d\udce1 **ScrapeOps: Fetch Business Details** deep-scrapes each listing for phone, website, reviews, and more.\n6. \ud83d\uddfa\ufe0f **Parse Full Business Info** normalizes all fields into a clean structured record.\n7. \ud83d\udcc2 **Read Previous Entries from Sheet** loads existing leads to check for duplicates.\n8. \ud83e\uddf9 **Compare & Deduplicate Leads** filters out businesses already saved in the sheet.\n9. \ud83d\udd00 **Filter New Leads Only** keeps only fresh, unseen contractors.\n10. \ud83d\udcbe **Save New Leads to Sheet** appends new leads to Google Sheets.\n11. \ud83d\udce7 **Send Gmail Alert** notifies you of new leads by email.\n12. \ud83d\udce3 **Send Slack Alert** posts a summary to your Slack channel.\n\n### Setup steps\n- Register for a free ScrapeOps API key: https://scrapeops.io/app/register/n8n\n- Add ScrapeOps credentials in n8n. Docs: https://scrapeops.io/docs/n8n/overview/\n- Duplicate the [Google Sheet template](https://docs.google.com/spreadsheets/d/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
],
"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": {
"name": "<your credential>"
}
},
"executeOnce": true,
"typeVersion": 2.3
},
{
"id": "bc9699c1-f40f-4e74-a14c-88041a668cc9",
"name": "Send Gmail Alert",
"type": "n8n-nodes-base.gmail",
"position": [
2992,
320
],
"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": "={{ '\ud83d\udccd New Businesses Found in ' + $('Set Google Maps Configuration').first().json.city + ' (Roofing Contractor)' }}",
"emailType": "text"
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"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": {
"name": "<your credential>"
}
},
"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
],
"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": {
"name": "<your credential>"
}
},
"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
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
gmailOAuth2googleSheetsOAuth2ApiscrapeOpsApislackApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This n8n template automates finding 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.…
Source: https://n8n.io/workflows/14118/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This n8n template automates the generation of local business leads by scraping Google Maps. It goes beyond basic search results by visiting individual business pages to extract detailed contact inform
This repository contains an SLA-based lead routing workflow built in n8n, designed to ensure fast lead response, fair sales distribution, and controlled escalation without relying on a full CRM system
Find companies similar to your best clients using PredictLeads, enrich each with news, hiring, and tech signals, then score them 0–100 for outreach priority.
This workflow allows users to extract potential leads from their inboxes. The idea of a reverse outreach is based on the notion that the next big client/customer/partner might be sitting in your inbox
Automatically qualify, score, and route inbound B2B leads using GPT-4o-mini — no manual review needed.