{ "name": "LinkedIn jobs to Google Sheets (Cloud auto-setup)", "nodes": [ { "parameters": {}, "id": "0f8447af-83f0-4c36-bcad-ea7ae560b153", "name": "Manual Trigger", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [ -2200, -180 ] }, { "parameters": { "rule": { "interval": [ { "field": "days", "daysInterval": 1, "triggerAtHour": 8, "triggerAtMinute": 0 } ] } }, "id": "bc7d9a5a-d3c3-466b-9e6a-1c1467abed88", "name": "Daily Schedule", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [ -2200, 20 ] }, { "parameters": { "content": "## Cloud quick setup\n\nThis variant uses OAuth credentials and creates its own Google spreadsheet. You do not copy a spreadsheet ID or header.\n\n1. Connect an **Apify OAuth2** credential. In the credential's **Connection** settings, set **Allowed HTTP Request Domains** to **Specific Domains** and add `api.apify.com`; the default **None** blocks HTTP Request nodes, and **All** is unnecessary. Then select the credential on **Start Actor**, **Poll Actor Run**, **Get Run Output**, and **Get Dataset Items**. The current `apifyOAuth2Api` credential requests `profile` and `full_api_access`, which grants n8n full API access to the selected Apify account. The credential type is supplied by the official `@apify/n8n-nodes-apify` package; confirm that the package and credential are available in your n8n version. If you prefer a dedicated HTTP Header Auth token with narrower permissions, use the original HTTP Header Auth workflow as the optional fallback.\n2. Connect one **Google Sheets OAuth2** credential. In the credential's **Connection** settings, set **Allowed HTTP Request Domains** to **Specific Domains** and enter `www.googleapis.com,sheets.googleapis.com` as the exact comma-separated hosts; the default **None** blocks HTTP Request nodes, and **All** is unnecessary. Then select the credential on every Google Drive, Google Sheets, **Read Existing Jobs**, and **Append Rows** node when n8n asks for one.\n3. Save the workflow before the first run so `$workflow.id` is available. Open **Search settings** and set at least `keywords` and `location`.\n4. Run the Manual Trigger and inspect the new spreadsheet and `Jobs` worksheet. The workflow creates the exact ten-column header when the worksheet is empty.\n\nThe workflow tags one spreadsheet with this workflow's ID. Later runs reuse that spreadsheet and never rewrite existing job rows. Keep one writer per workflow; concurrent first runs are not protected by a global lock. The schedule remains inactive until you enable it.", "width": 980, "height": 520 }, "id": "84219f0b-d510-4d5c-bca6-c65a9d61ec1d", "name": "Before Running", "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -2200, -720 ] }, { "parameters": { "assignments": { "assignments": [ { "id": "44f182b9-78ab-46fe-96ac-f3ec0e794af3", "name": "keywords", "value": "software engineer", "type": "string" }, { "id": "0aa61d19-4101-4d2d-be3e-a9cf72ad005c", "name": "location", "value": "London, United Kingdom", "type": "string" }, { "id": "c9941192-8e6b-4143-b606-cd78a9cf18fc", "name": "datePosted", "value": "pastWeek", "type": "string" }, { "id": "2df74e4c-6017-493e-9128-97aa54cd89cc", "name": "limit", "value": 10, "type": "number" }, { "id": "ad409653-f9ab-44db-b786-b0e0b55a21fb", "name": "budgetUsd", "value": 0.05, "type": "number" }, { "id": "1f1514b8-7f0b-4f10-935c-66f5a7c21f69", "name": "scrapeCompany", "value": false, "type": "boolean" }, { "id": "bd34c0fa-a212-4e6b-aebd-ef41673f65ab", "name": "spreadsheetTitle", "value": "LinkedIn jobs", "type": "string" } ] }, "options": {} }, "id": "7f4493f5-7dc8-4887-8b9c-3ca85145c9c2", "name": "Search settings", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [ -1980, -80 ] }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "const config = $input.first()?.json ?? {};\n\nfunction requiredText(value, name) {\n if (typeof value !== 'string' || !value.trim()) {\n throw new Error(`${name} must be a non-empty string`);\n }\n return value.trim();\n}\n\nfunction positiveInteger(value, name, maximum) {\n if (!Number.isInteger(value) || value < 1 || value > maximum) {\n throw new Error(`${name} must be an integer from 1 to ${maximum}`);\n }\n return value;\n}\n\nconst workflowId = typeof $workflow === 'object'\n ? String($workflow?.id ?? '').trim()\n : '';\nif (!workflowId) {\n throw new Error('Save this workflow before running it so $workflow.id is available');\n}\n\nconst datePostedValues = new Set(['anyTime', 'past24Hours', 'pastWeek', 'pastMonth']);\nconst datePosted = requiredText(config.datePosted, 'datePosted');\nif (!datePostedValues.has(datePosted)) {\n throw new Error(`datePosted must be one of ${[...datePostedValues].join(', ')}`);\n}\n\nconst budgetUsd = Number(config.budgetUsd);\nif (!Number.isFinite(budgetUsd) || budgetUsd <= 0) {\n throw new Error('budgetUsd must be a positive number');\n}\n\nif (typeof config.scrapeCompany !== 'boolean') {\n throw new Error('scrapeCompany must be a boolean');\n}\n\nif (\n config.spreadsheetTitle != null\n && typeof config.spreadsheetTitle !== 'string'\n) {\n throw new Error('spreadsheetTitle must be a string');\n}\nconst spreadsheetTitle = config.spreadsheetTitle?.trim() || 'LinkedIn jobs';\nif (spreadsheetTitle.length > 200) {\n throw new Error('spreadsheetTitle must be 200 characters or fewer');\n}\n\nconst workflowIdQuery = workflowId\n .replaceAll('\\\\', '\\\\\\\\')\n .replaceAll(\"'\", \"\\\\'\");\n\nreturn [{\n json: {\n keywords: requiredText(config.keywords, 'keywords'),\n location: requiredText(config.location, 'location'),\n datePosted,\n limit: positiveInteger(config.limit, 'limit', 1000),\n budgetUsd,\n scrapeCompany: config.scrapeCompany,\n spreadsheetTitle,\n workflowId,\n workflowIdQuery,\n sheetName: 'Jobs',\n schemaVersion: '1',\n },\n}];\n" }, "id": "a1b3e0ac-4309-449f-b4fb-6cf5276fbd47", "name": "Validate Cloud Settings", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -1740, -80 ] }, { "parameters": { "method": "GET", "url": "https://www.googleapis.com/drive/v3/files", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "q", "value": "={{ \"trashed=false and mimeType='application/vnd.google-apps.spreadsheet' and appProperties has { key='mekaLinkedinJobsWorkflow' and value='\" + $json.workflowIdQuery + \"' }\" }}" }, { "name": "fields", "value": "files(id,name,mimeType,appProperties),nextPageToken" }, { "name": "pageSize", "value": "2" } ] }, "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 30000 } }, "id": "14e12b03-cdc84c35-a737-12fb7cc7855a", "name": "Find App Spreadsheet", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -1500, -80 ], "retryOnFail": false, "onError": "continueRegularOutput", "alwaysOutputData": true }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "function responseBody(label) {\n const response = $input.first()?.json ?? {};\n const statusCode = Number(response.statusCode);\n if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) {\n throw new Error(`${label} failed with HTTP ${Number.isFinite(statusCode) ? statusCode : 'NO_STATUS'}`);\n }\n\n let body = response.body !== undefined ? response.body : response;\n if (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`${label} returned invalid JSON`);\n }\n }\n if (!body || typeof body !== 'object' || Array.isArray(body)) {\n throw new Error(`${label} returned an invalid response`);\n }\n return body;\n}\n\nconst settings = $('Validate Cloud Settings').first()?.json ?? {};\nconst workflowId = String(settings.workflowId ?? '').trim();\nif (!workflowId) {\n throw new Error('Validate Cloud Settings did not produce a workflow ID');\n}\n\nconst body = responseBody('Google Drive spreadsheet lookup');\nif (!Array.isArray(body.files)) {\n throw new Error('Google Drive spreadsheet lookup did not return a files array');\n}\nif (body.nextPageToken != null && String(body.nextPageToken).trim()) {\n throw new Error('Google Drive spreadsheet lookup returned another page; refusing an ambiguous match');\n}\nif (body.files.length > 1) {\n throw new Error('Multiple app spreadsheets are tagged for this workflow; refusing to choose one');\n}\nif (body.files.length === 0) {\n return [{ json: { ...settings, nextAction: 'create' } }];\n}\n\nconst file = body.files[0];\nif (!file || typeof file !== 'object' || Array.isArray(file)) {\n throw new Error('Google Drive spreadsheet lookup returned an invalid file');\n}\nif (file.mimeType !== 'application/vnd.google-apps.spreadsheet') {\n throw new Error('The tagged Google Drive file is not a Google spreadsheet');\n}\n\nconst spreadsheetId = String(file.id ?? '').trim();\nif (!spreadsheetId) {\n throw new Error('The tagged Google Drive file has no spreadsheet ID');\n}\n\nconst properties = file.appProperties;\nif (!properties || typeof properties !== 'object' || Array.isArray(properties)) {\n throw new Error('The tagged Google Drive file has no app properties');\n}\nif (properties.mekaLinkedinJobsWorkflow !== workflowId) {\n throw new Error('The Google Drive file workflow tag does not match this workflow');\n}\n\nconst bootstrapState = String(properties.mekaLinkedinJobsBootstrapState ?? '').trim();\nif (bootstrapState !== 'pending' && bootstrapState !== 'ready') {\n throw new Error('The tagged Google Drive file has an unsupported bootstrap state');\n}\n\nconst schemaVersion = String(properties.mekaLinkedinJobsSchemaVersion ?? '').trim();\nif (schemaVersion && schemaVersion !== settings.schemaVersion) {\n throw new Error(`Unsupported app spreadsheet schema version ${schemaVersion}`);\n}\n\nreturn [{\n json: {\n ...settings,\n fileId: spreadsheetId,\n spreadsheetId,\n bootstrapState,\n created: false,\n nextAction: 'reuse',\n },\n}];\n" }, "id": "fa428ec8-2634-4452-bc80-92250662844a", "name": "Assess Drive Search", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -1260, -80 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "4f7ea8a7-01da-45fe-b774-f72a6c4533b6-condition", "leftValue": "={{ $json.nextAction === 'reuse' }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "id": "4f7ea8a7-01da-45fe-b774-f72a6c4533b6", "name": "Existing App Spreadsheet?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ -1020, -80 ] }, { "parameters": { "method": "GET", "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + encodeURIComponent($json.spreadsheetId) }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "includeGridData", "value": "false" }, { "name": "fields", "value": "sheets(properties(sheetId,title,gridProperties(rowCount,columnCount)))" } ] }, "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 30000 } }, "id": "c359df92-0dc8-4652-af48-aa6b1170e82d", "name": "Get Spreadsheet Metadata", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -780, -80 ], "retryOnFail": false, "onError": "continueRegularOutput", "alwaysOutputData": true }, { "parameters": { "method": "POST", "url": "https://www.googleapis.com/drive/v3/files?fields=id,name,mimeType,appProperties", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ { \"name\": $json.spreadsheetTitle, \"mimeType\": \"application/vnd.google-apps.spreadsheet\", \"appProperties\": { \"mekaLinkedinJobsWorkflow\": $json.workflowId, \"mekaLinkedinJobsBootstrapState\": \"pending\", \"mekaLinkedinJobsSchemaVersion\": \"1\" } } }}", "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 30000 } }, "id": "88667efe-0a6d-4d3f-baa2-0d91a78f2db1", "name": "Create App Spreadsheet", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -780, 120 ], "retryOnFail": false, "onError": "continueRegularOutput", "alwaysOutputData": true }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "function responseBody(label) {\n const response = $input.first()?.json ?? {};\n const statusCode = Number(response.statusCode);\n if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) {\n throw new Error(`${label} failed with HTTP ${Number.isFinite(statusCode) ? statusCode : 'NO_STATUS'}`);\n }\n\n let body = response.body !== undefined ? response.body : response;\n if (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`${label} returned invalid JSON`);\n }\n }\n if (!body || typeof body !== 'object' || Array.isArray(body)) {\n throw new Error(`${label} returned an invalid response`);\n }\n return body;\n}\n\nconst settings = $('Assess Drive Search').first()?.json ?? {};\nconst workflowId = String(settings.workflowId ?? '').trim();\nif (!workflowId) {\n throw new Error('Assess Drive Search did not preserve the workflow ID');\n}\n\nconst body = responseBody('Google Drive spreadsheet creation');\nconst spreadsheetId = String(body.id ?? '').trim();\nif (!spreadsheetId) {\n throw new Error('Google Drive spreadsheet creation did not return an ID');\n}\nif (body.mimeType !== 'application/vnd.google-apps.spreadsheet') {\n throw new Error('Google Drive did not create a Google spreadsheet');\n}\n\nconst properties = body.appProperties;\nif (!properties || typeof properties !== 'object' || Array.isArray(properties)) {\n throw new Error('The created Google spreadsheet has no app properties');\n}\nif (properties.mekaLinkedinJobsWorkflow !== workflowId) {\n throw new Error('The created Google spreadsheet workflow tag is incorrect');\n}\nif (properties.mekaLinkedinJobsBootstrapState !== 'pending') {\n throw new Error('The created Google spreadsheet is not in pending bootstrap state');\n}\nif (\n properties.mekaLinkedinJobsSchemaVersion\n && properties.mekaLinkedinJobsSchemaVersion !== settings.schemaVersion\n) {\n throw new Error('The created Google spreadsheet has an unsupported schema version');\n}\n\nreturn [{\n json: {\n ...settings,\n fileId: spreadsheetId,\n spreadsheetId,\n bootstrapState: 'pending',\n created: true,\n nextAction: 'inspect-sheet',\n },\n}];\n" }, "id": "52c8a79e-af09-4612-864f-7605bb0b0a1f", "name": "Assess Created Spreadsheet", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -540, 120 ] }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "function previousIfExecuted(name) {\n try {\n return $(name).first()?.json ?? null;\n } catch {\n return null;\n }\n}\n\nfunction responseBody(label) {\n const response = $input.first()?.json ?? {};\n const statusCode = Number(response.statusCode);\n if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) {\n throw new Error(`${label} failed with HTTP ${Number.isFinite(statusCode) ? statusCode : 'NO_STATUS'}`);\n }\n\n let body = response.body !== undefined ? response.body : response;\n if (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`${label} returned invalid JSON`);\n }\n }\n if (!body || typeof body !== 'object' || Array.isArray(body)) {\n throw new Error(`${label} returned an invalid response`);\n }\n return body;\n}\n\nconst settings = $('Validate Cloud Settings').first()?.json ?? {};\nconst search = $('Assess Drive Search').first()?.json ?? {};\nconst created = previousIfExecuted('Assess Created Spreadsheet');\nconst spreadsheetId = String(\n created?.spreadsheetId ?? search.spreadsheetId ?? '',\n).trim();\nconst bootstrapState = String(\n created?.bootstrapState ?? search.bootstrapState ?? '',\n).trim();\nif (!spreadsheetId) {\n throw new Error('Bootstrap did not resolve a spreadsheet ID');\n}\nif (bootstrapState !== 'pending' && bootstrapState !== 'ready') {\n throw new Error('Bootstrap did not resolve a supported spreadsheet state');\n}\n\nconst body = responseBody('Google Sheets metadata lookup');\nif (!Array.isArray(body.sheets)) {\n throw new Error('Google Sheets metadata lookup did not return a sheets array');\n}\n\nconst jobsSheets = body.sheets.filter(\n (sheet) => sheet?.properties?.title === 'Jobs',\n);\nif (jobsSheets.length > 1) {\n throw new Error('The spreadsheet contains multiple Jobs worksheets; refusing an ambiguous match');\n}\n\nconst base = { ...settings, ...search, ...(created ?? {}), spreadsheetId };\nif (jobsSheets.length === 0) {\n if (bootstrapState === 'ready') {\n throw new Error('The ready app spreadsheet is missing its Jobs worksheet');\n }\n return [{ json: { ...base, bootstrapState, nextAction: 'add-sheet' } }];\n}\n\nconst sheetId = jobsSheets[0].properties?.sheetId;\nif (!Number.isInteger(sheetId)) {\n throw new Error('The Jobs worksheet has an invalid sheet ID');\n}\n\nconst gridProperties = jobsSheets[0].properties?.gridProperties;\nconst rowCount = gridProperties?.rowCount;\nconst columnCount = gridProperties?.columnCount;\nif (\n !Number.isInteger(rowCount)\n || rowCount < 1\n || !Number.isInteger(columnCount)\n || columnCount < 1\n) {\n throw new Error('The Jobs worksheet has invalid grid bounds');\n}\n\nfunction columnLabel(value) {\n let remaining = value;\n let label = '';\n while (remaining > 0) {\n remaining -= 1;\n label = String.fromCharCode(65 + (remaining % 26)) + label;\n remaining = Math.floor(remaining / 26);\n }\n return label;\n}\n\nreturn [{\n json: {\n ...base,\n bootstrapState,\n sheetId,\n sheetName: 'Jobs',\n emptyCheckRange: `Jobs!A1:${columnLabel(columnCount)}${rowCount}`,\n nextAction: 'check-header',\n },\n}];\n" }, "id": "adfb9d15-82af-4800-94c7-5bf980f08337", "name": "Assess Sheet Structure", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -300, -80 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "7f366816-7654-4c72-9ce7-2071095b231d-condition", "leftValue": "={{ $json.nextAction === 'check-header' }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "id": "7f366816-7654-4c72-9ce7-2071095b231d", "name": "Has Jobs Worksheet?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ -60, -80 ] }, { "parameters": { "method": "POST", "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + encodeURIComponent($json.spreadsheetId) + ':batchUpdate' }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "fields", "value": "replies(addSheet(properties(sheetId,title,gridProperties(rowCount,columnCount))))" } ] }, "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ { \"requests\": [{ \"addSheet\": { \"properties\": { \"title\": \"Jobs\", \"sheetId\": 1731133718, \"gridProperties\": { \"rowCount\": 1000, \"columnCount\": 26 } } } }] } }}", "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 30000 } }, "id": "5f59decf-a542-4f36-ae3b-8514aa148a6a", "name": "Add Jobs Worksheet", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 180, 120 ], "retryOnFail": false, "onError": "continueRegularOutput", "alwaysOutputData": true }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "function responseBody(label) {\n const response = $input.first()?.json ?? {};\n const statusCode = Number(response.statusCode);\n if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) {\n throw new Error(`${label} failed with HTTP ${Number.isFinite(statusCode) ? statusCode : 'NO_STATUS'}`);\n }\n\n let body = response.body !== undefined ? response.body : response;\n if (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`${label} returned invalid JSON`);\n }\n }\n if (!body || typeof body !== 'object' || Array.isArray(body)) {\n throw new Error(`${label} returned an invalid response`);\n }\n return body;\n}\n\nconst state = $('Assess Sheet Structure').first()?.json ?? {};\nconst body = responseBody('Google Sheets worksheet creation');\nif (!Array.isArray(body.replies)) {\n throw new Error('Google Sheets worksheet creation did not return replies');\n}\n\nconst jobsSheetId = 1731133718;\nconst properties = body.replies\n .map((reply) => reply?.addSheet?.properties)\n .find(Boolean);\nif (!properties || properties.title !== 'Jobs' || properties.sheetId !== jobsSheetId) {\n throw new Error('Google Sheets did not create the expected Jobs worksheet');\n}\nconst rowCount = properties.gridProperties?.rowCount;\nconst columnCount = properties.gridProperties?.columnCount;\nif (\n !Number.isInteger(rowCount)\n || rowCount < 1\n || !Number.isInteger(columnCount)\n || columnCount < 1\n) {\n throw new Error('Google Sheets created the Jobs worksheet with invalid grid bounds');\n}\n\nfunction columnLabel(value) {\n let remaining = value;\n let label = '';\n while (remaining > 0) {\n remaining -= 1;\n label = String.fromCharCode(65 + (remaining % 26)) + label;\n remaining = Math.floor(remaining / 26);\n }\n return label;\n}\n\nreturn [{\n json: {\n ...state,\n sheetId: jobsSheetId,\n sheetName: 'Jobs',\n emptyCheckRange: `Jobs!A1:${columnLabel(columnCount)}${rowCount}`,\n nextAction: 'check-header',\n },\n}];\n" }, "id": "16c713f3-8fc4-4bea-929f-392fa6a588f0", "name": "Assess Added Sheet", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 420, 120 ] }, { "parameters": { "method": "GET", "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + encodeURIComponent($json.spreadsheetId) + '/values/Jobs!A1%3AJ1' }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "majorDimension", "value": "ROWS" }, { "name": "valueRenderOption", "value": "FORMATTED_VALUE" } ] }, "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 30000 } }, "id": "85da0fae-ee84-45f7-933f-eb7a1bc4476f", "name": "Get Jobs Header", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 660, -80 ], "retryOnFail": false, "onError": "continueRegularOutput", "alwaysOutputData": true }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "function previousIfExecuted(name) {\n try {\n return $(name).first()?.json ?? null;\n } catch {\n return null;\n }\n}\n\nfunction responseBody(label) {\n const response = $input.first()?.json ?? {};\n const statusCode = Number(response.statusCode);\n if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) {\n throw new Error(`${label} failed with HTTP ${Number.isFinite(statusCode) ? statusCode : 'NO_STATUS'}`);\n }\n\n let body = response.body !== undefined ? response.body : response;\n if (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`${label} returned invalid JSON`);\n }\n }\n if (!body || typeof body !== 'object' || Array.isArray(body)) {\n throw new Error(`${label} returned an invalid response`);\n }\n return body;\n}\n\nconst expectedHeader = [\n 'id',\n 'title',\n 'company',\n 'location',\n 'postedAt',\n 'url',\n 'salary',\n 'employmentType',\n 'seniority',\n 'firstSeenAt',\n];\n\nconst structure = $('Assess Sheet Structure').first()?.json ?? {};\nconst added = previousIfExecuted('Assess Added Sheet');\nconst state = { ...structure, ...(added ?? {}) };\nconst body = responseBody('Google Sheets header lookup');\n\nif (body.values != null && !Array.isArray(body.values)) {\n throw new Error('Google Sheets header lookup returned invalid values');\n}\nconst rows = body.values ?? [];\nfor (const row of rows) {\n if (!Array.isArray(row)) {\n throw new Error('Google Sheets header lookup returned a non-row value');\n }\n}\n\nfunction text(value) {\n return value == null ? '' : String(value).trim();\n}\n\nconst header = (rows[0] ?? []).map(text);\nconst hasHeader = (\n header.length === expectedHeader.length\n && header.every((value, index) => value === expectedHeader[index])\n);\nif (hasHeader) {\n return [{ json: { ...state, nextAction: 'continue' } }];\n}\n\nif (state.bootstrapState === 'ready') {\n throw new Error(\n 'The ready app spreadsheet has a missing or incompatible Jobs header; refusing to reset it automatically',\n );\n}\n\nconst headerHasContent = header.some((value) => value !== '');\nif (headerHasContent) {\n throw new Error(\n 'The existing Jobs worksheet has content, but row 1 is not the exact 10-column MEKA header; refusing to overwrite it',\n );\n}\n\nreturn [{ json: { ...state, nextAction: 'inspect-empty' } }];\n" }, "id": "3f097a1a-f371-4638-b7d1-29c331c63a61", "name": "Assess Jobs Header", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 900, -80 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "3f7aaa76-e8e1-4d91-9e0b-7789370f0760-condition", "leftValue": "={{ $json.nextAction === 'inspect-empty' }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "id": "3f7aaa76-e8e1-4d91-9e0b-7789370f0760", "name": "Empty Header Found?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1140, -80 ] }, { "parameters": { "method": "GET", "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + encodeURIComponent($json.spreadsheetId) + '/values/' + encodeURIComponent($json.emptyCheckRange) }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "majorDimension", "value": "ROWS" }, { "name": "valueRenderOption", "value": "FORMATTED_VALUE" } ] }, "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 30000 } }, "id": "2f8dc2cc-96b2-4b91-8ce4-87ce3ccb64e6", "name": "Confirm Jobs Worksheet Empty", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1380, 120 ], "retryOnFail": false, "onError": "continueRegularOutput", "alwaysOutputData": true }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "function responseBody(label) {\n const response = $input.first()?.json ?? {};\n const statusCode = Number(response.statusCode);\n if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) {\n throw new Error(`${label} failed with HTTP ${Number.isFinite(statusCode) ? statusCode : 'NO_STATUS'}`);\n }\n\n let body = response.body !== undefined ? response.body : response;\n if (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`${label} returned invalid JSON`);\n }\n }\n if (!body || typeof body !== 'object' || Array.isArray(body)) {\n throw new Error(`${label} returned an invalid response`);\n }\n return body;\n}\n\nconst state = $('Assess Jobs Header').first()?.json ?? {};\nif (state.bootstrapState !== 'pending') {\n throw new Error('Empty-worksheet confirmation is allowed only while bootstrap is pending');\n}\nif (typeof state.emptyCheckRange !== 'string' || !state.emptyCheckRange.trim()) {\n throw new Error('Bootstrap state did not include a bounded worksheet range');\n}\n\nconst body = responseBody('Google Sheets empty-worksheet confirmation');\nif (body.values != null && !Array.isArray(body.values)) {\n throw new Error('Google Sheets empty-worksheet confirmation returned invalid values');\n}\nfor (const row of body.values ?? []) {\n if (!Array.isArray(row)) {\n throw new Error('Google Sheets empty-worksheet confirmation returned a non-row value');\n }\n}\n\nfunction text(value) {\n return value == null ? '' : String(value).trim();\n}\n\nconst hasContent = (body.values ?? []).some(\n (row) => row.some((value) => text(value) !== ''),\n);\nif (hasContent) {\n throw new Error(\n 'The Jobs worksheet has content outside the empty header row; refusing to overwrite it',\n );\n}\n\nreturn [{\n json: {\n ...state,\n nextAction: 'write-header',\n },\n}];\n" }, "id": "ab589333-8122-48d3-a500-3362d3e68fa1", "name": "Assess Empty Worksheet", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1620, 120 ] }, { "parameters": { "method": "PUT", "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + encodeURIComponent($json.spreadsheetId) + '/values/Jobs!A1%3AJ1' }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "valueInputOption", "value": "RAW" }, { "name": "includeValuesInResponse", "value": "true" } ] }, "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ { \"range\": \"Jobs!A1:J1\", \"majorDimension\": \"ROWS\", \"values\": [[\"id\", \"title\", \"company\", \"location\", \"postedAt\", \"url\", \"salary\", \"employmentType\", \"seniority\", \"firstSeenAt\"]] } }}", "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 30000 } }, "id": "c94a80a6-c67e-4b56-b6fe-59c4222f38fa", "name": "Write Jobs Header", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1860, 120 ], "retryOnFail": false, "onError": "continueRegularOutput", "alwaysOutputData": true }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "function previousIfExecuted(name) {\n try {\n return $(name).first()?.json ?? null;\n } catch {\n return null;\n }\n}\n\nfunction responseBody(label) {\n const response = $input.first()?.json ?? {};\n const statusCode = Number(response.statusCode);\n if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) {\n throw new Error(`${label} failed with HTTP ${Number.isFinite(statusCode) ? statusCode : 'NO_STATUS'}`);\n }\n\n let body = response.body !== undefined ? response.body : response;\n if (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`${label} returned invalid JSON`);\n }\n }\n if (!body || typeof body !== 'object' || Array.isArray(body)) {\n throw new Error(`${label} returned an invalid response`);\n }\n return body;\n}\n\nconst expectedHeader = [\n 'id',\n 'title',\n 'company',\n 'location',\n 'postedAt',\n 'url',\n 'salary',\n 'employmentType',\n 'seniority',\n 'firstSeenAt',\n];\n\nconst structure = $('Assess Sheet Structure').first()?.json ?? {};\nconst added = previousIfExecuted('Assess Added Sheet');\nconst state = { ...structure, ...(added ?? {}) };\nconst body = responseBody('Google Sheets header write');\n\nif (String(body.spreadsheetId ?? '').trim() !== String(state.spreadsheetId ?? '').trim()) {\n throw new Error('Google Sheets header write returned a different spreadsheet ID');\n}\n\nfunction normalizeSheetRange(value) {\n return String(value ?? '')\n .trim()\n .toUpperCase()\n .replaceAll('$', '');\n}\nconst updatedRange = normalizeSheetRange(body.updatedRange);\nconst updatedDataRange = normalizeSheetRange(body.updatedData?.range);\nif (\n !['JOBS!A1:J1', \"'JOBS'!A1:J1\"].includes(updatedRange)\n || !['JOBS!A1:J1', \"'JOBS'!A1:J1\"].includes(updatedDataRange)\n) {\n throw new Error('Google Sheets header write returned an unexpected updated range');\n}\nif (body.updatedRows !== 1 || body.updatedColumns !== 10 || body.updatedCells !== 10) {\n throw new Error('Google Sheets header write returned unexpected update counts');\n}\nif (!Array.isArray(body.updatedData?.values) || body.updatedData.values.length !== 1) {\n throw new Error('Google Sheets header write did not echo the written row');\n}\nconst header = body.updatedData.values[0];\nif (\n !Array.isArray(header)\n || header.length !== expectedHeader.length\n || header.some((value, index) => String(value ?? '').trim() !== expectedHeader[index])\n) {\n throw new Error('Google Sheets header write did not preserve the exact 10-column header');\n}\n\nreturn [{\n json: {\n ...state,\n nextAction: 'mark-ready',\n },\n}];\n" }, "id": "c22db842-2b6b-48bc-90a9-45bb183f4176", "name": "Assess Header Write", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2100, 120 ] }, { "parameters": { "method": "PATCH", "url": "={{ 'https://www.googleapis.com/drive/v3/files/' + encodeURIComponent($json.spreadsheetId) + '?fields=id,appProperties' }}", "authentication": "predefinedCredentialType", "nodeCredentialType": "googleSheetsOAuth2Api", "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ { \"appProperties\": { \"mekaLinkedinJobsWorkflow\": $json.workflowId, \"mekaLinkedinJobsBootstrapState\": \"ready\", \"mekaLinkedinJobsSchemaVersion\": \"1\" } } }}", "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 30000 } }, "id": "7fb0e4fe-2338-4446-97ae-e78471f85a31", "name": "Mark Spreadsheet Ready", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 2340, -80 ], "retryOnFail": false, "onError": "continueRegularOutput", "alwaysOutputData": true }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "function previousIfExecuted(name) {\n try {\n return $(name).first()?.json ?? null;\n } catch {\n return null;\n }\n}\n\nfunction responseBody(label) {\n const response = $input.first()?.json ?? {};\n const statusCode = Number(response.statusCode);\n if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) {\n throw new Error(`${label} failed with HTTP ${Number.isFinite(statusCode) ? statusCode : 'NO_STATUS'}`);\n }\n\n let body = response.body !== undefined ? response.body : response;\n if (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`${label} returned invalid JSON`);\n }\n }\n if (!body || typeof body !== 'object' || Array.isArray(body)) {\n throw new Error(`${label} returned an invalid response`);\n }\n return body;\n}\n\nconst structure = $('Assess Sheet Structure').first()?.json ?? {};\nconst added = previousIfExecuted('Assess Added Sheet');\nconst state = { ...structure, ...(added ?? {}) };\nconst body = responseBody('Google Drive ready-state update');\nconst spreadsheetId = String(state.spreadsheetId ?? '').trim();\nconst workflowId = String(state.workflowId ?? '').trim();\nif (!spreadsheetId || !workflowId) {\n throw new Error('Bootstrap setup state is incomplete');\n}\nif (String(body.id ?? '').trim() !== spreadsheetId) {\n throw new Error('Google Drive ready-state update changed or omitted the spreadsheet ID');\n}\n\nconst properties = body.appProperties;\nif (!properties || typeof properties !== 'object' || Array.isArray(properties)) {\n throw new Error('Google Drive ready-state update did not return app properties');\n}\nif (properties.mekaLinkedinJobsWorkflow !== workflowId) {\n throw new Error('Google Drive ready-state update changed the workflow tag');\n}\nif (properties.mekaLinkedinJobsBootstrapState !== 'ready') {\n throw new Error('Google Drive ready-state update did not persist ready state');\n}\nif (properties.mekaLinkedinJobsSchemaVersion !== '1') {\n throw new Error('Google Drive ready-state update did not preserve schema version 1');\n}\n\nreturn [{\n json: {\n keywords: state.keywords,\n location: state.location,\n datePosted: state.datePosted,\n limit: state.limit,\n budgetUsd: state.budgetUsd,\n scrapeCompany: state.scrapeCompany,\n sheetId: spreadsheetId,\n sheetName: 'Jobs',\n spreadsheetTitle: state.spreadsheetTitle,\n workflowId,\n bootstrapState: 'ready',\n },\n}];\n" }, "id": "fe0c61ce-7777-4811-9661-d3ad2ed66e58", "name": "Assess Setup Ready", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2580, -80 ] }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "const config = $input.first()?.json ?? {};\n\nfunction requiredText(value, name) {\n if (typeof value !== 'string' || !value.trim()) {\n throw new Error(`${name} must be a non-empty string`);\n }\n return value.trim();\n}\n\nfunction positiveInteger(value, name, maximum) {\n if (!Number.isInteger(value) || value < 1 || value > maximum) {\n throw new Error(`${name} must be an integer from 1 to ${maximum}`);\n }\n return value;\n}\n\nconst datePostedValues = new Set(['anyTime', 'past24Hours', 'pastWeek', 'pastMonth']);\nconst datePosted = requiredText(config.datePosted, 'datePosted');\nif (!datePostedValues.has(datePosted)) {\n throw new Error(`datePosted must be one of ${[...datePostedValues].join(', ')}`);\n}\n\nconst budgetUsd = Number(config.budgetUsd);\nif (!Number.isFinite(budgetUsd) || budgetUsd <= 0) {\n throw new Error('budgetUsd must be a positive number');\n}\n\nconst limit = positiveInteger(config.limit, 'limit', 1000);\nconst sheetId = requiredText(config.sheetId, 'sheetId');\nif (!/^[A-Za-z0-9_-]{2,}$/.test(sheetId)) {\n throw new Error('sheetId must be the Google Sheets document ID');\n}\n\nreturn [{\n json: {\n keywords: requiredText(config.keywords, 'keywords'),\n location: requiredText(config.location, 'location'),\n datePosted,\n limit,\n count: limit,\n limitPerSource: limit,\n scrapeCompany: config.scrapeCompany === true,\n budgetUsd,\n sheetId,\n sheetName: requiredText(config.sheetName, 'sheetName'),\n actorTimeoutSeconds: 300,\n waitForFinishSeconds: 60,\n pollDelaySeconds: 5,\n maxPollAttempts: 6,\n runDeadlineEpochMs: Date.now() + 480_000,\n },\n}];\n" }, "id": "7acb8a09-d5b8-4620-b511-ea56beacb663", "name": "Input Validation", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2820, -80 ] }, { "parameters": { "method": "POST", "url": "https://api.apify.com/v2/acts/meka.im~linkedin-jobs-scraper/runs", "authentication": "predefinedCredentialType", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "memory", "value": "512" }, { "name": "timeout", "value": "={{ $json.actorTimeoutSeconds }}" }, { "name": "maxTotalChargeUsd", "value": "={{ $json.budgetUsd }}" }, { "name": "waitForFinish", "value": "={{ $json.waitForFinishSeconds }}" } ] }, "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ { \"keywords\": $json.keywords, \"location\": $json.location, \"datePosted\": $json.datePosted, \"count\": $json.count, \"limitPerSource\": $json.limitPerSource, \"scrapeCompany\": $json.scrapeCompany } }}", "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 70000 }, "nodeCredentialType": "apifyOAuth2Api" }, "id": "8fb1ea03-539e-42fd-94fe-da51f63eaa9b", "name": "Start Actor", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 3060, -80 ], "retryOnFail": false }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "const response = $input.first()?.json ?? {};\nconst settings = $('Input Validation').first()?.json ?? {};\n\nlet body = response.body !== undefined ? response.body : response;\nif (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error('Apify start response was not valid JSON');\n }\n}\n\nconst run = body?.data ?? body;\nif (!run || typeof run !== 'object') {\n throw new Error('Apify start response did not contain a run');\n}\n\nconst runId = String(run.id ?? '').trim();\nif (!runId) {\n throw new Error('Apify start response did not contain a run ID');\n}\n\nconst status = String(run.status ?? '').trim().toUpperCase();\nconst datasetId = String(run.defaultDatasetId ?? '').trim();\nconst state = {\n ...settings,\n runId,\n datasetId,\n status,\n pollCount: 0,\n};\n\nif (status === 'SUCCEEDED') {\n if (!datasetId) {\n throw new Error(`Actor run ${runId} succeeded without a defaultDatasetId`);\n }\n return [{ json: { ...state, nextAction: 'fetch' } }];\n}\n\nif (status === 'READY' || status === 'RUNNING') {\n if (Date.now() >= settings.runDeadlineEpochMs) {\n throw new Error(`Actor run ${runId} is still ${status} after the local deadline. Recovery run ID: ${runId}`);\n }\n return [{ json: { ...state, nextAction: 'poll' } }];\n}\n\nthrow new Error(`Actor run ${runId} ended with status ${status || 'UNKNOWN'}. Recovery run ID: ${runId}`);\n" }, "id": "b2923778-6d15-47cd-8b28-8d7088172168", "name": "Assess Start Run", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3300, -80 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "78e30302-df87-4a13-8a97-3d47481a30a3", "leftValue": "={{ $json.nextAction === 'fetch' }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "id": "308807c3-c0b6-44e6-bccb-71f5ebb15262", "name": "Is Run Succeeded?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 3540, -80 ] }, { "parameters": { "resume": "timeInterval", "amount": "={{ $json.pollDelaySeconds }}", "unit": "seconds", "options": {} }, "id": "2ae61a80-5fc1-435f-9163-95804778d0e0", "name": "Wait for Actor", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [ 3780, 120 ] }, { "parameters": { "method": "GET", "url": "={{ 'https://api.apify.com/v2/actor-runs/' + $json.runId }}", "authentication": "predefinedCredentialType", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "waitForFinish", "value": "={{ $json.waitForFinishSeconds }}" } ] }, "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 70000 }, "nodeCredentialType": "apifyOAuth2Api" }, "id": "90344a24-f6d2-46ab-a0ec-bc9f15d7ea22", "name": "Poll Actor Run", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 4020, 120 ], "retryOnFail": false }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "const previous = $('Wait for Actor').first()?.json ?? {};\nconst response = $input.first()?.json ?? {};\nconst settings = $('Input Validation').first()?.json ?? {};\n\nlet body = response.body !== undefined ? response.body : response;\nif (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`Apify poll response for run ${previous.runId ?? 'UNKNOWN'} was not valid JSON`);\n }\n}\n\nconst run = body?.data ?? body;\nif (!run || typeof run !== 'object') {\n throw new Error(`Apify poll response did not contain run ${previous.runId ?? 'UNKNOWN'}`);\n}\n\nconst runId = String(run.id ?? previous.runId ?? '').trim();\nif (!runId || runId !== previous.runId) {\n throw new Error(`Apify poll response changed or omitted run ID ${previous.runId ?? 'UNKNOWN'}`);\n}\n\nconst status = String(run.status ?? '').trim().toUpperCase();\nconst datasetId = String(run.defaultDatasetId ?? previous.datasetId ?? '').trim();\nconst pollCount = Number(previous.pollCount ?? 0) + 1;\nconst state = {\n ...previous,\n status,\n datasetId,\n pollCount,\n};\n\nif (status === 'SUCCEEDED') {\n if (!datasetId) {\n throw new Error(`Actor run ${runId} succeeded without a defaultDatasetId`);\n }\n return [{ json: { ...state, nextAction: 'fetch' } }];\n}\n\nif (status === 'READY' || status === 'RUNNING') {\n if (Date.now() >= settings.runDeadlineEpochMs) {\n throw new Error(`Actor run ${runId} is still ${status} after the local deadline. Recovery run ID: ${runId}`);\n }\n if (pollCount >= settings.maxPollAttempts) {\n throw new Error(`Actor run ${runId} did not finish after ${pollCount} polls. Recovery run ID: ${runId}`);\n }\n return [{ json: { ...state, nextAction: 'poll' } }];\n}\n\nthrow new Error(`Actor run ${runId} ended with status ${status || 'UNKNOWN'}. Recovery run ID: ${runId}`);\n" }, "id": "5e894632-3b96-485d-a413-dd70c90d44ce", "name": "Assess Polled Run", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4260, 120 ] }, { "parameters": { "method": "GET", "url": "={{ 'https://api.apify.com/v2/actor-runs/' + $json.runId + '/key-value-store/records/OUTPUT' }}", "authentication": "predefinedCredentialType", "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 70000 }, "nodeCredentialType": "apifyOAuth2Api" }, "id": "2a31d69a-2e72-47de-8c5d-46db26df32a5", "name": "Get Run Output", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 3780, -200 ], "retryOnFail": false }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "const response = $input.first()?.json ?? {};\nconst state = $('Is Run Succeeded?').first()?.json ?? {};\n\nlet body = response.body !== undefined ? response.body : response;\nif (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`Actor run ${state.runId ?? 'UNKNOWN'} returned an invalid OUTPUT summary`);\n }\n}\nconst summary = body?.data ?? body;\nif (!summary || typeof summary !== 'object' || Array.isArray(summary)) {\n throw new Error(`Actor run ${state.runId ?? 'UNKNOWN'} returned an invalid OUTPUT summary`);\n}\n\nconst numericFields = [\n 'delivered',\n 'uniqueJobs',\n 'searchPages',\n 'failedSearches',\n 'failedJobDetails',\n 'failedCompanies',\n 'cacheHits',\n];\nconst clean = { status: String(summary.status ?? '') };\nfor (const field of numericFields) {\n const value = summary[field];\n if (!Number.isInteger(value) || value < 0) {\n throw new Error(`Actor run ${state.runId ?? 'UNKNOWN'} OUTPUT summary has an invalid ${field}`);\n }\n clean[field] = value;\n}\nif (typeof summary.stoppedByBudget !== 'boolean') {\n throw new Error(`Actor run ${state.runId ?? 'UNKNOWN'} OUTPUT summary has an invalid stoppedByBudget`);\n}\nclean.stoppedByBudget = summary.stoppedByBudget;\n\nif (clean.failedSearches > 0) {\n throw new Error(\n `Actor run ${state.runId ?? 'UNKNOWN'} completed with ${clean.failedSearches} failed search(es); no dataset was accepted. Recovery run ID: ${state.runId ?? 'UNKNOWN'}`,\n );\n}\n\nclean.coverage = (\n clean.failedJobDetails > 0\n || clean.failedCompanies > 0\n || clean.stoppedByBudget\n) ? 'partial' : 'complete';\n\nreturn [{\n json: {\n ...state,\n actorSummary: clean,\n coverage: clean.coverage,\n },\n}];\n" }, "id": "a6675562-175f-4e38-903f-8bde3737d687", "name": "Assess Run Output", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4020, -200 ] }, { "parameters": { "method": "GET", "url": "={{ 'https://api.apify.com/v2/datasets/' + $json.datasetId + '/items' }}", "authentication": "predefinedCredentialType", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "format", "value": "json" }, { "name": "clean", "value": "true" }, { "name": "limit", "value": "={{ $json.count }}" } ] }, "options": { "response": { "response": { "fullResponse": true, "responseFormat": "json" } }, "timeout": 70000 }, "nodeCredentialType": "apifyOAuth2Api" }, "id": "1d577a21-233a-4a45-9b0c-a20b3f65ec42", "name": "Get Dataset Items", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 4260, -200 ], "retryOnFail": false }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "const response = $input.first()?.json ?? {};\nconst run = $('Assess Run Output').first()?.json ?? {};\n\nlet body = response.body !== undefined ? response.body : response;\nif (typeof body === 'string') {\n try {\n body = JSON.parse(body);\n } catch {\n throw new Error(`Dataset ${run.datasetId ?? 'UNKNOWN'} did not return valid JSON`);\n }\n}\nif (!Array.isArray(body) && Array.isArray(body?.data)) {\n body = body.data;\n}\nif (!Array.isArray(body)) {\n throw new Error(`Dataset ${run.datasetId ?? 'UNKNOWN'} response must be an array`);\n}\n\nfunction text(value) {\n return value == null ? '' : String(value).trim();\n}\n\nfunction linkedInJobPathId(link, label) {\n if (\n typeof link !== 'string'\n || link !== link.trim()\n || /[\\s\\\\\\u0000-\\u001f\\u007f-\\u009f]/u.test(link)\n || /%(?![0-9a-f]{2})/iu.test(link)\n ) {\n throw new Error(`${label} has an invalid link`);\n }\n\n for (const match of link.matchAll(/%([0-9a-f]{2})/giu)) {\n const byte = Number.parseInt(match[1], 16);\n if (byte < 0x20 || byte === 0x5c || byte === 0x7f) {\n throw new Error(`${label} has an unsafe percent-encoded character in its link`);\n }\n }\n\n if (!/^https:\\/\\//i.test(link)) {\n throw new Error(`${label} link must be a public LinkedIn HTTPS URL in /jobs/view/ form`);\n }\n const rest = link.slice('https://'.length);\n const suffixStart = rest.search(/[?#]/);\n const authorityAndPath = suffixStart === -1 ? rest : rest.slice(0, suffixStart);\n const slash = authorityAndPath.indexOf('/');\n if (slash <= 0) {\n throw new Error(`${label} link must be a public LinkedIn HTTPS URL in /jobs/view/ form`);\n }\n\n const authority = authorityAndPath.slice(0, slash);\n if (\n authority.includes('@')\n || authority.includes(':')\n || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*linkedin\\.com$/i.test(authority)\n ) {\n throw new Error(`${label} link must use a public LinkedIn HTTPS authority`);\n }\n\n const path = authorityAndPath.slice(slash);\n const prefix = '/jobs/view/';\n if (!path.startsWith(prefix)) {\n throw new Error(`${label} link must be a LinkedIn job view URL`);\n }\n let slug = path.slice(prefix.length);\n if (slug.endsWith('/')) slug = slug.slice(0, -1);\n if (!slug || slug.includes('/')) {\n throw new Error(`${label} link must contain one non-empty job slug`);\n }\n\n const pathIdMatch = /(?:^|[-_])([0-9]+)$/.exec(slug);\n if (!pathIdMatch) {\n throw new Error(`${label} link does not end with a numeric job ID`);\n }\n return pathIdMatch[1];\n}\n\nfunction requirePublicJob(item, index) {\n if (!item || typeof item !== 'object' || Array.isArray(item)) {\n throw new Error(`Dataset item ${index} is not an object`);\n }\n const id = text(item.id);\n const title = text(item.title);\n const link = item.link;\n if (!/^[0-9]+$/.test(id)) {\n throw new Error(`Dataset item ${index} has an invalid id`);\n }\n if (!title) {\n throw new Error(`Dataset item ${index} is missing title`);\n }\n const pathId = linkedInJobPathId(link, `Dataset item ${index}`);\n if (pathId !== id) {\n throw new Error(`Dataset item ${index} link job ID does not match its id`);\n }\n return { id, title, link };\n}\n\nconst firstSeenAt = new Date().toISOString();\nconst jobs = body.map((item, index) => {\n const publicJob = requirePublicJob(item, index);\n return {\n id: publicJob.id,\n title: publicJob.title,\n company: text(item.companyName),\n location: text(item.location),\n postedAt: text(item.postedAt),\n url: publicJob.link,\n salary: text(item.salary),\n employmentType: text(item.employmentType),\n seniority: text(item.seniorityLevel),\n firstSeenAt,\n };\n});\n\nreturn [{\n json: {\n jobs,\n runId: run.runId,\n datasetId: run.datasetId,\n status: run.status,\n count: run.count,\n sheetId: run.sheetId,\n sheetName: run.sheetName,\n actorSummary: run.actorSummary,\n coverage: run.coverage,\n fetchedCount: jobs.length,\n },\n}];\n" }, "id": "f2adbd4e-2b09-466a-9990-cb05579bf133", "name": "Normalize Jobs", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4500, -200 ] }, { "parameters": { "resource": "sheet", "operation": "read", "documentId": { "__rl": true, "mode": "id", "value": "={{ $('Input Validation').first().json.sheetId }}" }, "sheetName": { "__rl": true, "mode": "name", "value": "={{ $('Input Validation').first().json.sheetName }}" }, "options": {} }, "id": "ae530c96-97da-46aa-9567-8c339d1ab19d", "name": "Read Existing Jobs", "type": "n8n-nodes-base.googleSheets", "typeVersion": 4.7, "position": [ 4740, -200 ], "alwaysOutputData": true }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "const source = $('Normalize Jobs').first()?.json ?? {};\nconst tableItems = $input.all();\n\nfunction text(value) {\n return value == null ? '' : String(value).trim();\n}\n\nfunction linkedInJobPathId(link, label) {\n if (\n typeof link !== 'string'\n || link !== link.trim()\n || /[\\s\\\\\\u0000-\\u001f\\u007f-\\u009f]/u.test(link)\n || /%(?![0-9a-f]{2})/iu.test(link)\n ) {\n throw new Error(`${label} has an invalid link`);\n }\n\n for (const match of link.matchAll(/%([0-9a-f]{2})/giu)) {\n const byte = Number.parseInt(match[1], 16);\n if (byte < 0x20 || byte === 0x5c || byte === 0x7f) {\n throw new Error(`${label} has an unsafe percent-encoded character in its link`);\n }\n }\n\n if (!/^https:\\/\\//i.test(link)) {\n throw new Error(`${label} link must be a public LinkedIn HTTPS URL in /jobs/view/ form`);\n }\n const rest = link.slice('https://'.length);\n const suffixStart = rest.search(/[?#]/);\n const authorityAndPath = suffixStart === -1 ? rest : rest.slice(0, suffixStart);\n const slash = authorityAndPath.indexOf('/');\n if (slash <= 0) {\n throw new Error(`${label} link must be a public LinkedIn HTTPS URL in /jobs/view/ form`);\n }\n\n const authority = authorityAndPath.slice(0, slash);\n if (\n authority.includes('@')\n || authority.includes(':')\n || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*linkedin\\.com$/i.test(authority)\n ) {\n throw new Error(`${label} link must use a public LinkedIn HTTPS authority`);\n }\n\n const path = authorityAndPath.slice(slash);\n const prefix = '/jobs/view/';\n if (!path.startsWith(prefix)) {\n throw new Error(`${label} link must be a LinkedIn job view URL`);\n }\n let slug = path.slice(prefix.length);\n if (slug.endsWith('/')) slug = slug.slice(0, -1);\n if (!slug || slug.includes('/')) {\n throw new Error(`${label} link must contain one non-empty job slug`);\n }\n\n const pathIdMatch = /(?:^|[-_])([0-9]+)$/.exec(slug);\n if (!pathIdMatch) {\n throw new Error(`${label} link does not end with a numeric job ID`);\n }\n return pathIdMatch[1];\n}\n\nfunction publicId(value, label) {\n const id = text(value);\n if (!/^[0-9]+$/.test(id)) {\n throw new Error(`${label} has an invalid id`);\n }\n return id;\n}\n\nfunction validateJob(job, index) {\n if (!job || typeof job !== 'object' || Array.isArray(job)) {\n throw new Error(`Normalized job ${index} is not an object`);\n }\n const id = publicId(job.id, `Normalized job ${index}`);\n if (typeof job.title !== 'string' || !job.title.trim()) {\n throw new Error(`Normalized job ${index} is missing title`);\n }\n if (typeof job.url !== 'string') {\n throw new Error(`Normalized job ${index} is missing link`);\n }\n const pathId = linkedInJobPathId(job.url, `Normalized job ${index}`);\n if (pathId !== id) {\n throw new Error(`Normalized job ${index} link job ID does not match its id`);\n }\n return id;\n}\n\nif (!Array.isArray(source.jobs)) {\n throw new Error('Normalize Jobs did not return a jobs array');\n}\n\nconst existingIds = new Set();\ntableItems.forEach((item, rowIndex) => {\n const row = item?.json ?? {};\n const values = Object.entries(row)\n .filter(([key]) => key !== 'row_number')\n .map(([, value]) => value);\n const rowIsEmpty = values.every((value) => value == null || text(value) === '');\n if (rowIsEmpty) return;\n if (row.id == null || text(row.id) === '') {\n const sheetRow = text(row.row_number) || String(rowIndex + 1);\n throw new Error(`Existing sheet row ${sheetRow} has data but no id`);\n }\n existingIds.add(publicId(row.id, 'Existing sheet row'));\n});\n\nconst fetchedIds = new Set();\nconst newJobs = [];\nlet skippedExisting = 0;\nlet skippedBatchDuplicates = 0;\n\nsource.jobs.forEach((job, index) => {\n const id = validateJob(job, index);\n if (existingIds.has(id)) {\n skippedExisting += 1;\n } else if (fetchedIds.has(id)) {\n skippedBatchDuplicates += 1;\n } else {\n fetchedIds.add(id);\n newJobs.push(job);\n }\n});\n\nconst summary = {\n runId: source.runId,\n datasetId: source.datasetId,\n actorSummary: source.actorSummary,\n coverage: source.coverage,\n fetchedCount: source.jobs.length,\n newCount: newJobs.length,\n skippedExisting,\n skippedBatchDuplicates,\n hasNewJobs: newJobs.length > 0,\n};\n\nreturn [{ json: { newJobs, summary, hasNewJobs: summary.hasNewJobs } }];\n" }, "id": "be0ec152-d240-4abe-a128-cf080ac98c3f", "name": "Filter New IDs", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 4980, -200 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "cb120943-38a5-4509-8669-8e43cd19971e", "leftValue": "={{ $json.hasNewJobs }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} }, "id": "b92c5a25-59d8-41c2-895a-3557ea0bcbdf", "name": "Has New Jobs?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 5220, -200 ] }, { "parameters": { "fieldToSplitOut": "newJobs", "options": {} }, "id": "35af0c0c-277e-4aa1-9307-1e5c34646858", "name": "Split Out New Jobs", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [ 5460, -300 ] }, { "parameters": { "resource": "sheet", "operation": "append", "documentId": { "__rl": true, "mode": "id", "value": "={{ $('Input Validation').first().json.sheetId }}" }, "sheetName": { "__rl": true, "mode": "name", "value": "={{ $('Input Validation').first().json.sheetName }}" }, "columns": { "mappingMode": "defineBelow", "value": { "id": "={{ $json.id }}", "title": "={{ $json.title }}", "company": "={{ $json.company }}", "location": "={{ $json.location }}", "postedAt": "={{ $json.postedAt }}", "url": "={{ $json.url }}", "salary": "={{ $json.salary }}", "employmentType": "={{ $json.employmentType }}", "seniority": "={{ $json.seniority }}", "firstSeenAt": "={{ $json.firstSeenAt }}" }, "schema": [ { "id": "id", "displayName": "id", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true }, { "id": "title", "displayName": "title", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": false }, { "id": "company", "displayName": "company", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": false }, { "id": "location", "displayName": "location", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": false }, { "id": "postedAt", "displayName": "postedAt", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": false }, { "id": "url", "displayName": "url", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": false }, { "id": "salary", "displayName": "salary", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": false }, { "id": "employmentType", "displayName": "employmentType", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": false }, { "id": "seniority", "displayName": "seniority", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": false }, { "id": "firstSeenAt", "displayName": "firstSeenAt", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": false } ] }, "options": { "cellFormat": "RAW" } }, "id": "7d668f07-2fc2-40c6-a36b-4f4f7ec0b8b3", "name": "Append Rows", "type": "n8n-nodes-base.googleSheets", "typeVersion": 4.7, "position": [ 5700, -300 ], "retryOnFail": false }, { "parameters": { "mode": "runOnceForAllItems", "jsCode": "const filtered = $('Filter New IDs').first()?.json ?? {};\nconst summary = filtered.summary;\nif (!summary || typeof summary !== 'object') {\n throw new Error('Filter New IDs did not produce a summary');\n}\n\nconst appendedCount = summary.hasNewJobs ? summary.newCount : 0;\nconst partial = summary.coverage === 'partial';\nlet message;\nif (appendedCount && partial) {\n message = `Appended ${appendedCount} new job(s) from a partial Actor run. Inspect the warning counters.`;\n} else if (appendedCount) {\n message = `Appended ${appendedCount} new job(s) after deduplication.`;\n} else if (partial) {\n message = 'No new delivered jobs were appended, but the Actor run was partial; this does not establish that no matching jobs were posted.';\n} else {\n message = 'No unseen IDs were found in this fetched batch; no rows were appended.';\n}\n\nreturn [{\n json: {\n status: 'success',\n runId: summary.runId,\n datasetId: summary.datasetId,\n fetchedCount: summary.fetchedCount,\n newCount: summary.newCount,\n appendedCount,\n skippedExisting: summary.skippedExisting,\n skippedBatchDuplicates: summary.skippedBatchDuplicates,\n coverage: summary.coverage,\n actorSummary: summary.actorSummary,\n message,\n },\n}];\n" }, "id": "b98dfa3e-57ea-4f30-a315-69ba678adf60", "name": "Run Summary", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 5940, -200 ] } ], "connections": { "Manual Trigger": { "main": [ [ { "node": "Search settings", "type": "main", "index": 0 } ] ] }, "Daily Schedule": { "main": [ [ { "node": "Search settings", "type": "main", "index": 0 } ] ] }, "Search settings": { "main": [ [ { "node": "Validate Cloud Settings", "type": "main", "index": 0 } ] ] }, "Input Validation": { "main": [ [ { "node": "Start Actor", "type": "main", "index": 0 } ] ] }, "Start Actor": { "main": [ [ { "node": "Assess Start Run", "type": "main", "index": 0 } ] ] }, "Assess Start Run": { "main": [ [ { "node": "Is Run Succeeded?", "type": "main", "index": 0 } ] ] }, "Is Run Succeeded?": { "main": [ [ { "node": "Get Run Output", "type": "main", "index": 0 } ], [ { "node": "Wait for Actor", "type": "main", "index": 0 } ] ] }, "Wait for Actor": { "main": [ [ { "node": "Poll Actor Run", "type": "main", "index": 0 } ] ] }, "Poll Actor Run": { "main": [ [ { "node": "Assess Polled Run", "type": "main", "index": 0 } ] ] }, "Assess Polled Run": { "main": [ [ { "node": "Is Run Succeeded?", "type": "main", "index": 0 } ] ] }, "Get Run Output": { "main": [ [ { "node": "Assess Run Output", "type": "main", "index": 0 } ] ] }, "Assess Run Output": { "main": [ [ { "node": "Get Dataset Items", "type": "main", "index": 0 } ] ] }, "Get Dataset Items": { "main": [ [ { "node": "Normalize Jobs", "type": "main", "index": 0 } ] ] }, "Normalize Jobs": { "main": [ [ { "node": "Read Existing Jobs", "type": "main", "index": 0 } ] ] }, "Read Existing Jobs": { "main": [ [ { "node": "Filter New IDs", "type": "main", "index": 0 } ] ] }, "Filter New IDs": { "main": [ [ { "node": "Has New Jobs?", "type": "main", "index": 0 } ] ] }, "Has New Jobs?": { "main": [ [ { "node": "Split Out New Jobs", "type": "main", "index": 0 } ], [ { "node": "Run Summary", "type": "main", "index": 0 } ] ] }, "Split Out New Jobs": { "main": [ [ { "node": "Append Rows", "type": "main", "index": 0 } ] ] }, "Append Rows": { "main": [ [ { "node": "Run Summary", "type": "main", "index": 0 } ] ] }, "Validate Cloud Settings": { "main": [ [ { "node": "Find App Spreadsheet", "type": "main", "index": 0 } ] ] }, "Find App Spreadsheet": { "main": [ [ { "node": "Assess Drive Search", "type": "main", "index": 0 } ] ] }, "Assess Drive Search": { "main": [ [ { "node": "Existing App Spreadsheet?", "type": "main", "index": 0 } ] ] }, "Existing App Spreadsheet?": { "main": [ [ { "node": "Get Spreadsheet Metadata", "type": "main", "index": 0 } ], [ { "node": "Create App Spreadsheet", "type": "main", "index": 0 } ] ] }, "Create App Spreadsheet": { "main": [ [ { "node": "Assess Created Spreadsheet", "type": "main", "index": 0 } ] ] }, "Assess Created Spreadsheet": { "main": [ [ { "node": "Get Spreadsheet Metadata", "type": "main", "index": 0 } ] ] }, "Get Spreadsheet Metadata": { "main": [ [ { "node": "Assess Sheet Structure", "type": "main", "index": 0 } ] ] }, "Assess Sheet Structure": { "main": [ [ { "node": "Has Jobs Worksheet?", "type": "main", "index": 0 } ] ] }, "Has Jobs Worksheet?": { "main": [ [ { "node": "Get Jobs Header", "type": "main", "index": 0 } ], [ { "node": "Add Jobs Worksheet", "type": "main", "index": 0 } ] ] }, "Add Jobs Worksheet": { "main": [ [ { "node": "Assess Added Sheet", "type": "main", "index": 0 } ] ] }, "Assess Added Sheet": { "main": [ [ { "node": "Get Jobs Header", "type": "main", "index": 0 } ] ] }, "Get Jobs Header": { "main": [ [ { "node": "Assess Jobs Header", "type": "main", "index": 0 } ] ] }, "Assess Jobs Header": { "main": [ [ { "node": "Empty Header Found?", "type": "main", "index": 0 } ] ] }, "Empty Header Found?": { "main": [ [ { "node": "Confirm Jobs Worksheet Empty", "type": "main", "index": 0 } ], [ { "node": "Mark Spreadsheet Ready", "type": "main", "index": 0 } ] ] }, "Confirm Jobs Worksheet Empty": { "main": [ [ { "node": "Assess Empty Worksheet", "type": "main", "index": 0 } ] ] }, "Assess Empty Worksheet": { "main": [ [ { "node": "Write Jobs Header", "type": "main", "index": 0 } ] ] }, "Write Jobs Header": { "main": [ [ { "node": "Assess Header Write", "type": "main", "index": 0 } ] ] }, "Assess Header Write": { "main": [ [ { "node": "Mark Spreadsheet Ready", "type": "main", "index": 0 } ] ] }, "Mark Spreadsheet Ready": { "main": [ [ { "node": "Assess Setup Ready", "type": "main", "index": 0 } ] ] }, "Assess Setup Ready": { "main": [ [ { "node": "Input Validation", "type": "main", "index": 0 } ] ] } }, "active": false, "settings": { "executionOrder": "v1", "timezone": "UTC" }, "meta": { "templateCredsSetupCompleted": false } }