{
  "name": "Website Change & Uptime Monitor (no AI)",
  "nodes": [
    {
      "id": "sticky-03-overview",
      "name": "Overview & Setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -600,
        -40
      ],
      "parameters": {
        "color": 3,
        "width": 540,
        "height": 560,
        "content": "## \ud83d\udef0\ufe0f Website Change & Uptime Monitor\n\nWatches a list of URLs and posts a Slack alert only when something breaks or changes \u2014 no database, no AI.\n\n### \ud83d\udc64 Who's it for\nAnyone who wants lightweight uptime + content-change monitoring without a paid service or extra infrastructure.\n\n### \u2699\ufe0f How it works\n1. **Schedule** runs every 30 min.\n2. **Fetch Page** requests each URL (never errors on 4xx/5xx).\n3. A Code node diffs status + body against the last signature in **workflow static data**.\n4. **Slack** is messaged only for *down*, *changed* or *recovered* \u2014 quiet runs stay silent.\n\n### \ud83d\udd27 Set up (~2 min)\n- Edit the URLs in **Define URLs to Monitor** (each needs `label` + `url`).\n- Add a **Slack** credential, pick a channel, then activate.\n- First run records a baseline and sends nothing; alerts start from run two."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-03-s1",
      "name": "Section 1 \u00b7 Targets",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -10,
        -40
      ],
      "parameters": {
        "color": 7,
        "width": 520,
        "height": 360,
        "content": "### 1. Targets\nEdit the URLs to watch in **Define URLs to Monitor** \u2014 one item per page, each with a `label` and a `url`."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-03-s2",
      "name": "Section 2 \u00b7 Fetch",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        650,
        -40
      ],
      "parameters": {
        "color": 7,
        "width": 200,
        "height": 360,
        "content": "### 2. Fetch\n**Fetch Page** runs once per URL with *Never Error* + *Full Response*, so a down site returns its status code instead of aborting."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-03-s3",
      "name": "Section 3 \u00b7 Diff & State",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        990,
        -40
      ],
      "parameters": {
        "color": 7,
        "width": 540,
        "height": 360,
        "content": "### 3. Diff & State\nCompares each page to the last signature in **workflow static data**, then stores the new one. Emits items only for down / changed / recovered."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-03-s4",
      "name": "Section 4 \u00b7 Alert",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1650,
        -40
      ],
      "parameters": {
        "color": 7,
        "width": 200,
        "height": 360,
        "content": "### 4. Alert\nOne Slack message per problem. Quiet runs send nothing because the Code node returns zero items."
      },
      "typeVersion": 1
    },
    {
      "id": "schedule-trigger",
      "name": "Every 30 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        40,
        80
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 30
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "define-urls",
      "name": "Define URLs to Monitor",
      "type": "n8n-nodes-base.code",
      "position": [
        360,
        80
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Define the targets you want to watch.\n// Return ONE item per URL so the HTTP Request node fetches each one separately.\n// Edit this list: add/remove objects, change the \"label\" (shown in alerts) and \"url\".\nconst targets = [\n  { label: 'Example Homepage', url: 'https://example.com' },\n  { label: 'Example Pricing',  url: 'https://example.com/pricing' },\n  { label: 'Example Status',   url: 'https://example.com/status' },\n];\n\nreturn targets.map((t) => ({ json: { label: t.label, url: t.url } }));\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "fetch-page",
      "name": "Fetch Page",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        700,
        80
      ],
      "parameters": {
        "url": "={{ $json.url }}",
        "method": "GET",
        "options": {
          "timeout": 15000,
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true,
              "responseFormat": "text"
            }
          }
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "detect-change",
      "name": "Detect Status & Content Change",
      "type": "n8n-nodes-base.code",
      "position": [
        1040,
        80
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Compares each freshly fetched page against the last version we saw and\n// emits an alert item ONLY when a site is down or its content changed.\n//\n// State is persisted in workflow static data ('global'), keyed by URL, so it\n// survives between executions without any database. On the very first run a\n// URL has no stored baseline yet -> we record it and stay silent (no alert).\n\nconst staticData = $getWorkflowStaticData('global');\n// Namespaced bucket so we never collide with other workflow state.\nif (!staticData.siteMonitor) staticData.siteMonitor = {};\nconst store = staticData.siteMonitor;\n\n// Cheap, dependency-free signature of a page body. We combine length with a\n// rolling 32-bit hash so unrelated edits of equal length still differ.\nfunction signature(text) {\n  const s = String(text == null ? '' : text);\n  let hash = 0;\n  for (let i = 0; i < s.length; i++) {\n    hash = (hash * 31 + s.charCodeAt(i)) | 0; // | 0 keeps it a 32-bit int\n  }\n  return s.length + ':' + (hash >>> 0).toString(16);\n}\n\nconst alerts = [];\n\nfor (const item of $input.all()) {\n  const data = item.json || {};\n  // 'url' / 'label' were carried through from the target item; the HTTP node\n  // (fullResponse) adds statusCode + body. A network failure leaves them blank.\n  const url = data.url || data.requestUrl || '';\n  const label = data.label || url || 'Unknown target';\n  const statusCode = typeof data.statusCode === 'number' ? data.statusCode : null;\n  const body = data.body !== undefined ? data.body : data.data;\n\n  // Treat a missing/error status or any 4xx/5xx as \"down\".\n  const isDown = statusCode === null || statusCode >= 400;\n\n  const prev = store[url];\n  const newSig = isDown ? null : signature(body);\n\n  let changeType = 'ok';\n  let message = '';\n\n  if (isDown) {\n    changeType = 'down';\n    const codeText = statusCode === null ? 'no response' : 'HTTP ' + statusCode;\n    message = ':rotating_light: ' + label + ' is DOWN (' + codeText + ') \u2014 ' + url;\n  } else if (!prev || prev.signature == null) {\n    // First time we successfully see this URL: record baseline, stay silent.\n    changeType = 'baseline';\n  } else if (prev.signature !== newSig) {\n    changeType = 'changed';\n    message = ':eyes: Content changed on ' + label + ' (' + url + ')';\n  } else if (prev.lastStatus === 'down') {\n    // Recovery: it was down before and is healthy now \u2014 worth a heads-up.\n    changeType = 'recovered';\n    message = ':white_check_mark: ' + label + ' is back UP (HTTP ' + statusCode + ') \u2014 ' + url;\n  }\n\n  // Always update state so the next run compares against the latest version.\n  store[url] = {\n    signature: newSig,\n    lastStatus: isDown ? 'down' : 'ok',\n    statusCode: statusCode,\n    lastCheckedAt: new Date().toISOString(),\n  };\n\n  if (changeType === 'down' || changeType === 'changed' || changeType === 'recovered') {\n    alerts.push({\n      json: { label, url, statusCode, changeType, message },\n    });\n  }\n}\n\n// Returning an empty array means the workflow simply stops here on a quiet run,\n// so no Slack message is sent unless something actually happened.\nreturn alerts;\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "anything-to-report",
      "name": "Anything To Report?",
      "type": "n8n-nodes-base.if",
      "position": [
        1380,
        80
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-has-message",
              "operator": {
                "type": "string",
                "operation": "exists",
                "singleValue": true
              },
              "leftValue": "={{ $json.message }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "post-alert",
      "name": "Post Alert",
      "type": "n8n-nodes-base.slack",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1700,
        80
      ],
      "parameters": {
        "text": "={{ $json.message }}",
        "select": "channel",
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#alerts",
          "cachedResultName": "#alerts"
        },
        "operation": "post",
        "messageType": "text",
        "otherOptions": {}
      },
      "retryOnFail": true,
      "typeVersion": 2.4,
      "waitBetweenTries": 5000
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Fetch Page": {
      "main": [
        [
          {
            "node": "Detect Status & Content Change",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 30 Minutes": {
      "main": [
        [
          {
            "node": "Define URLs to Monitor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anything To Report?": {
      "main": [
        [
          {
            "node": "Post Alert",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Define URLs to Monitor": {
      "main": [
        [
          {
            "node": "Fetch Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect Status & Content Change": {
      "main": [
        [
          {
            "node": "Anything To Report?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}