{
  "name": "FL Contractor Lead Generator v1",
  "nodes": [
    {
      "parameters": {},
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [0, 0],
      "id": "trigger-manual",
      "name": "manual_trigger"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [0, 200],
      "id": "trigger-schedule",
      "name": "schedule_trigger"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "typeVersion": 1,
      "position": [0, 400],
      "id": "trigger-subworkflow",
      "name": "sub_workflow_trigger",
      "notes": "Entry point when called by parent lead-gen-orchestrator. Input: { searchTerm, locationQuery, city, state, vertical, maxPlaces }. Falls back to Port St. Lucie / roofing defaults when fields missing."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "a1",
              "name": "searchTerm",
              "value": "={{ $json.searchTerm || 'roofing contractor' }}",
              "type": "string"
            },
            {
              "id": "a2",
              "name": "locationQuery",
              "value": "={{ $json.locationQuery || 'Port St. Lucie, FL' }}",
              "type": "string"
            },
            {
              "id": "a3",
              "name": "city",
              "value": "={{ $json.city || 'Port St. Lucie' }}",
              "type": "string"
            },
            {
              "id": "a4",
              "name": "state",
              "value": "={{ $json.state || 'FL' }}",
              "type": "string"
            },
            {
              "id": "a5",
              "name": "vertical",
              "value": "={{ $json.vertical || 'roofing' }}",
              "type": "string"
            },
            {
              "id": "a6",
              "name": "maxPlaces",
              "value": "={{ Number($json.maxPlaces) || 20 }}",
              "type": "number"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [240, 100],
      "id": "set-params",
      "name": "set_search_params",
      "notes": "Change these values to target a different (vertical, city). Each run touches ONE combo. For a rotating multi-combo pump, chain this workflow under a parent that loops combos."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://api.apify.com/v2/acts/compass~crawler-google-places/run-sync-get-dataset-items?token={{ $env.APIFY_TOKEN }}&memory=2048&timeout=420",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"searchStringsArray\": [\"{{ $json.searchTerm }}\"],\n  \"locationQuery\": \"{{ $json.locationQuery }}\",\n  \"maxCrawledPlacesPerSearch\": {{ $json.maxPlaces }},\n  \"language\": \"en\",\n  \"skipClosedPlaces\": true,\n  \"scrapePlaceDetailPage\": false,\n  \"scrapeReviewsPersonalData\": false\n}",
        "options": {
          "timeout": 480000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [480, 100],
      "id": "http-apify",
      "name": "apify_run_sync",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000,
      "notes": "run-sync-get-dataset-items returns the item array directly in one call — no polling needed. Memory=2048MB, timeout=7min."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.OLLAMA_BASE_URL }}/api/chat",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"llama3.2:3b\",\n  \"stream\": false,\n  \"format\": \"json\",\n  \"options\": {\n    \"temperature\": 0.1\n  },\n  \"messages\": [\n    {\n      \"role\": \"system\",\n      \"content\": \"You are a strict B2B lead qualifier for FL home service contractors. Return ONLY a valid JSON object with keys: score (integer 0-100), email (string or null), decision_maker (boolean), notes (one short sentence).\\n\\nSCORE RUBRIC — apply precisely:\\n- 90-100 EXCEPTIONAL: local independent owner-operator. Must have ALL: own website (not Google Maps page, not Facebook, not a directory), own phone number, rating ≥ 4.5, reviews ≥ 30, AND at least 2 positive signals (company domain email, owner name in record, local address, specialty focus on FL home services).\\n- 70-89 STRONG: local business with website + phone + rating ≥ 4.0 + reviews ≥ 15, but missing one of the 90+ signals.\\n- 40-69 ACCEPTABLE: local business with website + phone but thin reviews (< 15), or unverified rating, or generic franchise branding but independently owned.\\n- 0-39 REJECT: any of — national chain (Home Depot, Lowes, Angi, Thumbtack, HomeAdvisor, Yelp, Yellow Pages, Houzz, BBB, Mr Rooter, Roto-Rooter, ServPro, Benjamin Franklin, One Hour, Aire Serv, Mister Sparky, Bath Fitter, Re-Bath), directory listing, aggregator, no website, no phone, rating < 3.5 AND reviews < 10, suspected duplicate/fake, or garfield-owned domain (floridadigitalmarketingexperts.com, floridaaimarketingexperts.com, speedyremodelingcompany.com, affordableroofrepairestimates.com, openclawskillpacks.com).\\n\\nEMAIL FIELD RULES: return null unless the business record contains a real email matching /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/. Never invent an email. Never return 'none', 'n/a', 'not found'.\\n\\nEXAMPLES:\\n- 'ABC Roofing Inc' with website, phone, 4.8 rating, 127 reviews, independent → score 95, decision_maker true.\\n- 'Home Depot #1234' → score 0, decision_maker false, notes 'national chain'.\\n- 'Local Roof Guy' with website, phone, 4.2 rating, 8 reviews → score 55, notes 'thin review count'.\\n- 'Joe Plumbing' with Facebook page only, no website → score 25, notes 'no own website'.\"\n    },\n    {\n      \"role\": \"user\",\n      \"content\": \"=Business to qualify:\\nName: {{ ($json.title || '').replace(/\\\"/g,'') }}\\nWebsite: {{ $json.website || '' }}\\nPhone: {{ $json.phone || '' }}\\nRating: {{ $json.totalScore || 'n/a' }}\\nReviews: {{ $json.reviewsCount || 0 }}\\nCategory: {{ ($json.categoryName || '').replace(/\\\"/g,'') }}\\nAddress: {{ ($json.address || '').replace(/\\\"/g,'') }}\\nEmails: {{ Array.isArray($json.emails) ? $json.emails.join(', ') : ($json.emails || '') }}\"\n    }\n  ]\n}",
        "options": {
          "timeout": 60000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [960, 100],
      "id": "http-ollama",
      "name": "ollama_qualify",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000,
      "notes": "Ollama local inference with llama3.2:3b + strict rubric prompt (90+ exceptional / 70-89 strong / 40-69 acceptable / 0-39 reject). With old weak prompt llama scored 100 on everything; with the new tighter rubric+examples llama correctly scored 40/85/0 on test cases where gemma3:4b-it-qat missed (scored the thin-review case at 85 vs expected 40-69). Temperature 0.1 for stricter output. Swap url to https://integrate.api.nvidia.com/v1/chat/completions + add Authorization header for NVIDIA, or api.openai.com for OpenAI."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "l1",
              "name": "business_name",
              "value": "={{ $('apify_run_sync').item.json.title || $('apify_run_sync').item.json.name }}",
              "type": "string"
            },
            {
              "id": "l2",
              "name": "business_url",
              "value": "={{ $('apify_run_sync').item.json.website || '' }}",
              "type": "string"
            },
            {
              "id": "l3",
              "name": "normalized_domain",
              "value": "={{ ($('apify_run_sync').item.json.website || '').replace(/^https?:\\/\\//,'').replace(/^www\\./,'').split('/')[0].toLowerCase() }}",
              "type": "string"
            },
            {
              "id": "l4",
              "name": "phone",
              "value": "={{ $('apify_run_sync').item.json.phone || $('apify_run_sync').item.json.phoneUnformatted || '' }}",
              "type": "string"
            },
            {
              "id": "l5",
              "name": "google_place_id",
              "value": "={{ $('apify_run_sync').item.json.placeId || $('apify_run_sync').item.json.cid || '' }}",
              "type": "string"
            },
            {
              "id": "l6",
              "name": "google_rating",
              "value": "={{ Number($('apify_run_sync').item.json.totalScore) || null }}",
              "type": "number"
            },
            {
              "id": "l7",
              "name": "review_count",
              "value": "={{ Number($('apify_run_sync').item.json.reviewsCount) || 0 }}",
              "type": "number"
            },
            {
              "id": "l8",
              "name": "city",
              "value": "={{ $('set_search_params').item.json.city }}",
              "type": "string"
            },
            {
              "id": "l9",
              "name": "state",
              "value": "={{ $('set_search_params').item.json.state }}",
              "type": "string"
            },
            {
              "id": "l10",
              "name": "vertical",
              "value": "={{ $('set_search_params').item.json.vertical }}",
              "type": "string"
            },
            {
              "id": "l11",
              "name": "fit_score",
              "value": "={{ Number(JSON.parse($json.message.content).score) || 0 }}",
              "type": "number"
            },
            {
              "id": "l12",
              "name": "email",
              "value": "={{ (() => { const e = (JSON.parse($json.message.content).email || '').trim(); return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(e) ? e.toLowerCase() : null; })() }}",
              "type": "string"
            },
            {
              "id": "l15",
              "name": "source",
              "value": "n8n-greenfield",
              "type": "string"
            },
            {
              "id": "l16",
              "name": "source_file",
              "value": "={{ 'n8n:fl-contractor-lead-gen-v1:' + $('set_search_params').item.json.vertical + ':' + $('set_search_params').item.json.city }}",
              "type": "string"
            },
            {
              "id": "l17",
              "name": "sourced_at",
              "value": "={{ new Date().toISOString() }}",
              "type": "string"
            },
            {
              "id": "l18",
              "name": "status",
              "value": "new",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [1200, 100],
      "id": "set-lead-row",
      "name": "compose_lead_row",
      "notes": "Parse Ollama JSON response + merge with Apify place data into Supabase leads-table row shape. 'n8n-greenfield' marker in source column so the new pipeline is traceable separately from legacy gmaps_apify rows."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.fit_score }}",
              "rightValue": 70,
              "operator": {
                "type": "number",
                "operation": "gte"
              }
            },
            {
              "id": "c2",
              "leftValue": "={{ ($json.email || '') + ($json.phone || '') }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1440, 100],
      "id": "filter-score",
      "name": "filter_qualified",
      "notes": "Gate: score >= 70 AND (email OR phone present). Low-score branch drops silently."
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://api.hunter.io/v2/domain-search?domain={{ $json.normalized_domain }}&limit=3&type=personal&api_key={{ $env.HUNTER_API_KEY }}",
        "options": {
          "timeout": 20000,
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1680, 100],
      "id": "http-hunter",
      "name": "hunter_enrich",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "continueOnFail": true,
      "notes": "Hunter.io domain-search — finds best personal email for the business domain. limit=3 keeps cost low (~$0.05/call). type=personal prefers owner/decision-maker over info@ catchalls. Fails gracefully: if Hunter rate-limits or returns nothing, the lead still inserts with the AI-guessed email (or null)."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "h1",
              "name": "hunter_attempted_at",
              "value": "={{ new Date().toISOString() }}",
              "type": "string"
            },
            {
              "id": "h2",
              "name": "hunter_confidence",
              "value": "={{ (($json.data?.emails || [])[0]?.confidence) || null }}",
              "type": "number"
            },
            {
              "id": "h3",
              "name": "email",
              "value": "={{ (() => { const hunterEmail = ($json.data?.emails || [])[0]?.value; if (hunterEmail && /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(hunterEmail)) return hunterEmail.toLowerCase(); return $('compose_lead_row').item.json.email || null; })() }}",
              "type": "string"
            },
            {
              "id": "h4",
              "name": "contact_name",
              "value": "={{ (() => { const e = ($json.data?.emails || [])[0]; if (!e) return null; const parts = [e.first_name, e.last_name].filter(Boolean); return parts.length ? parts.join(' ') : null; })() }}",
              "type": "string"
            },
            {
              "id": "h5",
              "name": "business_name",
              "value": "={{ $('compose_lead_row').item.json.business_name }}",
              "type": "string"
            },
            {
              "id": "h6",
              "name": "business_url",
              "value": "={{ $('compose_lead_row').item.json.business_url }}",
              "type": "string"
            },
            {
              "id": "h7",
              "name": "normalized_domain",
              "value": "={{ $('compose_lead_row').item.json.normalized_domain }}",
              "type": "string"
            },
            {
              "id": "h8",
              "name": "phone",
              "value": "={{ $('compose_lead_row').item.json.phone }}",
              "type": "string"
            },
            {
              "id": "h9",
              "name": "google_place_id",
              "value": "={{ $('compose_lead_row').item.json.google_place_id }}",
              "type": "string"
            },
            {
              "id": "h10",
              "name": "google_rating",
              "value": "={{ $('compose_lead_row').item.json.google_rating }}",
              "type": "number"
            },
            {
              "id": "h11",
              "name": "review_count",
              "value": "={{ $('compose_lead_row').item.json.review_count }}",
              "type": "number"
            },
            {
              "id": "h12",
              "name": "city",
              "value": "={{ $('compose_lead_row').item.json.city }}",
              "type": "string"
            },
            {
              "id": "h13",
              "name": "state",
              "value": "={{ $('compose_lead_row').item.json.state }}",
              "type": "string"
            },
            {
              "id": "h14",
              "name": "vertical",
              "value": "={{ $('compose_lead_row').item.json.vertical }}",
              "type": "string"
            },
            {
              "id": "h15",
              "name": "fit_score",
              "value": "={{ $('compose_lead_row').item.json.fit_score }}",
              "type": "number"
            },
            {
              "id": "h16",
              "name": "source",
              "value": "n8n-greenfield",
              "type": "string"
            },
            {
              "id": "h17",
              "name": "source_file",
              "value": "={{ $('compose_lead_row').item.json.source_file }}",
              "type": "string"
            },
            {
              "id": "h18",
              "name": "sourced_at",
              "value": "={{ $('compose_lead_row').item.json.sourced_at }}",
              "type": "string"
            },
            {
              "id": "h19",
              "name": "status",
              "value": "new",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [1920, 100],
      "id": "set-hunter-merge",
      "name": "merge_hunter_row",
      "notes": "Merge Hunter.io result into the lead row. Precedence for email: Hunter's #1 result (highest confidence) → AI-suggested email → null. Also captures contact_name (first+last from Hunter) and hunter_confidence. All other fields pass through from compose_lead_row."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.SUPABASE_MINI_HIVE_URL }}/rest/v1/leads",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "={{ $env.SUPABASE_MINI_HIVE_KEY }}"
            },
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.SUPABASE_MINI_HIVE_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=representation"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify([{business_name: $json.business_name, business_url: $json.business_url, normalized_domain: $json.normalized_domain, phone: $json.phone, email: $json.email, contact_name: $json.contact_name, google_place_id: $json.google_place_id, google_rating: $json.google_rating, review_count: $json.review_count, city: $json.city, state: $json.state, vertical: $json.vertical, fit_score: $json.fit_score, hunter_attempted_at: $json.hunter_attempted_at, hunter_confidence: $json.hunter_confidence, source: $json.source, source_file: $json.source_file, sourced_at: $json.sourced_at, status: $json.status}]) }}",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [2160, 100],
      "id": "http-supabase",
      "name": "supabase_insert",
      "continueOnFail": true,
      "notes": "Plain INSERT into Mini Hive leads. continueOnFail=true so dup-key 409s don't kill the whole batch. Now includes contact_name + hunter_confidence + hunter_attempted_at from the Hunter stage."
    }
  ],
  "pinData": {},
  "connections": {
    "manual_trigger": {
      "main": [[{ "node": "set_search_params", "type": "main", "index": 0 }]]
    },
    "schedule_trigger": {
      "main": [[{ "node": "set_search_params", "type": "main", "index": 0 }]]
    },
    "sub_workflow_trigger": {
      "main": [[{ "node": "set_search_params", "type": "main", "index": 0 }]]
    },
    "set_search_params": {
      "main": [[{ "node": "apify_run_sync", "type": "main", "index": 0 }]]
    },
    "apify_run_sync": {
      "main": [[{ "node": "ollama_qualify", "type": "main", "index": 0 }]]
    },
    "ollama_qualify": {
      "main": [[{ "node": "compose_lead_row", "type": "main", "index": 0 }]]
    },
    "compose_lead_row": {
      "main": [[{ "node": "filter_qualified", "type": "main", "index": 0 }]]
    },
    "filter_qualified": {
      "main": [
        [{ "node": "hunter_enrich", "type": "main", "index": 0 }],
        []
      ]
    },
    "hunter_enrich": {
      "main": [[{ "node": "merge_hunter_row", "type": "main", "index": 0 }]]
    },
    "merge_hunter_row": {
      "main": [[{ "node": "supabase_insert", "type": "main", "index": 0 }]]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "timezone": "America/New_York"
  },
  "tags": []
}
