AutomationFlowsWeb Scraping › Why? — Watch This N8n

Why? — Watch This N8n

why? — watch this n8n. Uses httpRequest. Scheduled trigger; 7 nodes.

Cron / scheduled trigger★★★★☆ complexity7 nodesHTTP Request
Web Scraping Trigger: Cron / scheduled Nodes: 7 Complexity: ★★★★☆ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "why? \u2014 watch this n8n",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "id": "w1000000-0000-4000-8000-000000000001",
      "name": "Every 15 minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "/* ------------------------------------------------------------------ *\n *  THE ONLY TWO THINGS YOU NEED TO EDIT\n * ------------------------------------------------------------------ */\n\n// Your n8n's address, no trailing slash.\nconst n8nBaseUrl = 'https://CHANGE-ME.app.n8n.cloud';\n\n// Where alerts go. A Slack incoming webhook or a Discord webhook - both work,\n// the message is sent in a shape either will accept.\nconst alertWebhookUrl = 'https://CHANGE-ME';\n\n/* ------------------------------------------------------------------ */\n\nreturn [{ json: { n8nBaseUrl, alertWebhookUrl } }];"
      },
      "id": "w1000000-0000-4000-8000-000000000002",
      "name": "Settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        200,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.n8nBaseUrl }}/api/v1/executions?limit=20",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "n8nApi",
        "options": {
          "timeout": 15000
        }
      },
      "id": "w1000000-0000-4000-8000-000000000003",
      "name": "List recent runs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        400,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "/* Only fetch detail for executions this workflow has not already judged.\n * A quiet instance therefore costs exactly one API call per poll. */\nconst memory = $getWorkflowStaticData('global');\nconst cfg = $('Settings').first().json;\n\nconst rows = ($input.first().json.data || []).filter(\n  r => r.status === 'success' || r.status === 'error' || r.status === 'crashed'\n);\nif (!rows.length) return [];\n\nconst ids = rows.map(r => Number(r.id) || 0);\nconst highest = Math.max(...ids, Number(memory.watermark || 0));\n\n// First ever run: take the mark and stay quiet. Announcing a week-old backlog\n// as though it just happened is how an alert channel gets muted on day one.\nif (!memory.watermark) {\n  memory.watermark = highest;\n  return [];\n}\n\nconst fresh = rows\n  .filter(r => (Number(r.id) || 0) > Number(memory.watermark))\n  .slice(0, 15);\n\nmemory.watermark = highest;\nreturn fresh.map(r => ({ json: { id: r.id, baseUrl: cfg.n8nBaseUrl } }));"
      },
      "id": "w1000000-0000-4000-8000-000000000004",
      "name": "Only what is new",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        600,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.baseUrl }}/api/v1/executions/{{ $json.id }}?includeData=true",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "n8nApi",
        "options": {
          "timeout": 20000
        }
      },
      "id": "w1000000-0000-4000-8000-000000000005",
      "name": "Fetch each run",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        800,
        0
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/* why? for n8n - analysis engine (no DOM, no network).\n * Shared by the content script and the offline CLI. */\n(function (root) {\n  'use strict';\n\n  function isBlank(v) {\n    if (v === null || v === undefined) return true;\n    if (typeof v === 'string') return v.trim() === '';\n    if (Array.isArray(v)) return v.length === 0;\n    if (typeof v === 'object') return Object.keys(v).length === 0;\n    return false;\n  }\n\n  // Every map below is keyed by something an attacker controls: payload field\n  // names, payload values, node types. A plain {} inherits from\n  // Object.prototype, so a field called \"__proto__\" reads back the prototype\n  // itself - truthy, so the \"not seen yet\" branch is skipped - and the next\n  // write lands on Object.prototype, corrupting every object on the n8n page.\n  // Null-prototype maps have no such key to hit.\n  function dict(src) {\n    var d = Object.create(null);\n    if (src) Object.keys(src).forEach(function (k) { d[k] = src[k]; });\n    return d;\n  }\n\n  function show(v, max) {\n    max = max || 60;\n    var s;\n    try { s = JSON.stringify(v); } catch (e) { s = String(v); }\n    if (s === undefined) s = 'undefined';\n    return s.length > max ? s.slice(0, max) + '...' : s;\n  }\n\n  function asEmbeddedJson(s) {\n    if (typeof s !== 'string' || s.length > 20000) return null;\n    var t = s.trim();\n    if (t.charAt(0) !== '{' && t.charAt(0) !== '[') return null;\n    try { var p = JSON.parse(t); return p && typeof p === 'object' ? p : null; } catch (e) { return null; }\n  }\n\n  function join(prefix, k) {\n    if (!prefix) return k;\n    return prefix.slice(-2) === '::' ? prefix + k : prefix + '.' + k;\n  }\n\n  var MAX_DEPTH = 24;\n\n  function flatten(value, prefix, out, limit, depth) {\n    if (limit === undefined) limit = 8;\n    depth = depth || 0;\n    // An API can hand back arbitrarily nested JSON. Without this, a deep\n    // payload overflows the stack and takes the whole scan down with it -\n    // which reads to the user as \"nothing wrong here\".\n    if (depth > MAX_DEPTH) { out.push([prefix, '{...}', true]); return out; }\n    if (typeof value === 'string') {\n      var inner = asEmbeddedJson(value);\n      if (inner) return flatten(inner, prefix + '::', out, limit, depth + 1);\n      out.push([prefix, value]);\n      return out;\n    }\n    if (value === null || typeof value !== 'object') { out.push([prefix, value]); return out; }\n    if (Array.isArray(value)) {\n      if (!value.length) { out.push([prefix, []]); return out; }\n      if (value.length > limit) { out.push([prefix, '[' + value.length + ' items]', true]); return out; }\n      for (var i = 0; i < value.length; i++) flatten(value[i], prefix + '[' + i + ']', out, limit, depth + 1);\n      return out;\n    }\n    var keys = Object.keys(value);\n    if (!keys.length) { out.push([prefix, {}]); return out; }\n    if (keys.length > limit) { out.push([prefix, '{' + keys.length + ' fields}', true]); return out; }\n    for (var j = 0; j < keys.length; j++) flatten(value[keys[j]], join(prefix, keys[j]), out, limit, depth + 1);\n    return out;\n  }\n\n  function itemsOf(runData, name) {\n    var runs = runData[name];\n    if (!runs || !runs.length) return null;\n    var items = [], stored = false;\n    for (var i = 0; i < runs.length; i++) {\n      var r = runs[i];\n      if (!r || !r.data || !r.data.main) continue;\n      stored = true;\n      for (var b = 0; b < r.data.main.length; b++) {\n        if (r.data.main[b]) items = items.concat(r.data.main[b]);\n      }\n    }\n    return stored ? items : null;\n  }\n\n  // Nodes whose whole job is to remove things. Emitting nothing is them\n  // working, not them failing - flagging these is how a tool gets uninstalled.\n  var REDUCERS = dict({\n    'n8n-nodes-base.filter': 'filtered everything out',\n    'n8n-nodes-base.removeDuplicates': 'found nothing new',\n    'n8n-nodes-base.limit': 'kept nothing',\n    'n8n-nodes-base.splitInBatches': 'finished looping',\n    'n8n-nodes-base.compareDatasets': 'found no differences',\n    'n8n-nodes-base.merge': 'merged to nothing',\n    'n8n-nodes-base.if': 'sent nothing down either branch',\n    'n8n-nodes-base.switch': 'matched no branch'\n  });\n\n  function nodeType(exec, name) {\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    for (var i = 0; i < nodes.length; i++) if (nodes[i].name === name) return nodes[i].type || '';\n    return '';\n  }\n\n  function hasUpstream(exec, name) {\n    var conns = (exec.workflowData && exec.workflowData.connections) || {};\n    for (var from in conns) {\n      var mains = (conns[from] && conns[from].main) || [];\n      for (var m = 0; m < mains.length; m++) {\n        var b = mains[m] || [];\n        for (var c = 0; c < b.length; c++) if (b[c] && b[c].node === name) return true;\n      }\n    }\n    return false;\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Two silent failures that have nothing to do with AI.\n   *\n   * Most of what this tool learned to spot needs a model in the loop, which\n   * is no help at all to the majority of n8n workflows - Code, HTTP, Sheets,\n   * Slack. These two are the ones that bite those workflows, and neither\n   * shows up anywhere in n8n's UI.\n   * ------------------------------------------------------------------ */\n\n  // 1. A 200 response carrying an error. The HTTP node is perfectly happy -\n  //    it got a response - and the API is telling you it refused. Rate limits,\n  //    expired tokens and validation failures all arrive this way.\n  var ERROR_KEYS = dict({\n    error: 1, errors: 1, error_message: 1, errormessage: 1, error_description: 1,\n    exception: 1, fault: 1, failure: 1\n  });\n  var NEGATIVE = dict({ error: 1, failed: 1, failure: 1, denied: 1, rejected: 1, unauthorized: 1 });\n\n  function errorShaped(json) {\n    if (!json || typeof json !== 'object') return null;\n    var hit = null;\n    Object.keys(json).slice(0, 60).forEach(function (k) {\n      if (hit) return;\n      var key = k.toLowerCase().replace(/[^a-z_]/g, '');\n      var v = json[k];\n\n      // { error: \"rate limit exceeded\" } - but not { error: null } or { errors: [] }\n      if (ERROR_KEYS[key] && !isBlank(v)) {\n        hit = { key: k, why: 'carries an error', value: show(v, 80) };\n        return;\n      }\n      // { success: false } / { ok: false }\n      if ((key === 'success' || key === 'ok' || key === 'succeeded') && v === false) {\n        hit = { key: k, why: 'says it did not succeed', value: 'false' };\n        return;\n      }\n      // { status: \"failed\" }\n      if ((key === 'status' || key === 'state' || key === 'result') && typeof v === 'string'\n          && NEGATIVE[v.toLowerCase().replace(/[^a-z]/g, '')]) {\n        hit = { key: k, why: 'reports a failed state', value: show(v, 40) };\n        return;\n      }\n      // { statusCode: 429 } inside a body the HTTP node treated as fine\n      if ((key === 'statuscode' || key === 'status_code' || key === 'code')\n          && typeof v === 'number' && v >= 400 && v <= 599) {\n        hit = { key: k, why: 'carries an HTTP error code', value: String(v) };\n      }\n    });\n    return hit;\n  }\n\n  function errorPayloads(exec) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var out = [];\n    Object.keys(rd).forEach(function (name) {\n      if (out.length >= 3) return;\n      var items = itemsOf(rd, name);\n      if (!items || !items.length) return;\n      for (var i = 0; i < Math.min(items.length, 50); i++) {\n        var hit = errorShaped(items[i] && items[i].json);\n        if (hit) {\n          out.push({ node: name, key: hit.key, why: hit.why, value: hit.value,\n                     item: items.length > 1 ? i : null, total: items.length });\n          return;\n        }\n      }\n    });\n    return out;\n  }\n\n  // 2. Items going missing partway through. Fifty rows in, forty-seven out -\n  //    nobody notices three customers were dropped.\n  //\n  //    Only judged where the count MUST be preserved: an HTTP node runs once\n  //    per item, and a Code node set to run once for each item does too. A\n  //    Code node in its default mode legitimately turns fifty items into one,\n  //    which is why this cannot simply compare every node.\n  function itemLoss(exec) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    var byName = dict();\n    nodes.forEach(function (n) { if (n && n.name) byName[n.name] = n; });\n\n    var out = [];\n    Object.keys(rd).forEach(function (name) {\n      var def = byName[name];\n      if (!def) return;\n      var oneToOne = def.type === 'n8n-nodes-base.httpRequest'\n        || (def.type === 'n8n-nodes-base.code'\n            && def.parameters && def.parameters.mode === 'runOnceForEachItem');\n      if (!oneToOne) return;\n\n      var got = itemsOf(rd, name);\n      if (!got) return;\n      var up = upstreamOf(exec, name);\n      var had = up ? itemsOf(rd, up) : null;\n      if (!had || !had.length) return;\n      if (got.length >= had.length) return;\n\n      out.push({ node: name, from: up, had: had.length, got: got.length,\n                 lost: had.length - got.length });\n    });\n    return out;\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Expressions pointing at fields that are not there.\n   *\n   * {{ $json.customer.email }} where the input has no `customer` does not\n   * error. It resolves to nothing, the node runs, the field goes out blank,\n   * and n8n marks the run successful. Rename a field upstream, or hit a\n   * record shaped slightly differently, and a workflow starts writing empty\n   * values into a CRM until somebody downstream complains.\n   *\n   * This is probably the most common silent failure in n8n, it has nothing to\n   * do with AI, and nothing surfaces it. The workflow stores the expressions\n   * and the run stores what actually arrived, so it can simply be checked.\n   * ------------------------------------------------------------------ */\n\n  // An expression that handles absence on purpose is not a bug. ||, ??, ?.,\n  // a ternary or an explicit if() all mean the author already thought about\n  // the field being missing.\n  var HAS_FALLBACK = /\\|\\||\\?\\?|\\?\\.|\\bif\\s*\\(|\\?[^:]*:/;\n  var MAX_REFS = 40;\n\n  function normPath(s) {\n    return String(s)\n      .replace(/\\[\\s*['\"]([^'\"]+)['\"]\\s*\\]/g, '.$1')\n      .replace(/^\\./, '')\n      .split('.')\n      .filter(Boolean);\n  }\n\n  function refsIn(src) {\n    var out = [], m;\n    // {{ $json.a.b }} and {{ $json[\"a\"][\"b\"] }}\n    var re = /\\$json((?:\\.[A-Za-z_$][\\w$]*|\\[\\s*['\"][^'\"\\]]+['\"]\\s*\\])+)/g;\n    while ((m = re.exec(src)) !== null && out.length < MAX_REFS) {\n      out.push({ from: null, segs: normPath(m[1]), text: '$json' + m[1] });\n    }\n    // {{ $('Some Node').item.json.a.b }} - equally silent when wrong\n    var re2 = /\\$\\(\\s*['\"]([^'\"]+)['\"]\\s*\\)\\s*\\.\\s*(?:item|first\\(\\)|last\\(\\))\\s*\\.\\s*json((?:\\.[A-Za-z_$][\\w$]*)+)/g;\n    while ((m = re2.exec(src)) !== null && out.length < MAX_REFS) {\n      out.push({ from: m[1], segs: normPath(m[2]), text: \"$('\" + m[1] + \"')\u2026json\" + m[2] });\n    }\n    return out;\n  }\n\n  // How far down the path does this item get? Returns the number of segments\n  // that resolved, so the report can say which one actually broke.\n  function depthOn(json, segs) {\n    var v = json, i = 0;\n    for (; i < segs.length; i++) {\n      if (v === null || v === undefined || typeof v !== 'object') break;\n      if (!Object.prototype.hasOwnProperty.call(v, segs[i])) break;\n      v = v[segs[i]];\n    }\n    return i;\n  }\n\n  function brokenRefs(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    var def = null;\n    for (var i = 0; i < nodes.length; i++) if (nodes[i].name === nodeName) def = nodes[i];\n    if (!def || !def.parameters) return [];\n\n    var up = upstreamOf(exec, nodeName);\n    var ownInput = up ? itemsOf(rd, up) : null;\n\n    var out = [], seen = dict();\n\n    flatten(def.parameters, '', [], 64).forEach(function (leaf) {\n      if (out.length >= 4) return;\n      var raw = typeof leaf[1] === 'string' ? leaf[1] : '';\n      if (raw.indexOf('{{') === -1 && raw.charAt(0) !== '=') return;\n      if (HAS_FALLBACK.test(raw)) return;              // absence already handled\n\n      refsIn(raw).forEach(function (ref) {\n        if (out.length >= 4) return;\n\n        // Which items should this have resolved against?\n        var against = ref.from ? itemsOf(rd, ref.from) : ownInput;\n        // Nothing to check against is not evidence of anything.\n        if (!against || !against.length) return;\n\n        var best = 0;\n        for (var k = 0; k < Math.min(against.length, 50); k++) {\n          var d = depthOn(against[k] && against[k].json, ref.segs);\n          if (d > best) best = d;\n          if (best === ref.segs.length) return;         // resolves on some item\n        }\n\n        var key = String(leaf[0]) + '|' + ref.text;\n        if (seen[key]) return;\n        seen[key] = true;\n\n        var missing = ref.segs[best];\n        var parent = best === 0\n          ? (ref.from ? '\"' + ref.from + '\"' : 'its input')\n          : ref.segs.slice(0, best).join('.');\n\n        out.push({\n          param: String(leaf[0]) || '(parameter)',\n          expr: ref.text,\n          missing: missing,\n          where: parent,\n          resolvedDepth: best,\n          items: against.length\n        });\n      });\n    });\n\n    return out;\n  }\n\n  // Every node in the run, not just the blamed one - a broken reference three\n  // steps upstream is what produced the empty field you are staring at.\n  function allBrokenRefs(exec) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var out = [];\n    Object.keys(rd).forEach(function (name) {\n      if (out.length >= 4) return;\n      brokenRefs(exec, name).forEach(function (b) {\n        if (out.length >= 4) return;\n        b.node = name;\n        out.push(b);\n      });\n    });\n    return out;\n  }\n\n  // Which node do we blame? The one n8n flagged, or the first that quietly\n  // emitted nothing.\n  function blame(exec) {\n    var res = (exec.data && exec.data.resultData) || {};\n    var rd = res.runData || {};\n    if (exec.status === 'error') {\n      var err = res.error || {};\n      return {\n        name: (err.node && err.node.name) || res.lastNodeExecuted,\n        why: 'failed', kind: 'failed', error: err\n      };\n    }\n    var ordered = Object.keys(rd).map(function (n) {\n      var runs = rd[n];\n      return { name: n, idx: (runs && runs[0] && runs[0].executionIndex) || 0, items: itemsOf(rd, n) };\n    }).sort(function (a, b) { return a.idx - b.idx; });\n\n    for (var i = 0; i < ordered.length; i++) {\n      var n = ordered[i];\n      if (n.items === null) continue;\n\n      if (!n.items.length) {\n        var type = nodeType(exec, n.name);\n        // A reducer emptying, or a trigger with nothing to hand on, is the\n        // workflow behaving - report it as such, not as a fault.\n        if (REDUCERS[type]) {\n          return { name: n.name, why: REDUCERS[type], kind: 'filtered', error: null };\n        }\n        if (!hasUpstream(exec, n.name)) {\n          return { name: n.name, why: 'had nothing to start from', kind: 'filtered', error: null };\n        }\n        return { name: n.name, why: 'produced 0 items', kind: 'broke', error: null };\n      }\n\n      var allBlank = true;\n      for (var k = 0; k < n.items.length; k++) if (!isBlank(n.items[k].json)) { allBlank = false; break; }\n      if (allBlank) return { name: n.name, why: 'produced empty output', kind: 'broke', error: null };\n    }\n    return null;\n  }\n\n  // Which node never ran as a result? Names the downstream victim for the list view.\n  function skipped(exec, blamedName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var conns = (exec.workflowData && exec.workflowData.connections) || {};\n    var outs = (conns[blamedName] && conns[blamedName].main) || [];\n    for (var m = 0; m < outs.length; m++) {\n      var branch = outs[m] || [];\n      for (var c = 0; c < branch.length; c++) {\n        if (branch[c] && branch[c].node && !rd[branch[c].node]) return branch[c].node;\n      }\n    }\n    return null;\n  }\n\n  function upstreamOf(exec, name) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var runs = rd[name];\n    if (runs && runs[0] && runs[0].source && runs[0].source[0] && runs[0].source[0].previousNode) {\n      return runs[0].source[0].previousNode;\n    }\n    var conns = (exec.workflowData && exec.workflowData.connections) || {};\n    for (var from in conns) {\n      var mains = (conns[from] && conns[from].main) || [];\n      for (var m = 0; m < mains.length; m++) {\n        var branch = mains[m] || [];\n        for (var c = 0; c < branch.length; c++) if (branch[c] && branch[c].node === name) return from;\n      }\n    }\n    return null;\n  }\n\n  function expressions(exec, nodeName) {\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    var def = null;\n    for (var i = 0; i < nodes.length; i++) if (nodes[i].name === nodeName) def = nodes[i];\n    if (!def || !def.parameters) return [];\n    var hits = [];\n    // Bounded, not Infinity: node parameters are workflow-supplied and a\n    // pathological node would otherwise flatten into millions of leaves.\n    flatten(def.parameters, '', [], 64).forEach(function (leaf) {\n      var raw = typeof leaf[1] === 'string' ? leaf[1] : '';\n      if (raw.indexOf('{{') !== -1 || raw.charAt(0) === '=') hits.push(leaf);\n    });\n    return hits.slice(0, 2);\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Provenance - claims the node could not have got from its input.\n   *\n   * Shape profiling catches output that looks wrong. It cannot catch output\n   * where every field is present, correctly typed, plausibly formatted, and\n   * simply invented. That is the expensive failure with a model in the loop:\n   * \"everything looks correct individually but it makes shit up\".\n   *\n   * No judge model and no ground truth are needed for the damaging half of\n   * it. n8n stores both sides of every node. If the output carries a phone\n   * number, an email domain, an id or a link that appears nowhere in what\n   * the node was handed, the model did not read that - it produced it.\n   *\n   * Deliberately restricted to tokens that should be COPIED rather than\n   * composed. A summary is free to paraphrase; an account number is not.\n   * ------------------------------------------------------------------ */\n\n  var CLAIM_MIN_DIGITS = 6;   // below this it is a quantity, a price, a year\n  var MAX_CLAIMS = 6;\n\n  function haystack(items) {\n    var parts = [];\n    (items || []).slice(0, 200).forEach(function (i) {\n      try { parts.push(JSON.stringify(i && i.json)); } catch (e) { /* skip */ }\n    });\n    return parts.join(' ').toLowerCase();\n  }\n\n  function claimsIn(json) {\n    var out = [];\n    flatten(json, '', [], 12).forEach(function (leaf) {\n      if (typeof leaf[1] !== 'string') return;\n      var s = leaf[1], m;\n\n      // Email: compare the DOMAIN, never the whole address. Enrichment\n      // legitimately composes first.last@domain from parts it was given, and\n      // flagging that would be exactly the false positive that gets a tool\n      // uninstalled. An unknown domain, though, was invented outright.\n      var re = /[^\\s@\"']+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})/g;\n      while ((m = re.exec(s)) !== null) {\n        out.push({ kind: 'email domain', value: m[1], field: leaf[0], full: m[0] });\n      }\n\n      var reU = /https?:\\/\\/([A-Za-z0-9.-]+)/gi;\n      while ((m = reU.exec(s)) !== null) {\n        out.push({ kind: 'link', value: m[1], field: leaf[0], full: m[0] });\n      }\n\n      var reN = /\\d[\\d\\s().-]{4,}\\d/g;\n      while ((m = reN.exec(s)) !== null) {\n        var digits = m[0].replace(/\\D/g, '');\n        if (digits.length < CLAIM_MIN_DIGITS) continue;\n        out.push({ kind: 'number', value: digits, field: leaf[0], full: m[0].trim() });\n      }\n    });\n    return out;\n  }\n\n  /* ---- AI root nodes ----------------------------------------------------\n   *\n   * An Agent's real context does not arrive down its main input. The model\n   * sees whatever its tools returned, whatever memory held, and whatever a\n   * vector store retrieved - all of which n8n stores on the SUB-nodes, under\n   * channels like ai_tool and ai_memory rather than main.\n   *\n   * Checking an agent's output against its main input alone would therefore\n   * call every correctly-retrieved fact an invention. Any RAG or tool-using\n   * agent would light up red on its first run.\n   *\n   * The channel names are read from the data rather than hardcoded: n8n keeps\n   * adding connection types, and a list baked in here would silently go stale\n   * and start producing exactly the false positives it was meant to prevent.\n   */\n  function itemsAnyChannel(runData, name) {\n    var runs = runData[name];\n    if (!runs || !runs.length) return null;\n    var items = [], stored = false;\n    for (var i = 0; i < runs.length; i++) {\n      var r = runs[i];\n      if (!r || !r.data) continue;\n      var channels = Object.keys(r.data);\n      for (var c = 0; c < channels.length; c++) {\n        var branches = r.data[channels[c]];\n        if (!Array.isArray(branches)) continue;\n        stored = true;\n        for (var b = 0; b < branches.length; b++) {\n          if (Array.isArray(branches[b])) items = items.concat(branches[b]);\n        }\n      }\n    }\n    return stored ? items : null;\n  }\n\n  // Sub-nodes wired into this node by anything other than a main connection.\n  function helpersOf(exec, nodeName) {\n    var conns = (exec.workflowData && exec.workflowData.connections) || {};\n    var out = [];\n    Object.keys(conns).forEach(function (from) {\n      var byType = conns[from] || {};\n      Object.keys(byType).forEach(function (type) {\n        if (type === 'main') return;\n        (byType[type] || []).forEach(function (branch) {\n          (branch || []).forEach(function (link) {\n            if (link && link.node === nodeName) out.push({ name: from, type: type });\n          });\n        });\n      });\n    });\n    return out;\n  }\n\n  // Everything the node could legitimately have drawn on.\n  function contextFor(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var ctx = [];\n\n    var up = upstreamOf(exec, nodeName);\n    var main = up ? itemsOf(rd, up) : null;\n    if (main) ctx = ctx.concat(main);\n\n    helpersOf(exec, nodeName).forEach(function (h) {\n      var got = itemsAnyChannel(rd, h.name);\n      if (got) ctx = ctx.concat(got);\n    });\n\n    // The agent's own intermediate steps hold the tool observations it acted\n    // on. Those are retrieved context, not invention.\n    var own = itemsOf(rd, nodeName) || [];\n    own.forEach(function (it) {\n      var steps = it && it.json && it.json.intermediateSteps;\n      if (Array.isArray(steps)) ctx.push({ json: steps });\n    });\n\n    return ctx;\n  }\n\n  // Only a model can \"make something up\". A Code node that mints an order id,\n  // or an HTTP node returning a reference number, is behaving normally - and\n  // asking where those digits came from produces an alert on nearly every\n  // healthy run. So provenance applies to nodes whose text a model wrote, and\n  // to nothing else.\n  var AI_HINT = /langchain|openai|anthropic|cohere|mistral|ollama|llm|agent|chatmodel/i;\n\n  function isModelNode(exec, nodeName) {\n    if (AI_HINT.test(nodeType(exec, nodeName))) return true;\n    // Whatever it is called, a node with a language model wired into it is\n    // producing model output.\n    var helpers = helpersOf(exec, nodeName);\n    for (var i = 0; i < helpers.length; i++) {\n      if (/languageModel|Model$/i.test(helpers[i].type)) return true;\n    }\n    return false;\n  }\n\n  function provenance(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var items = itemsOf(rd, nodeName);\n    if (!items || !items.length) return [];\n    if (!isModelNode(exec, nodeName)) return [];\n\n    var input = contextFor(exec, nodeName);\n    // With nothing to compare against, say nothing. A check that did not run\n    // must never read as a clean bill of health.\n    if (!input || !input.length) return [];\n\n    var hay = haystack(input);\n    if (!hay) return [];\n    var hayDigits = hay.replace(/\\D/g, '');\n\n    var miss = [], seen = dict();\n    items.slice(0, 50).forEach(function (it) {\n      claimsIn(it && it.json).forEach(function (c) {\n        var v = String(c.value).toLowerCase();\n        if (!v || seen[v]) return;\n        var found;\n        if (c.kind === 'number') {\n          // Digits are compared stripped, so \"+44 161 555 0123\" in the input\n          // covers \"441615550123\" in the output.\n          found = hayDigits.indexOf(v) !== -1;\n          // A phone number moving between international and national form -\n          // +44 161 555 0123 becoming (0161) 555-0123 - shares only its tail,\n          // because the trunk zero replaces the country code. Matching the\n          // last seven digits keeps that legitimate rewrite from reading as an\n          // invention. The cost is the occasional missed short id, which is\n          // the right way round: a false alarm here is what gets this muted.\n          if (!found && v.length >= 7) found = hayDigits.indexOf(v.slice(-7)) !== -1;\n        } else {\n          found = hay.indexOf(v) !== -1;\n        }\n        if (found) return;\n        seen[v] = true;\n        miss.push(c);\n      });\n    });\n    return miss.slice(0, MAX_CLAIMS);\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Run against previous run.\n   *\n   * Everything else here answers \"is something broken\", which is a question\n   * people ask rarely. While actually building a workflow they hit Execute\n   * dozens of times a day and ask a different one: did that change help?\n   *\n   * n8n answers it with a JSON tree and no memory of the last run, so the\n   * comparison happens in the builder's head. This does it properly: two\n   * executions of the same workflow, field by field.\n   *\n   * Deliberately NOT the drift profile. That learns \"normal\" over many runs\n   * and is the right tool for a silent regression in production. This is the\n   * opposite - the immediately previous run, no history needed, useful on the\n   * second execution of a workflow that is ten minutes old.\n   * ------------------------------------------------------------------ */\n\n  var TEXT_SHIFT = 0.25;    // report a text length move beyond this fraction\n\n  function leafMap(items) {\n    var map = dict();\n    if (!items || !items.length) return map;\n    flatten(items[0] && items[0].json, '', [], 12).forEach(function (leaf) {\n      map[String(leaf[0])] = leaf[1];\n    });\n    return map;\n  }\n\n  function compareRuns(prev, curr) {\n    var pNode = resultNode(prev), cNode = resultNode(curr);\n    var pItems = pNode ? resultItems(prev, pNode) : null;\n    var cItems = cNode ? resultItems(curr, cNode) : null;\n\n    var changes = [];\n\n    // Item count first: 50 rows becoming 3 matters more than any field.\n    var pn = pItems ? pItems.length : 0;\n    var cn = cItems ? cItems.length : 0;\n    if (pn !== cn) {\n      changes.push({ field: '(item count)', kind: 'count', was: String(pn), now: String(cn) });\n    }\n    // When the workflow stops producing where it used to, the result node\n    // falls back to an earlier one - and diffing that node's fields against\n    // the old one's compares two unrelated shapes. Every field reads as\n    // \"gone\", burying the single fact that matters: it stopped at a different\n    // place. Report the move and say nothing else.\n    if (pNode !== cNode) {\n      changes.unshift({ field: '(last node with output)', kind: 'moved',\n                        was: String(pNode || 'nothing'), now: String(cNode || 'nothing') });\n      return { node: cNode, changes: changes, identical: false, nodeMoved: true };\n    }\n\n    var a = leafMap(pItems), b = leafMap(cItems);\n    var keys = [], seenKey = dict();\n    Object.keys(a).concat(Object.keys(b)).forEach(function (k) {\n      if (!seenKey[k]) { seenKey[k] = true; keys.push(k); }\n    });\n\n    keys.forEach(function (k) {\n      var was = a[k], now = b[k];\n      var hadIt = k in a, hasIt = k in b;\n\n      if (hadIt && !hasIt) {\n        changes.push({ field: k, kind: 'gone', was: show(was, 40), now: '\u2014' });\n        return;\n      }\n      if (!hadIt && hasIt) {\n        changes.push({ field: k, kind: 'new', was: '\u2014', now: show(now, 40) });\n        return;\n      }\n      if (isBlank(was) && !isBlank(now)) {\n        changes.push({ field: k, kind: 'filled', was: 'empty', now: show(now, 40) });\n        return;\n      }\n      if (!isBlank(was) && isBlank(now)) {\n        changes.push({ field: k, kind: 'emptied', was: show(was, 40), now: 'empty' });\n        return;\n      }\n      if (typeOf(was) !== typeOf(now)) {\n        changes.push({ field: k, kind: 'type', was: typeOf(was), now: typeOf(now) + ' ' + show(now, 30) });\n        return;\n      }\n      if (typeof was === 'string' && typeof now === 'string') {\n        if (was === now) return;\n        // Long text rewords on every run of anything with a model in it.\n        // Reporting the whole string as \"changed\" would drown the real\n        // signal, so past a certain length only a size move is worth saying.\n        if (was.length > 80 || now.length > 80) {\n          var shift = Math.abs(now.length - was.length) / Math.max(1, was.length);\n          if (shift >= TEXT_SHIFT) {\n            changes.push({ field: k, kind: now.length < was.length ? 'shorter' : 'longer',\n                           was: was.length + ' chars', now: now.length + ' chars: ' + show(now, 50) });\n          }\n          return;\n        }\n        changes.push({ field: k, kind: 'changed', was: show(was, 40), now: show(now, 40) });\n        return;\n      }\n      if (was !== now) {\n        changes.push({ field: k, kind: 'changed', was: show(was, 40), now: show(now, 40) });\n      }\n    });\n\n    return { node: cNode, changes: changes, identical: changes.length === 0 };\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Diagnosis - which KIND of wrong.\n   *\n   * From a user describing how he debugs an agent by hand: \"that usually\n   * helps pinpoint whether it's hallucinating, using bad context, or just\n   * making the wrong decision from correct data.\"\n   *\n   * Those three branches need different fixes - a retrieval bug, a prompt\n   * bug, and a model bug are not the same afternoon of work - and knowing\n   * which one you are in is most of the job. n8n stores everything needed to\n   * separate them: the ask, what came back from the tools, and the answer.\n   *\n   * The third branch is the honest limit. \"Correct data, wrong conclusion\"\n   * needs to know the right answer. What IS detectable is the subset where\n   * the answer contradicts its own source - every field plausible, one of\n   * them disagreeing with the document it came from.\n   * ------------------------------------------------------------------ */\n\n  var ID_RE = /\\b[A-Z][A-Z0-9]{1,}-\\d{2,}\\b/g;      // INV-8842, ACC-88421905\n  var NUM_RE = /\\b\\d{4,}\\b/g;                        // order numbers, ids\n  var MAIL_RE = /[^\\s@\"']+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g;\n  var URL_RE = /https?:\\/\\/[^\\s\"',]+/g;\n\n  function isYear(s) {\n    var n = Number(s);\n    return s.length === 4 && n >= 1900 && n <= 2100;\n  }\n\n  // The specifics a request is *about*. Common words are useless here - only\n  // things that must appear verbatim if the right record was found.\n  function distinctiveTokens(items) {\n    var text = haystack(items);\n    var raw = [];\n    [ID_RE, NUM_RE, MAIL_RE, URL_RE].forEach(function (re) {\n      re.lastIndex = 0;\n      var m;\n      while ((m = re.exec(text)) !== null) raw.push(m[0]);\n    });\n    var out = dict(), list = [];\n    raw.forEach(function (t) {\n      var k = String(t).toLowerCase();\n      if (isYear(k)) return;\n      if (out[k]) return;\n      out[k] = true;\n      list.push(k);\n    });\n    return list;\n  }\n\n  // Main input versus what the sub-nodes actually returned. contextFor() folds\n  // these together, which is right for grounding and wrong here: the whole\n  // question is whether the retrieval covered the ask.\n  function splitContext(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var up = upstreamOf(exec, nodeName);\n    var ask = (up ? itemsOf(rd, up) : null) || [];\n\n    var retrieved = [], failedTools = [], toolNodes = 0;\n    helpersOf(exec, nodeName).forEach(function (h) {\n      // A language model is not a source of facts - it is the thing being\n      // checked. Only tools, retrievers, vector stores and memory count.\n      if (/languageModel|outputParser/i.test(h.type)) return;\n      toolNodes++;\n      var runs = rd[h.name] || [];\n      var errored = runs.some(function (r) { return r && r.error; });\n      if (errored) failedTools.push(h.name);\n      var got = itemsAnyChannel(rd, h.name);\n      if (got) retrieved = retrieved.concat(got);\n    });\n\n    // Observations inside intermediateSteps are retrieved context too, and on\n    // a default-configured agent they are the only record of a tool result.\n    (itemsOf(rd, nodeName) || []).forEach(function (it) {\n      var steps = it && it.json && it.json.intermediateSteps;\n      if (!Array.isArray(steps)) return;\n      if (!toolNodes) toolNodes = steps.length ? 1 : 0;\n      steps.forEach(function (s) {\n        if (s && s.observation !== undefined) retrieved.push({ json: s.observation });\n      });\n    });\n\n    return { ask: ask, retrieved: retrieved, toolNodes: toolNodes, failedTools: failedTools };\n  }\n\n  function norm(v) {\n    return String(v).toLowerCase().replace(/[^a-z0-9]+/g, '');\n  }\n\n  // Leaf field name -> every value seen under it. Keyed on the last path\n  // segment so item[3].tier and tier are the same field.\n  function fieldValues(items) {\n    var map = dict();\n    (items || []).slice(0, 100).forEach(function (it) {\n      flatten(it && it.json, '', [], 12).forEach(function (leaf) {\n        var v = leaf[1];\n        if (v === null || v === undefined || typeof v === 'object') return;\n        var s = String(v);\n        // Free text cannot contradict anything usefully, and one-character\n        // values collide with everything.\n        if (s.length < 2 || s.length > 60) return;\n        var path = String(leaf[0]);\n        var key = path.split(/[.:]/).pop().replace(/\\[\\d*\\]/g, '');\n        if (!key) return;\n        (map[key] = map[key] || []).push(norm(s));\n      });\n    });\n    return map;\n  }\n\n  function diagnose(exec, nodeName) {\n    if (!isModelNode(exec, nodeName)) return null;\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var answer = itemsOf(rd, nodeName);\n    if (!answer || !answer.length) return null;\n\n    var split = splitContext(exec, nodeName);\n\n    // 1. A tool blew up and the agent answered anyway. Root cause, not a\n    //    symptom - everything downstream of this is explained by it.\n    if (split.failedTools.length) {\n      return {\n        branch: 'bad context',\n        why: 'a tool it depends on failed, and it answered anyway',\n        evidence: split.failedTools.map(function (n) { return n + ' errored'; })\n      };\n    }\n\n    // 2. Tools were wired in and returned nothing at all.\n    if (split.toolNodes && !split.retrieved.length) {\n      return {\n        branch: 'bad context',\n        why: 'nothing came back from its tools, and it answered anyway',\n        evidence: ['no retrieved context in this run']\n      };\n    }\n\n    // 3. The ask names specifics the retrieval never found. Only judged when\n    //    there was retrieval to judge and the ask HAS specifics - otherwise\n    //    the check has not run and must say nothing.\n    if (split.toolNodes && split.retrieved.length) {\n      var wanted = distinctiveTokens(split.ask);\n      if (wanted.length) {\n        var have = haystack(split.retrieved);\n        var haveDigits = have.replace(/\\D/g, '');\n        var missed = wanted.filter(function (t) {\n          if (/^\\d+$/.test(t)) return haveDigits.indexOf(t) === -1;\n          return have.indexOf(t) === -1;\n        });\n        if (missed.length === wanted.length) {\n          return {\n            branch: 'bad context',\n            why: 'what it was asked about never appears in what it retrieved',\n            evidence: missed.slice(0, 4).map(function (t) { return '\"' + t + '\" not in any retrieved document'; })\n          };\n        }\n      }\n    }\n\n    // 4. The answer disagrees with its own source. This is the detectable\n    //    slice of \"everything looks correct individually\": each field is\n    //    plausible, one of them contradicts the record it came from.\n    var ctxAll = split.ask.concat(split.retrieved);\n    if (ctxAll.length) {\n      var ctxVals = fieldValues(ctxAll);\n      var ansVals = fieldValues(answer);\n      var clashes = [];\n      Object.keys(ansVals).forEach(function (k) {\n        var known = ctxVals[k];\n        if (!known || !known.length) return;          // field not in the source\n        // Many records may share a field; matching ANY of them is fine.\n        ansVals[k].forEach(function (v) {\n          if (!v || known.indexOf(v) !== -1) return;\n          if (known.length > 12) return;              // too varied to judge\n          clashes.push(k + ' = ' + v + ', but its source says '\n            + known.slice(0, 3).join(' / '));\n        });\n      });\n      if (clashes.length) {\n        return {\n          branch: 'wrong decision',\n          why: 'the answer contradicts the data it was given',\n          evidence: clashes.slice(0, 3)\n        };\n      }\n    }\n\n    // 5. Facts in the answer that came from nowhere.\n    var invented = provenance(exec, nodeName);\n    if (invented.length) {\n      return {\n        branch: 'hallucinated',\n        why: 'the answer contains details that appear in nothing it was given',\n        evidence: invented.slice(0, 3).map(function (c) {\n          return c.field + ': ' + c.full + ' (' + c.kind + ')';\n        })\n      };\n    }\n\n    return null;\n  }\n\n  /* ---- the backwards trace ----------------------------------------------\n   *\n   * \"I trace it backwards: what context it received, what it retrieved, which\n   * tools it called, and what instructions it followed.\" That is the slow\n   * step, done by hand, through nested JSON. n8n has all of it already.\n   *\n   * Two sources, because either can be absent: the agent's own\n   * intermediateSteps (only present when Return Intermediate Steps is on),\n   * and the sub-nodes' own stored runs (always there if the node ran).\n   */\n  function summarise(v, max) {\n    if (v === null || v === undefined) return '';\n    if (typeof v === 'string') return v.length > max ? v.slice(0, max) + '\u2026' : v;\n    return show(v, max);\n  }\n\n  function agentTrace(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var steps = [];\n\n    (itemsOf(rd, nodeName) || []).forEach(function (it) {\n      var list = it && it.json && it.json.intermediateSteps;\n      if (!Array.isArray(list)) return;\n      list.slice(0, 20).forEach(function (s) {\n        var a = (s && s.action) || {};\n        steps.push({\n          from: 'step',\n          tool: a.tool || a.toolName || 'unknown tool',\n          input: summarise(a.toolInput !== undefined ? a.toolInput : a.input, 120),\n          output: summarise(s && s.observation, 200)\n        });\n      });\n    });\n\n    // Sub-nodes that actually ran. Reported even when intermediateSteps is\n    // missing, which is the common case - the option is off by default, and\n    // it is also dropped entirely when streaming is enabled.\n    helpersOf(exec, nodeName).forEach(function (h) {\n      var runs = rd[h.name];\n      if (!runs || !runs.length) return;\n      var got = itemsAnyChannel(rd, h.name) || [];\n      steps.push({\n        from: 'node',\n        tool: h.name,\n        kind: String(h.type).replace(/^ai_/, ''),\n        calls: runs.length,\n        output: got.length ? summarise(got[0] && got[0].json, 200) : '(nothing stored)'\n      });\n    });\n\n    return steps;\n  }\n\n  // An Execute Workflow node that returns nothing is NOT a payload problem -\n  // the cause is inside the child workflow. Pointing at the parent's webhook\n  // fields here would be actively misleading.\n  function subWorkflow(exec, nodeName) {\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    for (var i = 0; i < nodes.length; i++) {\n      var n = nodes[i];\n      if (n.name !== nodeName) continue;\n      if (n.type !== 'n8n-nodes-base.executeWorkflow') return null;\n      var w = n.parameters && n.parameters.workflowId;\n      if (!w) return { id: null, name: null };\n      if (typeof w === 'string') return { id: w, name: null };\n      return { id: w.value || null, name: w.cachedResultName || null };\n    }\n    return null;\n  }\n\n  function analyze(exec) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var target = blame(exec);\n    if (!target || !target.name) return { clean: true };\n\n    var sub = subWorkflow(exec, target.name);\n    if (sub && target.why !== 'failed') {\n      return {\n        clean: false, target: target, err: {}, exprs: [], keys: [], context: [],\n        from: null, items: null, total: 0,\n        skipped: skipped(exec, target.name), sub: sub\n      };\n    }\n\n    var err = target.error || {};\n    var errText = [err.message, err.description].filter(Boolean).join(' ');\n    var exprs = expressions(exec, target.name);\n    var up = upstreamOf(exec, target.name);\n    var items = up ? itemsOf(rd, up) : null;\n    var keys = [], context = [];\n    var total = items ? items.length : 0;\n\n    if (items && items.length) {\n      var scan = Math.min(total, 200);\n      var leaves = [];\n      for (var i = 0; i < scan; i++) flatten(items[i].json, total > 1 ? 'item[' + i + ']' : '', leaves);\n\n      var hits = [], blanks = [];\n      leaves.forEach(function (leaf) {\n        var raw = typeof leaf[1] === 'string' ? leaf[1] : '';\n        if (raw.length > 3 && errText && errText.indexOf(raw) !== -1) hits.push(leaf);\n        else if (!leaf[2] && isBlank(leaf[1])) blanks.push(leaf);\n        else context.push(leaf);\n      });\n\n      function group(list, label) {\n        var by = dict(), order = [];\n        list.forEach(function (l) {\n          var k = String(l[0]).replace(/\\[\\d+\\]/g, '[]');\n          if (!by[k]) { by[k] = { path: k, first: l, n: 0 }; order.push(k); }\n          by[k].n++;\n        });\n        return order.map(function (k) {\n          var g = by[k];\n          if (total <= 1) return [[g.path, g.first[1], g.first[2]], label];\n          if (g.n === 1) return [[g.first[0], g.first[1], g.first[2]], label + ' (1 of ' + total + ' items)'];\n          return [[g.path, g.first[1], g.first[2]], label + ' in ' + g.n + ' of ' + total + ' items'];\n        });\n      }\n\n      keys = group(hits, 'appears in the error');\n      if (target.why !== 'failed' || (!keys.length && !exprs.length)) {\n        keys = keys.concat(group(blanks, 'empty'));\n      } else {\n        context = context.concat(blanks);\n      }\n\n      var ctxBy = dict(), ctxOrder = [];\n      context.forEach(function (c) {\n        var k = String(c[0]).replace(/\\[\\d+\\]/g, '[]');\n        if (!ctxBy[k]) { ctxBy[k] = [k, c[1], c[2]]; ctxOrder.push(k); }\n      });\n      var seen = dict();\n      context = ctxOrder.map(function (k) { return ctxBy[k]; }).filter(function (c) {\n        var top = String(c[0]).split(/[.[:]/)[0];\n        seen[top] = (seen[top] || 0) + 1;\n        return seen[top] <= 2;\n      }).slice(0, 4);\n    }\n\n    return {\n      clean: false, target: target, err: err, exprs: exprs, keys: keys,\n      context: context, from: up, items: items, total: total,\n      skipped: target.why !== 'failed' ? skipped(exec, target.name) : null\n    };\n  }\n\n  // One-line verdict for the executions list.\n  //   kind: 'error'    - n8n already told you\n  //         'silent'   - reports success, produced nothing, should not have\n  //         'filtered' - produced nothing on purpose (Filter, dedupe, no input)\n  function verdict(exec) {\n    var r = analyze(exec);\n    if (r.clean) return null;\n    if (r.target.why === 'failed') {\n      return {\n        kind: 'error', node: r.target.name,\n        text: r.target.name + ' failed' + (r.err.httpCode ? ' (' + r.err.httpCode + ')' : '')\n      };\n    }\n    if (r.sub) {\n      return {\n        kind: 'silent', node: r.target.name, key: null, sub: r.sub,\n        text: r.target.name + ' returned nothing from sub-workflow'\n      };\n    }\n    if (r.target.kind === 'filtered') {\n      return {\n        kind: 'filtered', node: r.target.name,\n        text: r.target.name + ' ' + r.target.why + (r.skipped ? ' \u2192 ' + r.skipped + ' never ran' : '')\n      };\n    }\n    // Naming the empty key is the whole point of the tool, so it belongs in\n    // the one-line verdict. \"Push to CRM never ran\" is the symptom three nodes\n    // downstream; \"extraction was empty\" is the thing you go and fix.\n    var key = null, klabel = '';\n    if (r.keys.length) { key = r.keys[0][0][0]; klabel = String(r.keys[0][1]); }\n\n    var text;\n    if (key && klabel.indexOf('empty') === 0) {\n      text = key + ' ' + klabel.replace(/^empty/, 'was empty')\n           + ' \u2192 ' + r.target.name + ' produced nothing';\n    } else if (r.skipped) {\n      text = r.target.name + ' ' + r.target.why + ' \u2192 ' + r.skipped + ' never ran';\n    } else {\n      text = r.target.name + ' ' + r.target.why;\n    }\n    return { kind: 'silent', text: text, node: r.target.name, key: key };\n  }\n\n  var REPLAY_METHODS = dict({ GET: 1, POST: 1, PUT: 1, PATCH: 1, DELETE: 1, HEAD: 1 });\n\n  // The webhook path comes out of workflowData, which is not trusted: n8n\n  // templates are shared and imported freely, and in a shared workspace someone\n  // else wrote the node you are debugging. A path of \"../rest/workflows\" would\n  // turn the Replay button into a one-click CSRF against the user's own n8n,\n  // authenticated with their session cookie, on a same-origin fetch. So the\n  // final URL is resolved and then checked to still be under /webhook/.\n  function webhookUrl(origin, rawPath) {\n    var clean = String(rawPath === undefined || rawPath === null ? '' : rawPath).trim();\n    if (!clean || clean.length > 512) return null;\n    if (/[?#\\\\]/.test(clean)) return null;\n\n    var decoded = clean;\n    try { decoded = decodeURIComponent(clean); } catch (e) { return null; }\n    if (decoded.indexOf('..') !== -1) return null;\n\n    var base, url;\n    try {\n      base = new URL(origin);\n      url = new URL('/webhook/' + clean.replace(/^\\/+/, ''), base);\n    } catch (e) { return null; }\n\n    if (url.origin !== base.origin) return null;\n    if (url.pathname.indexOf('/webhook/') !== 0) return null;\n    return url.href;\n  }\n\n  function replayTarget(exec, origin) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    var hook = null;\n    for (var i = 0; i < nodes.length; i++) if (nodes[i].type === 'n8n-nodes-base.webhook') hook = nodes[i];\n    if (!hook) return null;\n    var items = itemsOf(rd, hook.name);\n    if (!items || !items.length) return null;\n    var p = hook.parameters || {};\n\n    var url = webhookUrl(origin, p.path);\n    if (!url) return null;\n\n    var method = String(p.httpMethod || 'POST').toUpperCase();\n    if (!REPLAY_METHODS[method]) method = 'POST';\n\n    var payload = items[0].json || {};\n    return { url: url, method: method, body: payload.body === undefined ? {} : payload.body };\n  }\n\n  // n8n may hand back \"flatted\" (index-referenced) execution data.\n  function unflatten(arr) {\n    if (!Array.isArray(arr)) return arr;\n    var seen = {};\n    function ref(v) {\n      if (typeof v !== 'string') return v;\n      var i = Number(v);\n      return (String(i) === v && i >= 0 && i < arr.length) ? node(i) : v;\n    }\n    function node(i) {\n      if (seen[i] !== undefined) return seen[i];\n      var raw = arr[i];\n      if (raw === null || typeof raw !== 'object') { seen[i] = raw; return raw; }\n      var out = Array.isArray(raw) ? [] : {};\n      seen[i] = out;\n      if (Array.isArray(raw)) raw.forEach(function (v) { out.push(ref(v)); });\n      else Object.keys(raw).forEach(function (k) { out[k] = ref(raw[k]); });\n      return out;\n    }\n    return node(0);\n  }\n\n  function normalise(raw) {\n    var exec = (raw && raw.data && raw.data.resultData) ? raw : (raw && raw.data) ? raw.data : raw;\n    if (exec && typeof exec.data === 'string') {\n      try {\n        var parsed = JSON.parse(exec.data);\n        exec.data = Array.isArray(parsed) ? unflatten(parsed) : parsed;\n      } catch (e) { /* leave it; caller reports */ }\n    }\n    return exec;\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Output shape profiling - \"confidently wrong\" detection.\n   *\n   * An empty output is easy. The expensive failures are the ones that come\n   * back populated and plausible: an LLM answering \"I'm sorry, I can't help\n   * with that\" where JSON should be, a field that silently became null, an\n   * extraction that collapsed from 200 characters to 12.\n   *\n   * No judge model is needed for that, and no SDK wrapping either. n8n has\n   * already stored what this node's output normally looks like. Learn the\n   * shape from the runs that were fine, then flag the run that breaks it.\n   * ------------------------------------------------------------------ */\n\n  var MIN_PROFILE_RUNS = 8;     // below this, \"normal\" is not established\n  var MAX_ENUM = 6;             // a field with few distinct values is a set\n  var LEN_COLLAPSE = 0.4;       // 40% of the shortest ever seen\n  var MAX_FIELDS = 64;          // payload width is attacker-controlled\n\n  var FORMATS = dict({\n    email: /^[^@\\s]+@[^@\\s.]+\\.[^@\\s]+$/,\n    url: /^https?:\\/\\/[^\\s]+$/i,\n    isoDate: /^\\d{4}-\\d{2}-\\d{2}([T ]|$)/\n  });\n  var FORMAT_NAMES = Object.keys(FORMATS);\n\n  // Ranked by how sharply the finding points at a cause. A type flip or a\n  // field that was never empty going empty is unambiguous; a number drifting\n  // out of its usual band is the softest signal here.\n  var DRIFT_RANK = { type: 0, empty: 1, format: 2, unexpected: 3, shrank: 4, range: 5 };\n  var BIG_DROP = 50;\n\n  function rankOf(d) {\n    var r = DRIFT_RANK[d.kind];\n    if (r === undefined) r = 9;\n    // A text field that lost most of its body outranks everything but an\n    // outright type flip: it carries the refusal or the truncation verbatim,\n    // which is the line that makes the cause obvious to a human. Ranking it\n    // by kind alone buries it under the consequences it caused.\n    if (d.kind === 'shrank' && (d.drop || 0) >= BIG_DROP) r = 0.5;\n    return r;\n  }\n\n  function typeOf(v) {\n    if (v === null || v === undefined) return 'null';\n    if (Array.isArray(v)) return 'array';\n    return typeof v;\n  }\n\n  function formatsOf(v) {\n    var out = [];\n    if (typeof v === 'string') {\n      FORMAT_NAMES.forEach(function (f) { if (FORMATS[f].test(v)) out.push(f); });\n    }\n    return out;\n  }\n\n  // Fold one item's json into a profile. Call once per clean run.\n  function addToProfile(profile, json) {\n    profile = profile || { runs: 0, fields: dict() };\n    profile.runs++;\n    if (!json || typeof json !== 'object') return profile;\n\n    Object.keys(json).slice(0, MAX_FIELDS).forEach(function (k) {\n      var v = json[k];\n      var f = profile.fields[k];\n      if (!f) {\n        // Runs need not share a schema, so cap the total too - otherwise a\n        // node emitting dynamic keys grows this map on every single run.\n        if (Object.keys(profile.fields).length >= MAX_FIELDS) return;\n        f = profile.fields[k] = {\n          seen: 0, filled: 0, types: dict(), values: dict(), distinct: 0,\n          lenMin: null, lenMax: null, numMin: null, numMax: null, formats: dict()\n        };\n      }\n      f.seen++;\n      if (!isBlank(v)) f.filled++;\n\n      var t = typeOf(v);\n      f.types[t] = (f.types[t] || 0) + 1;\n\n      if (typeof v === 'string') {\n        f.lenMin = f.lenMin === null ? v.length : Math.min(f.lenMin, v.length);\n        f.lenMax = f.lenMax === null ? v.length : Math.max(f.lenMax, v.length);\n        if (f.distinct <= MAX_ENUM) {\n          if (f.values[v] === undefined) { f.values[v] = 0; f.distinct = Object.keys(f.values).length; }\n          f.values[v]++;\n        }\n        formatsOf(v).forEach(function (fmt) { f.formats[fmt] = (f.formats[fmt] || 0) + 1; });\n      } else if (typeof v === 'number' && isFinite(v)) {\n        f.numMin = f.numMin === null ? v : Math.min(f.numMin, v);\n        f.numMax = f.numMax === null ? v : Math.max(f.numMax, v);\n      }\n    });\n    return profile;\n  }\n\n  function dominantType(f) {\n    var best = null, n = 0, total = 0;\n    for (var t in f.types) { total += f.types[t]; if (f.types[t] > n) { n = f.types[t]; best = t; } }\n    return (total && n / total >= 0.9) ? best : null;\n  }\n\n  // What does this item break, compared with what the node normally emits?\n  // Every rule needs the field to have been consistent BEFORE, so a field that\n  // was always varied never trips anything.\n  // opts.pooled - the profile was built from SEVERAL workflows rather than\n  // this node's own past. Different workflows legitimately emit different\n  // field sets, so an absent field means \"this one does not use it\", not\n  // \"it went empty\". Judging absence against a pooled profile flags healthy\n  // workflows for the crime of being different, which is worse than useless.\n  function driftAgainst(profile, json, opts) {\n    var out = [];\n    if (!profile || profile.runs < MIN_PROFILE_RUNS || !json || typeof json !== 'object') return out;\n    var pooled = !!(opts && opts.pooled);\n\n    Object.keys(profile.fields).forEach(function (k) {\n      var f = profile.fields[k];\n      if (f.seen < profile.runs) return;             // not always present: skip entirely\n      if (pooled && !(k in json)) return;            // different schema, not a fault\n      var v = json[k];\n\n      // 1. always filled, now empty\n      if (f.filled === f.seen && isBlank(v)) {\n        out.push({ field: k, kind: 'empty', was: 'always filled', now: show(v, 30) });\n        return;\n      }\n      if (isBlank(v)) return;\n\n      // 2. type changed\n      var dom = dominantType(f);\n      var t = typeOf(v);\n      if (dom && t !== dom) {\n        out.push({ field: k, kind: 'type', was: dom, now: t + ' ' + show(v, 30) });\n        return;\n      }\n\n      if (typeof v === 'string') {\n        // 3. a value outside a small, stable set\n        if (f.distinct > 0 && f.distinct <= MAX_ENUM && f.values[v] === und
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

why? — watch this n8n. Uses httpRequest. Scheduled trigger; 7 nodes.

Source: https://github.com/widjajaanthony24-svg/why-n8n/blob/main/watcher/why-watch-n8n.json — original creator credit. Request a take-down →

More Web Scraping workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Web Scraping

Proactively alert to service endpoint changes and pod/container issues (Pending, Not Ready, Restart spikes) using Prometheus metrics, formatted and sent to Slack.

HTTP Request
Web Scraping

Tired of being let down by the Google Drive Trigger? Rather not exhaust system resources by polling every minute? Then this workflow is for you!

HTTP Request, Execute Workflow Trigger
Web Scraping

Triggers at a regular interval or via a webhook request. Solves AWS WAF challenge then makes a request to fetch the product page. Extracts product data from the retrieved HTML page. Compares the curre

N8N Nodes Capsolver, HTTP Request
Web Scraping

🔄 Monitor Container Images from Docker Hub or GHCR.

HTTP Request
Web Scraping

Automatically monitor billable Kimai projects every weekday morning and receive a formatted HTML email when a project deadline is approaching or its hour budget is running low. If nothing requires att

Email Send, HTTP Request