{
  "name": "Supplier price changes — start once or collect an existing comparison",
  "nodes": [
    {
      "id": "comparison-collector-1",
      "name": "Collect existing run",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        200
      ],
      "parameters": {}
    },
    {
      "id": "comparison-collector-2",
      "name": "Comparison settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        200
      ],
      "parameters": {
        "jsCode": "// Default: read an EXISTING comparison; no new Actor is started.\nreturn [{json:{mode:'resume',runId:'PASTE_EXISTING_RUN_ID',revision:'',beforePdfUrl:'',afterPdfUrl:'',currency:'USD',numberFormat:'decimal-dot',maxPages:20,maxTotalChargeUsd:0.25,confirmation:''}}];"
      }
    },
    {
      "id": "comparison-collector-3",
      "name": "Validate input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        200
      ],
      "parameters": {
        "jsCode": "return await (function initialize() {\n  const items = $input.all();\n  if (items.length !== 1) throw new Error('Collect one existing run per execution.');\n  const runId = items[0].json.runId;\n  if (typeof runId !== 'string' || !/^[A-Za-z0-9]{17}$/.test(runId)) {\n    throw new Error('Set runId to the 17-character ID of an existing Supplier Catalog Comparison run.');\n  }\n  return [{ json: { runId, actorId: 'mGKOcnAMxtbJ9eAc0', maxChecks: 18 } }];\n}).call(this);"
      }
    },
    {
      "id": "comparison-collector-4",
      "name": "Read existing run",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        200
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/actor-runs/' + $('Validate input').first().json.runId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "responseFormat": "json",
              "outputPropertyName": "body",
              "neverError": false
            }
          }
        }
      },
      "retryOnFail": false,
      "onError": "stopWorkflow",
      "notesInFlow": true,
      "notes": "Select your protected Apify Header Auth credential. Reads only; API/storage usage may apply."
    },
    {
      "id": "comparison-collector-5",
      "name": "Check run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        200
      ],
      "parameters": {
        "jsCode": "return await (function checkRun() {\n  const ctx = $('Validate input').first().json;\n  const response = $input.first().json;\n  if (response.statusCode !== 200) throw new Error('Run lookup failed. Resume this same run ID later.');\n  const run = response.body?.data;\n  if (!run || run.id !== ctx.runId || run.actId !== ctx.actorId) {\n    throw new Error('Run or Actor identity mismatch. No output will be collected.');\n  }\n  const checks = $runIndex + 1;\n  if (run.status === 'SUCCEEDED') {\n    if (!/^[A-Za-z0-9]{17}$/.test(run.defaultKeyValueStoreId || '')) {\n      throw new Error('Missing or invalid output store ID.');\n    }\n    return [{ json: { ...ctx, checks, status: run.status, storeId: run.defaultKeyValueStoreId } }];\n  }\n  const pending = ['READY', 'RUNNING', 'TIMING-OUT', 'ABORTING'];\n  if (!pending.includes(run.status)) {\n    throw new Error('Existing run ended with ' + String(run.status) + '. This workflow never starts a replacement.');\n  }\n  if (checks >= ctx.maxChecks) {\n    throw new Error('Collection paused after 18 checks. Resume the SAME run ID later; the cloud run was not stopped.');\n  }\n  return [{ json: { ...ctx, checks, status: run.status } }];\n}).call(this);"
      }
    },
    {
      "id": "comparison-collector-6",
      "name": "Run succeeded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1100,
        200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "success",
              "leftValue": "={{ $json.status }}",
              "rightValue": "SUCCEEDED",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "comparison-collector-7",
      "name": "Wait 10 seconds",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1100,
        420
      ],
      "parameters": {
        "resume": "timeInterval",
        "amount": 10,
        "unit": "seconds"
      },
      "webhookId": "0b9fd67d-2312-41f3-ae7f-c21b4ca4159e"
    },
    {
      "id": "comparison-collector-8",
      "name": "Successful run",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1320,
        100
      ],
      "parameters": {}
    },
    {
      "id": "comparison-collector-9",
      "name": "Read SUMMARY",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1540,
        100
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/key-value-stores/' + $('Successful run').first().json.storeId + '/records/SUMMARY' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "responseFormat": "json",
              "outputPropertyName": "body",
              "neverError": false
            }
          }
        }
      },
      "retryOnFail": false,
      "onError": "stopWorkflow",
      "notesInFlow": true,
      "notes": "Select your protected Apify Header Auth credential. Reads only; API/storage usage may apply."
    },
    {
      "id": "comparison-collector-10",
      "name": "Read REVIEW",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        100
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/key-value-stores/' + $('Successful run').first().json.storeId + '/records/REVIEW' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "responseFormat": "json",
              "outputPropertyName": "body",
              "neverError": false
            }
          }
        }
      },
      "retryOnFail": false,
      "onError": "stopWorkflow",
      "notesInFlow": true,
      "notes": "Select your protected Apify Header Auth credential. Reads only; API/storage usage may apply."
    },
    {
      "id": "comparison-collector-12",
      "name": "Read COMPARISON.csv",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2200,
        100
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/key-value-stores/' + $('Successful run').first().json.storeId + '/records/COMPARISON.csv' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "responseFormat": "text",
              "outputPropertyName": "body",
              "neverError": false
            }
          }
        }
      },
      "retryOnFail": false,
      "onError": "stopWorkflow",
      "notesInFlow": true,
      "notes": "Select your protected Apify Header Auth credential. Reads only; API/storage usage may apply."
    },
    {
      "id": "comparison-collector-13",
      "name": "Validated export",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2420,
        100
      ],
      "parameters": {
        "jsCode": "return await (async function exportResult() {\n  const ctx = $('Successful run').first().json;\n  function body(name) {\n    const r = $(name).first().json;\n    if (r.statusCode !== 200) throw new Error(name + ' was not available. Resume the same run ID.');\n    return r.body;\n  }\n  const summary = body('Read SUMMARY');\n  const review = body('Read REVIEW');\n  const documents = []; // Comparison diagnostics are in SUMMARY and REVIEW.\n  const csv = body('Read COMPARISON.csv');\n  if (!summary || typeof summary !== 'object' || Array.isArray(summary) ||\n      !Number.isInteger(summary.delivered_rows) || summary.delivered_rows < 0 || summary.delivered_rows > 10000) {\n    throw new Error('SUMMARY must contain an integer delivered_rows from 0 to 10000.');\n  }\n  if (!Array.isArray(review) || !Array.isArray(documents)) throw new Error('Invalid diagnostics format.');\n  if (typeof csv !== 'string' || Buffer.byteLength(csv, 'utf8') > 25 * 1024 * 1024) {\n    throw new Error('Missing CSV or export exceeds this collector\\'s 25 MiB processing limit.');\n  }\n  // Strict CSV parser: quoted commas/newlines, doubled quotes, CRLF, BOM.\n  // Do not split on newlines: supplier descriptions may themselves contain them.\n  const text = csv.replace(/^\\uFEFF/, '');\n  const rows = [];\n  let row = [], cell = '', quoted = false, closed = false;\n  function field() { row.push(cell); cell = ''; closed = false; }\n  function record() { field(); rows.push(row); row = []; }\n  for (let i = 0; i < text.length; i++) {\n    const ch = text[i];\n    if (quoted) {\n      if (ch === '\"') {\n        if (text[i + 1] === '\"') { cell += '\"'; i++; }\n        else { quoted = false; closed = true; }\n      } else cell += ch;\n      continue;\n    }\n    if (ch === ',') field();\n    else if (ch === '\\r' || ch === '\\n') {\n      if (ch === '\\r' && text[i + 1] === '\\n') i++;\n      record();\n    } else if (ch === '\"' && cell === '' && !closed) quoted = true;\n    else {\n      if (closed || ch === '\"') throw new Error('Malformed CSV quoting.');\n      cell += ch;\n    }\n  }\n  if (quoted) throw new Error('Unterminated CSV quoted field.');\n  if (row.length || cell.length || closed) record();\n  const header = rows.shift();\n  if (!header || new Set(header).size !== header.length ||\n      !['sku', 'change_type'].every(key => header.includes(key))) {\n    throw new Error('CSV lacks the expected unique product headers.');\n  }\n  if (rows.some(r => r.length !== header.length)) throw new Error('CSV column count mismatch.');\n  if (rows.length !== summary.delivered_rows) throw new Error('CSV record count differs from delivered_rows.');\n  const flags = [];\n  if (summary.budget_stopped) flags.push('spending_limit');\n  if (review.length) flags.push('review_records');\n  if (summary.status !== 'ok') flags.push('summary_status_' + String(summary.status || 'unknown'));\n  if (Array.isArray(summary.issues) && summary.issues.length) flags.push('summary_issues');\n  if (documents.some(d => !d || ['failed', 'error'].includes(d.status))) flags.push('document_failures');\n  if (rows.some(r => r.some(v => /^[\\t\\r\\n ]*[=+@-]/.test(v)))) flags.push('spreadsheet_formula_like_text');\n  const diagnostic = {\n    runId: ctx.runId, actorId: ctx.actorId, deliveredRows: rows.length,\n    collectionOnly: $('Comparison settings').first().json.mode === 'resume', destinationWriteApproved: false,\n    completeness: 'Not independently established; validate destination mapping and exception policy.',\n    flags, summary, review, documents,\n  };\n  return [{\n    json: diagnostic,\n    binary: {\n      products: await this.helpers.prepareBinaryData(Buffer.from(csv, 'utf8'), 'COMPARISON.csv', 'text/csv'),\n      diagnostics: await this.helpers.prepareBinaryData(Buffer.from(JSON.stringify(diagnostic, null, 2), 'utf8'), 'DIAGNOSTICS.json', 'application/json'),\n    },\n  }];\n}).call(this);"
      }
    },
    {
      "id": "comparison-validate-settings",
      "name": "Validate settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "parameters": {
        "jsCode": "const all=$input.all();if(all.length!==1)throw new Error('One revision per execution.');const c=all[0].json;\nif(!['start','resume'].includes(c.mode))throw new Error('Choose start or resume.');\nif(c.mode==='resume')return [{json:c}];\nif(c.runId && c.runId!=='PASTE_EXISTING_RUN_ID')throw new Error('Existing run ID present: choose resume.');\nif(c.confirmation!=='START_ONE_COMPARISON')throw new Error('Set confirmation to START_ONE_COMPARISON only after checking that this revision has not already run.');\nif(typeof c.revision!=='string'||!c.revision.trim()||c.revision.length>120)throw new Error('Set a supplier revision label. This is NOT an automatic deduplication lock.');\nfor(const key of ['beforePdfUrl','afterPdfUrl']){if(typeof c[key]!=='string'||!/^https:\\/\\/[A-Za-z0-9.-]+(?::443)?(?:\\/[^\\s]*)?$/.test(c[key]))throw new Error('Use authorized public HTTPS documents without URL credentials.');}\nif(c.beforePdfUrl===c.afterPdfUrl)throw new Error('Use two different catalog revisions.');\nif(!['USD','CAD','AUD','EUR','GBP'].includes(c.currency)||!['decimal-dot','decimal-comma'].includes(c.numberFormat))throw new Error('Choose a supported currency and number format.');\nif(!Number.isInteger(c.maxPages)||c.maxPages<1||c.maxPages>20)throw new Error('Choose 1 to 20 pages.');\nif(typeof c.maxTotalChargeUsd!=='number'||!Number.isFinite(c.maxTotalChargeUsd)||c.maxTotalChargeUsd<0.005||c.maxTotalChargeUsd>1)throw new Error('This starter accepts a positive budget from $0.005 to $1.');\nreturn [{json:{...c,input:{beforePdfUrl:c.beforePdfUrl,afterPdfUrl:c.afterPdfUrl,currency:c.currency,numberFormat:c.numberFormat,maxPages:c.maxPages,beforeTableMode:'ruled',afterTableMode:'ruled'}}}];"
      },
      "position": [
        440,
        -200
      ]
    },
    {
      "id": "comparison-start-requested?",
      "name": "Start requested?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "start",
              "leftValue": "={{ $json.mode }}",
              "rightValue": "start",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "position": [
        660,
        -200
      ]
    },
    {
      "id": "comparison-start-one-comparison",
      "name": "Start ONE comparison",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "parameters": {
        "method": "POST",
        "url": "https://api.apify.com/v2/actors/mGKOcnAMxtbJ9eAc0/runs",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "build",
              "value": "latest"
            },
            {
              "name": "maxTotalChargeUsd",
              "value": "={{ $json.maxTotalChargeUsd }}"
            },
            {
              "name": "timeout",
              "value": "120"
            },
            {
              "name": "memory",
              "value": "1024"
            },
            {
              "name": "restartOnError",
              "value": "false"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.input) }}",
        "options": {
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "responseFormat": "json",
              "neverError": false
            }
          }
        }
      },
      "position": [
        880,
        -200
      ],
      "retryOnFail": false,
      "onError": "stopWorkflow",
      "notesInFlow": true,
      "notes": "Chargeable start. Never retry this node or rerun the whole workflow after an uncertain response. Reconcile the existing run in Apify Console, then choose resume. A label is NOT durable deduplication."
    },
    {
      "id": "comparison-save-returned-run-id",
      "name": "Save returned run ID",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "parameters": {
        "jsCode": "const r=$input.first().json;const run=r.body?.data;const c=$('Validate settings').first().json;\nif(![200,201].includes(r.statusCode)||!run||!/^[A-Za-z0-9]{17}$/.test(run.id||'')||run.actId!=='mGKOcnAMxtbJ9eAc0')throw new Error('Start outcome uncertain. Reconcile in Apify Console; do not start again.');\nif(run.options?.maxTotalChargeUsd!==c.maxTotalChargeUsd)throw new Error('Accepted budget mismatch for run '+run.id+'. Inspect this run in Console; do not restart.');\nreturn [{json:{runId:run.id,revision:c.revision,acceptedBudget:c.maxTotalChargeUsd,recovery:'Save this ID outside execution history. Resume this same ID after collection errors.'}}];"
      },
      "position": [
        1100,
        -200
      ]
    },
    {
      "id": "comparison-read-before-use",
      "name": "Read before use",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "parameters": {
        "width": 690,
        "height": 450,
        "content": "## Manual start / resumable collection\nDefault mode resume reads an EXISTING comparison run. Bind protected Header Auth to all 5 HTTP nodes.\nFor ONE new revision: set mode=start, two authorized public ruled-table PDF URLs, currency, revision and confirmation=START_ONE_COMPARISON. Budget defaults to $0.25 (at most 50 charged comparisons at the listed rate).\nNever retry the start request. Save the returned run ID externally. After any uncertain start, reconcile in Console and use resume. No schedule, automatic deduplication or destination write is included.\nDownload COMPARISON.csv and DIAGNOSTICS.json from Validated export. Partial output and review records remain visible.\nSee the accompanying guide for validation status and limits."
      },
      "position": [
        0,
        -760
      ]
    }
  ],
  "connections": {
    "Collect existing run": {
      "main": [
        [
          {
            "node": "Comparison settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Comparison settings": {
      "main": [
        [
          {
            "node": "Validate settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate settings": {
      "main": [
        [
          {
            "node": "Start requested?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start requested?": {
      "main": [
        [
          {
            "node": "Start ONE comparison",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Validate input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start ONE comparison": {
      "main": [
        [
          {
            "node": "Save returned run ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save returned run ID": {
      "main": [
        [
          {
            "node": "Validate input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate input": {
      "main": [
        [
          {
            "node": "Read existing run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read existing run": {
      "main": [
        [
          {
            "node": "Check run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check run": {
      "main": [
        [
          {
            "node": "Run succeeded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run succeeded?": {
      "main": [
        [
          {
            "node": "Successful run",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait 10 seconds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 10 seconds": {
      "main": [
        [
          {
            "node": "Read existing run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Successful run": {
      "main": [
        [
          {
            "node": "Read SUMMARY",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read SUMMARY": {
      "main": [
        [
          {
            "node": "Read REVIEW",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read REVIEW": {
      "main": [
        [
          {
            "node": "Read COMPARISON.csv",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read COMPARISON.csv": {
      "main": [
        [
          {
            "node": "Validated export",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "pinData": {},
  "settings": {
    "executionOrder": "v1",
    "executionTimeout": 900,
    "saveManualExecutions": true
  },
  "tags": [],
  "id": "MaagCompare2026v1"
}
