{
  "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
          }
        ]
      ]
    }
  }
}