AutomationFlowsMarketing & Ads › Find High-mismatch Local Business Leads with Firecrawl and Groq

Find High-mismatch Local Business Leads with Firecrawl and Groq

ByMychel Garzon @mychel-garzon on n8n.io

You know the businesses that need your services, but finding them is the hard part. They have 150+ five-star reviews, customers raving about specific services, and zero way to book online. They exist, they're profitable, and they don't know they're leaving money on the table.

Cron / scheduled trigger★★★★★ complexityAI-powered44 nodesChain LlmGroq Chat@Mendable/N8N Nodes FirecrawlData TableSlack
Marketing & Ads Trigger: Cron / scheduled Nodes: 44 Complexity: ★★★★★ AI nodes: yes Added:
Find High-mismatch Local Business Leads with Firecrawl and Groq — n8n workflow card showing Chain Llm, Groq Chat, @Mendable/N8N Nodes Firecrawl integration

This workflow corresponds to n8n.io template #15363 — we link there as the canonical source.

This workflow follows the Chainllm → Slack recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "RLgV19NH2JlyGlwF",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "The Mismatch Engine: AI Lead Discovery for Local Businesses",
  "tags": [],
  "nodes": [
    {
      "id": "5f6d08a4-cf94-42aa-9ac5-bf5b50e50566",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2800,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 2244,
        "height": 642,
        "content": "## Discovery & Targeting\n1. Set the Location & Category\n2. AI determines seasonal patterns & localized search terms\n3. Firecrawl hunts down relevant directory profiles\n4. AI parses exactly 3 high-potential leads from the noise"
      },
      "typeVersion": 1
    },
    {
      "id": "483c7a5e-4da8-4852-8ae5-03de0612ac90",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -528,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 418,
        "height": 636,
        "content": "## Deduplication\nChecks the existing database so we never process or pay for the same lead twice."
      },
      "typeVersion": 1
    },
    {
      "id": "f36154e3-2033-4fc1-a861-e7dbbdacf356",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -80,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 2898,
        "height": 638,
        "content": "## The Deep Scrape Loop\nIterates through the new leads with built-in rate limiting.\n1. Scrapes their actual website\n2. Finds and scrapes their 3 closest competitors\n3. Pulls recent customer review snippets for analysis"
      },
      "typeVersion": 1
    },
    {
      "id": "7ddedaa6-3534-41f7-9623-ae957bd8f79a",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2864,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 578,
        "height": 626,
        "content": "## Mismatch Engine\nCalculates reputation vs. digital capability, finds revenue leaks, and writes the sales pitch."
      },
      "typeVersion": 1
    },
    {
      "id": "2810ca9d-3c27-4423-8091-58dc35a89228",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        560,
        688
      ],
      "parameters": {
        "color": 7,
        "width": 1494,
        "height": 646,
        "content": "## Output & Reporting\nRanks the finished leads by opportunity score, saves them to the n8n database, and compiles a clean HTML report."
      },
      "typeVersion": 1
    },
    {
      "id": "67b4088a-feb8-46f6-a612-878dd86e598d",
      "name": "Weekly Monday 9AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -2736,
        224
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * 1"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "e041e89a-a3bd-4b23-adcb-f598c0e644a7",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "position": [
        -2512,
        224
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "s1",
              "name": "location",
              "type": "string",
              "value": "Helsinki, Finland"
            },
            {
              "id": "s2",
              "name": "category",
              "type": "string",
              "value": "Restaurants"
            },
            {
              "id": "s3",
              "name": "current_month",
              "type": "number",
              "value": "={{ $now.month }}"
            },
            {
              "id": "s4",
              "name": "run_date",
              "type": "string",
              "value": "={{ $now.toFormat('yyyy-MM-dd') }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "36459253-a414-4d2b-8424-a00f8f4ea234",
      "name": "Generate Category Signals",
      "type": "n8n-nodes-base.code",
      "position": [
        -2288,
        224
      ],
      "parameters": {
        "jsCode": "const cfg = $input.first().json;\n\nconst locationLower = cfg.location.toLowerCase();\nconst isGerman = locationLower.includes('germany') || locationLower.includes('berlin') || locationLower.includes('munich');\nconst isFinnish = locationLower.includes('finland') || locationLower.includes('helsinki');\nconst isSpanish = locationLower.includes('spain') || locationLower.includes('madrid');\n\nconst languageHint = isGerman ? ' Include German terms.' : \n                     isFinnish ? ' Include Finnish terms.' : \n                     isSpanish ? ' Include Spanish terms.' : \n                     ' Use English terms.';\n\nconst prompt = `Analyze this business category for seasonal patterns and conversion signals.\\n\\nCategory: ${cfg.category}\\nLocation: ${cfg.location}\\n\\nTask 1 - Peak Season Analysis:\\nIdentify the 2-4 months (1-12) when this business type has highest demand in this location.\\nConsider: local climate, cultural events, holidays, consumer behavior patterns.\\n\\nTask 2 - Conversion Signals:\\nList 5-8 keywords/phrases that indicate a website has online booking or conversion capability.\\n${languageHint}\\n\\nReturn ONLY this JSON structure with NO additional text:\\n{\\n  \"peak_months\": [1, 2, 9],\\n  \"peak_label\": \"Brief explanation of why these months (max 60 chars)\",\\n  \"conversion_signals\": [\"keyword1\", \"keyword2\", \"keyword3\", \"keyword4\", \"keyword5\"]\\n}`;\n\nreturn [{\n  json: {\n    ...cfg,\n    signal_prompt: prompt\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "25c135ed-eb72-4c42-b703-e2279aa5376e",
      "name": "Basic LLM Chain2",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        -2064,
        224
      ],
      "parameters": {
        "text": "={{ $json.signal_prompt }}",
        "batching": {},
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "5d733b19-790a-421e-8d41-c9c049dd46d6",
      "name": "Groq Chat Model2",
      "type": "@n8n/n8n-nodes-langchain.lmChatGroq",
      "position": [
        -1992,
        448
      ],
      "parameters": {
        "model": "llama-3.3-70b-versatile",
        "options": {}
      },
      "credentials": {
        "groqApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "0f45da6e-6e79-411c-b42b-e194b7a6df33",
      "name": "Parse Category Signals ",
      "type": "n8n-nodes-base.code",
      "position": [
        -1712,
        224
      ],
      "parameters": {
        "jsCode": "const cfg = $('Configuration').first().json;\nconst rawText = $input.first().json.text || $input.first().json.output || \"\";\n\nlet parsedSignals = {\n  peak_months: [1, 6, 12],\n  peak_label: \"Peak demand periods\",\n  conversion_signals: ['book', 'appointment', 'schedule', 'reserve', 'contact']\n};\n\ntry {\n  const jsonMatch = rawText.match(/\\{[\\s\\S]*\\}/);\n  if (jsonMatch) {\n    const cleanJson = jsonMatch[0];\n    const parsed = JSON.parse(cleanJson);\n    if (parsed.peak_months && Array.isArray(parsed.peak_months) && parsed.peak_months.length > 0) {\n      parsedSignals.peak_months = parsed.peak_months;\n    }\n    if (parsed.peak_label && typeof parsed.peak_label === 'string') {\n      parsedSignals.peak_label = parsed.peak_label;\n    }\n    if (parsed.conversion_signals && Array.isArray(parsed.conversion_signals) && parsed.conversion_signals.length > 0) {\n      parsedSignals.conversion_signals = parsed.conversion_signals;\n    }\n  }\n} catch (error) {\n  console.error('Failed to parse LLM signals, using defaults:', error.message);\n}\n\nreturn [{\n  json: {\n    category: cfg.category,\n    location: cfg.location,\n    current_month: cfg.current_month,\n    run_date: cfg.run_date,\n    season_matrix: { [cfg.category]: { peak_months: parsedSignals.peak_months, label: parsedSignals.peak_label } },\n    conversion_signals: { [cfg.category]: parsedSignals.conversion_signals }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "dd452e7e-4bba-4e69-8184-0328b9977547",
      "name": "Discovery Search",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawl",
      "maxTries": 3,
      "position": [
        -1488,
        224
      ],
      "parameters": {
        "query": "={{ $json.category + ' ' + $json.location + ' (site:yelp.com OR site:tripadvisor.com OR site:google.com/maps)' }}",
        "resource": "MapSearch",
        "operation": "search",
        "requestOptions": {}
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 3000
    },
    {
      "id": "11feb953-9703-4411-b680-8225bdb107e6",
      "name": "Format Search Context",
      "type": "n8n-nodes-base.code",
      "position": [
        -1264,
        224
      ],
      "parameters": {
        "jsCode": "const inputItems = $input.all();\nconst cfg = $('Parse Category Signals ').first().json;\n\nconst normalizeFirecrawlResponse = (data) => {\n  if (!data) return [];\n  if (Array.isArray(data)) return data;\n  if (data.data && Array.isArray(data.data)) return data.data;\n  if (data.web && Array.isArray(data.web)) return data.web;\n  if (data.results && Array.isArray(data.results)) return data.results;\n  if (data.data && data.data.web && Array.isArray(data.data.web)) return data.data.web;\n  if (data.data && data.data.data && Array.isArray(data.data.data)) return data.data.data;\n  return [];\n};\n\nreturn inputItems.map((itemWrapper) => {\n  const item = itemWrapper.json;\n  const originalData = { ...cfg };\n  let results = normalizeFirecrawlResponse(item);\n  let ctx = 'No results returned';\n  \n  if (results.length > 0) {\n    ctx = results.slice(0, 8).map((r, i) => {\n      const title = r.title || r.metadata?.title || 'No Title';\n      const url = r.url || 'No URL';\n      const desc = (r.description || r.markdown || '').substring(0, 300);\n      return `[${i+1}] ${title}\\nURL: ${url}\\nSnippet: ${desc}`;\n    }).join('\\n\\n');\n  }\n\n  return { json: { ...originalData, search_context: ctx } };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "3d68a84e-dc1a-40ba-83a3-3f5ca01feb74",
      "name": "Basic LLM Chain",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        -1040,
        224
      ],
      "parameters": {
        "text": "=Extract exactly 3 local businesses from the search results that match this profile:\n- Independent businesses (NOT chains)\n- High ratings or strong reputation signals\n- Likely need digital improvement\n\nCategory: {{ $('Configuration').first().json.category }}\nLocation: {{ $('Configuration').first().json.location }}\n\nCRITICAL: ONLY extract businesses that are EXACTLY in the \"{{ $('Configuration').first().json.category }}\" category.\nDo NOT include businesses from other categories even if they appear in results.\n\nSearch Results:\n{{ $json.search_context }}\n\nSELECTION CRITERIA (in priority order):\n1. Has reviews/ratings mentioned \u2192 signals reputation\n2. Independent local business \u2192 NOT franchise/chain\n3. Weak digital signals \u2192 old site, no online booking, basic presence\n4. Category match \u2192 must be {{ $('Configuration').first().json.category }}\n\nCRITICAL - URL EXTRACTION:\n- Extract the SPECIFIC business profile URL from ANY directory (Yelp, Google Maps, Foursquare, TripAdvisor, etc.)\n- Must be the BUSINESS PROFILE page\n- NOT search results pages\n- NOT directory homepages\n- Each business MUST have a unique profile URL from a directory site\n\nIf you find fewer than 3 businesses, return only what you found.\nIf search results are empty or contain no {{ $('Configuration').first().json.category }} businesses, return: {\"businesses\": []}\n\nReturn ONLY this JSON structure with NO additional text:\n{\n  \"businesses\": [\n    {\n      \"business_name\": \"Exact name from listing\",\n      \"category\": \"{{ $('Configuration').first().json.category }}\",\n      \"website_url\": \"Full directory profile URL (any accepted source)\"\n    }\n  ]\n}",
        "batching": {},
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "9138da5b-f713-4ec1-9fe1-4cec025c1822",
      "name": "Groq Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatGroq",
      "position": [
        -968,
        448
      ],
      "parameters": {
        "model": "llama-3.3-70b-versatile",
        "options": {}
      },
      "credentials": {
        "groqApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "6b0298ac-24f1-4db3-9ee4-fa563e595dae",
      "name": "Parse Business List",
      "type": "n8n-nodes-base.code",
      "position": [
        -688,
        224
      ],
      "parameters": {
        "jsCode": "const rawText = $input.first().json.text || \"\";\ntry {\n  const jsonMatch = rawText.match(/\\{[\\s\\S]*\\}/);\n  if (!jsonMatch) {\n    console.log('No JSON found in response:', rawText.substring(0, 200));\n    return [];\n  }\n  const data = JSON.parse(jsonMatch[0]);\n  if (!data.businesses || !Array.isArray(data.businesses)) {\n    console.error('Invalid schema from LLM: missing businesses array');\n    return [];\n  }\n  const validBusinesses = data.businesses.filter(b => \n    b.business_name && typeof b.business_name === 'string' &&\n    b.website_url && typeof b.website_url === 'string'\n  );\n  if (validBusinesses.length > 0) {\n    return validBusinesses.slice(0, 3).map(b => ({ \n      json: {\n        ...b,\n        category: b.category ? (b.category.endsWith('s') ? b.category : b.category + 's') : 'Unknown'\n      }\n    }));\n  } else {\n    return [];\n  }\n} catch (error) {\n  console.error('JSON Parsing Failed:', error.message);\n  return [{ json: { error: \"JSON Parsing Failed\", message: error.message } }];\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "5613c64b-f7b4-47df-ba59-0f12e1e4aab7",
      "name": "Fetch Existing Leads",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        -464,
        224
      ],
      "parameters": {
        "operation": "get",
        "returnAll": true,
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "2pGhdxpAThxGwmss",
          "cachedResultUrl": "/projects/hDHhdcMr4jVn06kt/datatables/2pGhdxpAThxGwmss",
          "cachedResultName": "Lead Database"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true,
      "alwaysOutputData": true
    },
    {
      "id": "30d925c4-afb0-4407-b5b4-2638038181e6",
      "name": "Filter New Leads",
      "type": "n8n-nodes-base.code",
      "position": [
        -240,
        224
      ],
      "parameters": {
        "jsCode": "const parsedLeads = $('Parse Business List').all().map(i => i.json);\nconst existingLeads = $input.all().map(i => i.json);\n\nconst existingNames = new Set(existingLeads.map(l => (l.business_name || '').toLowerCase().trim()));\n\nconst newLeads = parsedLeads.filter(l => !existingNames.has((l.business_name || '').toLowerCase().trim()) && !l.error);\n\nif (newLeads.length === 0) {\n  return []; // Gracefully stops if there are no new leads to process\n}\n\nreturn newLeads.map(l => ({ json: l }));"
      },
      "typeVersion": 2
    },
    {
      "id": "08cf87d8-0240-4354-a72a-fccd9190009f",
      "name": "Process Each Lead",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -16,
        224
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3,
      "continueOnFail": true
    },
    {
      "id": "891d88e6-56f8-4418-823d-5450fddde130",
      "name": "Rate Limit Delay",
      "type": "n8n-nodes-base.wait",
      "position": [
        208,
        224
      ],
      "parameters": {
        "amount": 2
      },
      "typeVersion": 1.1
    },
    {
      "id": "41ab7688-feea-409e-b8bb-4cb509a43222",
      "name": "Scrape Business Profile",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawl",
      "maxTries": 3,
      "position": [
        432,
        224
      ],
      "parameters": {
        "url": "={{ $json.website_url }}",
        "parsers": "=",
        "operation": "scrape",
        "requestOptions": {}
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 3000
    },
    {
      "id": "a7209fa1-c3fd-43b9-aea7-b8f5629cd5b3",
      "name": "Has Website?",
      "type": "n8n-nodes-base.if",
      "position": [
        656,
        224
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.website }}",
              "rightValue": "none"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "177faa98-6891-4621-8055-4b1b2c29ffb4",
      "name": "Scrape Website",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawl",
      "maxTries": 3,
      "position": [
        880,
        128
      ],
      "parameters": {
        "url": "={{ $json.website }}",
        "operation": "scrape",
        "requestOptions": {}
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 3000
    },
    {
      "id": "61c4ca7a-f4b7-49a7-a0fa-d61af8d64416",
      "name": "Merge Website",
      "type": "n8n-nodes-base.code",
      "position": [
        1104,
        128
      ],
      "parameters": {
        "jsCode": "try {\n  const lead = $('Process Each Lead').item.json;\n  if (lead._error) return [{ json: lead }];\n\n  const scrape = $input.first().json || {};\n  if (scrape.error) throw new Error(scrape.error.message || 'Website Scrape API Error');\n\n  const scrapeData = scrape.data || scrape;\n  return [{\n    json: {\n      ...lead,\n      website_markdown: scrapeData.markdown || '',\n      website_screenshot: scrapeData.screenshot || null,\n      website_exists: true\n    }\n  }];\n} catch (error) {\n  const lead = $('Process Each Lead').item?.json || {};\n  return [{ json: { ...lead, _error: error.message, _failed_step: 'website_scrape', website_exists: false } }];\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "cc62249c-4e26-4769-9142-572895eb3f9f",
      "name": "No Website Path",
      "type": "n8n-nodes-base.code",
      "position": [
        1104,
        320
      ],
      "parameters": {
        "jsCode": "const lead = $('Process Each Lead').item.json;\nif (lead._error) return [{ json: lead }];\nreturn [{\n  json: {\n    ...lead,\n    website_markdown: '',\n    website_screenshot: null,\n    website_exists: false\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "9d95a0da-0f37-47a8-b67f-d102333674f0",
      "name": "Find Competitors",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawl",
      "maxTries": 3,
      "position": [
        1328,
        224
      ],
      "parameters": {
        "query": "={{ $json.category + ' near ' + ($json.address || $json.location) + ' -' + $json.business_name }}",
        "resource": "MapSearch",
        "operation": "search",
        "requestOptions": {}
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 3000
    },
    {
      "id": "083ee6cc-9099-45dc-a180-8e3da908a4d7",
      "name": "Filter Competitors",
      "type": "n8n-nodes-base.code",
      "position": [
        1552,
        224
      ],
      "parameters": {
        "jsCode": "try {\n  const lead = $input.first().json;\n  if (lead._error) return [{ json: lead }];\n\n  const normalizeFirecrawlResponse = (data) => {\n    if (!data) return [];\n    if (Array.isArray(data)) return data;\n    if (data.data && Array.isArray(data.data)) return data.data;\n    if (data.web && Array.isArray(data.web)) return data.web;\n    if (data.results && Array.isArray(data.results)) return data.results;\n    if (data.data && data.data.web && Array.isArray(data.data.web)) return data.data.web;\n    if (data.data && data.data.data && Array.isArray(data.data.data)) return data.data.data;\n    return [];\n  };\n\n  const results = normalizeFirecrawlResponse(lead);\n  \n  let leadDomain = '';\n  if (lead.website_url) {\n    try { leadDomain = new URL(lead.website_url).hostname.replace('www.', ''); } catch(e) {}\n  }\n\n  const leadNameLower = (lead.business_name || '').toLowerCase();\n  const skipDomains = ['yelp.com', 'facebook.com', 'instagram.com', 'tripadvisor.com', 'google.com', 'googleusercontent.com'];\n\n  const competitors = results\n    .map(r => ({ url: r.url || '', title: r.title || r.metadata?.title || '' }))\n    .filter(c => {\n      if (!c.url || !c.url.startsWith('http')) return false;\n      if (leadDomain && c.url.includes(leadDomain)) return false;\n      if (leadNameLower.length > 4 && c.title.toLowerCase().includes(leadNameLower)) return false;\n      try {\n        const hostname = new URL(c.url).hostname.replace('www.', '');\n        if (skipDomains.some(d => hostname.includes(d))) return false;\n      } catch(e) { return false; }\n      return true;\n    })\n    .slice(0, 3);\n\n  return [{ json: { ...lead, data: undefined, results: undefined, competitor_urls: competitors.map(c => c.url) } }];\n} catch (error) {\n  const lead = $input.first().json || {};\n  return [{ json: { ...lead, _error: error.message, _failed_step: 'filter_competitors' } }];\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "723c14a4-9058-445f-82d8-a0193853f108",
      "name": "Split Competitors",
      "type": "n8n-nodes-base.code",
      "position": [
        1776,
        224
      ],
      "parameters": {
        "jsCode": "try {\n  const lead = $input.first().json;\n  if (lead._error) return [{ json: lead }];\n\n  const urls = lead.competitor_urls || [];\n  if (urls.length === 0) return [{ json: { ...lead, _skip_comp: true, _comp_idx: 0, _total: 0 } }];\n\n  return urls.map((url, idx) => ({\n    json: { _parent_lead: lead, _comp_url: url, _comp_idx: idx, _total: urls.length }\n  }));\n} catch (error) {\n  const lead = $input.first().json || {};\n  return [{ json: { ...lead, _error: error.message, _failed_step: 'split_competitors' } }];\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "28f37a4c-e554-4f31-8870-141ef4db0e93",
      "name": "Scrape Competitor",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawl",
      "maxTries": 3,
      "position": [
        2000,
        224
      ],
      "parameters": {
        "url": "={{ $json._comp_url }}",
        "operation": "scrape",
        "requestOptions": {}
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 3000
    },
    {
      "id": "d637d4ab-cac1-4225-bd35-d091219cdb54",
      "name": "Merge Competitors",
      "type": "n8n-nodes-base.code",
      "position": [
        2224,
        224
      ],
      "parameters": {
        "jsCode": "try {\n  const items = $input.all();\n  if (!items.length) return [];\n  if (items[0].json._error) return [{ json: items[0].json }];\n\n  if (items[0].json._skip_comp) {\n    const { _skip_comp, _comp_idx, _total, ...cleanLead } = items[0].json;\n    return [{ json: { ...cleanLead, competitor_markdowns: [] } }];\n  }\n\n  const parentLead = items[0].json._parent_lead;\n  const validItems = items.filter(i => typeof i.json._comp_idx === 'number');\n  \n  if (validItems.length !== items.length) {\n    console.error(`Missing competitor indexes: expected ${items.length}, got ${validItems.length}`);\n  }\n\n  const sorted = validItems.sort((a, b) => a.json._comp_idx - b.json._comp_idx);\n  const markdowns = sorted.map(i => (i.json.data || i.json).markdown || '');\n\n  return [{ json: { ...parentLead, competitor_markdowns: markdowns } }];\n} catch (error) {\n  const parentLead = $input.all()[0]?.json?._parent_lead || {};\n  return [{ json: { ...parentLead, _error: error.message, _failed_step: 'merge_competitors' } }];\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "fbf76368-8703-4fcf-86d6-f8bedfb1226a",
      "name": "Search Reviews",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawl",
      "maxTries": 3,
      "position": [
        2448,
        224
      ],
      "parameters": {
        "query": "={{ $json.business_name + ' ' + ($json.address || '') + ' reviews bewertungen' }}",
        "resource": "MapSearch",
        "operation": "search",
        "requestOptions": {}
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "continueOnFail": true,
      "waitBetweenTries": 3000
    },
    {
      "id": "ab9bf777-f316-4488-b498-4a70e00453c4",
      "name": "Extract Review Snippets",
      "type": "n8n-nodes-base.code",
      "position": [
        2672,
        224
      ],
      "parameters": {
        "jsCode": "try {\n  const lead = $input.first().json;\n  if (lead._error) return [{ json: lead }];\n\n  const normalizeFirecrawlResponse = (data) => {\n    if (!data) return [];\n    if (Array.isArray(data)) return data;\n    if (data.data && Array.isArray(data.data)) return data.data;\n    if (data.web && Array.isArray(data.web)) return data.web;\n    if (data.results && Array.isArray(data.results)) return data.results;\n    if (data.data && data.data.web && Array.isArray(data.data.web)) return data.data.web;\n    if (data.data && data.data.data && Array.isArray(data.data.data)) return data.data.data;\n    return [];\n  };\n\n  const results = normalizeFirecrawlResponse(lead);\n  const snippets = results\n    .slice(0, 5)\n    .map(r => (r.description || r.markdown || '').substring(0, 500))\n    .filter(t => t.length > 30);\n\n  return [{ json: { ...lead, data: undefined, results: undefined, review_snippets: snippets } }];\n} catch (error) {\n  const lead = $input.first().json || {};\n  return [{ json: { ...lead, _error: error.message, _failed_step: 'extract_reviews' } }];\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "235a4e95-1bff-4671-a8e3-4f809d7e3cf2",
      "name": "Basic LLM Chain1",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        2976,
        224
      ],
      "parameters": {
        "text": "=Extract specific services that customers are praising from these reviews.\n\nBusiness: {{ $json.business_name }}\nCategory: {{ $json.category }}\n\nReviews:\n{{ ($json.review_snippets || []).join('\\n---\\n').substring(0, 2500) }}\n\nRULES:\n1. Only extract CONCRETE SERVICES that customers explicitly praise or describe positively\n2. Use the exact terminology from reviews when possible (e.g., \"balayage\" not \"hair coloring\")\n3. Ignore: staff names, atmosphere comments, parking, prices, general praise (\"great service\")\n4. Extract service names only - no descriptions or qualifiers\n5. Return empty array if no specific services are mentioned\n\nReturn ONLY valid JSON in this exact format:\n{\n  \"services\": [\"service1\", \"service2\"]\n}",
        "batching": {},
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "6871732b-628e-4ead-9432-1e49f5c0447f",
      "name": "Groq Chat Model1",
      "type": "@n8n/n8n-nodes-langchain.lmChatGroq",
      "position": [
        2976,
        448
      ],
      "parameters": {
        "model": "llama-3.3-70b-versatile",
        "options": {}
      },
      "credentials": {
        "groqApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "f8e88a98-6ac3-4532-8317-e0069576d3aa",
      "name": "The Mismatch Engine",
      "type": "n8n-nodes-base.code",
      "position": [
        3264,
        400
      ],
      "parameters": {
        "jsCode": "try {\n  const lead = $('Extract Review Snippets').item?.json || $input.first().json || {};\n  if (lead._error) return [{ json: lead }];\n\n  const openaiResp = $input.first().json || {};\n  let praisedServices = [];\n  try {\n    const content = openaiResp?.choices?.[0]?.message?.content || openaiResp?.text || '{}';\n    const parsed = JSON.parse(content);\n    praisedServices = Array.isArray(parsed.services) ? parsed.services : [];\n  } catch(e) { praisedServices = []; }\n\n  const currentMonth = parseInt(lead.current_month) || (new Date().getMonth() + 1);\n  const category = lead.category || 'Unknown';\n  const seasonMatrix = lead.season_matrix || {};\n  const conversionSignals = lead.conversion_signals || {};\n\n  const mkdwn = (lead.website_markdown || '').toLowerCase();\n  const websiteExists = !!lead.website_exists;\n  const hasHttps = lead.website_url ? lead.website_url.startsWith('https') : false;\n  const hasMobileViewport = mkdwn.includes('viewport');\n  const copyrightMatch = mkdwn.match(/copyright[^\\d]*(20\\d{2})|\u00a9\\s*(20\\d{2})/);\n  const copyrightYear = copyrightMatch ? parseInt(copyrightMatch[1] || copyrightMatch[2]) : null;\n  const hasOldCopyright = copyrightYear ? copyrightYear < 2020 : false;\n\n  const convSignals = conversionSignals[category] || [];\n  const hasConversionPath = convSignals.some(s => mkdwn.includes(s.toLowerCase()));\n\n  const cmsMap = { WordPress: ['wp-content','wp-includes'], Wix: ['wix.com','wixsite'], Squarespace: ['squarespace'], Jimdo: ['jimdo'], Joomla: ['joomla'] };\n  let detectedCms = null;\n  for (const [cms, sigs] of Object.entries(cmsMap)) {\n    if (sigs.some(s => mkdwn.includes(s))) { detectedCms = cms; break; }\n  }\n\n  const revenueLeak = praisedServices.filter(svc =>\n    svc && typeof svc === 'string' && !mkdwn.includes(svc.toLowerCase())\n  );\n\n  const compMarkdowns = lead.competitor_markdowns || [];\n  let competitorsWithConversion = 0;\n  for (const md of compMarkdowns) {\n    if (convSignals.some(s => (md || '').toLowerCase().includes(s.toLowerCase()))) {\n      competitorsWithConversion++;\n    }\n  }\n\n  const seasonData = seasonMatrix[category];\n  let seasonalUrgency = null;\n  let weeksToPeak = null;\n  let peakLabel = null;\n  if (seasonData && Array.isArray(seasonData.peak_months)) {\n    peakLabel = seasonData.label;\n    const diffs = seasonData.peak_months.map(pm => {\n      let d = pm - currentMonth;\n      if (d <= 0) d += 12;\n      return d;\n    });\n    const minMonths = Math.min(...diffs);\n    weeksToPeak = Math.round(minMonths * 4.33);\n    seasonalUrgency = minMonths <= 2 ? 'CRITICAL' : minMonths <= 4 ? 'HIGH' : 'MEDIUM';\n  }\n\n  const reviewCount = parseInt(lead.directory_review_count) || 0;\n  const rating = parseFloat(lead.directory_rating) || 0;\n\n  const reputationScore = Math.min(100, (Math.min(reviewCount, 200) / 200 * 60) + (rating / 5 * 40));\n\n  let digitalScore = 0;\n  if (websiteExists) {\n    if (hasHttps) digitalScore += 20;\n    if (hasMobileViewport) digitalScore += 20;\n    if (!hasOldCopyright) digitalScore += 15;\n    if (hasConversionPath) digitalScore += 30;\n    if (!detectedCms || !['Wix','Jimdo','Joomla'].includes(detectedCms)) digitalScore += 15;\n  }\n\n  const mismatchRaw = reputationScore - (digitalScore * 0.6);\n  const mismatchScore = Math.max(0, Math.min(100, Math.round(mismatchRaw) + 40));\n\n  let opportunityScore = mismatchScore;\n  if (revenueLeak.length > 0) opportunityScore += 15;\n  if (competitorsWithConversion > 0) opportunityScore += 10;\n  if (seasonalUrgency === 'CRITICAL') opportunityScore += 10;\n  else if (seasonalUrgency === 'HIGH') opportunityScore += 5;\n  opportunityScore = Math.min(100, Math.round(opportunityScore));\n\n  const opportunityTier = opportunityScore >= 75 ? 'High' : opportunityScore >= 50 ? 'Medium' : 'Low';\n\n  const objectiveIssues = [];\n  const heuristicIssues = [];\n  if (!websiteExists) objectiveIssues.push('No website found in public listings');\n  else {\n    if (!hasHttps) objectiveIssues.push('No HTTPS');\n    if (!hasMobileViewport) objectiveIssues.push('No mobile viewport meta tag');\n    if (hasOldCopyright) objectiveIssues.push(`Copyright year ${copyrightYear}`);\n    if (!hasConversionPath) objectiveIssues.push(`No ${category} conversion path`);\n  }\n  if (detectedCms && ['Wix','Jimdo','Joomla'].includes(detectedCms)) heuristicIssues.push(`Built with ${detectedCms} \u2014 may indicate template-based site`);\n  if (hasOldCopyright) heuristicIssues.push('Site freshness appears low');\n\n  const websiteIssues = [\n    ...objectiveIssues.map(i => `[Objective] ${i}`),\n    ...heuristicIssues.map(i => `[Heuristic] ${i}`)\n  ].join('; ') || 'None detected';\n\n  const observedFacts = [];\n  const heuristicAssessment = [];\n  if (!websiteExists) observedFacts.push('No website found in public directory listings');\n  else {\n    observedFacts.push(`Website: ${lead.website_url}`);\n    observedFacts.push(hasHttps ? 'HTTPS present' : 'HTTPS absent');\n    observedFacts.push(hasMobileViewport ? 'Mobile viewport tag present' : 'Mobile viewport tag absent');\n    if (copyrightYear) observedFacts.push(`Footer copyright: ${copyrightYear}`);\n    observedFacts.push(hasConversionPath ? `${category} conversion path detected` : `No ${category} conversion path`);\n    if (detectedCms) observedFacts.push(`CMS: ${detectedCms}`);\n  }\n  if (reviewCount > 0) observedFacts.push(`${reviewCount} public reviews at ${rating} stars average`);\n  if (hasOldCopyright) heuristicAssessment.push(`Site freshness low (~${copyrightYear})`);\n  if (detectedCms && ['Wix','Jimdo','Joomla'].includes(detectedCms)) heuristicAssessment.push(`${detectedCms} template suggests DIY build`);\n  if (competitorsWithConversion > 0) heuristicAssessment.push(`${competitorsWithConversion} nearby competitors appear more digitally capable`);\n  if (reputationScore > 70 && digitalScore < 40) heuristicAssessment.push('Strong reputation paired with weak digital execution');\n\n  let pitchHook, pitchAngle;\n  if (revenueLeak.length > 0) {\n    pitchAngle = 'Revenue Leak';\n    pitchHook = `Your customers are telling Google you do exceptional ${revenueLeak[0]}. Your website does not know that word exists.`;\n  } else if (!websiteExists) {\n    pitchAngle = 'Missing Presence';\n    pitchHook = `You have ${reviewCount} reviews and ${rating} stars. Every person who finds you online and wants to book has nowhere to go.`;\n  } else if (!hasConversionPath) {\n    pitchAngle = 'Missing Conversion';\n    pitchHook = `You have ${reviewCount} strong reviews and no way for a new customer to take action after reading them.`;\n  } else if (competitorsWithConversion > 0) {\n    pitchAngle = 'Competitive Pressure';\n    pitchHook = `${competitorsWithConversion} of your nearest competitors have online booking. You are the only one in this cluster without it.`;\n  } else if (seasonalUrgency === 'CRITICAL') {\n    pitchAngle = 'Seasonal Timing';\n    pitchHook = `${peakLabel} is ${weeksToPeak} weeks away. Now is the window where a website upgrade pays for itself fastest.`;\n  } else {\n    pitchAngle = 'General Mismatch';\n    pitchHook = `Strong reputation with ${reviewCount} reviews \u2014 your digital presence is not converting that trust into new bookings.`;\n  }\n\n  const whyPoints = [\n    !websiteExists ? 'No website in public listings' : null,\n    revenueLeak.length > 0 ? `Revenue leak: praised services absent from website (${revenueLeak.slice(0,2).join(', ')})` : null,\n    !hasConversionPath && websiteExists ? `No ${category} conversion path` : null,\n    competitorsWithConversion > 0 ? `${competitorsWithConversion} nearby competitors stronger digitally` : null,\n    seasonalUrgency === 'CRITICAL' ? `Peak season in ~${weeksToPeak} weeks` : null\n  ].filter(Boolean);\n\n  return [{ json: {\n    run_date: lead.run_date,\n    business_name: lead.business_name,\n    category,\n    address: lead.address || 'not listed',\n    phone: lead.phone || 'not listed',\n    website: lead.website_url || 'none',\n    website_issues: websiteIssues,\n    services_listed: Array.isArray(lead.services_listed) ? lead.services_listed.join(', ') : 'not listed',\n    opportunity_score: opportunityTier,\n    opportunity_score_numeric: opportunityScore,\n    why_a_lead: whyPoints.join('. '),\n    source_links: lead.directory_source_url || 'not listed',\n    public_rating: rating,\n    review_count: reviewCount,\n    tech_signals: detectedCms || 'not detected',\n    freshness_signal: copyrightYear ? `Footer copyright ${copyrightYear}` : 'not detected',\n    competitor_urls: (lead.competitor_urls || []).join(', '),\n    competitors_with_booking: competitorsWithConversion,\n    website_screenshot: lead.website_screenshot || null,\n    praised_services: praisedServices.join(', ') || 'none extracted',\n    revenue_leak_services: revenueLeak.join(', ') || 'none',\n    revenue_leak_detected: revenueLeak.length > 0,\n    seasonal_urgency: seasonalUrgency || 'not applicable',\n    weeks_to_peak_season: weeksToPeak || null,\n    peak_season_context: peakLabel || null,\n    reputation_score: parseFloat(reputationScore.toFixed(1)),\n    digital_score: parseFloat(digitalScore.toFixed(1)),\n    mismatch_score: mismatchScore,\n    pitch_angle: pitchAngle,\n    pitch_hook: pitchHook,\n    observed_facts: observedFacts.join('. '),\n    heuristic_assessment: heuristicAssessment.join('. ') || 'No heuristic flags'\n  }}];\n} catch (error) {\n  const lead = $('Extract Review Snippets').item?.json || $input.first().json || {};\n  return [{ json: { ...lead, _error: error.message, _failed_step: 'mismatch_engine' } }];\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "eacd61a7-1f9c-4913-857e-9d8d7fe5eb6c",
      "name": "Aggregate All Leads",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        688,
        896
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "a37a5b0e-5e88-4b45-af25-5ab7fd05f72c",
      "name": "Rank and Deduplicate",
      "type": "n8n-nodes-base.code",
      "position": [
        912,
        896
      ],
      "parameters": {
        "jsCode": "const aggregated = $('Aggregate All Leads').first().json.data || $('Aggregate All Leads').first().json || [];\nconst leadsArray = Array.isArray(aggregated) ? aggregated : [aggregated];\n\nconst newLeads = leadsArray\n  .filter(l => l && l.business_name && !l._error) \n  .sort((a, b) => (b.opportunity_score_numeric || 0) - (a.opportunity_score_numeric || 0))\n  .slice(0, 10);\n\nif (newLeads.length === 0) return [{ json: { _no_new_leads: true } }];\nreturn newLeads.map(l => ({ json: l }));"
      },
      "typeVersion": 2
    },
    {
      "id": "553c7d96-7b05-441d-a1fa-4522e03b833d",
      "name": "Filter Valid Leads",
      "type": "n8n-nodes-base.code",
      "position": [
        1136,
        896
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst validLeads = items.filter(item => !item.json._no_new_leads && !item.json._error);\nif (validLeads.length === 0) return [];\nreturn validLeads;"
      },
      "typeVersion": 2
    },
    {
      "id": "f397728b-ddc0-4e92-80e8-084a9780af59",
      "name": "Save to n8n Table",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        1360,
        800
      ],
      "parameters": {
        "columns": {
          "value": {
            "website": "={{ $json.website }}",
            "category": "={{ $json.category }}",
            "pitch_hook": "={{ $json.pitch_hook }}",
            "business_name": "={{ $json.business_name }}",
            "opportunity_score": "={{ $json.opportunity_score }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "2pGhdxpAThxGwmss",
          "cachedResultUrl": "/projects/hDHhdcMr4jVn06kt/datatables/2pGhdxpAThxGwmss",
          "cachedResultName": "Lead Database"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "6af50b02-95e0-421d-b774-6f6d3ed27b12",
      "name": "Aggregate for Report",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        1360,
        992
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "842b3184-8e0e-483f-9221-74ff791c7dda",
      "name": "Build HTML Report",
      "type": "n8n-nodes-base.code",
      "position": [
        1584,
        896
      ],
      "parameters": {
        "jsCode": "const allLeads = $input.first().json.data || [];\nconst leads = allLeads.filter(l => !l._no_new_leads);\nconst top5 = leads.slice(0, 5);\nconst runDate = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });\n\nif (leads.length === 0) {\n return [{ json: { html_report: '<html><body><h1>No new leads this week</h1></body></html>' } }];\n}\n\nconst tierColor = t => ({ High: '#10b981', Medium: '#f59e0b', Low: '#6b7280' }[t] || '#6b7280');\nconst ue = u => ({ CRITICAL: '\ud83d\udd34', HIGH: '\ud83d\udfe0', MEDIUM: '\ud83d\udfe1' }[u] || '');\n\nconst renderLead = (lead, rank) => {\n const hasShot = lead.website_screenshot && String(lead.website_screenshot).length > 100;\n const urgEmoji = lead.seasonal_urgency && lead.seasonal_urgency !== 'not applicable' ? ue(lead.seasonal_urgency) : '';\n return `\\n<div style=\"background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:28px;margin-bottom:20px;\">\\n <div style=\"display:flex;align-items:center;gap:16px;margin-bottom:20px;\">\\n <div style=\"font-size:26px;font-weight:800;color:#444;min-width:50px;\">#${rank+1}</div>\\n <div style=\"flex:1;\">\\n <h2 style=\"color:#fff;margin:0 0 4px;font-size:20px;\">${lead.business_name}</h2>\\n <div style=\"color:#aaa;font-size:13px;\">${lead.category} \u00b7 ${lead.address}</div>\\n </div>\\n <div style=\"border:2px solid ${tierColor(lead.opportunity_score)};border-radius:10px;padding:10px 16px;text-align:center;\">\\n <div style=\"font-size:24px;font-weight:800;color:#fff;\">${lead.opportunity_score_numeric}</div>\\n <div style=\"font-size:11px;font-weight:700;color:${tierColor(lead.opportunity_score)};text-transform:uppercase;\">${lead.opportunity_score}</div>\\n </div>\\n </div>\\n <div style=\"background:#111;border-radius:8px;padding:14px;margin-bottom:16px;display:flex;flex-wrap:wrap;gap:14px;font-size:13px;color:#ccc;\">\\n <span>\u2b50 ${lead.public_rating} stars \u00b7 ${lead.review_count} reviews</span>\\n <span>\ud83d\udcde ${lead.phone}</span>\\n <span>\ud83c\udf10 ${lead.website !== 'none' ? `<a href=\"${lead.website}\" style=\"color:#3b82f6;\">${lead.website}</a>` : '<em>No website</em>'}</span>\\n ${urgEmoji ? `<span style=\"color:#f59e0b;font-weight:600;\">${urgEmoji} Peak in ~${lead.weeks_to_peak_season} weeks</span>` : ''}\\n </div>\\n ${hasShot ? `<div style=\"margin-bottom:16px;border-radius:8px;overflow:hidden;border:1px solid #333;max-height:280px;\"><img src=\"${lead.website_screenshot}\" style=\"width:100%;display:block;\" /></div>` : ''}\\n <div style=\"border-left:3px solid #10b981;background:linear-gradient(135deg,#064e3b15,#1e3a8a15);padding:16px;border-radius:8px;margin-bottom:16px;\">\\n <div style=\"font-size:11px;font-weight:700;color:#10b981;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;\">\ud83d\udcac ${lead.pitch_angle} Pitch</div>\\n <div style=\"font-size:16px;font-style:italic;color:#f5f5f5;line-height:1.5;\">${lead.pitch_hook}</div>\\n </div>\\n <div style=\"margin-bottom:16px;\">\\n <div style=\"display:grid;grid-template-columns:130px 1fr 36px;gap:10px;align-items:center;font-size:12px;margin-bottom:6px;\">\\n <span style=\"color:#aaa;\">Reputation</span><div style=\"background:#0a0a0a;border-radius:3px;height:7px;overflow:hidden;\"><div style=\"background:#10b981;width:${lead.reputation_score}%;height:100%;\"></div></div><span style=\"color:#ccc;text-align:right;\">${lead.reputation_score}</span>\\n </div>\\n <div style=\"display:grid;grid-template-columns:130px 1fr 36px;gap:10px;align-items:center;font-size:12px;margin-bottom:6px;\">\\n <span style=\"color:#aaa;\">Digital Presence</span><div style=\"background:#0a0a0a;border-radius:3px;height:7px;overflow:hidden;\"><div style=\"background:#3b82f6;width:${lead.digital_score}%;height:100%;\"></div></div><span style=\"color:#ccc;text-align:right;\">${lead.digital_score}</span>\\n </div>\\n <div style=\"display:grid;grid-template-columns:130px 1fr 36px;gap:10px;align-items:center;font-size:12px;\">\\n <span style=\"color:#fbbf24;font-weight:600;\">Mismatch</span><div style=\"background:#0a0a0a;border-radius:3px;height:7px;overflow:hidden;\"><div style=\"background:linear-gradient(90deg,#f59e0b,#dc2626);width:${lead.mismatch_score}%;height:100%;\"></div></div><span style=\"color:#fbbf24;text-align:right;font-weight:600;\">${lead.mismatch_score}</span>\\n </div>\\n </div>\\n ${lead.revenue_leak_detected ? `<div style=\"background:#7f1d1d20;border-left:3px solid #ef4444;padding:12px 16px;border-radius:6px;margin-bottom:10px;font-size:13px;\"><strong style=\"color:#f87171;\">\ud83d\udcb8 Revenue Leak:</strong> Customers praise <em>${lead.revenue_leak_services}</em> but absent from website.</div>` : ''}\\n ${lead.competitors_with_booking > 0 ? `<div style=\"background:#7c2d1220;border-left:3px solid #f97316;padding:12px 16px;border-radius:6px;margin-bottom:10px;font-size:13px;\"><strong style=\"color:#fb923c;\">\u2694\ufe0f Competitor Pressure:</strong> ${lead.competitors_with_booking} nearby competitors have conversion paths this lead lacks.</div>` : ''}\\n <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px;padding-top:16px;border-top:1px solid #333;font-size:12px;\">\\n <div><div style=\"color:#666;text-transform:uppercase;font-size:10px;letter-spacing:0.5px;margin-bottom:4px;\">Observed Facts</div><div style=\"color:#ccc;\">${lead.observed_facts}</div></div>\\n <div><div style=\"color:#666;text-transform:uppercase;font-size:10px;letter-spacing:0.5px;margin-bottom:4px;\">Heuristic Assessment</div><div style=\"color:#999;font-style:italic;\">${lead.heuristic_assessment}</div></div>\\n </div>\\n <div style=\"margin-top:14px;padding-top:12px;border-top:1px solid #333;display:flex;justify-content:space-between;font-size:11px;color:#666;\">\\n <span>${lead.tech_signals !== 'not detected' ? 'Built on ' + lead.tech_signals : ''}</span>\\n <a href=\"${lead.source_links}\" style=\"color:#3b82f6;text-decoration:none;\">Directory source \u2192</a>\\n </div>\\n</div>`;\n};\n\nconst html = `<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Mismatch Engine Report \u2014 ${runDate}</title></head>\\n<body style=\"background:#0a0a0a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Inter,sans-serif;padding:40px 20px;line-height:1.5;\">\\n<div style=\"max-width:860px;margin:0 auto;\">\\n <div style=\"text-align:center;padding:40px 0 50px;border-bottom:1px solid #222;margin-bottom:36px;\">\\n <h1 style=\"font-size:38px;font-weight:800;margin:0 0 8px;background:linear-gradient(135deg,#10b981,#3b82f6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;\">Mismatch Engine Report</h1>\\n <div style=\"color:#aaa;font-size:15px;\">${runDate} \u00b7 Top ${top5.length} leads by Mismatch Score</div>\\n </div>\\n <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:36px;\">\\n <div style=\"background:#171717;border:1px solid #333;border-radius:10px;padding:18px;text-align:center;\"><div style=\"font-size:28px;font-weight:700;color:#10b981;\">${leads.length}</div><div style=\"color:#666;font-size:12px;margin-top:3px;\">New Leads</div></div>\\n <div style=\"background:#171717;border:1px solid #333;border-radius:10px;padding:18px;text-align:center;\"><div style=\"font-size:28px;font-weight:700;color:#ef4444;\">${leads.filter(l=>l.revenue_leak_detected).length}</div><div style=\"color:#666;font-size:12px;margin-top:3px;\">Revenue Leaks</div></div>\\n <div style=\"background:#171717;border:1px solid #333;border-radius:10px;padding:18px;text-align:center;\"><div style=\"font-size:28px;font-weight:700;color:#f59e0b;\">${leads.filter(l=>l.website==='none').length}</div><div style=\"color:#666;font-size:12px;margin-top:3px;\">No Website</div></div>\\n <div style=\"background:#171717;border:1px solid #333;border-radius:10px;padding:18px;text-align:center;\"><div style=\"font-size:28px;font-weight:700;color:#3b82f6;\">${leads.filter(l=>l.seasonal_urgency==='CRITICAL').length}</div><div style=\"color:#666;font-size:12px;margin-top:3px;\">Critical Timing</div></div>\\n </div>\\n ${top5.map((l,i) => renderLead(l,i)).join('')}\\n <div style=\"text-align:center;padding:30px 0;color:#444;font-size:11px;\">Generated by The Mismatch Engine \u00b7 n8n \u00d7 Firecrawl</div>\\n</div></body></html>`;\n\nreturn [{ json: { html_report: html, lead_count: leads.length } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "2dc123c4-0d04-4475-b94f-20a3adc5f0d3",
      "name": "Build Slack Report",
      "type": "n8n-nodes-base.code",
      "position": [
        1584,
        1088
      ],
      "parameters": {
        "jsCode": "const aggregated = $('Aggregate for Report').first().json.data || [];\nif (aggregated.length === 0) return [{ json: { _no_new_leads: true } }];\nreturn aggregated.map(l => ({ json: { ...l } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "9bd29ec0-5738-445b-aa8c-68f2f8667aaf",
      "name": "Send Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        1808,
        1088
      ],
      "parameters": {
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "typeVersion": 2.2
    },
    {
      "id": "85c2658a-2464-466b-9275-b5c78e3b992e",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -3344,
        0
      ],
      "parameters": {
        "width": 486,
        "height": 640,
        "content": "\n\n\n\n# The Mismatch Engine: AI Lead Discovery for Local Businesses\n## How it works\n1. **Trigger**: Runs automatically every week (or manually).\n2. **Discover**: AI & Firecrawl hunt down relevant local businesses.\n3. **Scrape**: Analyzes websites, top competitors, and reviews in depth.\n4. **Evaluate**: Calculates a Mismatch Score (reputation vs. digital presence).\n5. **Report**: Generates an HTML report and saves leads to the database.\n\n## Setup steps\n- [ ] Connect Firecrawl API Credentials\n- [ ] Connect Groq API Credentials (LLM)\n- [ ] Update `location` and `category` in Configuration node\n- [ ] Set up 'Lead Database' n8n Table\n- [ ] (Optional) Connect Slack account for notifications"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "de31735c-cdee-4d66-ab16-3cda8368c8c0",
  "connections": {
    "Has Website?": {
      "main": [
        [
          {
            "node": "Scrape Website",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Website Path",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuration": {
      "main": [
        [
          {
            "node": "Generate Category Signals",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Website": {
      "main": [
        [
          {
            "node": "Find Competitors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Website": {
      "main": [
        [
          {
            "node": "Merge Website",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search Reviews": {
      "main": [
        [
          {
            "node": "Extract Review Snippets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Basic LLM Chain": {
      "main": [
        [
          {
            "node": "Parse Business List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Basic LLM Chain",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "No Website Path": {
      "main": [
        [
          {
            "node": "Find Competitors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Basic LLM Chain1": {
      "main": [
        [
          {
            "node": "The Mismatch Engine",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Basic LLM Chain2": {
      "main": [
        [
          {
            "node": "Parse Category Signals ",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Discovery Search": {
      "main": [
        [
          {
            "node": "Format Search Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter New Leads": {
      "main": [
        [
          {
            "node": "Process Each Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Competitors": {
      "main": [
        [
          {
            "node": "Filter Competitors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Chat Model1": {
      "ai_languageModel": [
        [
          {
            "node": "Basic LLM Chain1",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Groq Chat Model2": {
      "ai_languageModel": [
        [
          {
            "node": "Basic LLM Chain2",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Rate Limit Delay": {
      "main": [
        [
          {
            "node": "Scrape Business Profile",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Competitors": {
      "main": [
        [
          {
            "node": "Search Reviews",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process Each Lead": {
      "main": [
        [
          {
            "node": "Rate Limit Delay",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Aggregate All Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Competitor": {
      "main": [
        [
          {
            "node": "Merge Competitors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Competitors": {
      "main": [
        [
          {
            "node": "Scrape Competitor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Weekly Monday 9AM": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Slack Report": {
      "main": [
        [
          {
            "node": "Send Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Competitors": {
      "main": [
        [
          {
            "node": "Split Competitors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Valid Leads": {
      "main": [
        [
          {
            "node": "Save to n8n Table",
            "type": "main",
            "index": 0
          },
          {
            "node": "Aggregate for Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate All Leads": {
      "main": [
        [
          {
            "node": "Rank and Deduplicate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Business List": {
      "main": [
        [
          {
            "node": "Fetch Existing Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "The Mismatch Engine": {
      "main": [
        [
          {
            "node": "Process Each Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate for Report": {
      "main": [
        [
          {
            "node": "Build HTML Report",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Slack Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Existing Leads": {
      "main": [
        [
          {
            "node": "Filter New Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank and Deduplicate": {
      "main": [
        [
          {
            "node": "Filter Valid Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Search Context": {
      "main": [
        [
          {
            "node": "Basic LLM Chain",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Review Snippets": {
      "main": [
        [
          {
            "node": "Basic LLM Chain1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Category Signals ": {
      "main": [
        [
          {
            "node": "Discovery Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Business Profile": {
      "main": [
        [
          {
            "node": "Has Website?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Category Signals": {
      "main": [
        [
          {
            "node": "Basic LLM Chain2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

You know the businesses that need your services, but finding them is the hard part. They have 150+ five-star reviews, customers raving about specific services, and zero way to book online. They exist, they're profitable, and they don't know they're leaving money on the table.

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

More Marketing & Ads workflows → · Browse all categories →

Related workflows

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

Marketing & Ads

Stop manually digging through endless Google Ads search term reports! 📊 This workflow puts your brand campaign analysis on autopilot, acting as an AI-powered performance marketer that works for you 24

Slack, Chain Llm, Google Gemini Chat +3
Marketing & Ads

This workflow automatically monitors your Google Ads campaigns every day, analyzing performance with AI-powered scoring to identify scaling opportunities and catch issues before they drain your budget

Google Ads, Google Sheets, Slack +1
Marketing & Ads

This workflow monitors Meta Ads and Google Ads campaigns on a daily schedule to detect performance drops. It fetches yesterday’s campaign data, standardizes metrics, and calculates CTR and ROAS agains

Google Sheets, HTTP Request, Slack +3
Marketing & Ads

This workflow reads a list of prospect company domains from Google Sheets, enriches each company with multiple PredictLeads data sources, calculates a weighted intent score based on job openings, tech

@Predictleads/N8N Nodes Predictleads, Slack, Google Sheets
Marketing & Ads

This is built for the plastering and stucco owners who are out on the tools while the partner (the 'Patricia' of the business) tries to keep the admin from exploding. It's for anyone losing money beca

Information Extractor, Groq Chat, HTTP Request