{
  "name": "On-Page.ai - single-page SEO audit",
  "nodes": [
    {
      "id": "manual-trigger",
      "name": "Run audit",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [0, 0],
      "parameters": {}
    },
    {
      "id": "audit-inputs",
      "name": "Audit inputs",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [220, 0],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "url",
              "name": "url",
              "value": "https://api.on-page.ai/best-seo-api",
              "type": "string"
            },
            {
              "id": "keyword",
              "name": "keyword",
              "value": "best seo api",
              "type": "string"
            },
            {
              "id": "region",
              "name": "region",
              "value": "US",
              "type": "string"
            },
            {
              "id": "resumeJobId",
              "name": "resumeJobId",
              "value": "",
              "type": "string"
            },
            {
              "id": "requestKey",
              "name": "requestKey",
              "value": "={{ 'n8n-audit-' + $workflow.id + '-' + $execution.id }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "prepare-scan",
      "name": "Prepare scan",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [440, 0],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "if ($input.all().length !== 1) {\n  throw new Error(\"This workflow expects one page per execution.\");\n}\nconst input = $input.first().json;\nconst required = (name) => {\n  const value = input[name];\n  if (typeof value !== \"string\" || !value.trim()) throw new Error(\"Set \" + name + \".\");\n  return value.trim();\n};\nconst resumeJobId = String(input.resumeJobId ?? \"\").trim();\nif (resumeJobId && !/^job_[A-Za-z0-9_-]+$/.test(resumeJobId)) {\n  throw new Error(\"Use the job ID from the original scan response.\");\n}\nconst requestKey = resumeJobId ? String(input.requestKey ?? \"\") : required(\"requestKey\");\nif (!resumeJobId && (requestKey.length < 8 || requestKey.length > 200)) {\n  throw new Error(\"Use an idempotency key between 8 and 200 characters.\");\n}\nconst url = required(\"url\");\nif (!/^https?:\\/\\/[^\\s]+$/i.test(url)) throw new Error(\"Use a complete http or https URL.\");\nreturn [{ json: {\n  url, keyword: required(\"keyword\"), region: required(\"region\"),\n  requestKey, resumeJobId,\n} }];"
      }
    },
    {
      "id": "resume-job",
      "name": "Resume existing job?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [660, 0],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "boolean-check",
              "leftValue": "={{ $json.resumeJobId !== \"\" }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "submit-scan",
      "name": "Submit scan",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [880, 140],
      "parameters": {
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "method": "POST",
        "url": "https://api.on-page.ai/v1/scan",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.requestKey }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { url: $json.url, keyword: $json.keyword, region: $json.region } }}",
        "options": {
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      }
    },
    {
      "id": "track-job",
      "name": "Track job",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1100, 0],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function successfulBody(response, step) {\n  const status = response.statusCode;\n  if (typeof status !== \"number\") throw new Error(step + \": expected the HTTP response status and body.\");\n  if (status < 200 || status >= 300) {\n    const retryAfter = response.headers?.[\"retry-after\"];\n    const delay = retryAfter == null ? \"\" : \" Retry-After: \" + String(retryAfter) + \".\";\n    throw new Error(step + \" returned HTTP \" + status + \".\" + delay +\n      \" Keep the original scan key when retrying a submission, or resume by job ID after submission succeeded.\");\n  }\n  if (!response.body || typeof response.body !== \"object\" || Array.isArray(response.body)) {\n    throw new Error(step + \": expected a JSON object in the response body.\");\n  }\n  return response.body;\n}\n\nconst input = $(\"Prepare scan\").first().json;\nconst jobId = input.resumeJobId || successfulBody($input.first().json, \"Submit scan\").job_id;\nif (typeof jobId !== \"string\" || !/^job_[A-Za-z0-9_-]+$/.test(jobId)) {\n  throw new Error(\"The scan response did not contain a valid job_id.\");\n}\nreturn [{ json: { jobId, checks: 0, requestKey: input.requestKey } }];"
      }
    },
    {
      "id": "wait",
      "name": "Wait before check",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [1320, 0],
      "parameters": {
        "resume": "timeInterval",
        "amount": 15,
        "unit": "seconds"
      }
    },
    {
      "id": "check-status",
      "name": "Check status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1540, 0],
      "parameters": {
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "url": "={{ \"https://api.on-page.ai/v1/jobs/\" + encodeURIComponent($json.jobId) }}",
        "options": {
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      }
    },
    {
      "id": "read-status",
      "name": "Read status",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1760, 0],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function successfulBody(response, step) {\n  const status = response.statusCode;\n  if (typeof status !== \"number\") throw new Error(step + \": expected the HTTP response status and body.\");\n  if (status < 200 || status >= 300) {\n    const retryAfter = response.headers?.[\"retry-after\"];\n    const delay = retryAfter == null ? \"\" : \" Retry-After: \" + String(retryAfter) + \".\";\n    throw new Error(step + \" returned HTTP \" + status + \".\" + delay +\n      \" Keep the original scan key when retrying a submission, or resume by job ID after submission succeeded.\");\n  }\n  if (!response.body || typeof response.body !== \"object\" || Array.isArray(response.body)) {\n    throw new Error(step + \": expected a JSON object in the response body.\");\n  }\n  return response.body;\n}\n\nconst state = $(\"Wait before check\").item.json;\nconst response = successfulBody($input.first().json, \"Check status\");\nconst checks = state.checks + 1;\nconst status = response.status;\nif ([\"failed\", \"cancelled\"].includes(status)) {\n  throw new Error(\"Scan \" + state.jobId + \" ended with status \" + status + \". Inspect the job error before starting another scan.\");\n}\nif (![\"queued\", \"waiting\", \"running\", \"processing\", \"completed\"].includes(status)) {\n  throw new Error(\"Unexpected job status. Inspect the Check status node output.\");\n}\nif (status !== \"completed\" && checks >= 20) {\n  throw new Error(\"Stopped after 20 checks. Set resumeJobId to \" + state.jobId + \" in Audit inputs to keep checking the same job.\");\n}\nreturn [{ json: { ...state, checks, completed: status === \"completed\" } }];"
      }
    },
    {
      "id": "completed",
      "name": "Completed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1980, 0],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "boolean-check",
              "leftValue": "={{ $json.completed }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "fetch-report",
      "name": "Fetch report",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [2200, -100],
      "parameters": {
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "url": "={{ \"https://api.on-page.ai/v1/jobs/\" + encodeURIComponent($json.jobId) + \"/result?response_format=customer_v1\" }}",
        "options": {
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      }
    },
    {
      "id": "review-queue",
      "name": "Build review queue",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [2420, -100],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function buildOptimizer(report, { minCoverage = 0.5, ignoredTerms = [] } = {}) {\n  if (minCoverage < 0 || minCoverage > 1 || !Number.isFinite(minCoverage)) {\n    throw new Error(\"minCoverage must be between 0 and 1.\");\n  }\n  const entities = report.entity_coverage?.natural_language_entities;\n  const matrix = report.competitor_term_coverage;\n  if (!Array.isArray(entities) || !Array.isArray(matrix?.domains) || !Array.isArray(matrix?.terms)) {\n    throw new Error(\"Expected entity_coverage and competitor_term_coverage arrays.\");\n  }\n  const key = (text) => String(text).trim().toLowerCase();\n  const statuses = new Map(entities.map((row) => [key(row.entity), row.coverage_status]));\n  const ignored = new Set(ignoredTerms.map(key));\n  const seen = new Set();\n  const gaps = [];\n  for (const row of matrix.terms) {\n    const counts = row.competitor_counts;\n    if (!Array.isArray(counts) || counts.length !== matrix.domains.length ||\n        counts.some((n) => typeof n !== \"number\" || !Number.isFinite(n) || n < 0)) {\n      throw new Error(\"Competitor counts must align with domains and contain numbers.\");\n    }\n    const term = key(row.keyword);\n    if (!term || seen.has(term)) continue;\n    seen.add(term);\n    // Require both signals; present_not_entity is already present in the page.\n    if (statuses.get(term) !== \"missing\" || row.your_url_count !== 0 || ignored.has(term)) continue;\n    const competitorPages = counts.filter((n) => n > 0).length;\n    const comparedPages = counts.length;\n    if (!comparedPages || !competitorPages || competitorPages / comparedPages < minCoverage) continue;\n    gaps.push({\n      id: \"coverage:\" + term, term: row.keyword,\n      importance: Number.isFinite(row.importance) ? row.importance : 0,\n      competitorPages, comparedPages,\n      prevalence: competitorPages / comparedPages,\n      status: \"needs_review\",\n    });\n  }\n  gaps.sort((a, b) => b.prevalence - a.prevalence || b.importance - a.importance ||\n    (a.term < b.term ? -1 : a.term > b.term ? 1 : 0));\n  const gain = report.originality?.information_gain;\n  const ideas = gain?.potential_uncovered_topics_for_information_gain;\n  return {\n    jobId: report.jobId,\n    targetUrl: report.meta?.url ?? null,\n    keyword: report.meta?.target_keyword ?? null,\n    region: report.meta?.location ?? null,\n    score: report.on_page_optimization?.score ?? null,\n    scoreAlgorithm: report.on_page_optimization?.algorithm_version ?? null,\n    comparedPages: matrix.domains.length,\n    gaps,\n    originalIdeas: {\n      available: Array.isArray(ideas),\n      items: Array.isArray(ideas) ? [...new Set(ideas.filter((s) => typeof s === \"string\" && s.trim()))] : [],\n      uniqueDataPoints: gain?.unique_data_points ?? null,\n    },\n  };\n}\n\nfunction successfulBody(response, step) {\n  const status = response.statusCode;\n  if (typeof status !== \"number\") throw new Error(step + \": expected the HTTP response status and body.\");\n  if (status < 200 || status >= 300) {\n    const retryAfter = response.headers?.[\"retry-after\"];\n    const delay = retryAfter == null ? \"\" : \" Retry-After: \" + String(retryAfter) + \".\";\n    throw new Error(step + \" returned HTTP \" + status + \".\" + delay +\n      \" Keep the original scan key when retrying a submission, or resume by job ID after submission succeeded.\");\n  }\n  if (!response.body || typeof response.body !== \"object\" || Array.isArray(response.body)) {\n    throw new Error(step + \": expected a JSON object in the response body.\");\n  }\n  return response.body;\n}\n\nif ($input.all().length !== 1) throw new Error(\"Expected one completed report.\");\nconst input = $input.first().json;\nconst report = input.statusCode === undefined ? input : successfulBody(input, \"Fetch report\");\nif (!report.jobId || !report.meta?.url || !report.meta?.target_keyword) {\n  throw new Error(\"Expected the top-level customer_v1 report from the result endpoint.\");\n}\nconst review = buildOptimizer(report);\nreturn [{ json: { ...review, nextStep: \"editorial_review\" } }];"
      }
    }
  ],
  "connections": {
    "Run audit": {
      "main": [
        [
          {
            "node": "Audit inputs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Audit inputs": {
      "main": [
        [
          {
            "node": "Prepare scan",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare scan": {
      "main": [
        [
          {
            "node": "Resume existing job?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resume existing job?": {
      "main": [
        [
          {
            "node": "Track job",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Submit scan",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit scan": {
      "main": [
        [
          {
            "node": "Track job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Track job": {
      "main": [
        [
          {
            "node": "Wait before check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait before check": {
      "main": [
        [
          {
            "node": "Check status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check status": {
      "main": [
        [
          {
            "node": "Read status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read status": {
      "main": [
        [
          {
            "node": "Completed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Completed?": {
      "main": [
        [
          {
            "node": "Fetch report",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait before check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch report": {
      "main": [
        [
          {
            "node": "Build review queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "executionTimeout": 900
  }
}
