{
  "name": "URL list to website screenshots",
  "nodes": [
    {
      "parameters": {},
      "id": "node-1",
      "name": "Manual run",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [0, 300]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "mode",
              "name": "mode",
              "value": "snapshot",
              "type": "string"
            },
            {
              "id": "full-page",
              "name": "fullPage",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "urls",
              "name": "urlsJson",
              "value": "[{\"url\":\"https://example.com\",\"externalId\":\"home\"},{\"url\":\"https://example.org\",\"externalId\":\"docs\"},{\"url\":\"https://example.net\",\"externalId\":\"terms\"}]",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "node-2",
      "name": "Configure list",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [220, 300]
    },
    {
      "parameters": {
        "jsCode": "const mode = $json.mode;\nlet urls;\ntry {\n  urls = JSON.parse($json.urlsJson);\n} catch (error) {\n  throw new Error('urlsJson must be a JSON array.');\n}\nif (mode !== 'snapshot' && mode !== 'monitor') throw new Error('mode must be snapshot or monitor.');\nif (!Array.isArray(urls) || urls.length < 1 || urls.length > 20) throw new Error('Provide 1 to 20 public URLs.');\nfor (const item of urls) {\n  if (!item || typeof item.url !== 'string') throw new Error('Each entry needs a url string.');\n  let parsed;\n  try {\n    parsed = new URL(item.url);\n  } catch (error) {\n    throw new Error('URL is not valid.');\n  }\n  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('Only http and https URLs are accepted.');\n  if (parsed.username || parsed.password) throw new Error('Remove credentials from the URL.');\n}\nconst fullPage = $json.fullPage !== false;\nreturn [{json:{mode, input:{urls, mode, fullPage}}}];"
      },
      "id": "node-3",
      "name": "Validate URL list",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [440, 300]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.apify.com/v2/acts/automa-flow~website-screenshot-visual-monitor/runs?waitForFinish=180&memory=1024&timeout=600&maxTotalChargeUsd=0.04",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.input }}",
        "options": {
          "timeout": 200000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-4",
      "name": "Start Actor",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [660, 300]
    },
    {
      "parameters": {
        "jsCode": "const run = $input.first().json.data;\nif (!run || run.actId !== 'f4avoyY8bHwu0bPqV') throw new Error('Response was not a run of website-screenshot-visual-monitor.');\nif (run.status !== 'SUCCEEDED') throw new Error('Run status is ' + run.status + '. This workflow does not read a partial dataset.');\nif (!/^[A-Za-z0-9]{17}$/.test(run.defaultDatasetId || '')) throw new Error('Missing dataset ID.');\nreturn [{json:{runId:run.id, datasetId:run.defaultDatasetId}}];"
      },
      "id": "node-5",
      "name": "Require finished run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [880, 300]
    },
    {
      "parameters": {
        "url": "=https://api.apify.com/v2/datasets/{{ $json.datasetId }}/items?format=json&clean=false&limit=20",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "node-6",
      "name": "Read rows",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1100, 300]
    },
    {
      "parameters": {
        "jsCode": "const mode = $('Validate URL list').first().json.mode;\nconst items = $input.all().flatMap(item => Array.isArray(item.json) ? item.json : [item.json]);\nif (!items.length) throw new Error('Dataset was empty.');\nconst out = [];\nfor (const row of items) {\n  if (!row || typeof row !== 'object' || Array.isArray(row)) throw new Error('Dataset row was not an object.');\n  const base = {external_id: row.external_id || '', requested_url: row.requested_url || '', status: row.status || ''};\n  if (row.status !== 'SUCCESS') {\n    const error = row.error == null ? '' : (typeof row.error === 'string' ? row.error : JSON.stringify(row.error));\n    out.push({json:{row_kind:'problem', ...base, error}});\n    continue;\n  }\n  if (mode === 'snapshot') {\n    if (!row.screenshot_url) throw new Error('Successful row has no screenshot_url.');\n    out.push({json:{row_kind:'screenshot', ...base, final_url: row.final_url || '', screenshot_url: row.screenshot_url}});\n    continue;\n  }\n  if (row.change_type !== 'VISUAL_CHANGE') continue;\n  if (!row.screenshot_url || !row.previous_screenshot_url || !row.diff_image_url) throw new Error('VISUAL_CHANGE row is missing a before, after or diff URL.');\n  out.push({json:{row_kind:'visual_change', ...base, change_percentage: row.change_percentage, screenshot_url: row.screenshot_url, previous_screenshot_url: row.previous_screenshot_url, diff_image_url: row.diff_image_url}});\n}\nif (!out.length) {\n  if (mode === 'monitor') return [{json:{row_kind:'none', note:'No confirmed visual changes and no failed rows. Baselines and unchanged pages are omitted.'}}];\n  throw new Error('No screenshot or problem rows were produced.');\n}\nreturn out;"
      },
      "id": "node-7",
      "name": "Keep useful rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1320, 300]
    },
    {
      "parameters": {
        "content": "## URL list to website screenshots\n1. Import this inactive workflow.\n2. On both HTTP Request nodes, select HTTP Header Auth: Authorization / Bearer YOUR_APIFY_TOKEN. Store the token only in n8n credentials.\n3. Configure list: snapshot returns PNG links. monitor returns only confirmed visual changes, plus failed rows. Replace the sample URLs. 1 to 20 public http(s) URLs.\n4. The run uses 1024 MB, waits up to 180 seconds, and caps the Actor charge at $0.04. A still-running or failed run stops here and does not read a partial dataset.\n\nProblem rows are not screenshots. Unchanged pages and baselines produce no visual_change row.",
        "height": 280,
        "width": 520
      },
      "id": "node-8",
      "name": "Setup and limits",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [1540, 280]
    }
  ],
  "pinData": {},
  "connections": {
    "Manual run": {
      "main": [[{"node": "Configure list", "type": "main", "index": 0}]]
    },
    "Configure list": {
      "main": [[{"node": "Validate URL list", "type": "main", "index": 0}]]
    },
    "Validate URL list": {
      "main": [[{"node": "Start Actor", "type": "main", "index": 0}]]
    },
    "Start Actor": {
      "main": [[{"node": "Require finished run", "type": "main", "index": 0}]]
    },
    "Require finished run": {
      "main": [[{"node": "Read rows", "type": "main", "index": 0}]]
    },
    "Read rows": {
      "main": [[{"node": "Keep useful rows", "type": "main", "index": 0}]]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "tags": []
}
