This workflow follows the Form Trigger → Google Sheets 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 →
{
"name": "SEO Tracker",
"nodes": [
{
"parameters": {
"resource": "spreadsheet",
"title": "={{ $json['File name'] }}",
"sheetsUi": {
"sheetValues": [
{
"title": "Keywords"
},
{
"title": "Rankings Log"
},
{
"title": "Dashboard"
},
{
"title": "Setup"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
-224,
224
],
"id": "a4abd179-72e9-480a-9105-cd49dc7da9be",
"name": "Create spreadsheet",
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"formTitle": "Create SEO Tracker Spreadsheet",
"formFields": {
"values": [
{
"fieldLabel": "File name",
"placeholder": "HasData SEO Tracker",
"requiredField": true
},
{
"fieldLabel": "Slack User ID",
"placeholder": "Example U0AC2G2RVN0",
"requiredField": true
}
]
},
"options": {}
},
"type": "n8n-nodes-base.formTrigger",
"typeVersion": 2.5,
"position": [
-448,
224
],
"id": "749ab543-00bf-4778-8159-784ddc394c0b",
"name": "Create Tracker Sheet"
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "weeks"
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
-432,
736
],
"id": "e15b4b49-02d6-466f-a006-de798867b912",
"name": "Schedule Trigger"
},
{
"parameters": {
"jsCode": "const sheets = $input.first().json.sheets || [];\n\nfunction findSheetId(title) {\n const sheet = sheets.find((item) => item.properties?.title === title);\n return sheet?.properties?.sheetId ?? '';\n}\n\nreturn {\n json: {\n config_key: 'default',\n document_id: String($input.first().json.spreadsheetId || '').trim(),\n document_url: String($input.first().json.spreadsheetUrl || '').trim(),\n keyword_sheet_name: 'Keywords',\n log_sheet_name: 'Rankings Log',\n dashboard_sheet_name: 'Dashboard',\n setup_sheet_name: 'Setup',\n keyword_sheet_id: findSheetId('Keywords'),\n log_sheet_id: findSheetId('Rankings Log'),\n dashboard_sheet_id: findSheetId('Dashboard'),\n setup_sheet_id: findSheetId('Setup'),\n drop_threshold: 5,\n top_threshold: 20,\n default_depth: 30,\n slack_user_id: $('Create Tracker Sheet').first().json['Slack User ID'],\n },\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
0,
0
],
"id": "95656666-b9c2-446b-857f-29e1047a295d",
"name": "Build Tracker Config"
},
{
"parameters": {
"resource": "table",
"operation": "create",
"tableName": "seo_tracker_config",
"columns": {
"column": [
{
"name": "config_key"
},
{
"name": "document_id"
},
{
"name": "document_url"
},
{
"name": "keyword_sheet_name"
},
{
"name": "log_sheet_name"
},
{
"name": "dashboard_sheet_name"
},
{
"name": "drop_threshold",
"type": "number"
},
{
"name": "top_threshold",
"type": "number"
},
{
"name": "default_depth",
"type": "number"
},
{
"name": "slack_user_id"
}
]
},
"options": {
"createIfNotExists": true
}
},
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
208,
0
],
"id": "777911d5-64fa-4f20-9d22-535be60d1887",
"name": "Ensure tracker config table"
},
{
"parameters": {
"operation": "upsert",
"dataTableId": {
"__rl": true,
"value": "seo_tracker_config",
"mode": "name"
},
"matchType": "allConditions",
"filters": {
"conditions": [
{
"keyName": "config_key",
"keyValue": "default"
}
]
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"config_key": "={{ $('Build Tracker Config').item.json.config_key }}",
"document_id": "={{ $('Build Tracker Config').item.json.document_id }}",
"document_url": "={{ $('Build Tracker Config').item.json.document_url }}",
"keyword_sheet_name": "={{ $('Build Tracker Config').item.json.keyword_sheet_name }}",
"log_sheet_name": "={{ $('Build Tracker Config').item.json.log_sheet_name }}",
"dashboard_sheet_name": "={{ $('Build Tracker Config').item.json.dashboard_sheet_name }}",
"drop_threshold": "={{ $('Build Tracker Config').item.json.drop_threshold }}",
"top_threshold": "={{ $('Build Tracker Config').item.json.top_threshold }}",
"default_depth": "={{ $('Build Tracker Config').item.json.default_depth }}",
"slack_user_id": "={{ $('Build Tracker Config').item.json.slack_user_id }}"
},
"matchingColumns": [],
"schema": [
{
"id": "config_key",
"displayName": "config_key",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "document_id",
"displayName": "document_id",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "document_url",
"displayName": "document_url",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "keyword_sheet_name",
"displayName": "keyword_sheet_name",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "log_sheet_name",
"displayName": "log_sheet_name",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "dashboard_sheet_name",
"displayName": "dashboard_sheet_name",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "drop_threshold",
"displayName": "drop_threshold",
"required": false,
"defaultMatch": false,
"display": true,
"type": "number",
"readOnly": false,
"removed": false
},
{
"id": "top_threshold",
"displayName": "top_threshold",
"required": false,
"defaultMatch": false,
"display": true,
"type": "number",
"readOnly": false,
"removed": false
},
{
"id": "default_depth",
"displayName": "default_depth",
"required": false,
"defaultMatch": false,
"display": true,
"type": "number",
"readOnly": false,
"removed": false
},
{
"id": "slack_user_id",
"displayName": "slack_user_id",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
432,
0
],
"id": "21a74d5c-0c3d-422f-bbc0-88b9fe78451f",
"name": "Save tracker config"
},
{
"parameters": {
"jsCode": "const input = $input.first().json;\nconst sheets = input.sheets || [];\nconst spreadsheetId = String(input.spreadsheetId || '').trim();\nconst spreadsheetUrl = String(input.spreadsheetUrl || '').trim();\n\nfunction findSheetId(title) {\n const sheet = sheets.find((item) => item.properties?.title === title);\n return sheet?.properties?.sheetId ?? '';\n}\n\nreturn [\n {\n json: {\n sheetId: findSheetId('Keywords'),\n data: {\n keyword: '',\n target_domain: '',\n location: '',\n device: '',\n language: '',\n depth: '',\n },\n },\n },\n {\n json: {\n sheetId: findSheetId('Rankings Log'),\n data: {\n run_date: '',\n keyword: '',\n target_domain: '',\n position: '',\n target_url: '',\n top1_domain: '',\n top1_url: '',\n top2_domain: '',\n top3_domain: '',\n total_results: '',\n scrape_status: '',\n error_message: '',\n },\n },\n },\n {\n json: {\n sheetId: findSheetId('Dashboard'),\n data: {\n keyword: '=ARRAYFORMULA(IFERROR(INDEX(SORT(UNIQUE(FILTER({\\'Rankings Log\\'!B2:B,\\'Rankings Log\\'!C2:C}, \\'Rankings Log\\'!B2:B<>\"\", \\'Rankings Log\\'!C2:C<>\"\")), 1, TRUE, 2, TRUE), , 1), \"\"))',\n target_domain: '=ARRAYFORMULA(IFERROR(INDEX(SORT(UNIQUE(FILTER({\\'Rankings Log\\'!B2:B,\\'Rankings Log\\'!C2:C}, \\'Rankings Log\\'!B2:B<>\"\", \\'Rankings Log\\'!C2:C<>\"\")), 1, TRUE, 2, TRUE), , 2), \"\"))',\n current_position: '=MAP(A2:A, B2:B, LAMBDA(k, d, IF(k=\"\", \"\", IFERROR(INDEX(SORT(FILTER({\\'Rankings Log\\'!D:D, \\'Rankings Log\\'!A:A}, \\'Rankings Log\\'!B:B=k, \\'Rankings Log\\'!C:C=d), 2, FALSE), 1, 1), \"\"))))',\n last_position: '=MAP(A2:A, B2:B, LAMBDA(k, d, IF(k=\"\", \"\", IFERROR(INDEX(SORT(FILTER({\\'Rankings Log\\'!D:D, \\'Rankings Log\\'!A:A}, \\'Rankings Log\\'!B:B=k, \\'Rankings Log\\'!C:C=d), 2, FALSE), 2, 1), \"\"))))',\n delta: '=ARRAYFORMULA(IF(A2:A=\"\", \"\", IF(ISNUMBER(C2:C) * ISNUMBER(D2:D), C2:C - D2:D, \"\")))',\n current_date_time: '=MAP(A2:A, B2:B, LAMBDA(k, d, IF(k=\"\", \"\", IFERROR(INDEX(SORT(FILTER(\\'Rankings Log\\'!A:A, \\'Rankings Log\\'!B:B=k, \\'Rankings Log\\'!C:C=d), 1, FALSE), 1, 1), \"\"))))',\n last_date_time: '=MAP(A2:A, B2:B, LAMBDA(k, d, IF(k=\"\", \"\", IFERROR(INDEX(SORT(FILTER(\\'Rankings Log\\'!A:A, \\'Rankings Log\\'!B:B=k, \\'Rankings Log\\'!C:C=d), 1, FALSE), 2, 1), \"\"))))',\n did_drop_by_5: '=ARRAYFORMULA(IF(A2:A=\"\", \"\", IF(ISNUMBER(E2:E), E2:E >= 5, FALSE)))',\n did_fall_out_of_top_results: '=ARRAYFORMULA(IF(A2:A=\"\", \"\", IF(ISNUMBER(D2:D) * (D2:D <= 20), IF(NOT(ISNUMBER(C2:C)), TRUE, C2:C > 20), FALSE)))',\n sparkline: '=IF(A2=\"\",\"\",IFERROR(SPARKLINE(FILTER(IF(\\'Rankings Log\\'!$D$2:$D=\"\",0,IF(\\'Rankings Log\\'!$D$2:$D<=20,21-\\'Rankings Log\\'!$D$2:$D,0)), \\'Rankings Log\\'!$B$2:$B=$A2, \\'Rankings Log\\'!$C$2:$C=$B2), {\"charttype\",\"line\"; \"ymin\",0; \"ymax\",20; \"color\", IF(AND(ISNUMBER($C2), ISNUMBER($D2)), IF($C2<=$D2, \"#34a853\", \"#ea4335\"), \"#9aa0a6\")}), \"\"))',\n },\n },\n },\n {\n json: {\n sheetId: findSheetId('Setup'),\n data: {\n document_id: spreadsheetId,\n document_url: spreadsheetUrl,\n keywords_sheet_id: findSheetId('Keywords'),\n rankings_log_sheet_id: findSheetId('Rankings Log'),\n dashboard_sheet_id: findSheetId('Dashboard'),\n data_table_name: 'seo_tracker_config',\n config_key: 'default',\n notes: 'Schedule branch reads spreadsheet config from the Data Table row where config_key=default.',\n },\n },\n },\n];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
0,
224
],
"id": "0b5dd764-9324-485b-9e16-b95ea888e29e",
"name": "Code Setup Sheets"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
208,
224
],
"id": "e59e0a10-6e71-4bcf-bd67-3d80e116103a",
"name": "Loop Over Setup Rows"
},
{
"parameters": {
"jsCode": "return { json: $input.first().json.data };"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
432,
224
],
"id": "bbffaab5-2765-42b9-8b4f-d85e21cf5278",
"name": "Setup Row Payload"
},
{
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"value": "={{ $('Create spreadsheet').item.json.spreadsheetId }}",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "={{ $('Loop Over Setup Rows').item.json.sheetId }}",
"mode": "id"
},
"columns": {
"mappingMode": "autoMapInputData",
"value": {},
"matchingColumns": [],
"schema": [],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
608,
224
],
"id": "557fa6d6-e87b-4402-9ea8-9f19c21b18c8",
"name": "Append row in setup sheet",
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"resource": "table",
"operation": "create",
"tableName": "seo_tracker_config",
"columns": {
"column": [
{
"name": "config_key"
},
{
"name": "document_id"
},
{
"name": "document_url"
},
{
"name": "keyword_sheet_name"
},
{
"name": "log_sheet_name"
},
{
"name": "dashboard_sheet_name"
},
{
"name": "drop_threshold",
"type": "number"
},
{
"name": "top_threshold",
"type": "number"
},
{
"name": "default_depth",
"type": "number"
},
{
"name": "slack_user_id"
}
]
},
"options": {
"createIfNotExists": true
}
},
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
-224,
736
],
"id": "4bc7fd89-0df4-408e-b32f-7ac4bcae89a2",
"name": "Ensure tracker config table (runner)"
},
{
"parameters": {
"operation": "get",
"dataTableId": {
"__rl": true,
"value": "seo_tracker_config",
"mode": "name"
},
"matchType": "allConditions",
"filters": {
"conditions": [
{
"keyName": "config_key",
"keyValue": "default"
}
]
}
},
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
-16,
672
],
"id": "d160b5d1-d138-4fba-8ac1-a45acf4704a6",
"name": "Get Tracker Config",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "const row = $input.first()?.json || {};\nconst documentId = String(row.document_id || '').trim();\n\nif (!documentId) {\n throw new Error('Tracker config row is missing. Run \"Create Tracker Sheet\" once to create and save the spreadsheet config, then fill the Keywords sheet.');\n}\n\nreturn {\n json: {\n document_id: documentId,\n document_url: String(row.document_url || '').trim(),\n keyword_sheet_name: String(row.keyword_sheet_name || 'Keywords').trim() || 'Keywords',\n log_sheet_name: String(row.log_sheet_name || 'Rankings Log').trim() || 'Rankings Log',\n dashboard_sheet_name: String(row.dashboard_sheet_name || 'Dashboard').trim() || 'Dashboard',\n drop_threshold: Number(row.drop_threshold || 5),\n top_threshold: Number(row.top_threshold || 20),\n default_depth: Number(row.default_depth || 30),\n slack_user_id: String(row.slack_user_id || '').trim(),\n },\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
160,
736
],
"id": "f538943e-2281-476a-811e-e46e94c51e41",
"name": "Load Tracker Config"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "={{ $('Load Tracker Config').item.json.document_id }}",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "={{ $('Load Tracker Config').item.json.log_sheet_name }}",
"mode": "name"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
336,
688
],
"id": "155c1270-af43-4b0e-98dd-5c577ea30568",
"name": "Get row(s) in Rankings Log",
"alwaysOutputData": true,
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"options": {}
},
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
512,
736
],
"id": "accee551-aca4-42ee-bf50-8d46da596d40",
"name": "Aggregate Existing Log"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "={{ $('Load Tracker Config').item.json.document_id }}",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "={{ $('Load Tracker Config').item.json.keyword_sheet_name }}",
"mode": "name"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
704,
784
],
"id": "6e46d7ab-6d83-4afc-b276-5397281510f0",
"name": "Get row(s) in Keywords",
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "function normalizeDomain(value) {\n if (value === undefined || value === null) return '';\n\n let domain = String(value).trim().toLowerCase();\n if (!domain) return '';\n\n domain = domain.replace(/^[a-z]+:\\/\\//i, '');\n domain = domain.split('/')[0].split('?')[0].split('#')[0].split(':')[0];\n domain = domain.replace(/^www\\./, '');\n\n const parts = domain.split('.').filter(Boolean);\n if (parts.length <= 2) return domain;\n\n const compoundTlds = new Set(['co.uk', 'org.uk', 'ac.uk', 'co.jp', 'com.au', 'com.br', 'co.in']);\n const lastTwo = parts.slice(-2).join('.');\n const lastThree = parts.slice(-3).join('.');\n\n return compoundTlds.has(lastTwo) ? lastThree : lastTwo;\n}\n\nfunction parsePosition(value) {\n if (value === undefined || value === null) return null;\n if (typeof value === 'string' && value.trim() === '') return null;\n\n const num = Number(value);\n return Number.isFinite(num) ? num : null;\n}\n\nconst config = $('Load Tracker Config').first().json;\nconst existingRows = $('Aggregate Existing Log').first()?.json?.data || [];\nconst previousByKey = new Map();\n\nfor (const row of existingRows) {\n const keyword = String(row.keyword || '').trim();\n const targetDomain = normalizeDomain(row.target_domain);\n\n if (!keyword || !targetDomain) continue;\n\n previousByKey.set(`${keyword}||${targetDomain}`, {\n last_position: parsePosition(row.position),\n last_run_date: row.run_date || '',\n });\n}\n\nconst defaultDepth = Number(config.default_depth || 30);\nconst runDate = new Date().toISOString();\nconst out = [];\n\nfor (const item of $input.all()) {\n const keyword = String(item.json.keyword || '').trim();\n const targetDomain = normalizeDomain(item.json.target_domain);\n\n if (!keyword || !targetDomain) continue;\n\n const rawDepth = Number(item.json.depth || defaultDepth);\n const depth = Number.isFinite(rawDepth)\n ? Math.max(10, Math.min(100, Math.round(rawDepth)))\n : Math.max(10, Math.min(100, Math.round(defaultDepth)));\n const totalPages = Math.max(1, Math.min(10, Math.ceil(depth / 10)));\n const requestKey = `${keyword}||${targetDomain}`;\n const previous = previousByKey.get(requestKey) || { last_position: null, last_run_date: '' };\n\n out.push({\n json: {\n request_key: requestKey,\n run_date: runDate,\n keyword,\n target_domain: targetDomain,\n location: String(item.json.location || '').trim(),\n device: String(item.json.device || '').trim() || 'desktop',\n language: String(item.json.language || '').trim() || 'en',\n depth,\n total_pages: totalPages,\n page_index: 0,\n page_offset: 0,\n position: null,\n target_url: '',\n top1_domain: '',\n top1_url: '',\n top2_domain: '',\n top3_domain: '',\n total_seen: 0,\n total_results: '',\n any_success: false,\n error_messages: [],\n previous_position: previous.last_position,\n previous_run_date: previous.last_run_date,\n drop_threshold: Number(config.drop_threshold || 5),\n top_threshold: Number(config.top_threshold || 20),\n slack_user_id: String(config.slack_user_id || '').trim(),\n continue_paging: true,\n },\n });\n}\n\nreturn out;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
928,
736
],
"id": "9bb35e12-9dcb-49ce-af30-5ec1594413d2",
"name": "Code Prepare Run"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
1104,
736
],
"id": "e44b10a4-c590-423a-8901-f010d677a1de",
"name": "Loop Over Keywords"
},
{
"parameters": {
"jsCode": "return $input.all();"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1456,
752
],
"id": "198b5475-51d9-431b-860c-be8a492fd9f3",
"name": "Code Current Page Request"
},
{
"parameters": {
"q": "={{ $json.keyword }}",
"additionalFields": {
"location": "={{ $json.location }}",
"hl": "={{ $json.language }}",
"start": "={{ $json.page_offset }}",
"num": 10,
"deviceType": "={{ $json.device }}"
}
},
"type": "@hasdata/n8n-nodes-hasdata.hasData",
"typeVersion": 1,
"position": [
1632,
752
],
"id": "1a0ad891-53a0-4ea5-a38d-6b0d6c8b8780",
"name": "Get Google Search Results",
"credentials": {
"hasDataApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "function normalizeDomain(value) {\n if (value === undefined || value === null) return '';\n\n let domain = String(value).trim().toLowerCase();\n if (!domain) return '';\n\n domain = domain.replace(/^[a-z]+:\\/\\//i, '');\n domain = domain.split('/')[0].split('?')[0].split('#')[0].split(':')[0];\n domain = domain.replace(/^www\\./, '');\n\n const parts = domain.split('.').filter(Boolean);\n if (parts.length <= 2) return domain;\n\n const compoundTlds = new Set(['co.uk', 'org.uk', 'ac.uk', 'co.jp', 'com.au', 'com.br', 'co.in']);\n const lastTwo = parts.slice(-2).join('.');\n const lastThree = parts.slice(-3).join('.');\n\n return compoundTlds.has(lastTwo) ? lastThree : lastTwo;\n}\n\nfunction parsePosition(value) {\n if (value === undefined || value === null) return null;\n if (typeof value === 'string' && value.trim() === '') return null;\n\n const num = Number(value);\n return Number.isFinite(num) ? num : null;\n}\n\nconst state = $('Code Current Page Request').item.json;\nconst response = $input.first()?.json || {};\nconst errorMessages = Array.isArray(state.error_messages) ? [...state.error_messages] : [];\nlet anySuccess = Boolean(state.any_success);\nlet totalResults = state.total_results || '';\nlet totalSeen = Number(state.total_seen || 0);\nlet position = parsePosition(state.position);\nlet targetUrl = String(state.target_url || '');\nlet top1Domain = String(state.top1_domain || '');\nlet top1Url = String(state.top1_url || '');\nlet top2Domain = String(state.top2_domain || '');\nlet top3Domain = String(state.top3_domain || '');\nconst currentPageIndex = Number(state.page_index || 0);\nconst currentOffset = Number(state.page_offset || 0);\nconst depth = Number(state.depth || 10);\nconst totalPages = Math.max(1, Number(state.total_pages || 1));\n\nif (response.error) {\n errorMessages.push(String(response.error));\n}\n\nconst organicResults = Array.isArray(response.organicResults) ? response.organicResults : [];\n\nif (response.requestMetadata?.status === 'ok' || organicResults.length > 0) {\n anySuccess = true;\n}\n\nif (!totalResults && response.searchInformation?.totalResults) {\n totalResults = response.searchInformation.totalResults;\n}\n\nif (currentPageIndex === 0) {\n top1Domain = normalizeDomain(organicResults[0]?.link);\n top1Url = organicResults[0]?.link || '';\n top2Domain = normalizeDomain(organicResults[1]?.link);\n top3Domain = normalizeDomain(organicResults[2]?.link);\n}\n\nfor (let index = 0; index < organicResults.length; index++) {\n const result = organicResults[index];\n const relativePosition = parsePosition(result.position);\n const resultPosition = relativePosition !== null\n ? currentOffset + relativePosition\n : (currentOffset + index + 1);\n\n if (resultPosition > depth) {\n break;\n }\n\n totalSeen = Math.max(totalSeen, resultPosition);\n\n if (position === null && normalizeDomain(result.link) === state.target_domain) {\n position = resultPosition;\n targetUrl = result.link || '';\n }\n}\n\nconst reachedLimit = currentPageIndex + 1 >= totalPages;\nconst emptyPage = organicResults.length === 0;\nconst continuePaging = position === null && !reachedLimit && !emptyPage;\n\nreturn {\n json: {\n ...state,\n position,\n target_url: targetUrl,\n top1_domain: top1Domain,\n top1_url: top1Url,\n top2_domain: top2Domain,\n top3_domain: top3Domain,\n total_results: totalResults || totalSeen,\n total_seen: totalSeen,\n any_success: anySuccess,\n error_messages: errorMessages,\n page_index: continuePaging ? currentPageIndex + 1 : currentPageIndex,\n page_offset: continuePaging ? currentOffset + 10 : currentOffset,\n continue_paging: continuePaging,\n },\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1824,
752
],
"id": "bfeb7052-ee8c-468a-bcc4-f7af350d7f92",
"name": "Code Handle Page"
},
{
"parameters": {
"jsCode": "function parsePosition(value) {\n if (value === undefined || value === null) return null;\n if (typeof value === 'string' && value.trim() === '') return null;\n\n const num = Number(value);\n return Number.isFinite(num) ? num : null;\n}\n\nconst state = $input.first()?.json || {};\nconst position = parsePosition(state.position);\nconst previousPosition = parsePosition(state.previous_position);\nconst errorMessages = Array.isArray(state.error_messages)\n ? state.error_messages.filter((value) => value !== undefined && value !== null && String(value).trim() !== '').map(String)\n : [];\nconst delta = Number.isFinite(position) && Number.isFinite(previousPosition)\n ? position - previousPosition\n : null;\nconst didDropBy5 = typeof delta === 'number' && delta >= Number(state.drop_threshold || 5);\nconst didFallOutOfTop20 = Number.isFinite(previousPosition)\n && previousPosition <= Number(state.top_threshold || 20)\n && (!Number.isFinite(position) || position > Number(state.top_threshold || 20));\nconst scrapeStatus = errorMessages.length > 0 && !Number.isFinite(position)\n ? 'request_failed'\n : (Number.isFinite(position) ? 'found' : 'not_found');\nconst currentLabel = Number.isFinite(position) ? position : `not in top ${state.depth}`;\nconst previousLabel = Number.isFinite(previousPosition) ? previousPosition : 'not previously ranked';\n\nreturn {\n json: {\n run_date: state.run_date,\n keyword: state.keyword,\n target_domain: state.target_domain,\n position,\n target_url: state.target_url || '',\n top1_domain: state.top1_domain || '',\n top1_url: state.top1_url || '',\n top2_domain: state.top2_domain || '',\n top3_domain: state.top3_domain || '',\n total_results: state.total_results || state.total_seen || '',\n scrape_status: scrapeStatus,\n error_message: errorMessages.join(' | '),\n previous_position: previousPosition,\n previous_run_date: state.previous_run_date || '',\n delta,\n did_drop_by_5: didDropBy5,\n did_fall_out_of_top_20: didFallOutOfTop20,\n should_send_drop_slack: didDropBy5 && Boolean(state.slack_user_id),\n should_send_fall_slack: didFallOutOfTop20 && Boolean(state.slack_user_id),\n drop_alert_message: didDropBy5\n ? `${state.target_domain} | ${state.keyword} dropped from ${previousLabel} to ${currentLabel}`\n : '',\n fall_alert_message: didFallOutOfTop20\n ? `${state.target_domain} | ${state.keyword} fell out of top ${state.top_threshold} (was ${previousLabel}, now ${currentLabel})`\n : '',\n slack_user_id: state.slack_user_id,\n },\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2240,
768
],
"id": "1dc2cd67-b066-4350-a415-a45a18c6ea5f",
"name": "Code Finalize Keyword Result"
},
{
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"value": "={{ $('Load Tracker Config').first().json.document_id }}",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "={{ $('Load Tracker Config').first().json.log_sheet_name }}",
"mode": "name"
},
"columns": {
"mappingMode": "autoMapInputData",
"value": {},
"matchingColumns": [],
"schema": [
{
"id": "run_date",
"displayName": "run_date",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "keyword",
"displayName": "keyword",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "target_domain",
"displayName": "target_domain",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "position",
"displayName": "position",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "target_url",
"displayName": "target_url",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "top1_domain",
"displayName": "top1_domain",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "top1_url",
"displayName": "top1_url",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "top2_domain",
"displayName": "top2_domain",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "top3_domain",
"displayName": "top3_domain",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "total_results",
"displayName": "total_results",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "scrape_status",
"displayName": "scrape_status",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "error_message",
"displayName": "error_message",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
2976,
768
],
"id": "ff7b2408-50e2-4cba-a557-c00c0eccd530",
"name": "Append row in Rankings Log",
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "if-fell-out-top-20",
"leftValue": "={{ $json.should_send_fall_slack }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
2736,
608
],
"id": "8beee38a-d7e7-4865-bb51-210bc16ca2a2",
"name": "If Fell Out of Top 20"
},
{
"parameters": {
"authentication": "oAuth2",
"select": "user",
"user": {
"__rl": true,
"value": "={{ $json.slack_user_id }}",
"mode": "id"
},
"text": "={{ $json.fall_alert_message }}",
"otherOptions": {
"includeLinkToWorkflow": false,
"unfurl_links": false
}
},
"type": "n8n-nodes-base.slack",
"typeVersion": 2.4,
"position": [
2976,
608
],
"id": "ca6a7aa6-1a85-467e-99c2-71ea4a2be782",
"name": "Send fall alert",
"credentials": {
"slackOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "if-dropped-by-5",
"leftValue": "={{ $json.should_send_drop_slack }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
2736,
448
],
"id": "fa086fb9-fd50-48a6-b7e1-37d639b2813e",
"name": "If Dropped by 5"
},
{
"parameters": {
"authentication": "oAuth2",
"select": "user",
"user": {
"__rl": true,
"value": "={{ $json.slack_user_id }}",
"mode": "id"
},
"text": "={{ $json.drop_alert_message }}",
"otherOptions": {
"includeLinkToWorkflow": false,
"unfurl_links": false
}
},
"type": "n8n-nodes-base.slack",
"typeVersion": 2.4,
"position": [
2976,
448
],
"id": "883cc926-3981-4d1b-822a-237dc4d0ca81",
"name": "Send drop alert",
"credentials": {
"slackOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "if-continue-paging",
"leftValue": "={{ $json.continue_paging }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
2032,
752
],
"id": "67fc9ac0-3e8e-4aca-b03e-a2d04dca87d5",
"name": "If Continue Paging"
},
{
"parameters": {
"jsCode": "const rows = $input.first().json.data;\nconst trackedRows = rows.filter((row) => row.keyword);\nconst rankedPositions = trackedRows\n .map((row) => Number(row.position))\n .filter((value) => Number.isFinite(value));\n\nconst trackedCount = trackedRows.length;\nconst rankedCount = rankedPositions.length;\nconst avgPosition = rankedCount\n ? (rankedPositions.reduce((sum, value) => sum + value, 0) / rankedCount).toFixed(1)\n : 'n/a';\nconst droppedCount = trackedRows.filter((row) => row.did_drop_by_5 === true).length;\nconst fellOutCount = trackedRows.filter((row) => row.did_fall_out_of_top_20 === true).length;\nconst notFoundCount = trackedRows.filter((row) => row.scrape_status === 'not_found').length;\nconst failedCount = trackedRows.filter((row) => row.scrape_status === 'request_failed').length;\nconst slackUserId = String(trackedRows[0]?.slack_user_id || '').trim();\nconst runDate = trackedRows[0]?.run_date || new Date().toISOString();\n\nreturn {\n json: {\n summary_message: `SEO tracker run ${runDate} \\nTracked ${trackedCount} rows \\nAverage position ${avgPosition} | calculated across ${rankedCount} ranked rows, \\nNot ranked : ${notFoundCount} \\nFailed count: ${failedCount} \\nDropped by 5+ count: ${droppedCount} \\nFell out of top 20 count: ${fellOutCount}.`,\n should_send_summary_slack: Boolean(slackUserId) && trackedCount > 0,\n slack_user_id: slackUserId,\n rowsLength: rows,\n },\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1648,
544
],
"id": "0934c800-72c0-413e-9c38-a5454c958ef3",
"name": "Code Build Summary"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "if-send-summary",
"leftValue": "={{ $json.should_send_summary_slack }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
1840,
544
],
"id": "0cfa4da9-8425-4a77-a16b-dca1fef369c8",
"name": "If Send Summary"
},
{
"parameters": {
"authentication": "oAuth2",
"select": "user",
"user": {
"__rl": true,
"value": "={{ $json.slack_user_id }}",
"mode": "id"
},
"text": "={{ $json.summary_message }}",
"otherOptions": {
"includeLinkToWorkflow": false,
"unfurl_links": false
}
},
"type": "n8n-nodes-base.slack",
"typeVersion": 2.4,
"position": [
2080,
528
],
"id": "0a34a52f-8155-411f-961a-460ef0ccf917",
"name": "Send summary",
"credentials": {
"slackOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"options": {}
},
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
1440,
544
],
"id": "be13572c-6652-4510-92b8-cf63c89c74bb",
"name": "Aggregate"
}
],
"connections": {
"Create Tracker Sheet": {
"main": [
[
{
"node": "Create spreadsheet",
"type": "main",
"index": 0
}
]
]
},
"Create spreadsheet": {
"main": [
[
{
"node": "Build Tracker Config",
"type": "main",
"index": 0
},
{
"node": "Code Setup Sheets",
"type": "main",
"index": 0
}
]
]
},
"Build Tracker Config": {
"main": [
[
{
"node": "Ensure tracker config table",
"type": "main",
"index": 0
}
]
]
},
"Ensure tracker config table": {
"main": [
[
{
"node": "Save tracker config",
"type": "main",
"index": 0
}
]
]
},
"Code Setup Sheets": {
"main": [
[
{
"node": "Loop Over Setup Rows",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Setup Rows": {
"main": [
[],
[
{
"node": "Setup Row Payload",
"type": "main",
"index": 0
}
]
]
},
"Setup Row Payload": {
"main": [
[
{
"node": "Append row in setup sheet",
"type": "main",
"index": 0
}
]
]
},
"Append row in setup sheet": {
"main": [
[
{
"node": "Loop Over Setup Rows",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "Ensure tracker config table (runner)",
"type": "main",
"index": 0
}
]
]
},
"Ensure tracker config table (runner)": {
"main": [
[
{
"node": "Get Tracker Config",
"type": "main",
"index": 0
}
]
]
},
"Get Tracker Config": {
"main": [
[
{
"node": "Load Tracker Config",
"type": "main",
"index": 0
}
]
]
},
"Load Tracker Config": {
"main": [
[
{
"node": "Get row(s) in Rankings Log",
"type": "main",
"index": 0
}
]
]
},
"Get row(s) in Rankings Log": {
"main": [
[
{
"node": "Aggregate Existing Log",
"type": "main",
"index": 0
}
]
]
},
"Aggregate Existing Log": {
"main": [
[
{
"node": "Get row(s) in Keywords",
"type": "main",
"index": 0
}
]
]
},
"Get row(s) in Keywords": {
"main": [
[
{
"node": "Code Prepare Run",
"type": "main",
"index": 0
}
]
]
},
"Code Prepare Run": {
"main": [
[
{
"node": "Loop Over Keywords",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Keywords": {
"main": [
[
{
"node": "Aggregate",
"type": "main",
"index": 0
}
],
[
{
"node": "Code Current Page Request",
"type": "main",
"index": 0
}
]
]
},
"Code Current Page Request": {
"main": [
[
{
"node": "Get Google Search Results",
"type": "main",
"index": 0
}
]
]
},
"Get Google Search Results": {
"main": [
[
{
"node": "Code Handle Page",
"type": "main",
"index": 0
}
]
]
},
"Code Handle Page": {
"main": [
[
{
"node": "If Continue Paging",
"type": "main",
"index": 0
}
]
]
},
"If Continue Paging": {
"main": [
[
{
"node": "Code Current Page Request",
"type": "main",
"index": 0
}
],
[
{
"node": "Code Finalize Keyword Result",
"type": "main",
"index": 0
}
]
]
},
"Code Finalize Keyword Result": {
"main": [
[
{
"node": "Append row in Rankings Log",
"type": "main",
"index": 0
},
{
"node": "If Fell Out of Top 20",
"type": "main",
"index": 0
},
{
"node": "If Dropped by 5",
"type": "main",
"index": 0
}
]
]
},
"Append row in Rankings Log": {
"main": [
[
{
"node": "Loop Over Keywords",
"type": "main",
"index": 0
}
]
]
},
"If Fell Out of Top 20": {
"main": [
[
{
"node": "Send fall alert",
"type": "main",
"index": 0
}
]
]
},
"If Dropped by 5": {
"main": [
[
{
"node": "Send drop alert",
"type": "main",
"index": 0
}
]
]
},
"Code Build Summary": {
"main": [
[
{
"node": "If Send Summary",
"type": "main",
"index": 0
}
]
]
},
"If Send Summary": {
"main": [
[
{
"node": "Send summary",
"type": "main",
"index": 0
}
]
]
},
"Aggregate": {
"main": [
[
{
"node": "Code Build Summary",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"versionId": "3b81ea4a-6454-4925-b1fd-82e48f65f65d",
"meta": {
"templateCredsSetupCompleted": true
},
"id": "EBl5eQaIVIc1pi0q",
"tags": []
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googleSheetsOAuth2ApihasDataApislackOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
SEO Tracker. Uses googleSheets, formTrigger, dataTable, @hasdata/n8n-nodes-hasdata. Event-driven trigger; 32 nodes.
Source: https://gist.github.com/CursedCode45/affa503eb679cba5e37ce1967c75ef46 — 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 collects a blog brief via an n8n form, uses Anthropic Claude to generate an outline and write each section, saves both outline and article as formatted Google Docs in Google Drive, then
Expenses Tracker (video). Uses httpRequest, splitInBatches, googleSheets, googleDrive. Event-driven trigger; 21 nodes.
Form Googlesheets. Uses form, formTrigger, googleSheets, stickyNote. Event-driven trigger; 12 nodes.
Transform your lead list into an AI-powered calling machine. This workflow automates your entire cold calling process using Vapi's conversational AI to initiate calls, qualify leads, capture detailed
Type in Slack. Walk away. Get a professional PDF report and a structured Excel fix sheet delivered to Google Drive and posted back in your Slack thread — fully automated, zero manual work.