{
  "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": []
}