This workflow corresponds to n8n.io template #14806 — we link there as the canonical source.
This workflow follows the Google Sheets → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/ /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
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Stop manually checking dozens of career pages. This workflow runs every morning, hits the public APIs of 8+ ATS platforms and job boards, normalizes every listing into a single clean schema, and syncs everything to Supabase and Google Sheets deduplicated and ready to query.
Source: https://n8n.io/workflows/14806/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow monitors customer health by combining payment behavior, complaint signals, and AI-driven feedback analysis. It runs on daily and weekly schedules to evaluate risk levels, escalate high-r
Code Postgres. Uses httpRequest, splitInBatches, postgres, hubspot. Scheduled trigger; 23 nodes.
Continuous monitoring: Real-time surveillance of supplier performance, financial health, and operational status Risk scoring: AI-powered assessment of supplier risks across multiple dimensions (financ
Regulatory monitoring: Continuously tracks changes in laws, regulations, and compliance requirements across multiple jurisdictions Contract analysis: AI-powered review of existing contracts to identif
Stop manually checking dozens of career pages. This workflow runs every morning, hits the public APIs of 8+ ATS platforms and job boards, normalizes every listing into a single clean schema, and syncs