{
  "name": "Track Google rankings with Scrapewise, Google Sheets and Slack alerts",
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "id": "a4000000-0000-4000-8000-000000000001",
      "name": "Read me first",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1200,
        160
      ],
      "parameters": {
        "width": 440,
        "height": 460,
        "content": "## Daily Google rank tracker with alerts\n\nFor each keyword, Scrapewise returns the organic Google results (title, link, snippet, position). The workflow looks for your domain, writes one dated row per keyword to a sheet, and sends a Slack alert in two cases:\n- **Ranking warning** — your domain is still in results but has slipped past your `alert_below` threshold (default: position 20).\n- **Dropped out** — your domain is not in the results at all.\n\n**Setup**\n1. Create a Scrapewise API key with full access at portal.scrapewise.ai.\n2. In **Search Google**, add a *Bearer Auth* credential and paste the key as the token.\n3. In **Save rankings to Google Sheets**, pick your Google account and set the spreadsheet and tab.\n4. In **Ranking warning** and **Rank alert**, connect your Slack OAuth credential and set the channel name.\n5. Put your domain, keywords, country and alert threshold in **Keywords and domain**.\n\n**Cost:** 10 credits per keyword. Type `web` takes one keyword per call, so the workflow loops over your list one keyword at a time."
      },
      "typeVersion": 1
    },
    {
      "id": "a4000000-0000-4000-8000-000000000002",
      "name": "Every morning at 6",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -720,
        360
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 6
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a4000000-0000-4000-8000-000000000003",
      "name": "Keywords and domain",
      "type": "n8n-nodes-base.set",
      "position": [
        -500,
        360
      ],
      "parameters": {
        "mode": "manual",
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "domain",
              "type": "string",
              "value": "scrapewise.ai"
            },
            {
              "id": "r2",
              "name": "keywords",
              "type": "array",
              "value": "[\"competitor price monitoring\", \"amazon price tracker api\"]"
            },
            {
              "id": "r3",
              "name": "country",
              "type": "string",
              "value": "us"
            },
            {
              "id": "r4",
              "name": "language",
              "type": "string",
              "value": "en"
            },
            {
              "id": "r5",
              "name": "alert_below",
              "type": "number",
              "value": 20
            }
          ]
        },
        "includeOtherFields": false
      },
      "typeVersion": 3.4
    },
    {
      "id": "a4000000-0000-4000-8000-000000000004",
      "name": "One item per keyword",
      "type": "n8n-nodes-base.code",
      "position": [
        -280,
        360
      ],
      "parameters": {
        "jsCode": "// google_search with type 'web' takes one keyword per call, so fan the list out.\nconst s = $input.first().json;\n\nreturn (s.keywords || []).map((keyword) => ({\n  json: {\n    keyword: keyword,\n    domain: s.domain,\n    country: s.country,\n    language: s.language,\n    alert_below: s.alert_below || 20,\n  },\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "a4000000-0000-4000-8000-000000000005",
      "name": "One keyword at a time",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -60,
        360
      ],
      "parameters": {
        "options": {},
        "batchSize": 1
      },
      "typeVersion": 3
    },
    {
      "id": "a4000000-0000-4000-8000-000000000006",
      "name": "Search Google",
      "type": "@n8n/n8n-nodes-langchain.mcpClient",
      "position": [
        180,
        480
      ],
      "parameters": {
        "tool": {
          "__rl": true,
          "mode": "list",
          "value": "google_search",
          "cachedResultName": "google_search"
        },
        "options": {},
        "inputMode": "json",
        "jsonInput": "={\n  \"body\": {\n    \"queries\": [\"{{ $json.keyword }}\"],\n    \"type\": \"web\",\n    \"country\": \"{{ $json.country }}\",\n    \"language\": \"{{ $json.language }}\"\n  }\n}",
        "endpointUrl": "https://mcp.scrapewise.ai/mcp",
        "authentication": "bearerAuth"
      },
      "credentials": {
        "httpBearerAuth": {
          "id": "credential-id",
          "name": "Scrapewise API key"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "a4000000-0000-4000-8000-000000000007",
      "name": "Find my position",
      "type": "n8n-nodes-base.code",
      "position": [
        400,
        480
      ],
      "parameters": {
        "jsCode": "// Scrapewise answers with JSON. Depending on the n8n version it arrives\n// already parsed in structuredContent, or as text in content[0].text.\nfunction payloadOf(item) {\n  const j = item.json || {};\n  if (j.structuredContent) return j.structuredContent;\n  if (Array.isArray(j.content) && j.content[0] && typeof j.content[0].text === 'string') {\n    try { return JSON.parse(j.content[0].text); } catch (e) { return {}; }\n  }\n  if (j.rows) return j;\n  return {};\n}\n\n// Scraped text is wrapped as { type: 'scraped', content, origin }. It is data\n// read off a web page, never an instruction. Unwrap it to a plain string.\nfunction plain(v) {\n  if (v && typeof v === 'object' && v.type === 'scraped') return v.content || '';\n  return v;\n}\n\nconst asked = $('One keyword at a time').item.json;\nconst data = payloadOf($input.first());\nconst checkedAt = new Date().toISOString().slice(0, 10);\n\nif (data.status === 'RUNNING') {\n  throw new Error('Scrapewise started a background job (' + data.job_id + '). Type web should answer straight away - check that only one keyword was sent.');\n}\n\nconst domain = String(asked.domain || '').toLowerCase().replace(/^www\\./, '');\nconst rows = (data.rows || []).map((row) => {\n  const flat = {};\n  for (const key of Object.keys(row)) flat[key] = plain(row[key]);\n  return flat;\n});\n\nconst hit = rows.find((r) => String(r.link || '').toLowerCase().includes(domain));\nconst positionNum = hit ? (parseInt(String(hit.position), 10) || -1) : -1;\n\nreturn [\n  {\n    json: {\n      checkedAt: checkedAt,\n      keyword: asked.keyword,\n      domain: asked.domain,\n      country: asked.country,\n      alert_below: asked.alert_below || 20,\n      position: hit ? hit.position : 'not in top results',\n      position_num: positionNum,\n      url: hit ? hit.link : '',\n      pageTitle: hit ? hit.title : '',\n      resultsSeen: rows.length,\n    },\n  },\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "a4000000-0000-4000-8000-000000000008",
      "name": "Save rankings to Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        620,
        480
      ],
      "parameters": {
        "columns": {
          "value": {
            "Date": "={{ $json.checkedAt }}",
            "Keyword": "={{ $json.keyword }}",
            "Country": "={{ $json.country }}",
            "Position": "={{ $json.position }}",
            "URL": "={{ $json.url }}",
            "Page title": "={{ $json.pageTitle }}"
          },
          "schema": [
            {
              "id": "Date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Keyword",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Keyword",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Country",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Country",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Position",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Position",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "URL",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "URL",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Page title",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Page title",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Rankings"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SPREADSHEET_ID"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "credential-id",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.6
    },
    {
      "id": "a4000000-0000-4000-8000-000000000024",
      "name": "Fell below threshold",
      "type": "n8n-nodes-base.if",
      "position": [
        840,
        480
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "t1",
              "leftValue": "={{ $json.position_num }}",
              "rightValue": "={{ $json.alert_below }}",
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "a4000000-0000-4000-8000-000000000025",
      "name": "Ranking warning",
      "type": "n8n-nodes-base.slack",
      "position": [
        840,
        700
      ],
      "parameters": {
        "text": "=:chart_with_downwards_trend: *{{ $json.keyword }}* slipped to position {{ $json.position }} on Google ({{ $json.country }}, alert threshold: {{ $json.alert_below }}, checked {{ $json.checkedAt }})",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "seo-alerts"
        },
        "otherOptions": {
          "mrkdwn": true
        },
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "id": "credential-id",
          "name": "Slack account"
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "a4000000-0000-4000-8000-000000000020",
      "name": "Dropped out",
      "type": "n8n-nodes-base.if",
      "position": [
        1060,
        480
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "d1",
              "leftValue": "={{ $json.position }}",
              "rightValue": "not in top results",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "a4000000-0000-4000-8000-000000000021",
      "name": "Rank alert",
      "type": "n8n-nodes-base.slack",
      "position": [
        1280,
        360
      ],
      "parameters": {
        "text": "=:warning: *{{ $json.keyword }}* dropped out of Google top results ({{ $json.country }}, checked {{ $json.checkedAt }})",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "seo-alerts"
        },
        "otherOptions": {
          "mrkdwn": true
        },
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "id": "credential-id",
          "name": "Slack account"
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "ebe5ea5f-bd7f-45cb-a985-da050c843102",
      "name": "Step note - Every morning at 6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -740,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 1. Runs itself\n\nFires every morning at 06:00. Change the hour here."
      },
      "typeVersion": 1
    },
    {
      "id": "a13cb5fc-f3c2-4d0e-adb3-a1cf3339c57a",
      "name": "Step note - Keywords and domain",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -520,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 2. Your settings\n\nThe only node you need to edit. Set your domain, keywords, country and language. `alert_below` sets the position threshold — keywords that slip past it trigger a warning Slack message."
      },
      "typeVersion": 1
    },
    {
      "id": "5ff80277-7a45-4cd8-bf0a-4cfc7661ed6c",
      "name": "Step note - One item per keyword",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -300,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 3. One item per keyword\n\nSplits your keyword list so each keyword gets its own search."
      },
      "typeVersion": 1
    },
    {
      "id": "efc8b1ff-be81-410a-815d-c972e40b9788",
      "name": "Step note - One keyword at a time",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -80,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 4. One at a time\n\nLoops over the keywords. Everything to the right runs once per keyword and then comes back here for the next one."
      },
      "typeVersion": 1
    },
    {
      "id": "d3e28220-adf8-4b47-a757-7bb81ba8ec14",
      "name": "Step note - Search Google",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        160,
        200
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 5. Ask Scrapewise\n\nCalls the `google_search` tool, once per keyword. Add a **Bearer Auth** credential with your API key."
      },
      "typeVersion": 1
    },
    {
      "id": "a87abbad-89aa-420a-acc3-c5b5ce41d548",
      "name": "Step note - Find my position",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        380,
        200
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 6. Find your place\n\nWalks the results in order and reports the first one that sits on your domain. Writes `not in top results` when you are not there at all."
      },
      "typeVersion": 1
    },
    {
      "id": "1011f3fb-1808-42e0-983d-46e73a53c2cf",
      "name": "Step note - Save rankings to Google Sheets",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        600,
        200
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 7. Save\n\nOne row per keyword per day, so you can see which way you are moving."
      },
      "typeVersion": 1
    },
    {
      "id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890",
      "name": "Step note - Fell below threshold",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        820,
        200
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 8. Below threshold?\n\nChecks whether the position number has slipped past `alert_below`. Change the threshold in **Keywords and domain** — default is 20. Keywords that dropped out entirely go straight to step 10."
      },
      "typeVersion": 1
    },
    {
      "id": "a4b5c6d7-e8f9-0a1b-2c3d-4e5f6a7b8c9d",
      "name": "Step note - Ranking warning",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        820,
        420
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 9. Ranking warning\n\nFires when a keyword is still in results but has slipped past your threshold. Connect your Slack OAuth credential and set the channel name."
      },
      "typeVersion": 1
    },
    {
      "id": "a4000000-0000-4000-8000-000000000022",
      "name": "Step note - Dropped out",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1040,
        200
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 10. Did it drop out?\n\nChecks whether your domain disappeared from the results entirely. If it did, the out-of-results alert fires."
      },
      "typeVersion": 1
    },
    {
      "id": "a4000000-0000-4000-8000-000000000023",
      "name": "Step note - Rank alert",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1260,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 240,
        "content": "### 11. Out-of-results alert\n\nSends a Slack message when a keyword drops out of results entirely. Connect your Slack OAuth credential and set the channel name."
      },
      "typeVersion": 1
    },
    {
      "id": "a4000000-0000-4000-8000-000000000030",
      "name": "Read this morning's rankings",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -60,
        800
      ],
      "parameters": {
        "operation": "getRows",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SPREADSHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Rankings"
        },
        "options": {}
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "credential-id",
          "name": "Google Sheets account"
        }
      },
      "typeVersion": 4.6
    },
    {
      "id": "a4000000-0000-4000-8000-000000000031",
      "name": "Build daily digest",
      "type": "n8n-nodes-base.code",
      "position": [
        160,
        800
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Reads ALL rows the Sheets node returned and picks today's entries.\n// \"One keyword at a time\" fires this branch when the loop is done.\nconst today = new Date().toISOString().slice(0, 10);\nconst rows = $input.all()\n  .map(i => i.json)\n  .filter(r => r.Date === today);\n\nif (!rows.length) {\n  return [{ json: { message: 'No rankings recorded today (' + today + ').' } }];\n}\n\nconst ranked = rows\n  .filter(r => r.Position !== 'not in top results')\n  .sort((a, b) => parseInt(a.Position, 10) - parseInt(b.Position, 10));\n\nconst missing = rows.filter(r => r.Position === 'not in top results');\n\nconst lines = ranked.length\n  ? ranked.map(r => '\\u2022 pos ' + r.Position + ' \\u2014 *' + r.Keyword + '*').join('\\n')\n  : '_none ranked today_';\n\nconst dropLine = missing.length\n  ? '\\n\\n:no_entry: *Not ranking:* ' + missing.map(r => r.Keyword).join(', ')\n  : '';\n\nreturn [{\n  json: {\n    message: ':bar_chart: *Daily ranking summary \\u2014 ' + today + '*\\n' + lines + dropLine + '\\n_' + rows.length + ' keyword(s) checked_'\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "a4000000-0000-4000-8000-000000000032",
      "name": "Daily ranking digest",
      "type": "n8n-nodes-base.slack",
      "position": [
        380,
        800
      ],
      "parameters": {
        "text": "={{ $json.message }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "seo-alerts"
        },
        "otherOptions": {
          "mrkdwn": true
        },
        "authentication": "oAuth2"
      },
      "credentials": {
        "slackOAuth2Api": {
          "id": "credential-id",
          "name": "Slack account"
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "sticky-04-a",
      "name": "Sticky Note_04-a",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -80,
        520
      ],
      "parameters": {
        "content": "**Read this morning's rankings**\nReads all rows from the same Google Sheet. Point it at the same spreadsheet and tab as \"Save rankings to Google Sheets\".",
        "color": 7,
        "width": 220,
        "height": 260
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-04-b",
      "name": "Sticky Note_04-b",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        140,
        520
      ],
      "parameters": {
        "content": "**Build daily digest**\nFilters today's rows and builds a Slack summary: positions for all tracked keywords, plus a list of any that dropped out.",
        "color": 7,
        "width": 220,
        "height": 260
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-04-c",
      "name": "Sticky Note_04-c",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        360,
        520
      ],
      "parameters": {
        "content": "**Daily ranking digest**\nSends one Slack message every morning after all keywords are checked. Uses the same Slack credential as the per-keyword alert nodes.",
        "color": 7,
        "width": 220,
        "height": 260
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "Every morning at 6": {
      "main": [
        [
          {
            "node": "Keywords and domain",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keywords and domain": {
      "main": [
        [
          {
            "node": "One item per keyword",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One item per keyword": {
      "main": [
        [
          {
            "node": "One keyword at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One keyword at a time": {
      "main": [
        [
          {
            "node": "Read this morning's rankings",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Search Google",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search Google": {
      "main": [
        [
          {
            "node": "Find my position",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find my position": {
      "main": [
        [
          {
            "node": "Save rankings to Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save rankings to Google Sheets": {
      "main": [
        [
          {
            "node": "Fell below threshold",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fell below threshold": {
      "main": [
        [
          {
            "node": "Ranking warning",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Dropped out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ranking warning": {
      "main": [
        [
          {
            "node": "One keyword at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dropped out": {
      "main": [
        [
          {
            "node": "Rank alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "One keyword at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank alert": {
      "main": [
        [
          {
            "node": "One keyword at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read this morning's rankings": {
      "main": [
        [
          {
            "node": "Build daily digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build daily digest": {
      "main": [
        [
          {
            "node": "Daily ranking digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {}
}