{
  "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(/&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(/[\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          (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": {
          "name": "<your credential>"
        }
      },
      "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,
  "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
          }
        ]
      ]
    }
  }
}