{
  "name": "Job Search Pipeline — Scrape + Score + Track",
  "nodes": [
    {
      "parameters": {
        "rule": { "interval": [{ "triggerAtHour": 8 }] }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [0, 300],
      "id": "daily-trigger",
      "name": "Daily 8am"
    },
    {
      "parameters": {
        "command": "=cd /opt/jobspy && python3 search.py",
        "options": { "timeout": 120000 }
      },
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [220, 300],
      "id": "run-jobspy",
      "name": "Run JobSpy Search",
      "notes": "Executes the Python script below. Install: pip install python-jobspy. Script outputs JSON to stdout.\n\n--- search.py ---\nimport json\nfrom jobspy import scrape_jobs\n\nsearches = [\n  {'search_term': 'YOUR_JOB_TITLE_1', 'location': 'YOUR_LOCATION'},\n  {'search_term': 'YOUR_JOB_TITLE_2', 'location': 'Remote'},\n  {'search_term': 'YOUR_JOB_TITLE_3', 'location': 'Remote'},\n]\n\nall_jobs = []\nfor s in searches:\n  jobs = scrape_jobs(\n    site_name=['linkedin', 'indeed', 'glassdoor', 'zip_recruiter'],\n    search_term=s['search_term'],\n    location=s['location'],\n    results_wanted=25,\n    hours_old=48,\n    country_indeed='USA',\n    linkedin_fetch_description=True\n  )\n  for _, row in jobs.iterrows():\n    all_jobs.append(row.to_dict())\n\n# Deduplicate by title+company\nseen = set()\nunique = []\nfor j in all_jobs:\n  key = f\"{j.get('title','')}|{j.get('company','')}\".lower()\n  if key not in seen:\n    seen.add(key)\n    unique.append(j)\n\nprint(json.dumps(unique, default=str))"
    },
    {
      "parameters": {
        "jsCode": "// Parse JobSpy output from command stdout\nconst stdout = $input.first().json.stdout;\nlet jobs;\ntry {\n  jobs = JSON.parse(stdout);\n} catch (e) {\n  return [{ json: { error: 'Failed to parse JobSpy output', raw: stdout.substring(0, 500) } }];\n}\n\nreturn jobs.map(job => ({ json: job }));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [440, 300],
      "id": "parse-jobs",
      "name": "Parse Job Results"
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [660, 300],
      "id": "batch-jobs",
      "name": "Process One at a Time"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "x-api-key", "value": "YOUR_ANTHROPIC_API_KEY" },
            { "name": "anthropic-version", "value": "2023-06-01" },
            { "name": "content-type", "value": "application/json" }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"claude-sonnet-4-20250514\",\n  \"max_tokens\": 500,\n  \"messages\": [{\n    \"role\": \"user\",\n    \"content\": \"You are a job fit scoring engine. Score this job 0-100 for this candidate. Return JSON only — no markdown, no explanation.\\n\\nJSON schema: { \\\"score\\\": number, \\\"salary_est\\\": string, \\\"title_fit\\\": string, \\\"skills_match\\\": string[], \\\"red_flags\\\": string[], \\\"summary\\\": string }\\n\\nCANDIDATE PROFILE:\\n- Title preferences: YOUR_TARGET_TITLES\\n- Min salary: YOUR_MIN_SALARY\\n- Core skills: YOUR_CORE_SKILLS\\n- Experience: YOUR_EXPERIENCE_SUMMARY\\n- Location: YOUR_LOCATION_PREFERENCE\\n- Bonus: YOUR_BONUS_QUALIFICATIONS\\n\\nJOB:\\nTitle: {{ $json.title }}\\nCompany: {{ $json.company }}\\nLocation: {{ $json.location }}\\nDescription: {{ ($json.description || '').substring(0, 3000) }}\"\n  }]\n}",
        "options": { "timeout": 30000 }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [880, 300],
      "id": "claude-score",
      "name": "Claude — Score Job"
    },
    {
      "parameters": {
        "jsCode": "// Parse Claude's scoring response and merge with job data\nconst job = $('Process One at a Time').first().json;\nconst claudeResponse = $input.first().json;\n\nlet scoring;\ntry {\n  const text = claudeResponse.content?.[0]?.text || '';\n  // Strip markdown fences if present\n  const clean = text.replace(/```json\\n?/g, '').replace(/```/g, '').trim();\n  scoring = JSON.parse(clean);\n} catch (e) {\n  scoring = { score: 0, summary: 'Failed to parse scoring response', red_flags: ['Parse error'] };\n}\n\n// Determine status based on score\nlet status = 'archived';\nif (scoring.score >= 80) status = 'apply_queue';\nelse if (scoring.score >= 60) status = 'review_queue';\n\nreturn [{\n  json: {\n    // Job fields\n    title: job.title || '',\n    company: job.company || '',\n    location: job.location || '',\n    job_url: job.job_url || job.link || '',\n    source: job.site || 'unknown',\n    date_posted: job.date_posted || '',\n    salary: job.min_amount ? `$${job.min_amount}-$${job.max_amount}` : (scoring.salary_est || 'Not listed'),\n    \n    // Scoring fields\n    score: scoring.score,\n    title_fit: scoring.title_fit || '',\n    skills_match: (scoring.skills_match || []).join(', '),\n    red_flags: (scoring.red_flags || []).join(', '),\n    summary: scoring.summary || '',\n    \n    // Pipeline fields\n    status: status,\n    scored_at: new Date().toISOString(),\n    applied_at: null,\n    cover_letter: null,\n    notes: ''\n  }\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1100, 300],
      "id": "merge-score",
      "name": "Merge Job + Score"
    },
    {
      "parameters": {
        "operation": "create",
        "baseUrl": "https://YOUR_NOCODB_HOST",
        "table": "YOUR_TABLE_ID",
        "fieldsUi": {
          "fieldValues": [
            { "fieldName": "Title", "fieldValue": "={{ $json.title }}" },
            { "fieldName": "Company", "fieldValue": "={{ $json.company }}" },
            { "fieldName": "Location", "fieldValue": "={{ $json.location }}" },
            { "fieldName": "Job URL", "fieldValue": "={{ $json.job_url }}" },
            { "fieldName": "Source", "fieldValue": "={{ $json.source }}" },
            { "fieldName": "Score", "fieldValue": "={{ $json.score }}" },
            { "fieldName": "Salary", "fieldValue": "={{ $json.salary }}" },
            { "fieldName": "Status", "fieldValue": "={{ $json.status }}" },
            { "fieldName": "Summary", "fieldValue": "={{ $json.summary }}" },
            { "fieldName": "Skills Match", "fieldValue": "={{ $json.skills_match }}" },
            { "fieldName": "Red Flags", "fieldValue": "={{ $json.red_flags }}" },
            { "fieldName": "Scored At", "fieldValue": "={{ $json.scored_at }}" }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.nocoDb",
      "typeVersion": 3,
      "position": [1320, 300],
      "id": "save-to-nocodb",
      "name": "Save to NocoDB",
      "credentials": {
        "nocoDbApiToken": { "id": "YOUR_CREDENTIAL_ID", "name": "NocoDB" }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {},
          "conditions": [
            {
              "leftValue": "={{ $json.status }}",
              "rightValue": "apply_queue",
              "operator": { "type": "string", "operation": "equals" }
            }
          ]
        }
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [1540, 300],
      "id": "high-score-filter",
      "name": "Score >= 80?"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            { "name": "x-api-key", "value": "YOUR_ANTHROPIC_API_KEY" },
            { "name": "anthropic-version", "value": "2023-06-01" }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"claude-sonnet-4-20250514\",\n  \"max_tokens\": 400,\n  \"messages\": [{\n    \"role\": \"user\",\n    \"content\": \"Write a concise 3-paragraph cover letter for this role.\\nP1: Why I'm excited about THIS company specifically.\\nP2: Most relevant experience from my background.\\nP3: Clear ask — request interview, confident close.\\n\\nTone: confident, specific, not generic. Max 250 words.\\nNo 'I am writing to apply'. No 'Dear Hiring Manager'.\\n\\nMy background: YOUR_BACKGROUND_SUMMARY\\n\\nCompany: {{ $json.company }}\\nRole: {{ $json.title }}\\nKey requirements from JD: {{ $json.summary }}\"\n  }]\n}",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1760, 200],
      "id": "generate-cover-letter",
      "name": "Claude — Generate Cover Letter"
    },
    {
      "parameters": {
        "operation": "update",
        "baseUrl": "https://YOUR_NOCODB_HOST",
        "table": "YOUR_TABLE_ID",
        "id": "={{ $('Save to NocoDB').first().json.Id }}",
        "fieldsUi": {
          "fieldValues": [
            { "fieldName": "Cover Letter", "fieldValue": "={{ $json.content?.[0]?.text || 'Generation failed' }}" }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.nocoDb",
      "typeVersion": 3,
      "position": [1980, 200],
      "id": "save-cover-letter",
      "name": "Save Cover Letter to NocoDB",
      "credentials": {
        "nocoDbApiToken": { "id": "YOUR_CREDENTIAL_ID", "name": "NocoDB" }
      }
    },
    {
      "parameters": {
        "fromEmail": "jobs@yourdomain.com",
        "toEmail": "you@yourdomain.com",
        "subject": "=🎯 High-Score Job: {{ $('Merge Job + Score').first().json.title }} at {{ $('Merge Job + Score').first().json.company }} ({{ $('Merge Job + Score').first().json.score }}/100)",
        "emailType": "html",
        "html": "=<div style=\"font-family: sans-serif; max-width: 520px;\">\n<h2 style=\"color: #1B2838;\">{{ $('Merge Job + Score').first().json.title }}</h2>\n<p><strong>{{ $('Merge Job + Score').first().json.company }}</strong> · {{ $('Merge Job + Score').first().json.location }}</p>\n<p><strong>Score:</strong> {{ $('Merge Job + Score').first().json.score }}/100 · <strong>Salary:</strong> {{ $('Merge Job + Score').first().json.salary }}</p>\n<p><strong>Summary:</strong> {{ $('Merge Job + Score').first().json.summary }}</p>\n<p><strong>Skills Match:</strong> {{ $('Merge Job + Score').first().json.skills_match }}</p>\n<p style=\"color: #DC2626;\"><strong>Red Flags:</strong> {{ $('Merge Job + Score').first().json.red_flags || 'None' }}</p>\n<hr>\n<h3>Generated Cover Letter</h3>\n<p>{{ $json.content?.[0]?.text || 'See NocoDB' }}</p>\n<hr>\n<p><a href=\"{{ $('Merge Job + Score').first().json.job_url }}\" style=\"background: #1B2838; color: white; padding: 10px 24px; border-radius: 6px; text-decoration: none; display: inline-block;\">View Job Listing →</a></p>\n</div>",
        "options": {}
      },
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [2200, 200],
      "id": "notify-high-score",
      "name": "Email: High-Score Job Alert",
      "credentials": {
        "smtp": { "id": "YOUR_CREDENTIAL_ID", "name": "SMTP" }
      }
    }
  ],
  "connections": {
    "Daily 8am": { "main": [[{ "node": "Run JobSpy Search", "type": "main", "index": 0 }]] },
    "Run JobSpy Search": { "main": [[{ "node": "Parse Job Results", "type": "main", "index": 0 }]] },
    "Parse Job Results": { "main": [[{ "node": "Process One at a Time", "type": "main", "index": 0 }]] },
    "Process One at a Time": { "main": [[{ "node": "Claude — Score Job", "type": "main", "index": 0 }]] },
    "Claude — Score Job": { "main": [[{ "node": "Merge Job + Score", "type": "main", "index": 0 }]] },
    "Merge Job + Score": { "main": [[{ "node": "Save to NocoDB", "type": "main", "index": 0 }]] },
    "Save to NocoDB": { "main": [[{ "node": "Score >= 80?", "type": "main", "index": 0 }]] },
    "Score >= 80?": { "main": [[{ "node": "Claude — Generate Cover Letter", "type": "main", "index": 0 }], []] },
    "Claude — Generate Cover Letter": { "main": [[{ "node": "Save Cover Letter to NocoDB", "type": "main", "index": 0 }]] },
    "Save Cover Letter to NocoDB": { "main": [[{ "node": "Email: High-Score Job Alert", "type": "main", "index": 0 }]] }
  },
  "active": false,
  "settings": { "executionOrder": "v1" },
  "meta": {
    "notes": "SETUP: 1) Install python-jobspy on your n8n server (pip install python-jobspy). 2) Create the search.py script in /opt/jobspy/ (or update path). 3) Create NocoDB table with columns: Title, Company, Location, Job URL, Source, Score, Salary, Status, Summary, Skills Match, Red Flags, Cover Letter, Scored At, Applied At, Notes. 4) Add Anthropic API key, NocoDB credentials, SMTP. 5) Customize the CANDIDATE PROFILE in the scoring prompt. 6) Test with manual execution first.",
    "author": "Edward Chalupa — echalupa.com",
    "article": "echalupa.com/blog/ai-job-search-pipeline"
  }
}
