{
  "name": "Find Pages Losing Organic Search Traffic | Uptune",
  "nodes": [
    {
      "parameters": {
        "content": "## Find Pages Losing Organic Search Traffic\nCompare two Search Console windows and queue meaningful traffic drops for editorial review.\n\nRun **Run with sample data** first. **Setup** defaults to dry run and uses the sample fixture without contacting connected apps. Configure the accounts and destinations in README.md before switching modes.",
        "width": 450,
        "height": 235
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -760,
        -390
      ],
      "id": "0dc9079d-c929-5fcb-a6be-884316c8629a",
      "name": "Start here"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -720,
        80
      ],
      "id": "366177d1-a58a-50dd-8f87-acac592f2653",
      "name": "Run with sample data"
    },
    {
      "parameters": {
        "jsCode": "const input = $input.first().json;\nreturn [{json: {sampleRun: true, body: {}}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -480,
        80
      ],
      "id": "3e844e91-12e2-5749-86d9-623d6c4ba6a3",
      "name": "Sample input"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 8
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -480,
        300
      ],
      "id": "77664ee0-2fe4-5d8c-bfbe-18bf8f3e5ebc",
      "name": "Receive event"
    },
    {
      "parameters": {
        "jsCode": "const input = $input.first().json;\nconst config = {\n  \"dryRun\": true,\n  \"siteProperty\": \"\",\n  \"spreadsheetId\": \"\",\n  \"minimumBaselineClicks\": 30,\n  \"minimumLostClicks\": 15,\n  \"minimumDropPercent\": 25,\n  \"finalDataLagDays\": 4,\n  \"rowLimit\": 5000\n};\nreturn [{json: {...input, config}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -240,
        150
      ],
      "id": "0669ca70-1fc0-554b-9ceb-0b09511e5ca3",
      "name": "Setup"
    },
    {
      "parameters": {
        "jsCode": "const input = $input.first().json;\nconst cfg = input.config;\nconst preview = input.config.dryRun !== false || input.sampleRun === true;\nconst property = String(cfg.siteProperty || '');\nif (!preview && (!cfg.spreadsheetId || !(property.startsWith('sc-domain:') && /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$/i.test(property.slice(10)) || /^https:\\/\\/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}(?:[/?][^\\s<>\"\\\\]*)?$/i.test(property)))) {\n  throw new Error('Enter the exact Search Console property and spreadsheet ID.');\n}\nif (!Number.isInteger(cfg.finalDataLagDays) || cfg.finalDataLagDays<3 || cfg.finalDataLagDays>30 ||\n    !Number.isInteger(cfg.rowLimit) || cfg.rowLimit<100 || cfg.rowLimit>25000 ||\n    ![cfg.minimumBaselineClicks,cfg.minimumLostClicks,cfg.minimumDropPercent].every(x=>typeof x==='number' && Number.isFinite(x) && x>0) ||\n    cfg.minimumDropPercent>100) throw new Error('Check the date lag, API row limit and traffic thresholds.');\nconst day = offset => new Date(Date.now()-offset*86400000).toISOString().slice(0,10);\nconst lag = cfg.finalDataLagDays;\nreturn [{json:{preview,property,recent:{startDate:day(lag+27),endDate:day(lag)},\n  baseline:{startDate:day(lag+55),endDate:day(lag+28)}}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -75,
        150
      ],
      "id": "1052fd95-0c7d-5aa8-8608-7b9cbfc3c8a6",
      "name": "Plan Search Console comparison"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "e3a0abad-5d72-5392-8f07-3ff816c2c824",
              "leftValue": "={{ !$json.preview }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        160,
        150
      ],
      "id": "2782ce22-af0e-55ce-b060-c3aa38f2e7c7",
      "name": "Read live search metrics?"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://www.googleapis.com/webmasters/v3/sites/'+encodeURIComponent($(\"Plan Search Console comparison\").first().json.property)+'/searchAnalytics/query' }}",
        "authentication": "genericCredentialType",
        "options": {
          "timeout": 30000
        },
        "genericAuthType": "oAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({...$(\"Plan Search Console comparison\").first().json.baseline,dimensions:['page'],type:'web',dataState:'final',rowLimit:$(\"Setup\").first().json.config.rowLimit}) }}"
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        395,
        150
      ],
      "id": "a370e257-9574-504f-9a72-747b8262cc29",
      "name": "Read baseline page metrics"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://www.googleapis.com/webmasters/v3/sites/'+encodeURIComponent($(\"Plan Search Console comparison\").first().json.property)+'/searchAnalytics/query' }}",
        "authentication": "genericCredentialType",
        "options": {
          "timeout": 30000
        },
        "genericAuthType": "oAuth2Api",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({...$(\"Plan Search Console comparison\").first().json.recent,dimensions:['page'],type:'web',dataState:'final',rowLimit:$(\"Setup\").first().json.config.rowLimit}) }}"
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        630,
        150
      ],
      "id": "17451036-1e58-530d-a147-d8602b8d3e61",
      "name": "Read recent page metrics"
    },
    {
      "parameters": {
        "jsCode": "const input = $input.first().json;\nconst p = $(\"Plan Search Console comparison\").first().json;\nconst cfg = $(\"Setup\").first().json.config;\nconst baseline = p.preview ? {\"rows\": [{\"keys\": [\"https://site.example.com/handoff\"], \"clicks\": 80, \"impressions\": 1200}, {\"keys\": [\"https://site.example.com/crm\"], \"clicks\": 60, \"impressions\": 900}, {\"keys\": [\"https://site.example.com/missing\"], \"clicks\": 40, \"impressions\": 600}]} : $(\"Read baseline page metrics\").first().json;\nconst recent = p.preview ? {\"rows\": [{\"keys\": [\"https://site.example.com/handoff\"], \"clicks\": 40, \"impressions\": 1100}, {\"keys\": [\"https://site.example.com/crm\"], \"clicks\": 64, \"impressions\": 950}, {\"keys\": [\"https://site.example.com/new\"], \"clicks\": 20, \"impressions\": 700}]} : input;\nconst toMap = report => {\n  if (report.rows !== undefined && !Array.isArray(report.rows)) throw new Error('Search Console returned an invalid row list.');\n  const rows = report.rows || [];\n  if (rows.length >= cfg.rowLimit) throw new Error('Search Console reached the row limit. Narrow the report scope or increase rowLimit.');\n  const map = new Map();\n  for (const row of rows) {\n    const url = row.keys?.[0];\n    if (typeof url !== 'string' || !/^https:\\/\\/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}(?:[/?][^\\s<>\"\\\\]*)?$/i.test(url) || !Number.isFinite(row.clicks) || row.clicks<0 ||\n        !Number.isFinite(row.impressions) || row.impressions<0 || map.has(url)) throw new Error('A Search Console page row is invalid or duplicated.');\n    map.set(url,row);\n  }\n  return map;\n};\nconst old = toMap(baseline);\nconst current = toMap(recent);\nconst findings = [];\nlet missingFromRecent = 0;\nfor (const [url,earlier] of old) {\n  const latest = current.get(url);\n  if (!latest) {missingFromRecent++; continue;}\n  if (earlier.clicks<cfg.minimumBaselineClicks) continue;\n  const lost = earlier.clicks-latest.clicks;\n  const percent = lost/earlier.clicks*100;\n  if (lost>=cfg.minimumLostClicks && percent>=cfg.minimumDropPercent) findings.push({\n    url,key:p.recent.endDate+'|'+url,baselineClicks:earlier.clicks,recentClicks:latest.clicks,\n    baselineImpressions:earlier.impressions,recentImpressions:latest.impressions,\n    lost,dropPercent:Math.round(percent*10)/10,endDate:p.recent.endDate\n  });\n}\nfindings.sort((a,b)=>b.lost-a.lost);\nreturn [{json:{preview:p.preview,property:p.property,period:p,findings,missingFromRecent,\n  baselinePages:old.size,recentPages:current.size}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        865,
        150
      ],
      "id": "5dfee9a2-fbc3-5686-bd6a-67ab7c15cc3b",
      "name": "Compare search performance"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "dcf32446-b4cb-51cf-80e2-914c3f29ae5c",
              "leftValue": "={{ !$json.preview && $json.findings.length > 0 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1100,
        150
      ],
      "id": "d4a26292-c7bb-593d-896d-36b8070720a0",
      "name": "Queue live pages?"
    },
    {
      "parameters": {
        "jsCode": "const input = $input.first().json;\nreturn input.findings.map(row=>({json:row}));"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1335,
        150
      ],
      "id": "6af52965-10f3-5228-acd5-57aea9679d2e",
      "name": "One row per flagged page"
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "={{ $(\"Setup\").first().json.config.spreadsheetId }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Search Review Queue",
          "mode": "name"
        },
        "options": {
          "cellFormat": "RAW"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Finding ID": "={{ $json.key }}",
            "Page": "={{ $json.url }}",
            "Period End": "={{ $json.endDate }}",
            "Baseline Clicks": "={{ $json.baselineClicks }}",
            "Recent Clicks": "={{ $json.recentClicks }}",
            "Lost Clicks": "={{ $json.lost }}",
            "Drop Percent": "={{ $json.dropPercent }}",
            "Baseline Impressions": "={{ $json.baselineImpressions }}",
            "Recent Impressions": "={{ $json.recentImpressions }}"
          },
          "matchingColumns": [
            "Finding ID"
          ],
          "schema": [
            {
              "id": "Finding ID",
              "displayName": "Finding ID",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Page",
              "displayName": "Page",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Period End",
              "displayName": "Period End",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Baseline Clicks",
              "displayName": "Baseline Clicks",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Recent Clicks",
              "displayName": "Recent Clicks",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Lost Clicks",
              "displayName": "Lost Clicks",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Drop Percent",
              "displayName": "Drop Percent",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Baseline Impressions",
              "displayName": "Baseline Impressions",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "Recent Impressions",
              "displayName": "Recent Impressions",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1570,
        150
      ],
      "id": "950cd777-e598-5a5c-88b4-260bcb4dbff9",
      "name": "Save organic review queue"
    }
  ],
  "connections": {
    "Run with sample data": {
      "main": [
        [
          {
            "node": "Sample input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sample input": {
      "main": [
        [
          {
            "node": "Setup",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Receive event": {
      "main": [
        [
          {
            "node": "Setup",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Setup": {
      "main": [
        [
          {
            "node": "Plan Search Console comparison",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Plan Search Console comparison": {
      "main": [
        [
          {
            "node": "Read live search metrics?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read live search metrics?": {
      "main": [
        [
          {
            "node": "Read baseline page metrics",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Compare search performance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read baseline page metrics": {
      "main": [
        [
          {
            "node": "Read recent page metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read recent page metrics": {
      "main": [
        [
          {
            "node": "Compare search performance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare search performance": {
      "main": [
        [
          {
            "node": "Queue live pages?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Queue live pages?": {
      "main": [
        [
          {
            "node": "One row per flagged page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One row per flagged page": {
      "main": [
        [
          {
            "node": "Save organic review queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveDataSuccessExecution": "none",
    "saveDataErrorExecution": "all"
  },
  "pinData": {},
  "tags": []
}
