{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "57beb602-7b80-4a2e-80cf-5fb46dc17858",
      "name": "Main Sticky",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2368,
        2640
      ],
      "parameters": {
        "color": 2,
        "width": 500,
        "height": 600,
        "content": "## Aggregate Multi-Source Job Boards into Centralized Database\nAutomate the collection, parsing, and normalization of job postings from diverse ATS platforms into a unified database.\n\n\n### How it works\n1. Schedule daily execution using the trigger.\n2. Iterate through defined ATS source configurations.\n3. Fetch raw job data via HTTP requests.\n4. Normalize diverse JSON structures into a standard schema.\n5. Filter by domain and deduplicate entries.\n6. Sync results to Supabase and Google Sheets.\n\n\n### Setup\n1. Define your job board list in the first Code node.\n2. Add Supabase credentials in the Postgres node.\n3. Configure Google Sheets credentials and document ID.\n4. Update the `ALLOWED_DOMAINS` array to match your career target.\n\n\n### Customization\nConsolidate user-specific values like API keys or table IDs in a Set node at the workflow start for easy configuration."
      },
      "typeVersion": 1
    },
    {
      "id": "b5c07db8-536b-4212-8753-f3033ef81b55",
      "name": "\ud83d\udccb Company List",
      "type": "n8n-nodes-base.code",
      "position": [
        -1568,
        2736
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \ud83d\udccb COMPANY LIST  v7\n// \u2500 Board APIs first (no array-splitting)\n// \u2500 Per-company ATS (JSON objects, no splitting)\n// \u2500 HTML/JSON-LD career pages (test batch)\n// \u2500 Lever + RemoteOK LAST (bare array \u2192 n8n splits \u2192 handled by v7 parser)\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst sources = [\n\n  // \u2550\u2550 FREE GLOBAL JOB BOARDS (safe \u2014 return JSON objects) \u2550\u2550\u2550\u2550\u2550\n  { ats: 'remotive',  slug: 'remotive',  company: 'Remotive Board' },\n  { ats: 'himalayas', slug: 'himalayas', company: 'Himalayas' },\n  { ats: 'arbeitnow', slug: 'arbeitnow', company: 'Arbeitnow' },\n  { ats: 'jobicy',    slug: 'india',     company: 'Jobicy India' },\n  { ats: 'jobicy',    slug: 'usa',       company: 'Jobicy USA Remote' },\n\n  // \u2550\u2550 GREENHOUSE \u2014 returns {jobs:[]} object, no splitting \u2550\u2550\u2550\u2550\u2550\u2550\n  { ats: 'greenhouse', slug: 'gitlab',       company: 'GitLab' },\n  { ats: 'greenhouse', slug: 'razorpay',     company: 'Razorpay' },\n  { ats: 'greenhouse', slug: 'postman',      company: 'Postman' },\n  { ats: 'greenhouse', slug: 'browserstack', company: 'BrowserStack' },\n  { ats: 'greenhouse', slug: 'moengage',     company: 'MoEngage' },\n  { ats: 'greenhouse', slug: 'hasura',       company: 'Hasura' },\n  { ats: 'greenhouse', slug: 'notion',       company: 'Notion' },\n  { ats: 'greenhouse', slug: 'groww',        company: 'Groww' },\n  { ats: 'greenhouse', slug: 'druva',        company: 'Druva' },\n  { ats: 'greenhouse', slug: 'cloudflare',   company: 'Cloudflare' },\n  { ats: 'greenhouse', slug: 'rippling',     company: 'Rippling' },\n  { ats: 'greenhouse', slug: 'gusto',        company: 'Gusto' },\n  { ats: 'greenhouse', slug: 'twilio',       company: 'Twilio' },\n  { ats: 'greenhouse', slug: 'brex',         company: 'Brex' },\n  { ats: 'greenhouse', slug: 'grammarly',    company: 'Grammarly' },\n  { ats: 'greenhouse', slug: 'zapier',       company: 'Zapier' },\n\n  // \u2550\u2550 SMARTRECRUITERS \u2014 returns {content:[]} object \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  { ats: 'smartrecruiters', slug: 'Meesho',     company: 'Meesho' },\n  { ats: 'smartrecruiters', slug: 'Freshworks', company: 'Freshworks' },\n  { ats: 'smartrecruiters', slug: 'PhonePe',    company: 'PhonePe' },\n\n  // \u2550\u2550 ASHBY \u2014 returns {jobs:[]} object \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  { ats: 'ashby', slug: 'linear',   company: 'Linear' },\n  { ats: 'ashby', slug: 'clerk',    company: 'Clerk.dev' },\n  { ats: 'ashby', slug: 'cal',      company: 'Cal.com' },\n  { ats: 'ashby', slug: 'supabase', company: 'Supabase' },\n  { ats: 'ashby', slug: 'resend',   company: 'Resend' },\n  { ats: 'ashby', slug: 'posthog',  company: 'PostHog' },\n  { ats: 'ashby', slug: 'raycast',  company: 'Raycast' },\n  { ats: 'ashby', slug: 'vercel',   company: 'Vercel' },\n\n  // \u2550\u2550 WORKABLE \u2014 returns {results:[]} object \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  { ats: 'workable', slug: 'zepto',      company: 'Zepto' },\n  { ats: 'workable', slug: 'cred',       company: 'CRED' },\n  { ats: 'workable', slug: 'shiprocket', company: 'Shiprocket' },\n\n  // \u2550\u2550 REPLACED HTML LISTINGS WITH VERIFIED ENDPOINTS \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  { ats: 'greenhouse', slug: 'shopify',      company: 'Shopify' },\n  { ats: 'greenhouse', slug: 'chargebee',    company: 'Chargebee' },\n  { ats: 'greenhouse', slug: 'sprinklr',     company: 'Sprinklr' },\n\n  // \u2550\u2550 LEVER \u2014 bare array response \u2192 n8n SPLITS items \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  // Placed LAST so index-split only affects entries at the tail.\n  // v7 parser detects & resolves company from hostedUrl automatically.\n  { ats: 'lever', slug: 'gohighlevel', company: 'GoHighLevel' },\n  { ats: 'lever', slug: 'remote',      company: 'Remote.com' },\n  { ats: 'lever', slug: 'delhivery',   company: 'Delhivery' },\n  { ats: 'lever', slug: 'netlify',     company: 'Netlify' },\n  { ats: 'lever', slug: 'figma',       company: 'Figma' },\n  { ats: 'lever', slug: 'loom',        company: 'Loom' },\n\n  // \u2550\u2550 REMOTEOK \u2014 bare array response \u2192 n8n SPLITS items \u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  // Placed last. Parser reads .company field from each split item.\n  { ats: 'remoteok', slug: 'remoteok', company: 'Remote OK' },\n\n];\n\nconsole.log(`\ud83d\udccb Company List v7: ${sources.length} sources`);\nreturn sources.map(c => ({ json: c }));"
      },
      "typeVersion": 2
    },
    {
      "id": "9bf7162f-6b30-4b72-a2eb-de2b0f50c724",
      "name": "\ud83d\udd27 Prepare Request",
      "type": "n8n-nodes-base.code",
      "position": [
        -1056,
        2752
      ],
      "parameters": {
        "jsCode": "// Builds API URL + headers for ALL sources in one pass.\n// Each source gets its own item \u2192 HTTP Request calls each one.\n// v2: Adds pagination for Remotive (higher limit), SmartRecruiters (offset),\n//     and Himalayas (page param) to capture all available jobs.\n\nconst H = { 'Accept': 'application/json', 'User-Agent': 'Mozilla/5.0 (compatible; JobBot/4.2)' };\nconst results = [];\n\nfor (const entry of $input.all()) {\n  const { ats, slug, company, url: customUrl } = entry.json;\n  if (!ats || !slug) continue;\n\n  let headers = H;\n  if (ats === 'remoteok') {\n    headers = { ...H, 'Accept': 'application/json', 'Cache-Control': 'no-cache' };\n  } else if (ats === 'html_json_ld') {\n    headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html' };\n  }\n  const route = ats === 'html_json_ld' ? 'html' : 'api';\n  const base  = { ...entry.json, _headers: headers, _route: route };\n\n  // \u2500\u2500 Paginated sources \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  // SmartRecruiters: offset-based pagination (5 pages \u00d7 100 = 500 jobs max)\n  if (ats === 'smartrecruiters') {\n    for (let offset = 0; offset < 500; offset += 100) {\n      results.push({ json: { ...base, _url: `https://api.smartrecruiters.com/v1/companies/${slug}/postings?status=PUBLIC&limit=100&offset=${offset}` } });\n    }\n    continue;\n  }\n\n  // Himalayas: page-based pagination (10 pages \u00d7 50 = 500 jobs max)\n  if (ats === 'himalayas') {\n    for (let page = 1; page <= 10; page++) {\n      results.push({ json: { ...base, _url: `https://himalayas.app/jobs/api?limit=50&page=${page}` } });\n    }\n    continue;\n  }\n\n  // \u2500\u2500 Non-paginated sources (single URL) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  const urlMap = {\n    remotive:        'https://remotive.com/api/remote-jobs?limit=500',\n    remoteok:        'https://remoteok.com/api',\n    arbeitnow:       'https://www.arbeitnow.com/api/job-board-api',\n    jobicy:          slug === 'india'\n                       ? 'https://jobicy.com/api/v2/remote-jobs?count=100&geo=india'\n                       : 'https://jobicy.com/api/v2/remote-jobs?count=100&geo=usa',\n    lever:           `https://api.lever.co/v0/postings/${slug}?mode=json&limit=250`,\n    greenhouse:      `https://boards-api.greenhouse.io/v1/boards/${slug}/jobs?content=true`,\n    workable:        `https://apply.workable.com/api/v1/widget/accounts/${slug}`,\n    ashby:           `https://api.ashbyhq.com/posting-api/job-board/${slug}`,\n    html_json_ld:    customUrl\n  };\n\n  const url = urlMap[ats];\n  if (!url) { console.log(`\u26a0 Unknown ATS: ${ats}`); continue; }\n\n  results.push({ json: { ...base, _url: url } });\n}\n\nconsole.log(`Prepare Request v2: ${results.length} API calls queued (includes pagination)`);\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "be38d26b-6c7f-4929-9968-554af409ccbd",
      "name": "\ud83c\udf10 HTTP Request",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        -832,
        2752
      ],
      "parameters": {
        "url": "={{ $json._url }}",
        "options": {
          "timeout": 30000,
          "batching": {
            "batch": {
              "batchSize": 5
            }
          }
        },
        "jsonHeaders": "={{ JSON.stringify($json._headers) }}",
        "sendHeaders": true,
        "specifyHeaders": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "339dcf49-a2de-4f81-af07-05baf1672f0f",
      "name": "\ud83d\udcbe Upsert to Supabase",
      "type": "n8n-nodes-base.postgres",
      "onError": "continueRegularOutput",
      "position": [
        -272,
        2752
      ],
      "parameters": {
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_RESOURCE_ID_HERE"
        },
        "schema": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_RESOURCE_ID_HERE"
        },
        "columns": {
          "value": {
            "salary": "={{ $json.salary }}",
            "status": "={{ $json.status }}",
            "company": "={{ $json.company }}",
            "country": "={{ $json.country }}",
            "ats_type": "={{ $json.ats_type }}",
            "job_hash": "={{ $json.job_hash }}",
            "location": "={{ $json.location }}",
            "apply_url": "={{ $json.apply_url }}",
            "job_title": "={{ $json.job_title }}",
            "work_mode": "={{ $json.work_mode }}",
            "description": "={{ $json.description }}",
            "employment_type": "={{ $json.employment_type }}"
          },
          "schema": [
            {
              "id": "id",
              "type": "number",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "id",
              "defaultMatch": true,
              "canBeUsedToMatch": true
            },
            {
              "id": "job_title",
              "type": "string",
              "display": true,
              "required": true,
              "displayName": "job_title",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "company",
              "type": "string",
              "display": true,
              "required": true,
              "displayName": "company",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "location",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "location",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "country",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "country",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "work_mode",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "work_mode",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "employment_type",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "employment_type",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "apply_url",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "apply_url",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "ats_type",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "ats_type",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "job_hash",
              "type": "string",
              "display": true,
              "required": true,
              "displayName": "job_hash",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "description",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "description",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "salary",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "salary",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "scraped_at",
              "type": "dateTime",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "scraped_at",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "status",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "created_at",
              "type": "dateTime",
              "display": true,
              "removed": true,
              "required": false,
              "displayName": "created_at",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "job_hash"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "upsert"
      },
      "typeVersion": 2.5
    },
    {
      "id": "801e7955-bf21-4695-a591-cc716267e1e6",
      "name": "\ud83d\udcca Write to Google Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -16,
        2752
      ],
      "parameters": {
        "columns": {
          "value": {
            "ATS": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.ats_type }}",
            "Salary": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.salary }}",
            "Status": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.status }}",
            "Company": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.company }}",
            "Country": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.country }}",
            "Location": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.location }}",
            "job_hash": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.job_hash }}",
            "Apply URL": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.apply_url }}",
            "Job Title": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.job_title }}",
            "Work Mode": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.work_mode }}",
            "Description": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.description }}",
            "Employment Type": "={{ $('\ud83d\udd0d Parse + Enrich + Filter1').item.json.employment_type }}"
          },
          "schema": [
            {
              "id": "job_hash",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "job_hash",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Job Title",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Job Title",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Company",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Company",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Location",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Location",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Country",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Country",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Work Mode",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Work Mode",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Employment Type",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Employment Type",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Apply URL",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Apply URL",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "ATS",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "ATS",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Salary",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Salary",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Description",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Description",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "job_hash"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_RESOURCE_ID_HERE"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_RESOURCE_ID_HERE"
        }
      },
      "typeVersion": 4
    },
    {
      "id": "dcda27e6-1c40-419e-8351-4bc8a8547d7d",
      "name": "\ud83d\udd04 Loop Batches (5)",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -1344,
        2736
      ],
      "parameters": {
        "options": {},
        "batchSize": 5
      },
      "typeVersion": 3
    },
    {
      "id": "9f6b95c3-b316-4f9d-ba64-463737d5a672",
      "name": "\u23f0 Daily 8AM IST",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -1792,
        2736
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 8
            }
          ]
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "b367197d-eb1e-468e-8dd2-08173782b16a",
      "name": "\ud83d\udd0d Parse + Enrich + Filter1",
      "type": "n8n-nodes-base.code",
      "position": [
        -592,
        2752
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// \ud83d\udd0d PARSE + ENRICH + FILTER \n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n// \u2500\u2500 tiny helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst str = v => {\n  if (v == null) return '';\n  if (typeof v === 'object') return v.label || v.name || v.id || '';\n  return String(v);\n};\n\n// \u2500\u2500 ISO country map \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst ISO2 = {IN:'India',US:'United States',GB:'United Kingdom',AU:'Australia',CA:'Canada',DE:'Germany',SG:'Singapore',AE:'UAE',FR:'France',NL:'Netherlands',PK:'Pakistan',PH:'Philippines',NG:'Nigeria',KE:'Kenya',ZA:'South Africa',BR:'Brazil',JP:'Japan',KR:'South Korea',IE:'Ireland',PL:'Poland',NZ:'New Zealand',PT:'Portugal',IT:'Italy',ES:'Spain',UA:'Ukraine'};\nconst CNAMES = {india:'India',bharat:'India','united states':'United States',usa:'United States','united kingdom':'United Kingdom',australia:'Australia',canada:'Canada',germany:'Germany',singapore:'Singapore',uae:'UAE',dubai:'UAE',netherlands:'Netherlands',france:'France',pakistan:'Pakistan',philippines:'Philippines',nigeria:'Nigeria',kenya:'Kenya','south africa':'South Africa',brazil:'Brazil',japan:'Japan','south korea':'South Korea',ireland:'Ireland',poland:'Poland','new zealand':'New Zealand',portugal:'Portugal',italy:'Italy',spain:'Spain',ukraine:'Ukraine'};\nconst INDIA_CITIES = new Set(['bengaluru','bangalore','mumbai','bombay','delhi','new delhi','hyderabad','pune','chennai','madras','kolkata','calcutta','noida','gurugram','gurgaon','ahmedabad','jaipur','kochi','chandigarh','coimbatore','indore','nagpur','bhopal','lucknow','surat','vadodara','trivandrum','vizag','mangalore','mysuru','nashik']);\n\n// \u2500\u2500 parseSalary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Converts any salary shape from any ATS into a clean human-readable string.\n// Covers: plain strings, {min,max,currency,interval}, nested baseSalary\n// JSON-LD objects, numeric scalars, and common API salary fields.\nfunction parseSalary(raw) {\n  if (!raw && raw !== 0) return '';\n\n  // \u2460 Already a plain string (e.g. Remotive \"$60k\u2013$90k\", Jobicy \"5+1234567890 USD/year\")\n  if (typeof raw === 'string') {\n    const s = raw.trim();\n    if (!s || s === '0' || s === 'null') return '';\n    // Normalise \"5+1234567890\" style\n    if (/^\\d/.test(s)) {\n      const nums = s.match(/\\d+(?:[,.]\\d+)*/g);\n      if (nums && nums.length >= 2) {\n        const [lo, hi] = nums.map(n => Number(n.replace(/,/g,'')));\n        const sym = /INR|\u20b9/.test(s) ? '\u20b9' : /GBP|\u00a3/.test(s) ? '\u00a3' : /EUR|\u20ac/.test(s) ? '\u20ac' : '$';\n        const per = /year|annual/i.test(s) ? '/yr' : /month/i.test(s) ? '/mo' : /hour/i.test(s) ? '/hr' : '';\n        return `${sym}${fmt(lo)}\u2013${fmt(hi)}${per}`;\n      }\n    }\n    return s.slice(0, 200);\n  }\n\n  // \u2461 Numeric scalar (e.g. RemoteOK salary_min alone)\n  if (typeof raw === 'number') {\n    return raw > 0 ? `$${fmt(raw)}` : '';\n  }\n\n  // \u2462 Structured object {min?, max?, currency?, interval?, value?}\n  if (typeof raw === 'object') {\n    // JSON-LD baseSalary has a nested .value object\n    const inner = raw.value || raw;\n    let min = inner.minValue || inner.min || inner.minimum || inner.floor || 0;\n    let max = inner.maxValue || inner.max || inner.maximum || inner.ceiling || 0;\n    const currency = str(raw.currency || inner.currency || inner['@currency'] || raw.salaryCurrency || 'USD');\n    const interval = str(raw.unitText || inner.unitText || inner.interval || raw.salaryFrequency || '');\n    const sym = currency === 'INR' ? '\u20b9' : currency === 'GBP' ? '\u00a3' : currency === 'EUR' ? '\u20ac' : '$';\n    const perStr = interval ? ' /' + interval.toLowerCase().replace(/^per\\s*/i,'').replace('year','yr').replace('month','mo').replace('hour','hr') : '';\n    if (!min && !max) return '';\n    const parts = [];\n    if (min) parts.push(sym + fmt(min));\n    if (max && max !== min) parts.push(sym + fmt(max));\n    return `${parts.join('\u2013')}${perStr} ${currency}`.trim();\n  }\n  return '';\n}\n\nfunction fmt(n) {\n  n = Number(n);\n  if (n >= 10000000) return (n/10000000).toFixed(1).replace(/\\.0$/,'') + 'Cr';\n  if (n >= 100000)   return (n/100000).toFixed(1).replace(/\\.0$/,'') + 'L';\n  if (n >= 1000)     return (n/1000).toFixed(0) + 'k';\n  return String(Math.round(n));\n}\n\n// \u2500\u2500 parseDate \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Attempts to extract a clean ISO-8601 date string (YYYY-MM-DD) from\n// whatever date field an ATS provides (epoch ms, epoch s, ISO string,\n// human-readable, etc.).\nfunction parseDate(raw) {\n  if (!raw) return '';\n  let d;\n\n  if (typeof raw === 'number') {\n    // epoch in ms (>1e10) vs seconds\n    d = new Date(raw > 1e10 ? raw : raw * 1000);\n  } else if (typeof raw === 'string') {\n    const s = raw.trim();\n    if (!s) return '';\n    // Already ISO-like \"2024-05-01T\u2026\" or \"2024-05-01\"\n    if (/^\\d{4}-\\d{2}-\\d{2}/.test(s)) {\n      d = new Date(s);\n    } else {\n      // Try native Date parse for human strings like \"May 1, 2024\"\n      d = new Date(s);\n    }\n  } else if (raw instanceof Date) {\n    d = raw;\n  }\n\n  if (!d || isNaN(d.getTime())) return '';\n  // Return YYYY-MM-DD\n  return d.toISOString().slice(0, 10);\n}\n\n// \u2500\u2500 country / location helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction getCountry(rawLoc, rawCC) {\n  const cc = str(rawCC);\n  if (cc) {\n    const up = cc.trim().toUpperCase();\n    if (ISO2[up]) return ISO2[up];\n    const lo = cc.trim().toLowerCase();\n    if (CNAMES[lo]) return CNAMES[lo];\n    if (cc.trim().length > 2) return cc.trim();\n  }\n  const loc = str(rawLoc);\n  if (!loc) return 'Unknown';\n  const l = loc.toLowerCase();\n  if (/^(remote|remote worldwide|anywhere|global|worldwide)$/i.test(l.trim())) return 'Global';\n  if ([...INDIA_CITIES].some(c => l.includes(c))) return 'India';\n  if (/\\busa\\b|united states/.test(l)) return 'United States';\n  for (const [a,c] of Object.entries(CNAMES)) if (l.includes(a)) return c;\n  const last = loc.split(/[,|\\/]/).pop()?.trim().toUpperCase();\n  if (last && ISO2[last]) return ISO2[last];\n  return 'Unknown';\n}\n\nfunction getWorkMode(title, rawLoc, explicit) {\n  const ex = str(explicit).toLowerCase().replace(/[^a-z]/g,'');\n  if (ex === 'remote')  return 'Remote';\n  if (ex === 'hybrid')  return 'Hybrid';\n  if (ex === 'onsite' || ex === 'inoffice') return 'Onsite';\n  const t = (str(title) + ' ' + str(rawLoc)).toLowerCase();\n  if (/\\bremote\\b|\\bwfh\\b|work from home|distributed|\\banywhere\\b/.test(t)) return 'Remote';\n  if (/\\bhybrid\\b/.test(t)) return 'Hybrid';\n  return 'Onsite';\n}\n\nfunction cleanLoc(rawLoc, mode) {\n  const loc = str(rawLoc);\n  if (!loc) return mode === 'Remote' ? 'Remote' : 'Not specified';\n  const FIX = {bangalore:'Bengaluru',bombay:'Mumbai',madras:'Chennai',calcutta:'Kolkata',gurgaon:'Gurugram',cochin:'Kochi'};\n  let l = loc.replace(/[,\\s,]+/g, ', ').trim();\n  for (const [o,n] of Object.entries(FIX)) l = l.replace(new RegExp('\\\\b'+o+'\\\\b','gi'), n);\n  return l || 'Not specified';\n}\n\nfunction normType(raw) {\n  const t = str(raw).toLowerCase().replace(/[^a-z]/g,'');\n  if (/intern/.test(t))              return 'Internship';\n  if (/contract|contractor|freelance/.test(t)) return 'Contract';\n  if (/parttime/.test(t))            return 'Part-time';\n  return 'Full-time';\n}\n\nfunction cleanDesc(html) {\n  const h = str(html);\n  if (!h) return '';\n  return h\n    .replace(/<br\\s*\\/?>/gi, '\\n')\n    .replace(/<\\/?(p|li|div|h[1-6])[^>]*>/gi, '\\n')\n    .replace(/<[^>]+>/g, '')\n    .replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&nbsp;/g,' ').replace(/&#\\d+;/g,'')\n    .replace(/\\n{3,}/g, '\\n\\n').trim().slice(0, 2000);\n}\n\nfunction makeHash(ats, company, title, location) {\n  const s = [ats, company, title, location].map(x => (x||'').toLowerCase().trim()).join('|');\n  let h1 = 0, h2 = 0;\n  for (let i = 0; i < s.length; i++) {\n    h1 = Math.imul(31, h1) + s.charCodeAt(i) | 0;\n    h2 = Math.imul(37, h2) + s.charCodeAt(i) | 0;\n  }\n  return Math.abs(h1).toString(36) + Math.abs(h2).toString(36);\n}\n\n\n// \u2500\u2500 Job Domain / Title Filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Edit these keywords to control which job titles are kept.\nconst ALLOWED_DOMAINS = [\n  // 1. Business Analyst\n  'business analyst', 'business analysis', 'business systems analyst',\n\n  // 2. Product Manager\n  'product manager', 'product management', 'associate pm', 'senior pm',\n  'head of product', 'vp of product', 'director of product',\n\n  // 3. Data Analyst\n  'data analyst', 'data analysis', 'analytics analyst',\n  'business intelligence', 'bi analyst', 'reporting analyst',\n\n  // 4. Product Design\n  'product designer', 'ux designer', 'ui designer', 'ux/ui', 'ui/ux',\n  'product design', 'interaction designer', 'visual designer',\n  'user experience', 'user interface designer',\n\n  // 5. Full Stack Engineer\n  'full stack', 'fullstack', 'full-stack', 'software engineer',\n  'software developer', 'backend engineer', 'frontend engineer',\n  'backend developer', 'frontend developer', 'web developer',\n\n  // 6. Sales\n  'sales', 'account executive', 'account manager',\n  'business development', 'sdr', 'bdr',\n  'sales development', 'sales manager', 'revenue'\n];\n\nfunction isTitleAllowed(title) {\n  const t = title.toLowerCase();\n  return ALLOWED_DOMAINS.some(kw => t.includes(kw));\n}\n\nfunction shouldKeep(country, work_mode) {\n  if (country === 'India') return true;\n  if (work_mode === 'Remote') return true;  // Keep ALL remote jobs regardless of country\n  // Optionally keep specific onsite countries you care about:\n  // if (['United States', 'Canada', 'United Kingdom', 'Germany', 'Singapore'].includes(country)) return true;\n  return false;\n}\n\nfunction buildOutput(rawJobs, ats, company) {\n  const seen = new Set(), out = [];\n  for (const j of rawJobs) {\n    if (!j.title || j.title.length < 2) continue;\n    if (!isTitleAllowed(j.title)) continue;  // \u2190 domain filter\n    const work_mode      = getWorkMode(j.title, j.rawLoc, j.mode);\n    const country        = getCountry(j.rawLoc, j.rawCC);\n    if (!shouldKeep(country, work_mode)) continue;\n    const location       = cleanLoc(j.rawLoc, work_mode);\n    const employment_type= normType(j.type);\n    const job_hash       = makeHash(ats, company, j.title, location);\n    if (seen.has(job_hash)) continue;\n    seen.add(job_hash);\n    const salaryStr  = parseSalary(j.salary);\n    const postedDate = parseDate(j.posted);\n    out.push({ json: {\n      job_title:       j.title.trim(),\n      company,\n      location,\n      country,\n      work_mode,\n      employment_type,\n      apply_url:       str(j.url),\n      ats_type:        ats,\n      job_hash,\n      description:     cleanDesc(j.desc),\n      salary:          salaryStr,\n      posted_date:     postedDate,\n      status:          'active'\n    }});\n  }\n  return out;\n}\n\n// \u2500\u2500 detectATS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction detectATS(resp) {\n  if (typeof resp === 'string' && resp.includes('<html')) return 'html_json_ld';\n  if (resp && resp.data && typeof resp.data === 'string' && resp.data.includes('<html')) return 'html_json_ld';\n  if (!resp || typeof resp !== 'object') return null;\n  if ('text' in resp && 'hostedUrl' in resp && String(resp.hostedUrl).includes('lever.co')) return 'lever';\n  if ('position' in resp && ('apply_url' in resp || 'url' in resp) && 'company' in resp) return 'remoteok';\n  if (Array.isArray(resp)) {\n    const f = resp.find(j => j && typeof j === 'object');\n    if (!f) return null;\n    if ('position' in f && 'company' in f) return 'remoteok';\n    if ('text' in f && 'categories' in f && 'hostedUrl' in f) return 'lever';\n    return null;\n  }\n  if (resp.jobs && Array.isArray(resp.jobs)) {\n    const f = resp.jobs[0] || {};\n    if ('candidate_required_location' in f) return 'remotive';\n    if ('jobTitle' in f) return 'jobicy';\n    if ('absolute_url' in f) return 'greenhouse';\n    if ('jobUrl' in f || 'workplaceType' in f) return 'ashby';\n    return 'ashby';\n  }\n  if (resp.data && Array.isArray(resp.data))    return 'arbeitnow';\n  if (resp.content && Array.isArray(resp.content)) return 'smartrecruiters';\n  if (resp.results && Array.isArray(resp.results)) return 'workable';\n  return null;\n}\n\n// \u2500\u2500 getCompanyFromResp \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction getCompanyFromResp(resp, ats, cfgCompany, cfgSlug) {\n  if (ats === 'remotive')  return 'Remotive Board';\n  if (ats === 'himalayas') return 'Himalayas';\n  if (ats === 'arbeitnow') return 'Arbeitnow';\n  if (ats === 'jobicy')    return cfgSlug === 'india' ? 'Jobicy India' : 'Jobicy USA Remote';\n  if (ats === 'remoteok') {\n    const src = Array.isArray(resp) ? resp[0] : resp;\n    const co  = src && src.company;\n    return co && co !== '0' && co.length > 1 ? co : 'Remote OK';\n  }\n  if (ats === 'lever') {\n    const src = Array.isArray(resp) ? resp[0] : resp;\n    if (src && src.hostedUrl) {\n      const m = src.hostedUrl.match(/lever\\.co\\/([^/?#]+)/);\n      if (m) return m[1].replace(/-/g,' ').replace(/\\b\\w/g, c => c.toUpperCase());\n    }\n    return cfgCompany || cfgSlug || 'Lever';\n  }\n  if (ats === 'greenhouse') {\n    const f = resp.jobs && resp.jobs[0];\n    if (f && f.company_name && f.company_name.length > 1) return f.company_name;\n    if (f && f.absolute_url) { const m = f.absolute_url.match(/greenhouse\\.io\\/([^/?#]+)/); if (m) return m[1]; }\n    return cfgCompany || cfgSlug || 'Greenhouse';\n  }\n  if (ats === 'ashby') {\n    const f = resp.jobs && resp.jobs[0];\n    if (f && f.jobUrl) { const m = f.jobUrl.match(/ashbyhq\\.com\\/([^/?#]+)/); if (m) return m[1].replace(/-/g,' ').replace(/\\b\\w/g, c => c.toUpperCase()); }\n    return cfgCompany || cfgSlug || 'Ashby';\n  }\n  return cfgCompany || cfgSlug || ats;\n}\n\n// \u2500\u2500 Main Loop \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// v9: Uses n8n pairedItem metadata for reliable config\u2192response mapping.\n//     Eliminates the cfgIdx counter that drifted when Lever/RemoteOK\n//     arrays were split into multiple items by n8n.\nconst httpItems = $input.all();\nconst cfgAll    = $('\ud83d\udd27 Prepare Request').all();\nconst allJobs   = [];\n\nconsole.log(`Parse v10: ${httpItems.length} HTTP items | ${cfgAll.length} cfg entries`);\n\nfor (let i = 0; i < httpItems.length; i++) {\n  const resp = httpItems[i].json;\n\n  // \u2500\u2500 Resolve config via n8n item pairing (replaces fragile cfgIdx) \u2500\u2500\n  let cfgIndex = Math.min(i, cfgAll.length - 1);\n  const paired = httpItems[i].pairedItem;\n  if (paired != null) {\n    if (typeof paired === 'object' && !Array.isArray(paired) && typeof paired.item === 'number') {\n      cfgIndex = Math.min(paired.item, cfgAll.length - 1);\n    } else if (Array.isArray(paired) && paired.length > 0 && typeof paired[0].item === 'number') {\n      cfgIndex = Math.min(paired[0].item, cfgAll.length - 1);\n    }\n  }\n  const cfg = cfgAll[cfgIndex]?.json || {};\n\n  const ats      = detectATS(resp) || str(cfg.ats);\n  const cfgSlug  = str(cfg.slug);\n  const company  = getCompanyFromResp(resp, ats, str(cfg.company), cfgSlug);\n  const htmlStr  = (typeof resp === 'string') ? resp : (resp && typeof resp.data === 'string' ? resp.data : null);\n\n  if (!ats) { console.log(`\u26a0 item ${i}: cannot detect ATS`); continue; }\n\n  let rawJobs = [];\n\n  switch (ats) {\n\n    // \u2500\u2500 HTML / JSON-LD career pages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    case 'html_json_ld': {\n      if (!htmlStr) { console.log(`\u26a0 [html_json_ld] ${company}: no HTML body`); break; }\n      let matchCount = 0;\n      const regex = /<script[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi;\n      let m;\n      while ((m = regex.exec(htmlStr)) !== null) {\n        try {\n          const parsed = JSON.parse(m[1]);\n          const items  = Array.isArray(parsed) ? parsed : [parsed];\n          for (const item of items) {\n            const entities = item['@graph'] || [item];\n            for (const ent of entities) {\n              if (ent['@type'] !== 'JobPosting') continue;\n              matchCount++;\n              const rawLocObj = ent.jobLocation\n                ? (Array.isArray(ent.jobLocation) ? ent.jobLocation[0] : ent.jobLocation)\n                : null;\n              let locStr = 'Remote';\n              let rawCC  = '';\n              if (rawLocObj && rawLocObj.address) {\n                locStr = str(rawLocObj.address.addressLocality)\n                      || str(rawLocObj.address.addressRegion)\n                      || str(rawLocObj.address.addressCountry)\n                      || 'Remote';\n                rawCC  = str(rawLocObj.address.addressCountry);\n              }\n              const isRemote = str(ent.jobLocationType).toLowerCase().includes('remote')\n                            || str(ent.description).toLowerCase().includes('remote');\n              // salary: baseSalary in JSON-LD\n              let salary = '';\n              if (ent.baseSalary) {\n                salary = parseSalary(ent.baseSalary);\n              } else if (ent.estimatedSalary) {\n                salary = parseSalary(ent.estimatedSalary);\n              }\n              // posted date: datePosted in JSON-LD\n              const posted = parseDate(ent.datePosted || ent.dateCreated || '');\n              rawJobs.push({\n                title:  str(ent.title || ent.name).trim(),\n                rawLoc: isRemote ? 'Remote' : locStr,\n                rawCC,\n                type:   str(ent.employmentType) || 'Full-time',\n                mode:   isRemote ? 'Remote' : null,\n                url:    str(ent.url) || str(cfg.url) || '',\n                desc:   str(ent.description),\n                salary,\n                posted\n              });\n            }\n          }\n        } catch(e) { /* malformed JSON-LD */ }\n      }\n      console.log(`[html_json_ld] ${company}: ${matchCount} JobPosting entries`);\n      break;\n    }\n\n    // \u2500\u2500 Remotive \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary field: plain string e.g. \"$60,000 - $90,000\"\n    // posted date: publication_date  \"2024-05-01T12:00:00\"\n    case 'remotive':\n      rawJobs = (resp.jobs || []).map(j => ({\n        title:  str(j.title).trim(),\n        rawLoc: str(j.candidate_required_location),\n        rawCC:  '',\n        type:   str(j.job_type),\n        mode:   null,\n        url:    str(j.url),\n        desc:   str(j.description),\n        salary: parseSalary(j.salary),\n        posted: parseDate(j.publication_date)\n      }));\n      break;\n\n    // \u2500\u2500 RemoteOK \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary: salary_min / salary_max (numeric)\n    // posted: date (epoch seconds)\n    case 'remoteok': {\n      const list = Array.isArray(resp)\n        ? resp.filter(j => j && j.position)\n        : (resp && resp.position ? [resp] : []);\n      rawJobs = list.map(j => ({\n        title:  str(j.position).trim(),\n        rawLoc: str(j.location) || 'Worldwide',\n        rawCC:  '',\n        type:   'Full-time',\n        mode:   'Remote',\n        url:    str(j.apply_url) || str(j.url),\n        desc:   str(j.description),\n        salary: (j.salary_min || j.salary_max)\n                  ? parseSalary({ min: j.salary_min, max: j.salary_max, currency: 'USD', interval: 'YEAR' })\n                  : '',\n        posted: parseDate(j.date || j.epoch)\n      }));\n      break;\n    }\n\n    // \u2500\u2500 Himalayas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary: j.salary (string like \"$80k-$120k\") or j.minSalary/j.maxSalary\n    // posted: createdAt / publishedAt (ISO string)\n    case 'himalayas': {\n      const jobs = resp.jobs || resp.data || (Array.isArray(resp) ? resp : []);\n      rawJobs = jobs.map(j => ({\n        title:  str(j.title).trim(),\n        rawLoc: str(j.location) || str(j.regions) || 'Remote',\n        rawCC:  '',\n        type:   str(j.jobType) || str(j.type),\n        mode:   'Remote',\n        url:    str(j.applyUrl) || str(j.url),\n        desc:   str(j.description),\n        salary: j.salary\n                  ? parseSalary(j.salary)\n                  : (j.minSalary || j.maxSalary)\n                    ? parseSalary({ min: j.minSalary, max: j.maxSalary, currency: j.currency || 'USD', interval: 'YEAR' })\n                    : '',\n        posted: parseDate(j.createdAt || j.publishedAt || j.postedAt)\n      }));\n      break;\n    }\n\n    // \u2500\u2500 Arbeitnow \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary: no standard field  (sometimes in description)\n    // posted: created_at (ISO string)\n    case 'arbeitnow':\n      rawJobs = (resp.data || []).map(j => ({\n        title:  str(j.title).trim(),\n        rawLoc: str(j.location),\n        rawCC:  '',\n        type:   Array.isArray(j.job_types) ? str(j.job_types[0]) : 'Full-time',\n        mode:   j.remote ? 'Remote' : null,\n        url:    str(j.url),\n        desc:   str(j.description),\n        salary: parseSalary(j.salary || ''),\n        posted: parseDate(j.created_at || j.published_at)\n      }));\n      break;\n\n    // \u2500\u2500 Jobicy \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary: annualSalaryMin / annualSalaryMax (numeric, USD)\n    // posted: pubDate (RFC-2822 string)\n    case 'jobicy':\n      rawJobs = (resp.jobs || []).map(j => ({\n        title:  str(j.jobTitle).trim(),\n        rawLoc: str(j.jobGeo) || str(j.jobRegion) || 'Remote',\n        rawCC:  '',\n        type:   str(j.jobType),\n        mode:   'Remote',\n        url:    str(j.url),\n        desc:   str(j.jobExcerpt),\n        salary: (j.annualSalaryMin || j.annualSalaryMax)\n                  ? parseSalary({ min: j.annualSalaryMin, max: j.annualSalaryMax, currency: j.salaryCurrency || 'USD', interval: 'YEAR' })\n                  : '',\n        posted: parseDate(j.pubDate || j.postDate)\n      }));\n      break;\n\n    // \u2500\u2500 Lever \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary: salaryRange.min / salaryRange.max (numeric) + currency\n    // posted: createdAt (epoch ms)\n    case 'lever': {\n      const list = Array.isArray(resp) ? resp : (resp && resp.text ? [resp] : []);\n      rawJobs = list.map(j => ({\n        title:  str(j.text).trim(),\n        rawLoc: str(j.categories && j.categories.location),\n        rawCC:  '',\n        type:   str(j.categories && j.categories.commitment),\n        mode:   null,\n        url:    str(j.hostedUrl) || str(j.applyUrl),\n        desc:   str(j.descriptionPlain),\n        salary: (j.salaryRange && (j.salaryRange.min || j.salaryRange.max))\n                  ? parseSalary({\n                      min:      j.salaryRange.min,\n                      max:      j.salaryRange.max,\n                      currency: j.salaryRange.currency || 'USD',\n                      interval: j.salaryRange.interval || 'YEAR'\n                    })\n                  : '',\n        posted: parseDate(j.createdAt)\n      }));\n      break;\n    }\n\n    // \u2500\u2500 Greenhouse \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary: NOT in standard GH response (salary info is per-job page)\n    // posted: updated_at (ISO string)\n    case 'greenhouse':\n      rawJobs = (resp.jobs || []).map(j => ({\n        title:  str(j.title).trim(),\n        rawLoc: str(j.location && j.location.name),\n        rawCC:  '',\n        type:   '',\n        mode:   null,\n        url:    str(j.absolute_url) || `https://boards.greenhouse.io/${cfgSlug}`,\n        desc:   str(j.content),\n        salary: '',   // GH API doesn't surface salary at listing level\n        posted: parseDate(j.updated_at || j.created_at)\n      }));\n      break;\n\n    // \u2500\u2500 Workable \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary: no standard field in widget API\n    // posted: created_at (ISO string)\n    case 'workable':\n      rawJobs = (resp.results || []).map(j => {\n        const loc = j.location || {};\n        const city = str(loc.city), cc = str(loc.country_code) || str(loc.country);\n        const rem  = loc.telecommuting || j.remote || false;\n        return {\n          title:  str(j.title).trim(),\n          rawLoc: rem ? ('Remote' + (city ? ', '+city : '')) : [city, cc].filter(Boolean).join(', '),\n          rawCC:  cc,\n          type:   str(j.type),\n          mode:   rem ? 'Remote' : null,\n          url:    str(j.url) || `https://apply.workable.com/${cfgSlug}`,\n          desc:   str(j.description),\n          salary: '',\n          posted: parseDate(j.published_on || j.created_at)\n        };\n      });\n      break;\n\n    // \u2500\u2500 SmartRecruiters \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary: compensation.min / .max / .currency / .remuneration\n    // posted: releasedDate / createdOn (ISO string)\n    case 'smartrecruiters':\n      rawJobs = (resp.content || []).map(j => {\n        const loc  = j.location || {};\n        const city = str(loc.city), cc = str(loc.country);\n        const rem  = loc.remote || false;\n        const comp = j.compensation;\n        return {\n          title:  str(j.name).trim(),\n          rawLoc: rem ? ('Remote' + (city ? ', '+city : '')) : [city, cc].filter(Boolean).join(', '),\n          rawCC:  cc,\n          type:   str(j.typeOfEmployment && j.typeOfEmployment.label),\n          mode:   rem ? 'Remote' : null,\n          url:    str(j.ref) || `https://careers.smartrecruiters.com/${cfgSlug}`,\n          desc:   str(j.jobAdText),\n          salary: comp\n                    ? parseSalary({ min: comp.min, max: comp.max, currency: comp.currency, interval: comp.remuneration })\n                    : '',\n          posted: parseDate(j.releasedDate || j.createdOn)\n        };\n      });\n      break;\n\n    // \u2500\u2500 Ashby \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    // salary: compensationTierSummary (string) or salary object\n    // posted: publishedDate / createdAt (ISO string)\n    case 'ashby':\n      rawJobs = (resp.jobs || []).map(j => {\n        const addr = (j.address && j.address.postalAddress) || {};\n        return {\n          title:  str(j.title).trim(),\n          rawLoc: str(j.location) || [str(addr.addressLocality), str(addr.addressRegion), str(addr.addressCountry)].filter(Boolean).join(', '),\n          rawCC:  str(addr.addressCountry),\n          type:   str(j.employmentType),\n          mode:   str(j.workplaceType) || (j.isRemote ? 'Remote' : null),\n          url:    str(j.jobUrl) || str(j.applyUrl) || `https://jobs.ashbyhq.com/${cfgSlug}`,\n          desc:   str(j.descriptionHtml) || str(j.descriptionPlain),\n          salary: j.compensationTierSummary\n                    ? parseSalary(str(j.compensationTierSummary))\n                    : (j.salary ? parseSalary(j.salary) : ''),\n          posted: parseDate(j.publishedDate || j.createdAt)\n        };\n      });\n      break;\n\n    default:\n      console.log(`\u26a0 Unknown ATS: ${ats}`);\n      continue;\n  }\n\n  console.log(`[${ats}] ${company}: ${rawJobs.length} raw jobs`);\n  const kept = buildOutput(rawJobs, ats, company);\n  console.log(`[${ats}] ${company}: ${kept.length} kept after filter`);\n  for (const job of kept) allJobs.push(job);\n}\n\nconsole.log(`\u2705 Parse v10 total: ${allJobs.length} jobs`);\nreturn allJobs.length > 0 ? allJobs : [{ json: { _empty: true } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "05459557-5a0e-452f-a4a5-0b54b9dbb22a",
      "name": "Trigger & Config",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1824,
        2640
      ],
      "parameters": {
        "color": 7,
        "width": 668,
        "height": 280,
        "content": "## 1. Source Discovery & Trigger\nDefines target ATS sources and schedules the daily sync process."
      },
      "typeVersion": 1
    },
    {
      "id": "2931956b-1e57-4945-8209-033742390573",
      "name": "Parsing & Enrichment",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1104,
        2640
      ],
      "parameters": {
        "color": 7,
        "width": 684,
        "height": 280,
        "content": "## 2. Request & Normalization\nExecutes HTTP requests and uses advanced logic to map inconsistent ATS payloads to a standard schema."
      },
      "typeVersion": 1
    },
    {
      "id": "feb79c94-0130-47ac-ae6b-9b70d65c15c5",
      "name": "Destination",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -384,
        2640
      ],
      "parameters": {
        "color": 7,
        "width": 600,
        "height": 280,
        "content": "## 3. Storage & Output\nUpserts standardized job data into Supabase and updates the target Google Sheet."
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "\u23f0 Daily 8AM IST": {
      "main": [
        [
          {
            "node": "\ud83d\udccb Company List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83c\udf10 HTTP Request": {
      "main": [
        [
          {
            "node": "\ud83d\udd0d Parse + Enrich + Filter1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udccb Company List": {
      "main": [
        [
          {
            "node": "\ud83d\udd04 Loop Batches (5)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd27 Prepare Request": {
      "main": [
        [
          {
            "node": "\ud83c\udf10 HTTP Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd04 Loop Batches (5)": {
      "main": [
        [],
        [
          {
            "node": "\ud83d\udd27 Prepare Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcbe Upsert to Supabase": {
      "main": [
        [
          {
            "node": "\ud83d\udcca Write to Google Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udcca Write to Google Sheet": {
      "main": [
        [
          {
            "node": "\ud83d\udd04 Loop Batches (5)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd0d Parse + Enrich + Filter1": {
      "main": [
        [
          {
            "node": "\ud83d\udcbe Upsert to Supabase",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}