AutomationFlowsAI & RAG › Local Business Lead Discovery and Enrichment Agent

Local Business Lead Discovery and Enrichment Agent

ByNaz Akgül @naz on n8n.io

A weekly agent that finds local businesses with weak or missing web presence, and scores them as sales leads for web agencies, freelance developers, and digital consultants.

Cron / scheduled trigger★★★★☆ complexityAI-powered24 nodesAgentOpenAI Chat@Mendable/N8N Nodes FirecrawlGmailPostgres
AI & RAG Trigger: Cron / scheduled Nodes: 24 Complexity: ★★★★☆ AI nodes: yes Added:

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

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

The workflow JSON

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

Download .json
{
  "id": "QoCDYGBwkvIPpvUO",
  "name": "M2 Firecrawl - Case 3: Marco -Deliverable",
  "tags": [],
  "nodes": [
    {
      "id": "7280c5ef-e1c4-41af-96cf-7269fe7963a4",
      "name": "AI Agent \u2014 enrichment",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        -7056,
        6096
      ],
      "parameters": {
        "text": "=Enrich this ONE lead.\n\nInput lead JSON:\n{{ JSON.stringify($json) }}\n\nInstructions:\n1) Prefer URLs in enrichment_urls (if present):\n   - First: scrape the directory listing URL to confirm business_name, category, address, phone, website, public_rating, review_count, services_listed\n   - Second: if it is a business website, scrape it to assess website_issues, tech_signals, freshness_signal\n   If enrichment_urls is missing, fall back to source_links then website.\n2) If website exists, verify the site belongs to the business; if not, set website=null and explain in website_issues\n3) Set operating_status from evidence (recent reviews/listing freshness/website activity).\n4) Set opportunity_score:\n   - High if strong proof of active business AND (no website OR multiple website issues)\n   - Medium if some issues\n   - Low if minimal issues / weak evidence\n5) Return ONE raw JSON object with ALL required keys and nulls for unknowns.\n",
        "options": {
          "systemMessage": "OPPORTUNITY SCORING \u2014 STRICT GUARDRAILS (override)\n\nGoal: \"High\" should mean Marco can very likely help RIGHT NOW (no website or clear conversion/usability blockers).\nDo NOT give \"High\" just because a site looks old or basic.\n\nDefinitions:\n- Objective blocker = a verifiable, user-impacting issue observed via scraping (not taste).\n- Heuristic issue = subjective impressions like \"dated design\" or \"could be modernized\".\n\nRules:\n1) If website is null/none/unavailable AND you have evidence from a directory listing that no official site is listed:\n   - opportunity_score = High\n   - website_issues includes: \"No website listed (from <source>)\"\n   - why_lead mentions: \"No website\" as primary reason\n\n2) If website exists:\n   - You may set opportunity_score = High ONLY if you can confirm AT LEAST TWO objective blockers from this list:\n     - Broken / non-working contact path (contact form errors, mailto missing, phone/email not present anywhere obvious)\n     - Broken booking/reservation path (dead link, 404, unusable)\n     - Not mobile-friendly (missing viewport meta OR obvious mobile layout break described from evidence)\n     - No HTTPS (site served over http)\n     - Obvious page errors / broken layout / missing critical content (e.g., blank page, server error, endless redirect, site doesn't load)\n   - If you cannot confirm 2+ objective blockers, cap score at Medium.\n\n3) Heuristic issues (looks outdated / weak branding / competitors nicer) must go ONLY into heuristic_assessment\n   - Heuristics alone can never justify High.\n\n4) If the website has:\n   - HTTPS present AND\n   - clear working contact info or contact page AND\n   - no obvious errors\n   Then opportunity_score must be Medium or Low (not High).\n\n5) why_lead must be evidence-based:\n   - Include 1\u20132 short factual reasons (from website_issues or directory facts)\n   - Optionally add ONE heuristic note, clearly marked as heuristic in heuristic_assessment (not in why_lead)\n\nOutput discipline reminder:\n- website_issues = factual only\n- heuristic_assessment = ONLY lines beginning with \"[HEURISTIC]\"\n\n\nSCRAPE TIMEOUT HANDLING (MANDATORY)\n- If any Firecrawl scrape returns an error like HTTP 408 or code SCRAPE_TIMEOUT: \n  - Do NOT retry more than once (prefer to skip immediately).\n  - Treat the page as unavailable.\n  - Do NOT invent data. Set affected fields to null.\n  - Add a factual note in website_issues like: 'Scrape timeout (Firecrawl 408/SCRAPE_TIMEOUT)'.\n"
        },
        "promptType": "define"
      },
      "typeVersion": 1.7,
      "alwaysOutputData": true
    },
    {
      "id": "657d701e-72bc-4990-889d-483540e28075",
      "name": "GPT-5.",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        -7120,
        6320
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5.1",
          "cachedResultName": "gpt-5.1"
        },
        "options": {},
        "responsesApiEnabled": false
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "353f329d-7962-4fdf-996c-a01e7dcb8e61",
      "name": "Enrich Selected 10 \u2014 Parse",
      "type": "n8n-nodes-base.code",
      "position": [
        -6656,
        6096
      ],
      "parameters": {
        "jsCode": "// Enrich Selected 10 \u2014 Parse (handles N items, recomputes fingerprint)\nconst required = [\n  'business_name','category','address','phone','website',\n  'public_rating','review_count',\n  'website_issues','heuristic_assessment','services_listed','tech_signals',\n  'freshness_signal','operating_status',\n  'opportunity_score','why_lead','source_links'\n];\n\nfunction norm(s) {\n  return (s || '').toString().toLowerCase()\n    .replace(/\\s+/g, ' ')\n    .replace(/[^\\p{L}\\p{N}\\s]/gu, '')\n    .trim();\n}\nfunction host(url) {\n  try { if (!url) return ''; return new URL(url).hostname.replace(/^www\\./, '').toLowerCase(); }\n  catch { return ''; }\n}\nfunction makeFp(j) {\n  const name = norm(j.business_name);\n  const addr = norm(j.address);\n  const phone = norm(j.phone);\n  const whost = host(j.website);\n  if (name && addr) return `${name}|${addr}`;\n  if (name && phone) return `${name}|${phone}`;\n  if (name && whost) return `${name}|${whost}`;\n  return name ? `${name}|` : null;\n}\n\nconst out = [];\nfor (const item of items) {\n  const r = item.json || {};\n  let raw = r.output ?? r.text ?? r;\n\n  let obj;\n  if (typeof raw === 'string') {\n    let cleaned = raw.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();\n    try { obj = JSON.parse(cleaned); } catch { continue; }\n  } else if (typeof raw === 'object' && raw !== null) {\n    obj = raw;\n  } else continue;\n\n  if (obj.output && typeof obj.output === 'object') obj = obj.output;\n  for (const k of required) if (!(k in obj)) obj[k] = null;\n\n// Flatten arrays to strings for TEXT columns\nconst arrToStr = (v, sep = '; ') => Array.isArray(v) ? v.filter(Boolean).join(sep) : v;\nobj.services_listed = arrToStr(obj.services_listed, ', ');\nobj.tech_signals = arrToStr(obj.tech_signals, ', ');\nobj.website_issues = arrToStr(obj.website_issues, '; ');\nobj.source_links = arrToStr(obj.source_links, ' | ');\nobj.public_rating = obj.public_rating != null ? String(obj.public_rating) : null;\nobj.review_count = obj.review_count != null ? String(obj.review_count) : null;\n\n\n  // Recompute fingerprint from enriched data (agent drops it)\n  obj.fingerprint = makeFp(obj);\n\n  out.push({ json: obj });\n}\n\nif (out.length === 0) {\n  throw new Error('Parse produced 0 items. First input shape: ' + JSON.stringify(items[0]?.json).slice(0, 400));\n}\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "4fc41924-6acc-4524-9a64-c4f7bbc6cb6e",
      "name": "Enrich Selected 10 \u2014 Scrape Website",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawlTool",
      "position": [
        -6896,
        6400
      ],
      "parameters": {
        "url": "={{ (() => { const u = (($fromAI('url') || '')+'').trim(); if (/^https?:\\/\\//i.test(u)) return u; if (/^www\\./i.test(u) || /^[a-z0-9-]+(\\.[a-z0-9-]+)+/i.test(u)) return 'https://' + u; return ''; })() }}",
        "operation": "scrape",
        "scrapeOptions": {
          "options": {
            "formats": {
              "format": [
                {},
                {
                  "type": "links"
                }
              ]
            },
            "headers": {}
          }
        },
        "requestOptions": {},
        "descriptionType": "manual",
        "toolDescription": "Scrape a specific URL and return its content as markdown. \nUse this to scrape business directory listings and business websites \nto extract contact details and evaluate website quality."
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "4df6e379-dfe5-492a-b179-67ff58121fbb",
      "name": "Enrich Selected 10 \u2014 Scrape Listing",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawlTool",
      "position": [
        -6768,
        6304
      ],
      "parameters": {
        "url": "={{ (() => { const u = (($fromAI('url') || '')+'').trim(); if (/^https?:\\/\\//i.test(u)) return u; if (/^www\\./i.test(u) || /^[a-z0-9-]+(\\.[a-z0-9-]+)+/i.test(u)) return 'https://' + u; return ''; })() }}",
        "operation": "scrape",
        "scrapeOptions": {
          "options": {
            "formats": {
              "format": [
                {},
                {
                  "type": "links"
                }
              ]
            },
            "headers": {}
          }
        },
        "requestOptions": {},
        "descriptionType": "manual",
        "toolDescription": "Scrape a specific URL and return its content as markdown. \nUse this to scrape business directory listings and business websites \nto extract contact details and evaluate website quality."
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "d497407b-4207-400e-8178-4334e27ad762",
      "name": "Enrich Selected 10 \u2014 Prep",
      "type": "n8n-nodes-base.code",
      "position": [
        -7344,
        6096
      ],
      "parameters": {
        "jsCode": "// Enrich Selected 10 \u2014 Prep (no-LangChain edition)\nfunction norm(s){return (s??'').toString().trim();}\nreturn items.map(item=>{\n  const j=item.json;\n  const business_name=norm(j.business_name||j.name);\n  const website=norm(j.website);\n  const source_links=j.source_links ?? j.source_link ?? j.listing_url ?? j.url ?? null;\n  const urls=[];\n  if (Array.isArray(source_links)) urls.push(...source_links.filter(Boolean));\n  else if (typeof source_links==='string' && source_links) urls.push(source_links);\n  if (website) urls.push(website);\n  return {json:{...j, business_name, website: website||null, source_links: source_links??null, enrichment_urls: urls}};\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "ac53a2e7-99ea-4909-b609-33ae3e9ce24f",
      "name": "Compute Fingerprint",
      "type": "n8n-nodes-base.code",
      "position": [
        -8016,
        6096
      ],
      "parameters": {
        "jsCode": "function norm(s) {\n  return (s || '')\n    .toString()\n    .toLowerCase()\n    .replace(/\\s+/g, ' ')\n    .replace(/[^\\p{L}\\p{N}\\s]/gu, '')\n    .trim();\n}\n\nfunction host(url) {\n  try {\n    if (!url) return '';\n    const u = new URL(url);\n    return u.hostname.replace(/^www\\./, '').toLowerCase();\n  } catch {\n    return '';\n  }\n}\n\nreturn $input.all().map(item => {\n  const j = item.json;\n  const name = norm(j.business_name);\n  const addr = norm(j.address);\n  const phone = norm(j.phone);\n  const whost = host(j.website);\n\n  const fp =\n    (name && addr) ? `${name}|${addr}` :\n    (name && phone) ? `${name}|${phone}` :\n    (name && whost) ? `${name}|${whost}` :\n    name ? `${name}|` : null;\n\n  return { json: { ...j, fingerprint: fp } };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "6bc09b20-26fb-469f-9770-ddb48f6a8cdb",
      "name": "Parse Refill Output",
      "type": "n8n-nodes-base.code",
      "position": [
        -8240,
        6096
      ],
      "parameters": {
        "jsCode": "const agentOutput = $input.first().json.output;\n\nlet leads = [];\ntry {\n  const jsonMatch = agentOutput.match(/\\[\\s*\\{[\\s\\S]*\\}\\s*\\]/);\n  if (jsonMatch) {\n    leads = JSON.parse(jsonMatch[0]);\n  } else {\n    leads = JSON.parse(agentOutput);\n  }\n} catch (e) {\n  return [{ json: { error: 'Failed to parse agent output', raw: agentOutput } }];\n}\n\nconst weekOf = new Date().toISOString().split('T')[0];\n\nreturn leads.map(lead => ({\n  json: {\n    business_name: lead.business_name || null,\n    category: lead.category || null,\n    address: lead.address || null,\n    phone: lead.phone || null,\n    website: lead.website || null,\n    public_rating: lead.public_rating != null ? String(lead.public_rating) : null,\n    review_count: lead.review_count != null ? String(lead.review_count) : null,\n    website_issues: Array.isArray(lead.website_issues) \n      ? lead.website_issues.join('; ') \n      : (lead.website_issues || null),\n    heuristic_assessment: lead.heuristic_assessment || null,\n    services_listed: Array.isArray(lead.services_listed) \n      ? lead.services_listed.join(', ') \n      : (lead.services_listed || null),\n    tech_signals: Array.isArray(lead.tech_signals) \n      ? lead.tech_signals.join(', ') \n      : (lead.tech_signals || null),\n    freshness_signal: lead.freshness_signal || null,\n    opportunity_score: lead.opportunity_score || null,\n    why_lead: lead.why_lead || null,\n    // handle both source_link and source_links\n    source_links: lead.source_links || lead.source_link || null,\n    week_of: weekOf,\n    discovered_at: new Date().toISOString()\n  }\n}));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "28f0aef9-86c1-45f4-9e9c-a6ed11a75bac",
      "name": "AI Agent \u2014 Discover Fresh",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        -8656,
        6096
      ],
      "parameters": {
        "text": "==You are Marco's Lead Scout. Discover 20 fresh local Berlin business leads.\n\n{{ $json.rotation_hint }}\n\nSelection priority (important):\n- Prefer directory listings with NO official website listed (target at least 12/20 with website=null for this reason).\n- Only include leads with a website if the listing suggests weak web presence (social-only, suspicious/unofficial link, or obvious objective red flag).\n- Avoid clearly modern/professional websites unless you have a concrete red flag from the directory listing.\n\nRules:\n- Use /search in Firecrawl across the rotation categories and neighbourhoods (mix them).\n- Use /scrape in Firecrawl only on real https:// URLs from search results (prefer scraping the directory listing page at discovery stage).\n- Exclude chains, franchises, web/marketing/SEO agencies.\n- Output ONLY a raw JSON array of EXACTLY 20 objects. No markdown, no code fences.\n- Each object must include: business_name, category, address, phone, website, source_links.\n- Use null for unknown fields. Never omit a key.\n",
        "options": {
          "systemMessage": "You are Marco's Lead Scout. Discover 20 Berlin business leads using Firecrawl tools.\nRotate across the categories and neighbourhoods given in the user message.\nExclude chains, franchises, web/marketing/SEO agencies.\n\nPRIMARY GOAL\nFind local businesses that are likely good prospects for a freelance web designer because they have:\n- no official website listed, OR\n- weak/limited web presence, OR\n- obvious, evidence-backed website problems (only if you can confirm cheaply and reliably)\n\nTOOL DISCIPLINE\n- Use /search in Firecrawl to find business directory listing URLs (prefer yelp.de, gelbeseiten.de, dasoertliche.de or similarly public directories).\n- Use /scrape in Firecrawl ONLY on real https:// URLs from search results.\n- Never pass a search query directly to /scrape in Firecrawl.\n- Do not scrape aggressively. Keep it cheap:\n  - Prefer scraping the directory listing page (not the business website) at this discovery stage.\n\nLEAD SELECTION \u2014 QUALITY FIRST (IMPORTANT)\n- Prefer leads where the DIRECTORY LISTING shows NO official website.\n  - Target: at least 12 of the 20 leads should have website = null because no website is listed on the directory page.\n- For the remaining leads (up to 8) where a website exists, include ONLY if the listing suggests weak web presence, for example:\n  - only social links (facebook/instagram) instead of a real website, OR\n  - the website link looks suspicious/unofficial (e.g., strange domain unrelated to the business name), OR\n  - the listing/preview makes an obvious objective issue likely (e.g., dead link) WITHOUT requiring heavy website scraping.\n- Avoid leads with clearly modern, professional websites at this stage unless you also have a concrete red flag from the directory listing.\n\nDATA SAFETY / EVIDENCE\n- Use only public business information from directories and public pages.\n- Do not guess. If a field is not present on the listing, set it to null.\n\nOUTPUT (STRICT)\n- Return ONLY a raw JSON array of EXACTLY 20 objects. No markdown, no code fences, no prose.\n- Each object must include EXACTLY these keys:\n  business_name, category, address, phone, website, source_links\n- Use null for unknown fields. Never omit a key.\n- source_links must be the directory listing URL you used as evidence (https://).\n\n"
        },
        "promptType": "define"
      },
      "typeVersion": 1.7,
      "alwaysOutputData": true
    },
    {
      "id": "c8161953-a8fa-4396-b433-35189ee04b18",
      "name": "/search in Firecrawl",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawlTool",
      "position": [
        -8496,
        6304
      ],
      "parameters": {
        "query": "={{ $fromAI('query', 'Search query to find local businesses in Berlin') }}",
        "resource": "MapSearch",
        "operation": "search",
        "requestOptions": {},
        "descriptionType": "manual",
        "toolDescription": "Search the web for local businesses in Berlin using a search query. \nReturns a list of URLs matching the query. Use this to discover \nbusinesses in directories like yelp.de and gelbeseiten.de."
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "1628a072-e338-4cd0-9ac5-8e25690778c6",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -6480,
        5744
      ],
      "parameters": {
        "color": "#C4BC00",
        "height": 336,
        "content": "CREATE TABLE IF NOT EXISTS marco_leads (\n  id BIGSERIAL PRIMARY KEY,\n\n  -- dedup / upsert key (recommended unique)\n  fingerprint TEXT UNIQUE,\n\n  business_name TEXT NOT NULL,\n  category TEXT,\n  address TEXT,\n  phone TEXT,\n  website TEXT,\n\n  public_rating TEXT,\n  review_count TEXT,\n\n  website_issues TEXT,\n  heuristic_assessment TEXT,\n  services_listed TEXT,\n  tech_signals TEXT,\n  freshness_signal TEXT,\n\n  operating_status TEXT,\n  opportunity_score TEXT,\n  why_lead TEXT,\n\n  source_links TEXT,\n\n  week_of DATE,\n  discovered_at TIMESTAMPTZ\n);\n"
      },
      "typeVersion": 1
    },
    {
      "id": "f0348991-bc94-4607-96eb-f726abee611e",
      "name": "GPT-5.1",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        -8704,
        6320
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5.1",
          "cachedResultName": "gpt-5.1"
        },
        "options": {},
        "responsesApiEnabled": false
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "bcb40969-a8e9-4490-8e64-9c44afae58a1",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -9216,
        6288
      ],
      "parameters": {
        "color": 5,
        "width": 360,
        "height": 556,
        "content": "## Case 3: Marco - Local Lead Enrichment\n\n**Difficulty:** Advanced\n\n**Trigger:** Weekly schedule (Mondays at 9:00 AM)\n\n**Marco's criteria:**\n- Business types: Dentists, Hair salons, Fitness studios, Restaurants\n- Location: Berlin, Germany (24km radius)\n- Signals: No website, not mobile-friendly, outdated design, strong reviews but weak site\n- Exclude: Chains/franchises, businesses with a web agency\n- Target: 10 leads per week\n\n**Your task:**\n1. Use the AI Agent to discover local businesses\n2. Use Firecrawl to scrape directories and business websites\n3. Evaluate website quality\n4. Score and rank leads\n5. Write results to a database or sheet"
      },
      "typeVersion": 1
    },
    {
      "id": "fef8b690-cec2-4828-9069-915dbba8217e",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -8768,
        6544
      ],
      "parameters": {
        "color": 3,
        "width": 420,
        "height": 260,
        "content": "## Build your agent here\n\nConnect the AI Agent after the Schedule Trigger.\n\nThe agent should:\n- Search directories for businesses in the target area\n- Scrape each business website for quality signals\n- Score leads by opportunity (High/Medium/Low)\n- Distinguish objective checks from heuristic judgments\n- Deduplicate against previous runs\n- Handle businesses with no website explicitly\n\nAdd Firecrawl as a tool to the AI Agent node."
      },
      "typeVersion": 1
    },
    {
      "id": "cbcee736-c311-4df3-87f0-71c26a6c20f5",
      "name": "/scrape in Firecrawl",
      "type": "@mendable/n8n-nodes-firecrawl.firecrawlTool",
      "position": [
        -8240,
        6304
      ],
      "parameters": {
        "url": "={{ $fromAI('url', 'The full https:// URL to scrape') }}\n",
        "operation": "scrape",
        "scrapeOptions": {
          "options": {
            "formats": {
              "format": [
                {},
                {
                  "type": "links"
                }
              ]
            },
            "headers": {}
          }
        },
        "requestOptions": {},
        "descriptionType": "manual",
        "toolDescription": "Scrape a specific URL and return its content as markdown. \nUse this to scrape business directory listings and business websites \nto extract contact details and evaluate website quality."
      },
      "credentials": {
        "firecrawlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "a07abfb8-1e86-4d04-ad50-2acca61fff93",
      "name": "Sticky Note \u2014 Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -9296,
        5680
      ],
      "parameters": {
        "color": "#942192",
        "width": 340,
        "height": 328,
        "content": "## \u26a0\ufe0f Before running:\n1. Create the `marco_leads`  table in database of your choosing (mine us Supabase) using the provided SQL\n2. Set your Gmail address in the Gmail node\n3. Connect your Postgres (Supabase) credentials\n4. Connect your OpenAI + Firecrawl credentials\n5. Activate this workflow to automatically run on schedule"
      },
      "typeVersion": 1
    },
    {
      "id": "8c063430-b445-4d34-baca-720fa444db3a",
      "name": "Gmail \u2014 Send Weekly Summary",
      "type": "n8n-nodes-base.gmail",
      "position": [
        -5760,
        6096
      ],
      "parameters": {
        "sendTo": "Marco@EXAMPLE.com",
        "message": "={{ $json.html }}",
        "options": {},
        "subject": "={{ $json.subject }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "c78d2c77-baa5-4d90-8ef4-1a551e0a41ed",
      "name": "Build Email HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        -5984,
        6096
      ],
      "parameters": {
        "jsCode": "// Build a clean HTML email summarising the week's new leads\nconst raw = $input.all();\nlet leads = [];\nif (raw.length === 1 && raw[0].json && Array.isArray(raw[0].json.data)) {\n  leads = raw[0].json.data;\n} else {\n  leads = raw.map(i => i.json);\n}\n\nconst weekOf = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });\n\n// Emojis\nconst scoreEmoji = (s) => s === 'High' ? '\ud83d\ude80' : s === 'Medium' ? '\u2705' : '\ud83d\udfe1';\n\nconst safe = (v, f = '\u2014') => (v === null || v === undefined || v === '') ? f : v;\n\n// --- NEW: sort leads by opportunity_score (High -> Medium -> Low -> everything else) ---\nconst scoreRank = (s) => {\n  const v = (s ?? '').toString().trim().toLowerCase();\n  if (v === 'high') return 0;\n  if (v === 'medium') return 1;\n  if (v === 'low') return 2;\n  return 3; // unknown / null / anything else goes last\n};\n\n// Stable-ish ordering: keep original order inside each group via original index\nleads = leads\n  .map((lead, idx) => ({ lead, idx }))\n  .sort((a, b) => {\n    const ra = scoreRank(a.lead.opportunity_score);\n    const rb = scoreRank(b.lead.opportunity_score);\n    if (ra !== rb) return ra - rb;\n    return a.idx - b.idx;\n  })\n  .map(x => x.lead);\n\n// Cell styles\nconst tdWrap = \"padding:12px;color:#555;vertical-align:top;white-space:normal;overflow-wrap:anywhere;word-break:break-word;\";\nconst tdWrapStrong = \"padding:12px;font-weight:600;color:#111;vertical-align:top;white-space:normal;overflow-wrap:anywhere;word-break:break-word;\";\nconst tdScore = \"padding:12px;text-align:center;vertical-align:top;white-space:nowrap;\";\n\nconst row = (lead, i) => `\n  <tr style=\"background:${i % 2 === 0 ? '#ffffff' : '#f9f9f9'}\">\n    <td style=\"${tdWrapStrong}\">${safe(lead.business_name)}</td>\n    <td style=\"${tdWrap}text-align:left;\">\n      ${\n        lead.website\n          ? `<a href=\"${lead.website}\" target=\"_blank\" rel=\"noopener noreferrer\">Website</a>`\n          : '<em>No website</em>'\n      }\n    </td>\n    <td style=\"${tdScore}\">${scoreEmoji(lead.opportunity_score)} ${safe(lead.opportunity_score, '\u2014')}</td>\n    <td style=\"${tdWrap}font-size:13px;\">${safe(lead.why_lead, '')}</td>\n  </tr>`;\n\nconst html = `\n<!DOCTYPE html>\n<html>\n<head><meta charset=\"UTF-8\"></head>\n<body style=\"font-family:Inter,Arial,sans-serif;background:#f4f4f4;padding:32px;margin:0\">\n  <div style=\"max-width:900px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08)\">\n    <div style=\"background:#1a1a2e;padding:32px;color:#fff\">\n      <h1 style=\"margin:0;font-size:24px\">\ud83c\udfd7\ufe0f Marco's Lead Scout</h1>\n      <p style=\"margin:8px 0 0;opacity:0.7\">Week of ${weekOf} \u2014 ${leads.length} new lead${leads.length !== 1 ? 's' : ''} found</p>\n    </div>\n    <div style=\"padding:32px\">\n      <table style=\"width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed\">\n        <thead>\n          <tr style=\"background:#f0f0f0\">\n            <th style=\"padding:12px;text-align:left;color:#333;width:26%;white-space:normal;overflow-wrap:anywhere;\">Business</th>\n            <th style=\"padding:12px;text-align:left;color:#333;width:10%;white-space:nowrap;\">Website</th>\n            <th style=\"padding:12px;text-align:center;color:#333;width:10%;white-space:nowrap;\">Score</th>\n            <th style=\"padding:12px;text-align:left;color:#333;width:54%;white-space:normal;overflow-wrap:anywhere;\">Why Contact</th>\n          </tr>\n        </thead>\n        <tbody>\n          ${leads.map((lead, i) => row(lead, i)).join('')}\n        </tbody>\n      </table>\n      <p style=\"margin-top:32px;color:#999;font-size:12px\">Made with love by your n8n workflow.</p>\n    </div>\n  </div>\n</body>\n</html>`;\n\nreturn [{ json: { subject: `\ud83c\udfd7\ufe0f Marco's Lead Scout \u2014 Week of ${weekOf} (${leads.length} new leads)`, html } }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "8ca13f95-c786-4e93-bb48-7d383653bf04",
      "name": "Aggregate New Leads",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        -6208,
        6096
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "cbf97672-8a2c-4c1b-97e6-a5ec5655949c",
      "name": "Postgres \u2014 Insert Lead",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -6432,
        6096
      ],
      "parameters": {
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "marco_leads",
          "cachedResultName": "marco_leads"
        },
        "schema": {
          "__rl": true,
          "mode": "list",
          "value": "public"
        },
        "columns": {
          "value": {
            "phone": "={{ $json.phone }}",
            "address": "={{ $json.address }}",
            "website": "={{ $json.website }}",
            "week_of": "={{ $now.format('YYYY-MM-DD') }}",
            "category": "={{ $json.category }}",
            "why_lead": "={{ $json.why_lead ?? null }}",
            "fingerprint": "={{ $json.fingerprint ?? null }}",
            "review_count": "={{ $json.review_count ?? null }}",
            "source_links": "={{ $json.source_links }}",
            "tech_signals": "={{ $json.tech_signals ?? null }}",
            "business_name": "={{ $json.business_name }}",
            "discovered_at": "={{ $now.toISO() }}",
            "public_rating": "={{ $json.public_rating ?? null }}",
            "website_issues": "={{ $json.website_issues ?? null }}",
            "services_listed": "={{ $json.services_listed ?? null }}",
            "freshness_signal": "={{ $json.freshness_signal ?? null }}",
            "operating_status": "={{ $json.operating_status ?? null }}",
            "opportunity_score": "={{ $json.opportunity_score ?? null }}",
            "heuristic_assessment": "={{ $json.heuristic_assessment ?? null }}"
          },
          "schema": [
            {
              "id": "fingerprint",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "fingerprint",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "business_name",
              "type": "string",
              "display": true,
              "required": true,
              "displayName": "business_name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "category",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "category",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "address",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "address",
              "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": "public_rating",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "public_rating",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "review_count",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "review_count",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "website_issues",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "website_issues",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "heuristic_assessment",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "heuristic_assessment",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "services_listed",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "services_listed",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "tech_signals",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "tech_signals",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "freshness_signal",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "freshness_signal",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "operating_status",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "operating_status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "opportunity_score",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "opportunity_score",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "why_lead",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "why_lead",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "source_links",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "source_links",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "week_of",
              "type": "dateTime",
              "display": true,
              "required": false,
              "displayName": "week_of",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "discovered_at",
              "type": "dateTime",
              "display": true,
              "required": false,
              "displayName": "discovered_at",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "fingerprint"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5,
      "continueOnFail": true
    },
    {
      "id": "9b5118e4-35af-4be1-8c4e-f9c676439eb4",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -9152,
        6096
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * 1"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "7e3a79ac-d6c0-4c46-b0b8-607bc87e89ec",
      "name": "Rotation Seed",
      "type": "n8n-nodes-base.code",
      "position": [
        -8928,
        6096
      ],
      "parameters": {
        "jsCode": "// Rotation seed for category/area diversity\n// Picks a different rotation each week, deterministic by ISO week number\nconst now = new Date();\nconst start = new Date(now.getFullYear(), 0, 1);\nconst week = Math.ceil(((now - start) / 86400000 + start.getDay() + 1) / 7);\n\nconst categories = [\n  ['Friseur', 'Restaurant', 'Fitnessstudio'],\n  ['Fitnessstudio', 'Zahnarzt', 'Friseur'],\n  ['Restaurant', 'Friseur', 'Zahnarzt'],\n  ['Zahnarzt', 'Restaurant', 'Fitnessstudio']\n];\nconst areas = [\n  ['Mitte', 'Neuk\u00f6lln', 'Kreuzberg'],\n  ['Charlottenburg', 'Wilmersdorf', 'Sch\u00f6neberg'],\n  ['Prenzlauer Berg', 'Friedrichshain', 'Pankow'],\n  ['Spandau', 'Tempelhof', 'Steglitz']\n];\n\nconst cat = categories[week % categories.length];\nconst area = areas[week % areas.length];\n\nreturn [{\n  json: {\n    week_number: week,\n    rotation_categories: cat,\n    rotation_areas: area,\n    rotation_hint: `This week focus on: categories=${cat.join(', ')}; neighbourhoods=${area.join(', ')}`\n  }\n}];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "8b8d5b6d-752c-43f8-b9b3-d560980f3f97",
      "name": "Fetch Seen Fingerprints",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -7792,
        6096
      ],
      "parameters": {
        "query": "SELECT fingerprint FROM marco_leads WHERE fingerprint IS NOT NULL;",
        "options": {},
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5,
      "alwaysOutputData": true
    },
    {
      "id": "dcff5174-4c8e-4c21-83e0-1a1e592133fe",
      "name": "Filter Unseen (Top 10)",
      "type": "n8n-nodes-base.code",
      "position": [
        -7568,
        6096
      ],
      "parameters": {
        "jsCode": "// Candidates come from Compute Fingerprint (one node back in the chain)\nconst candidates = $('Compute Fingerprint').all();\nconst seenItems = $('Fetch Seen Fingerprints').all();\n\nconst seen = new Set(\n  seenItems.map(i => (i.json.fingerprint || '').trim()).filter(Boolean)\n);\n\nconst unseen = candidates.filter(c => {\n  const fp = (c.json.fingerprint || '').trim();\n  return fp && !seen.has(fp);\n});\n\nreturn unseen.slice(0, 10);\n\n// Return top 10\nreturn unseen.slice(0, 10);\n"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "96561b35-4ad2-4841-991e-6af3b4a49e61",
  "connections": {
    "GPT-5.": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent \u2014 enrichment",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "GPT-5.1": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent \u2014 Discover Fresh",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Rotation Seed": {
      "main": [
        [
          {
            "node": "AI Agent \u2014 Discover Fresh",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Email HTML": {
      "main": [
        [
          {
            "node": "Gmail \u2014 Send Weekly Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Rotation Seed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate New Leads": {
      "main": [
        [
          {
            "node": "Build Email HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute Fingerprint": {
      "main": [
        [
          {
            "node": "Fetch Seen Fingerprints",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Refill Output": {
      "main": [
        [
          {
            "node": "Compute Fingerprint",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "/scrape in Firecrawl": {
      "ai_tool": [
        [
          {
            "node": "AI Agent \u2014 Discover Fresh",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "/search in Firecrawl": {
      "ai_tool": [
        [
          {
            "node": "AI Agent \u2014 Discover Fresh",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Filter Unseen (Top 10)": {
      "main": [
        [
          {
            "node": "Enrich Selected 10 \u2014 Prep",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent \u2014 enrichment": {
      "main": [
        [
          {
            "node": "Enrich Selected 10 \u2014 Parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Seen Fingerprints": {
      "main": [
        [
          {
            "node": "Filter Unseen (Top 10)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Postgres \u2014 Insert Lead": {
      "main": [
        [
          {
            "node": "Aggregate New Leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent \u2014 Discover Fresh": {
      "main": [
        [
          {
            "node": "Parse Refill Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Enrich Selected 10 \u2014 Prep": {
      "main": [
        [
          {
            "node": "AI Agent \u2014 enrichment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Enrich Selected 10 \u2014 Parse": {
      "main": [
        [
          {
            "node": "Postgres \u2014 Insert Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Enrich Selected 10 \u2014 Scrape Listing": {
      "ai_tool": [
        [
          {
            "node": "AI Agent \u2014 enrichment",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Enrich Selected 10 \u2014 Scrape Website": {
      "ai_tool": [
        [
          {
            "node": "AI Agent \u2014 enrichment",
            "type": "ai_tool",
            "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

A weekly agent that finds local businesses with weak or missing web presence, and scores them as sales leads for web agencies, freelance developers, and digital consultants.

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

More AI & RAG workflows → · Browse all categories →

Related workflows

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

AI & RAG

Rss-Daily. Uses @mendable/n8n-nodes-firecrawl, agent, gmail, lmChatOllama. Scheduled trigger; 38 nodes.

@Mendable/N8N Nodes Firecrawl, Agent, Gmail +4
AI & RAG

Automates sales data analysis and strategic insight generation for sales managers and strategists needing actionable intelligence. Fetches multi-source data from sales, marketing, and financial system

HTTP Request, Agent, OpenAI Chat +6
AI & RAG

Automatically compare AI-generated email drafts against what your support team actually sent, learn from the differences, and improve future drafts over time — without any model fine-tuning.

Postgres, Gmail, Agent +3
AI & RAG

This workflow automates financial transaction surveillance by monitoring multiple payment systems, analyzing transaction patterns with AI, and triggering instant fraud alerts. Designed for finance tea

HTTP Request, Agent, OpenAI Chat +4
AI & RAG

This workflow contains community nodes that are only compatible with the self-hosted version of n8n.

Mailgun, OpenAI, OpenAI Chat +8