AutomationFlowsAI & RAG › Generate M&a Due Diligence Reports with Decodo, Openai and Pinecone

Generate M&a Due Diligence Reports with Decodo, Openai and Pinecone

ByKhairul Muhtadin @khmuhtadin on n8n.io

Turn unstructured pitch decks and investment memos into polished Due Diligence PDF reports automatically. This n8n workflow handles everything from document ingestion to final delivery, combining internal document analysis with live web research to produce analyst-grade output…

Webhook trigger★★★★★ complexityAI-powered61 nodesHTTP RequestPinecone Vector StoreOpenAI EmbeddingsDocument Default Data LoaderAgentOpenAI ChatOutput Parser StructuredN8N Nodes Puppeteer
AI & RAG Trigger: Webhook Nodes: 61 Complexity: ★★★★★ AI nodes: yes Added:

This workflow corresponds to n8n.io template #13500 — we link there as the canonical source.

This workflow follows the Agent → Documentdefaultdataloader recipe pattern — see all workflows that pair these two integrations.

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": "M&A Decodo",
  "nodes": [
    {
      "id": "6688b756-2970-42f6-8e23-8e9153acc05b",
      "name": "Retrieve Parsed Content",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        848,
        -128
      ],
      "parameters": {
        "url": "=https://api.cloud.llamaindex.ai/api/v1/parsing/job/{{ $json.id }}/result/markdown",
        "options": {},
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "accept",
              "value": "application/json"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "d9480c54-6c62-487c-a2c3-84fcb5995c39",
      "name": "Receive Upload Request",
      "type": "n8n-nodes-base.webhook",
      "onError": "continueRegularOutput",
      "position": [
        -1280,
        -144
      ],
      "parameters": {
        "path": "0d39d242-6a1d-4748-8c91-46008a3e6d28",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "0d442877-1d5d-4de8-9bfc-cce6b9d3ac66",
      "name": "Split Uploaded Files + Build Deal ID",
      "type": "n8n-nodes-base.code",
      "position": [
        -1056,
        -144
      ],
      "parameters": {
        "jsCode": "// Split webhook binary files into separate items\n// Generate unified dealId from all filenames\n\nconst item = $input.first();\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\n// Parse filenames from body\nlet filenames = [];\ntry {\n  filenames = JSON.parse(body.filenames || '[]');\n} catch (e) {\n  // If not JSON, try to get from binary\n  filenames = Object.values(binary).map(b => b.fileName);\n}\n\n// Generate unified deal ID from sorted filenames\nconst combinedNames = filenames.sort().join('|');\nconst dealId = Buffer.from(combinedNames).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 20);\n\n// Get all binary keys\nconst binaryKeys = Object.keys(binary).filter(k => k.startsWith('data'));\n\nif (binaryKeys.length === 0) {\n  throw new Error('No binary files found in webhook request');\n}\n\n// Create one item per file\nconst items = binaryKeys.map((key, index) => {\n  const binaryData = binary[key];\n  const extension = (binaryData.fileExtension || '').toLowerCase();\n  \n  return {\n    json: {\n      dealId: dealId,\n      sourceFile: binaryData.fileName,\n      fileType: extension,\n      mimeType: binaryData.mimeType,\n      fileIndex: index,\n      totalFiles: binaryKeys.length\n    },\n    binary: {\n      data: binaryData\n    }\n  };\n});\n\nreturn items;"
      },
      "typeVersion": 2
    },
    {
      "id": "cd76b011-6dba-44fb-83a3-bf1bbe9e21d1",
      "name": "Iterate Files for Parsing",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -128,
        -128
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "ff4f824c-4a75-4dab-b7c1-4db4124c5f53",
      "name": "Get Pinecone Index Stats",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        -832,
        -144
      ],
      "parameters": {
        "url": "=https://[redacted url]/describe_index_stats",
        "method": "POST",
        "options": {},
        "jsonBody": {},
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "nodeCredentialType": "pineconeApi"
      },
      "credentials": {
        "pineconeApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.3,
      "waitBetweenTries": 2000
    },
    {
      "id": "fae9cf29-3210-425a-897d-2eb47f5c5eef",
      "name": "Upsert Chunks to Pinecone",
      "type": "@n8n/n8n-nodes-langchain.vectorStorePinecone",
      "position": [
        1232,
        -128
      ],
      "parameters": {
        "mode": "insert",
        "options": {
          "clearNamespace": false,
          "pineconeNamespace": "={{ $('Iterate Files for Parsing').item.json.dealId }}"
        },
        "pineconeIndex": {
          "__rl": true,
          "mode": "list",
          "value": "poc",
          "cachedResultName": "poc"
        }
      },
      "credentials": {
        "pineconeApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "2260f3e1-ac3e-40e4-a43c-1684faa7b72a",
      "name": "Generate Embeddings (Ingest)",
      "type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
      "position": [
        1232,
        -16
      ],
      "parameters": {
        "options": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "7d8dc719-ad64-44ec-beaa-aab69e357f1f",
      "name": "Prepare Parsed Text Document",
      "type": "@n8n/n8n-nodes-langchain.documentDefaultDataLoader",
      "position": [
        1424,
        -16
      ],
      "parameters": {
        "options": {
          "metadata": {
            "metadataValues": [
              {
                "name": "deal_id",
                "value": "={{ $json.dealId }}"
              },
              {
                "name": "source_file",
                "value": "={{ $json.sourceFile }}"
              },
              {
                "name": "file_type",
                "value": "={{ $json.fileType }}"
              },
              {
                "name": "timestamp",
                "value": "={{ $now.toUTC() }}"
              }
            ]
          }
        },
        "jsonData": "={{ $json.parsedText || \"\" }}",
        "jsonMode": "expressionData"
      },
      "typeVersion": 1.1
    },
    {
      "id": "e0dd6cd9-ddd3-4292-8fc1-a05501dede95",
      "name": "Collect Ingested Deal IDs",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        64,
        -624
      ],
      "parameters": {
        "options": {},
        "fieldsToAggregate": {
          "fieldToAggregate": [
            {
              "fieldToAggregate": "metadata.deal_id"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "411ddd47-8033-4951-b9ca-adeae3a50cca",
      "name": "Prepare Analysis Context",
      "type": "n8n-nodes-base.code",
      "position": [
        -144,
        -624
      ],
      "parameters": {
        "jsCode": "// Handle both paths: from cache hit or from aggregated embeddings\n  const items = $input.all();\n\n  let dealId;\n\n  // Check if coming from cache hit path\n  if (items[0]?.json?.cacheHit === true) {\n    dealId = items[0].json.dealId;\n  } else {\n    // Coming from aggregate embeddings path\n    const dealIdArray = items[0]?.json?.deal_id;\n    dealId = Array.isArray(dealIdArray) ? dealIdArray[0] : (dealIdArray || 'unknown');\n  }\n\n  return [{\n    json: {\n      dealId,\n      filesProcessed: items[0]?.json?.vectorCount || (Array.isArray(items[0]?.json?.deal_id)\n   ? items[0].json.deal_id.length : 1),\n      fromCache: items[0]?.json?.cacheHit || false,\n      timestamp: new Date().toISOString()\n    }\n  }];"
      },
      "typeVersion": 2
    },
    {
      "id": "c3cd7b37-7c90-4d46-8ff8-810615797343",
      "name": "Run Due Diligence AI Analysis",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        3472,
        -560
      ],
      "parameters": {
        "text": "=You are a Senior Investment Analyst & Due Diligence Officer.\n\nMANDATORY RETRIEVAL STRATEGY:\nYou MUST make MULTIPLE Pinecone queries to gather ALL required data. Do\nNOT rely on a single query.\n\nREQUIRED QUERIES (execute ALL before generating output):\n  1. \"company name headquarters location employees industry overview\"\n  2. \"revenue financial performance FY2021 FY2022 FY2023 FY2024 FY2025 yearly results\"\n  3. \"gross margin net margin EBITDA margin profitability percentage\"\n  4. \"risk factors key risks challenges threats founder dependency labor market client concentration\"\n  5. \"customer concentration top clients revenue breakdown percentage\"\n  6. \"business model investment thesis value proposition growth strategy\"\n\nSTRICT RULES:\n  - Execute ALL 6 queries before generating JSON output\n  - Combine and synthesize evidence from ALL queries\n  - Extract ALL years of financial data (2021-2025), not just recent year\n  - Include ALL risks mentioned in documents (typically 5-7 risks)\n  - For customer concentration, include specific percentages (Top 3, Top 5, Top 10)\n  - If data is genuinely missing after all queries, use \"Not Available\" (string) or 0 (number)\n  - Do not hallucinate - only use retrieved evidence\n\nOUTPUT FORMAT:\nReturn ONLY valid JSON matching the parser schema. No markdown, no explanation.\n\nOUTPUT TYPES:\n  - String fields: use \"Not Available\" when missing\n  - Numeric fields (employee_count, year, amount, ebitda): use 0 when missing\n  - key_risks: MUST be array of strings with ALL identified risks\n  - revenue_history: MUST include ALL available years (up to 5 years)\n\nEXTERNAL EVIDENCE USAGE:\n  - Always factor external web evidence inserted into Pinecone namespace for this deal.\n  - Use external signals to validate company profile, commercial signals, and risk/compliance posture.\n  - If external evidence coverage is low, keep conclusions conservative and explicitly acknowledge lower confidence.",
        "options": {},
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 3.1
    },
    {
      "id": "effa0a6c-1e52-44e5-bea8-5011faf7e908",
      "name": " OpenAI Chat Model (5-mini)",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        3328,
        -448
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5-mini",
          "cachedResultName": "gpt-5-mini"
        },
        "options": {},
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "02ce8124-3a2b-4a30-86b7-324863092101",
      "name": "Parse Structured Analysis JSON",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        3744,
        -448
      ],
      "parameters": {
        "jsonSchemaExample": "{\n  \"company_profile\": {\n    \"company_name\": \"Example Corp\",\n    \"industry\": \"Manufacturing\",\n    \"location\": \"Jakarta, Indonesia\",\n    \"employee_count\": 120\n  },\n  \"financials\": {\n    \"revenue_history\": [\n      { \"year\": 2023, \"amount\": 1200000, \"currency\": \"USD\" },\n      { \"year\": 2024, \"amount\": 1400000, \"currency\": \"USD\" },\n      { \"year\": 2025, \"amount\": 1600000, \"currency\": \"USD\" }\n    ],\n    \"ebitda\": 250000,\n    \"margins\": {\n      \"gross_margin\": \"42%\",\n      \"net_margin\": \"11%\",\n      \"ebitda_margin\": \"18%\"\n    }\n  },\n  \"analysis\": {\n    \"business_model\": \"B2B recurring contracts\",\n    \"investment_thesis\": \"Strong growth with expanding margins\",\n    \"key_risks\": [\n      \"Customer concentration\",\n      \"FX exposure\"\n    ],\n    \"customer_concentration\": \"Top 3 customers contribute 55% revenue\"\n  }\n}"
      },
      "typeVersion": 1.3
    },
    {
      "id": "f0ce179e-eb36-4fc8-b409-16f24d109d6f",
      "name": " Generate Embeddings (Retrieval)",
      "type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
      "position": [
        3568,
        -352
      ],
      "parameters": {
        "options": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "0eff02e2-045f-47fc-8964-15f977775d0b",
      "name": "Map Analysis to Report Fields",
      "type": "n8n-nodes-base.code",
      "position": [
        3952,
        -512
      ],
      "parameters": {
        "jsCode": "const raw = $input.first()?.json ?? {};\nconst data = raw.output && typeof raw.output === 'object' ? raw.output : raw;\n\nconst companyProfile = data.company_profile && typeof data.company_profile === 'object' ? data.company_profile : {};\nconst financials = data.financials && typeof data.financials === 'object' ? data.financials : {};\nconst analysis = data.analysis && typeof data.analysis === 'object' ? data.analysis : {};\nconst margins = financials.margins && typeof financials.margins === 'object' ? financials.margins : {};\n\nconst isPresent = (value) => value !== null && value !== undefined && String(value).trim() !== '';\n\nlet companyName = 'Not Available';\nif (isPresent(companyProfile.company_name)) companyName = String(companyProfile.company_name);\n\nlet industry = 'Not Available';\nif (isPresent(companyProfile.industry)) industry = String(companyProfile.industry);\n\nlet location = 'Not Available';\nif (isPresent(companyProfile.location)) location = String(companyProfile.location);\n\nlet employeeCount = 'Not Available';\nif (typeof companyProfile.employee_count === 'number' && Number.isFinite(companyProfile.employee_count) && companyProfile.employee_count !== 0) {\n  employeeCount = companyProfile.employee_count.toLocaleString('en-US');\n} else if (isPresent(companyProfile.employee_count) && String(companyProfile.employee_count).trim() !== '0') {\n  employeeCount = String(companyProfile.employee_count).trim();\n}\n\nconst escapeHtml = (value) => String(value)\n  .replace(/&/g, '&amp;')\n  .replace(/</g, '&lt;')\n  .replace(/>/g, '&gt;')\n  .replace(/\"/g, '&quot;')\n  .replace(/'/g, '&#39;');\n\nlet revenueTableRows = '<tr><td colspan=\"3\" class=\"na-value\">No revenue data available</td></tr>';\nif (Array.isArray(financials.revenue_history) && financials.revenue_history.length > 0) {\n  const rows = [];\n  for (const row of financials.revenue_history) {\n    const year = isPresent(row?.year) ? String(row.year) : 'N/A';\n\n    let amount = 'Not Available';\n    if (typeof row?.amount === 'number' && Number.isFinite(row.amount) && row.amount !== 0) {\n      amount = row.amount.toLocaleString('en-US');\n    } else if (isPresent(row?.amount) && String(row.amount).trim() !== '0') {\n      amount = String(row.amount).trim();\n    }\n\n    const currency = isPresent(row?.currency) ? String(row.currency) : 'USD';\n    rows.push('<tr><td>' + escapeHtml(year) + '</td><td>' + escapeHtml(amount) + '</td><td>' + escapeHtml(currency) + '</td></tr>');\n  }\n  revenueTableRows = rows.join('');\n}\n\nlet ebitda = 'Not Available';\nif (typeof financials.ebitda === 'number' && Number.isFinite(financials.ebitda) && financials.ebitda !== 0) {\n  ebitda = financials.ebitda.toLocaleString('en-US');\n} else if (isPresent(financials.ebitda) && String(financials.ebitda).trim() !== '0') {\n  ebitda = String(financials.ebitda).trim();\n}\n\nconst grossMargin = isPresent(margins.gross_margin) ? String(margins.gross_margin) : 'Not Available';\nconst netMargin = isPresent(margins.net_margin) ? String(margins.net_margin) : 'Not Available';\nconst ebitdaMargin = isPresent(margins.ebitda_margin) ? String(margins.ebitda_margin) : 'Not Available';\n\nconst businessModel = isPresent(analysis.business_model) ? String(analysis.business_model) : 'Not Available';\nconst investmentThesis = isPresent(analysis.investment_thesis) ? String(analysis.investment_thesis) : 'Not Available';\nconst customerConcentration = isPresent(analysis.customer_concentration) ? String(analysis.customer_concentration) : 'Not Available';\n\nlet riskListItems = '<li class=\"risk-item\"><span class=\"risk-icon\">-</span><span class=\"risk-text\">No specific risks identified in the document</span></li>';\nif (Array.isArray(analysis.key_risks)) {\n  const riskRows = [];\n  let riskIndex = 1;\n  for (const risk of analysis.key_risks) {\n    if (!isPresent(risk)) continue;\n    riskRows.push('<li class=\"risk-item\"><span class=\"risk-icon\">' + String(riskIndex) + '</span><span class=\"risk-text\">' + escapeHtml(String(risk)) + '</span></li>');\n    riskIndex += 1;\n  }\n  if (riskRows.length > 0) {\n    riskListItems = riskRows.join('');\n  }\n}\n\nlet dealId = 'N/A';\nconst ctx = $('Prepare Analysis Context').first();\nif (ctx?.json?.dealId) dealId = String(ctx.json.dealId);\n\nconst reportDate = DateTime.now().toFormat(\"MMMM dd, yyyy 'at' HH:mm\");\n\nreturn [{\n  json: {\n    companyName,\n    industry,\n    location,\n    employeeCount,\n    revenueTableRows,\n    ebitda,\n    grossMargin,\n    netMargin,\n    ebitdaMargin,\n    businessModel,\n    investmentThesis,\n    riskListItems,\n    customerConcentration,\n    dealId,\n    reportDate,\n  },\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "0773b0d5-709a-42c5-a40f-4d4ed64e6b99",
      "name": "Render DD Report HTML",
      "type": "n8n-nodes-base.html",
      "position": [
        3584,
        -208
      ],
      "parameters": {
        "html": "=<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Due Diligence Report - {{ $json.companyName }}</title>\n    <style>\n        * { margin: 0; padding: 0; box-sizing: border-box; }\n        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; background-color: #ffffff; color: #1e293b; line-height: 1.6; padding: 40px; max-width: 800px; margin: 0 auto; }\n        .header { border-bottom: 3px solid #0f766e; padding-bottom: 24px; margin-bottom: 32px; }\n        .header h1 { font-size: 28px; color: #0f766e; margin-bottom: 8px; }\n        .header .subtitle { font-size: 14px; color: #64748b; }\n        .header .date { font-size: 12px; color: #94a3b8; margin-top: 4px; }\n        .section { margin-bottom: 32px; }\n        .section-title { font-size: 18px; font-weight: 700; color: #0f766e; border-left: 4px solid #0f766e; padding-left: 12px; margin-bottom: 16px; }\n        .card { background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin-bottom: 16px; }\n        .info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }\n        .info-item { padding: 12px; background-color: #ffffff; border-radius: 6px; border: 1px solid #e2e8f0; }\n        .info-label { font-size: 11px; text-transform: uppercase; color: #64748b; letter-spacing: 0.5px; margin-bottom: 4px; }\n        .info-value { font-size: 16px; font-weight: 600; color: #1e293b; }\n        table { width: 100%; border-collapse: collapse; margin-top: 12px; }\n        th { background-color: #0f766e; color: #ffffff; font-size: 12px; font-weight: 600; text-align: left; padding: 12px; text-transform: uppercase; letter-spacing: 0.5px; }\n        td { padding: 12px; border-bottom: 1px solid #e2e8f0; font-size: 14px; }\n        tr:nth-child(even) { background-color: #f8fafc; }\n        .metric-row { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #e2e8f0; }\n        .metric-row:last-child { border-bottom: none; }\n        .metric-label { color: #475569; font-size: 14px; }\n        .metric-value { font-weight: 600; color: #1e293b; }\n        .text-content { font-size: 14px; color: #475569; line-height: 1.8; white-space: pre-line; }\n        .risk-list { list-style: none; padding: 0; }\n        .risk-item { display: flex; align-items: flex-start; padding: 12px; background-color: #fef2f2; border-left: 4px solid #ef4444; margin-bottom: 8px; border-radius: 0 6px 6px 0; }\n        .risk-icon { width: 20px; height: 20px; background-color: #ef4444; color: #ffffff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; margin-right: 12px; flex-shrink: 0; }\n        .risk-text { font-size: 14px; color: #7f1d1d; }\n        .highlight-box { background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border: 1px solid #10b981; border-radius: 8px; padding: 20px; }\n        .highlight-content { font-size: 14px; color: #047857; line-height: 1.7; }\n        .footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #e2e8f0; text-align: center; font-size: 11px; color: #94a3b8; }\n        .na-value { color: #94a3b8; font-style: italic; }\n    </style>\n</head>\n<body>\n    <div class=\"header\">\n        <h1>Due Diligence Report</h1>\n        <div class=\"subtitle\">{{ $json.companyName }}</div>\n        <div class=\"date\">Generated: {{ $json.reportDate }}</div>\n    </div>\n    <div class=\"section\">\n        <h2 class=\"section-title\">Company Overview</h2>\n        <div class=\"card\">\n            <div class=\"info-grid\">\n                <div class=\"info-item\"><div class=\"info-label\">Company Name</div><div class=\"info-value\">{{ $json.companyName }}</div></div>\n                <div class=\"info-item\"><div class=\"info-label\">Industry</div><div class=\"info-value\">{{ $json.industry }}</div></div>\n                <div class=\"info-item\"><div class=\"info-label\">Location</div><div class=\"info-value\">{{ $json.location }}</div></div>\n                <div class=\"info-item\"><div class=\"info-label\">Employee Count</div><div class=\"info-value\">{{ $json.employeeCount }}</div></div>\n            </div>\n        </div>\n    </div>\n    <div class=\"section\">\n        <h2 class=\"section-title\">Financial Summary</h2>\n        <div class=\"card\">\n            <h3 style=\"font-size: 14px; color: #475569; margin-bottom: 12px;\">Revenue History</h3>\n            <table><thead><tr><th>Year</th><th>Revenue</th><th>Currency</th></tr></thead><tbody>{{ $json.revenueTableRows }}</tbody></table>\n        </div>\n        <div class=\"card\">\n            <h3 style=\"font-size: 14px; color: #475569; margin-bottom: 12px;\">Key Metrics</h3>\n            <div class=\"metric-row\"><span class=\"metric-label\">EBITDA</span><span class=\"metric-value\">{{ $json.ebitda }}</span></div>\n            <div class=\"metric-row\"><span class=\"metric-label\">Gross Margin</span><span class=\"metric-value\">{{ $json.grossMargin }}</span></div>\n            <div class=\"metric-row\"><span class=\"metric-label\">Net Margin</span><span class=\"metric-value\">{{ $json.netMargin }}</span></div>\n            <div class=\"metric-row\"><span class=\"metric-label\">EBITDA Margin</span><span class=\"metric-value\">{{ $json.ebitdaMargin }}</span></div>\n        </div>\n    </div>\n    <div class=\"section\">\n        <h2 class=\"section-title\">Business Model</h2>\n        <div class=\"card\"><div class=\"text-content\">{{ $json.businessModel }}</div></div>\n    </div>\n    <div class=\"section\">\n        <h2 class=\"section-title\">Investment Thesis</h2>\n        <div class=\"highlight-box\"><div class=\"highlight-content\">{{ $json.investmentThesis }}</div></div>\n    </div>\n    <div class=\"section\">\n        <h2 class=\"section-title\">Risk Analysis</h2>\n        <div class=\"card\">\n            <h3 style=\"font-size: 14px; color: #475569; margin-bottom: 12px;\">Key Risks</h3>\n            <ul class=\"risk-list\">{{ $json.riskListItems }}</ul>\n        </div>\n        <div class=\"card\">\n            <h3 style=\"font-size: 14px; color: #475569; margin-bottom: 12px;\">Customer Concentration</h3>\n            <div class=\"text-content\">{{ $json.customerConcentration }}</div>\n        </div>\n    </div>\n    <div class=\"footer\">\n        <p>This report was generated automatically using AI-powered document analysis.</p>\n        <p>Deal ID: {{ $json.dealId }}</p>\n    </div>\n</body>\n</html>"
      },
      "typeVersion": 1.2
    },
    {
      "id": "84a87c7e-e44a-4557-95a6-3b642cda42b4",
      "name": "Render PDF from HTML",
      "type": "n8n-nodes-puppeteer.puppeteer",
      "position": [
        3808,
        -208
      ],
      "parameters": {
        "options": {},
        "operation": "runCustomScript",
        "scriptCode": "=const html = $json.html || '';\n\nif (!html) {\n  throw new Error('HTML content is empty. Check Render DD Report HTML output.');\n}\n\nawait $page.setContent(html, {\n  waitUntil: 'networkidle0',\n  timeout: 60000,\n});\n\nconst pdfArray = await $page.pdf({\n  format: 'A4',\n  printBackground: true,\n  margin: {\n    top: '40px',\n    right: '40px',\n    bottom: '40px',\n    left: '40px',\n  },\n  scale: 0.95,\n  timeout: 60000,\n});\n\nconst pdfBase64 = Buffer.from(pdfArray).toString('base64');\nreturn [{ json: { pdfBase64 } }];"
      },
      "typeVersion": 1
    },
    {
      "id": "8f4ef527-4c3b-4410-86c9-30f8679cf279",
      "name": "Convert PDF Base64 to Binary File",
      "type": "n8n-nodes-base.convertToFile",
      "position": [
        4032,
        -208
      ],
      "parameters": {
        "options": {
          "fileName": "={{ $('Map Analysis to Report Fields').item.json.companyName }}-Analysis.pdf"
        },
        "operation": "toBinary",
        "sourceProperty": "pdfBase64"
      },
      "typeVersion": 1.1
    },
    {
      "id": "14b8fceb-4c56-4af8-940f-a57b256a239b",
      "name": "Upload Report PDF to S3",
      "type": "n8n-nodes-base.s3",
      "position": [
        4480,
        -208
      ],
      "parameters": {
        "fileName": "={{ $json.fileName }}",
        "operation": "upload",
        "bucketName": "poc",
        "additionalFields": {}
      },
      "credentials": {
        "s3": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "280a68bd-ab8e-4cb0-9a96-83bca3e7bccb",
      "name": "Build Public Report URL",
      "type": "n8n-nodes-base.code",
      "position": [
        4704,
        -208
      ],
      "parameters": {
        "jsCode": "const baseUrl = 'https://poc.atlr.dev';\n  const fileName = $('Prepare S3 File Metadata').first().json.fileName;\n  const encodedFileName = encodeURIComponent(fileName);\n  const publicUrl = `${baseUrl}/${encodedFileName}`;\n\n  return {\n    json: {\n      success: true,\n      fileName: fileName,\n      publicUrl: publicUrl\n    }\n  };"
      },
      "typeVersion": 2
    },
    {
      "id": "f8ea8c89-eccf-4b9a-a6f0-7b4e157150ad",
      "name": " Merge Analysis + Report URL",
      "type": "n8n-nodes-base.merge",
      "position": [
        4272,
        -544
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineByPosition"
      },
      "typeVersion": 3.2
    },
    {
      "id": "da95d545-b7db-49a6-ac5b-ab114e2ea3fd",
      "name": "Is Parsing Job Complete?",
      "type": "n8n-nodes-base.if",
      "position": [
        464,
        -112
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "921ff875-817d-47fd-bd47-530ebdc21902",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.status }}",
              "rightValue": "SUCCESS"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "52a8e8bd-ae59-4b99-990d-6dbacff0ea26",
      "name": "Upload File to LlamaParse",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        96,
        -112
      ],
      "parameters": {
        "url": "https://api.cloud.llamaindex.ai/api/v1/parsing/upload",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "multipart-form-data",
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "file",
              "parameterType": "formBinaryData",
              "inputDataFieldName": "data"
            }
          ]
        },
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "accept",
              "value": "application/json"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "ebde6a4b-5aa4-4803-a1ba-23c1ec6bad4a",
      "name": "Check LlamaParse Job Status",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        288,
        -112
      ],
      "parameters": {
        "url": "=https://api.cloud.llamaindex.ai/api/v1/parsing/job/{{ $json.id }}",
        "options": {},
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "accept",
              "value": "application/json"
            }
          ]
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "1c0d2632-3656-41aa-ba0e-06410b1e7830",
      "name": "Wait 10s Before Recheck",
      "type": "n8n-nodes-base.wait",
      "position": [
        640,
        -96
      ],
      "parameters": {
        "amount": 10
      },
      "typeVersion": 1.1
    },
    {
      "id": "bb6238c2-c2d1-4a2a-b3e9-a3415a20e9f9",
      "name": "Return API Response",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        4720,
        -544
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 1.5
    },
    {
      "id": "71bcafab-7d80-4aa0-990f-3cbe028b11bc",
      "name": "Normalize Parsed Text Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        1056,
        -128
      ],
      "parameters": {
        "jsCode": "const loopItem = $('Iterate Files for Parsing').item.json;\n  const parsedContent = $json.markdown || $json.text || '';\n\n  return [{\n    json: {\n      dealId: loopItem.dealId,\n      sourceFile: loopItem.sourceFile,\n      fileType: loopItem.fileType,\n      parsedText: parsedContent\n    }\n  }];"
      },
      "typeVersion": 2
    },
    {
      "id": "9bccd041-e39e-419c-a9c5-70bf8b40c428",
      "name": "Cache Hit?",
      "type": "n8n-nodes-base.if",
      "position": [
        -384,
        -144
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "a45d92c0-a88f-49fc-b393-b482a3585d21",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.cacheHit }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "af834630-8789-4a25-a1f1-00f310a4b8c1",
      "name": "Prepare S3 File Metadata",
      "type": "n8n-nodes-base.code",
      "position": [
        4256,
        -208
      ],
      "parameters": {
        "jsCode": "const companyName = $('Map Analysis to Report Fields').first().json.companyName;\n  const timestamp = Date.now();\n  const fileName = `${companyName}-assessment-${timestamp}.pdf`;\n\n  return [{\n    json: {\n      fileName: fileName\n    },\n    binary: $input.first().binary\n  }];"
      },
      "typeVersion": 2
    },
    {
      "id": "2c931666-2b98-42ad-b452-97359da5ab11",
      "name": "Derive Company Seed",
      "type": "n8n-nodes-base.code",
      "position": [
        272,
        -624
      ],
      "parameters": {
        "jsCode": "const input = $input.first()?.json || {};\nconst dealId = input.dealId ? String(input.dealId) : 'unknown';\n\nconst countryRaw = String(input.location || input.country || input.hqCountry || 'United States').trim();\nconst countryKey = countryRaw.toLowerCase();\nconst countryMap = {\n  us: { name: 'United States', code: 'us', locale: 'en-US' },\n  usa: { name: 'United States', code: 'us', locale: 'en-US' },\n  'united states': { name: 'United States', code: 'us', locale: 'en-US' },\n  id: { name: 'Indonesia', code: 'id', locale: 'id-ID' },\n  indonesia: { name: 'Indonesia', code: 'id', locale: 'id-ID' },\n  sg: { name: 'Singapore', code: 'sg', locale: 'en-SG' },\n  singapore: { name: 'Singapore', code: 'sg', locale: 'en-SG' },\n  uk: { name: 'United Kingdom', code: 'gb', locale: 'en-GB' },\n  'united kingdom': { name: 'United Kingdom', code: 'gb', locale: 'en-GB' },\n  de: { name: 'Germany', code: 'de', locale: 'de-DE' },\n  germany: { name: 'Germany', code: 'de', locale: 'de-DE' },\n  fr: { name: 'France', code: 'fr', locale: 'fr-FR' },\n  france: { name: 'France', code: 'fr', locale: 'fr-FR' },\n  in: { name: 'India', code: 'in', locale: 'en-IN' },\n  india: { name: 'India', code: 'in', locale: 'en-IN' },\n  jp: { name: 'Japan', code: 'jp', locale: 'ja-JP' },\n  japan: { name: 'Japan', code: 'jp', locale: 'ja-JP' },\n  cn: { name: 'China', code: 'cn', locale: 'zh-CN' },\n  china: { name: 'China', code: 'cn', locale: 'zh-CN' },\n  au: { name: 'Australia', code: 'au', locale: 'en-AU' },\n  australia: { name: 'Australia', code: 'au', locale: 'en-AU' },\n};\nconst countryInfo = countryMap[countryKey] || { name: countryRaw || 'United States', code: 'us', locale: 'en-US' };\n\nlet uploaded = [];\ntry {\n  uploaded = $('Split Uploaded Files + Build Deal ID').all().map((item) => item.json || {});\n} catch (error) {}\n\nconst filenames = [...new Set(uploaded.map((row) => String(row.sourceFile || '').trim()).filter(Boolean))];\nconst genericWords = new Set([\n  'teaser', 'pitch', 'deck', 'presentation', 'financial', 'model', 'appendix', 'report', 'company', 'profile',\n  'data', 'room', 'deal', 'final', 'draft', 'rev', 'revised', 'version', 'copy', 'updated', 'new', 'signed',\n  'executive', 'summary', 'overview', 'confidential', 'cim', 'im', 'memo', 'management', 'seller', 'buyer',\n  'nda', 'appendices', 'attachment', 'file', 'doc', 'docs'\n]);\nconst monthWords = new Set(['jan','january','feb','february','mar','march','apr','april','may','jun','june','jul','july','aug','august','sep','sept','september','oct','october','nov','november','dec','december']);\nconst invalidTlds = new Set(['pdf', 'ppt', 'pptx', 'doc', 'docx', 'xls', 'xlsx']);\n\nconst stripExtension = (value) => String(value || '').replace(/\\.[a-z0-9]{2,5}$/i, '');\nconst titleCase = (value) => String(value || '').split(' ').filter(Boolean).map((token) => token.charAt(0).toUpperCase() + token.slice(1)).join(' ');\nconst extractDomains = (value) => {\n  const cleaned = stripExtension(value);\n  const matches = String(cleaned || '').match(/\\b(?:[a-z0-9-]+\\.)+[a-z]{2,}\\b/gi) || [];\n  return matches.map((entry) => entry.toLowerCase()).filter((entry) => !invalidTlds.has(entry.split('.').pop()));\n};\nconst normalizeCandidate = (value) => stripExtension(value)\n  .replace(/[|_]+/g, ' ')\n  .replace(/[()\\[\\]]/g, ' ')\n  .replace(/[\u2013\u2014-]+/g, ' ')\n  .replace(/\\b(v\\d+|ver\\d+|version\\d+|final|draft|copy|signed|updated|new)\\b/gi, ' ')\n  .replace(/\\b(19|20)\\d{2}\\b/g, ' ')\n  .replace(/[^a-zA-Z0-9.& ]/g, ' ')\n  .replace(/\\s+/g, ' ')\n  .trim();\nconst meaningfulTokens = (value) => normalizeCandidate(value)\n  .split(/\\s+/)\n  .map((token) => token.trim())\n  .filter(Boolean)\n  .filter((token) => token.length > 1)\n  .filter((token) => !monthWords.has(token.toLowerCase()))\n  .filter((token) => !/^(jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)(19|20)?\\d{2}$/i.test(token))\n  .filter((token) => !genericWords.has(token.toLowerCase()))\n  .filter((token) => !/^\\d+$/.test(token))\n  .filter((token) => !/^(19|20)\\d{2}$/.test(token));\n\nconst tokenRows = filenames.map((name) => ({\n  raw: name,\n  tokens: meaningfulTokens(name),\n  domains: extractDomains(name),\n}));\nconst tokenCounts = {};\nfor (const row of tokenRows) {\n  for (const token of new Set(row.tokens.map((entry) => entry.toLowerCase()))) {\n    tokenCounts[token] = (tokenCounts[token] || 0) + 1;\n  }\n}\n\nlet consensusTokens = [];\nif (tokenRows.length > 1) {\n  const minSupport = Math.min(tokenRows.length, 2);\n  consensusTokens = Object.entries(tokenCounts)\n    .filter(([, count]) => count >= minSupport)\n    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))\n    .map(([token]) => token);\n}\n\nconst candidateRows = tokenRows.map((row) => {\n  let score = row.tokens.reduce((sum, token) => sum + ((tokenCounts[token.toLowerCase()] || 0) * 10), 0);\n  if (row.domains.length > 0) score -= 25;\n  if (row.tokens.length === 1) score += 12;\n  if (row.tokens.length >= 2) score += 8;\n  return {\n    raw: row.raw,\n    normalized: titleCase(row.tokens.slice(0, 4).join(' ')),\n    tokens: row.tokens,\n    score,\n  };\n}).filter((row) => row.tokens.length > 0);\ncandidateRows.sort((a, b) => b.score - a.score || a.normalized.localeCompare(b.normalized));\n\nlet companyTokens = [];\nif (consensusTokens.length > 0) {\n  companyTokens = consensusTokens.slice(0, 3);\n} else if (candidateRows[0]) {\n  companyTokens = candidateRows[0].tokens.slice(0, 4).map((token) => token.toLowerCase());\n}\n\nif (companyTokens.length === 0 && filenames[0]) {\n  companyTokens = normalizeCandidate(filenames[0]).split(/\\s+/).filter(Boolean).slice(0, 3).map((token) => token.toLowerCase());\n}\n\nlet companyNameGuess = titleCase(companyTokens.join(' ')).trim() || 'Target Company';\nconst genericFilenameFlag = companyTokens.length < 1;\nconst mentionedDomains = [...new Set(filenames.flatMap(extractDomains))];\nconst seedConfidence = mentionedDomains.length > 0 ? 'high' : (companyTokens.length >= 2 ? 'medium' : 'low');\nconst searchQuery = `${companyNameGuess} official website ${countryInfo.name}`.trim();\nconst fallbackSearchQuery = `${companyNameGuess} company ${countryInfo.name}`.trim();\n\nreturn [{\n  json: {\n    ...input,\n    dealId,\n    companyNameGuess,\n    primaryCompanyName: companyNameGuess,\n    sourceFilenames: filenames,\n    mentionedDomains,\n    genericFilenameFlag,\n    seedConfidence,\n    hqCountry: countryInfo.name,\n    searchCountryCode: countryInfo.code,\n    searchLocale: countryInfo.locale,\n    searchQuery,\n    fallbackSearchQuery,\n    tokenConsensus: consensusTokens.slice(0, 5),\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "a668fb86-a110-42af-aa51-58fdebbbce3f",
      "name": "Build Domain Candidates",
      "type": "n8n-nodes-base.code",
      "position": [
        672,
        -624
      ],
      "parameters": {
        "jsCode": "const seed = $('Derive Company Seed').first().json || {};\nconst payload = $json || {};\n\nconst blockedDomains = new Set([\n  'google.com', 'www.google.com', 'linkedin.com', 'www.linkedin.com', 'facebook.com', 'www.facebook.com',\n  'instagram.com', 'www.instagram.com', 'x.com', 'twitter.com', 'wikipedia.org', 'www.wikipedia.org',\n  'crunchbase.com', 'www.crunchbase.com', 'youtube.com', 'www.youtube.com', 'tiktok.com', 'www.tiktok.com',\n  'gmail.com', 'outlook.com', 'yahoo.com', 'drive.google.com', 'docs.google.com'\n]);\nconst blockedFragments = ['linkedin', 'facebook', 'instagram', 'twitter', 'x.com', 'wikipedia', 'crunchbase', 'glassdoor', 'indeed', 'zoominfo', 'pitchbook', 'bloomberg', 'mapquest', 'yelp', 'tripadvisor', 'directory', 'listing', 'provider', 'portal', 'forum', 'reddit', 'news'];\nconst blockedSuffixes = ['.gov', '.edu'];\n\nconst normalizeDomain = (value) => {\n  let raw = String(value || '').trim().toLowerCase();\n  if (!raw) return '';\n  raw = raw.replace(/^https?:\\/\\//, '').replace(/^www\\./, '');\n  raw = raw.split('/')[0].split('?')[0].split('#')[0].trim();\n  if (!raw || !raw.includes('.')) return '';\n  const tld = raw.split('.').pop();\n  if (['pdf', 'ppt', 'pptx', 'doc', 'docx', 'xls', 'xlsx'].includes(tld)) return '';\n  return raw;\n};\nconst normalizeUrl = (value) => {\n  const domain = normalizeDomain(value);\n  return domain ? `https://${domain}` : '';\n};\nconst isBlockedDomain = (domain) => {\n  if (!domain || blockedDomains.has(domain)) return true;\n  if (blockedSuffixes.some((suffix) => domain.endsWith(suffix))) return true;\n  if (domain.includes('.gov.') || domain.includes('.edu.')) return true;\n  if (domain.split('.').length > 4) return true;\n  return blockedFragments.some((fragment) => domain.includes(fragment));\n};\nconst companyTokens = String(seed.companyNameGuess || '').toLowerCase().split(/\\s+/).filter((token) => token.length > 2);\nconst scoreSearchHit = (title, snippet, domain) => {\n  const haystack = `${title} ${snippet} ${domain}`.toLowerCase();\n  let score = 0;\n  const matchedTokens = companyTokens.filter((token) => haystack.includes(token));\n  score += matchedTokens.length * 14;\n  if (haystack.includes('official')) score += 15;\n  if (haystack.includes('home')) score += 8;\n  if (haystack.includes('company')) score += 6;\n  if (haystack.includes((seed.hqCountry || '').toLowerCase())) score += 5;\n  if (blockedFragments.some((fragment) => haystack.includes(fragment))) score -= 50;\n  return score;\n};\n\nconst candidates = [];\nconst seen = new Set();\nconst addCandidate = (domainOrUrl, source, baseScore, extra = {}) => {\n  const domain = normalizeDomain(domainOrUrl);\n  if (!domain || isBlockedDomain(domain)) return;\n  if (!/^[a-z0-9.-]+\\.[a-z]{2,}$/i.test(domain)) return;\n  if (seen.has(domain)) return;\n  seen.add(domain);\n  candidates.push({\n    dealId: seed.dealId,\n    companyNameGuess: seed.companyNameGuess || 'Target Company',\n    hqCountry: seed.hqCountry || 'United States',\n    candidateDomain: domain,\n    url: normalizeUrl(domain),\n    source,\n    sourceSignals: [source],\n    baseScore,\n    resolutionReason: `candidate_from_${source}`,\n    ...extra,\n  });\n};\n\nfor (const domain of seed.mentionedDomains || []) addCandidate(domain, 'doc_domain', 65);\n\nconst organic = payload.results?.[0]?.content?.results?.results?.organic || payload.results?.[0]?.content?.results?.organic || [];\nconst searchResults = Array.isArray(organic) && organic.length > 0 ? organic : [];\nfor (const row of searchResults.slice(0, 8)) {\n  const title = String(row.title || row.favicon_text || '');\n  const snippet = String(row.desc || row.snippet || '');\n  const url = row.url || row.url_shown || '';\n  const domain = normalizeDomain(url);\n  if (!domain) continue;\n  const searchScore = scoreSearchHit(title, snippet, domain);\n  if (searchScore < 10) continue;\n  addCandidate(url, 'search_result', 30 + Math.min(searchScore, 25), {\n    resultTitle: title.slice(0, 240),\n    resultSnippet: snippet.slice(0, 500),\n    serpScore: searchScore,\n  });\n}\n\nconst normalizeBase = (value) => String(value || '')\n  .toLowerCase()\n  .replace(/\\b(inc|ltd|limited|llc|corp|corporation|co|group|holdings|company|pte|pt|cv|tbk|technologies|technology|solutions|management)\\b/g, ' ')\n  .replace(/[^a-z0-9\\s]/g, ' ')\n  .replace(/\\s+/g, ' ')\n  .trim();\nconst guessedBases = [];\nconst cleaned = normalizeBase(seed.companyNameGuess);\nconst parts = cleaned.split(' ').filter(Boolean);\nif (parts.length) guessedBases.push(parts.join(''));\nif (parts.length >= 2) guessedBases.push(parts[0]);\nconst tlds = ['.com', '.co'];\nif ((seed.searchCountryCode || '').toLowerCase() === 'id') tlds.push('.co.id', '.id');\nif ((seed.searchCountryCode || '').toLowerCase() === 'sg') tlds.push('.com.sg', '.sg');\nif ((seed.searchCountryCode || '').toLowerCase() === 'gb') tlds.push('.co.uk');\nfor (const base of [...new Set(guessedBases)].slice(0, 2)) {\n  if (base.length < 4) continue;\n  for (const tld of tlds) addCandidate(`${base}${tld}`, 'guessed', 10);\n}\n\ncandidates.sort((a, b) => (b.baseScore || 0) - (a.baseScore || 0) || a.candidateDomain.localeCompare(b.candidateDomain));\nreturn candidates.slice(0, 3).map((candidate, index) => ({ json: { ...candidate, candidateRank: index + 1 } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "f2bb8172-ed14-4a74-82c1-1e77da43f768",
      "name": "Iterate Domain Candidates",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        864,
        -624
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "cefcae70-069f-421b-9cae-5857f525259a",
      "name": "Decodo Verify Official Domain",
      "type": "@decodo/n8n-nodes-decodo.decodo",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1040,
        -528
      ],
      "parameters": {
        "geo": "={{ $json.hqCountry || 'United States' }}",
        "url": "={{ $json.url }}",
        "headless": false,
        "markdown": true
      },
      "credentials": {
        "decodoApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "waitBetweenTries": 5000
    },
    {
      "id": "a099198d-09b1-44a9-9b0f-647b656b43d3",
      "name": "Score Domain Match",
      "type": "n8n-nodes-base.code",
      "position": [
        1216,
        -528
      ],
      "parameters": {
        "jsCode": "const seed = $('Derive Company Seed').first().json || {};\nconst candidate = $('Iterate Domain Candidates').item.json || {};\nconst payload = $json || {};\nconst rawError = payload.error ? String(payload.error) : '';\nconst rawContent = payload.results?.[0]?.content || payload.content || payload.markdown || payload.html || payload.text || '';\nconst content = typeof rawContent === 'string' ? rawContent : JSON.stringify(rawContent);\nconst lc = content.toLowerCase();\n\nconst companyName = String(seed.companyNameGuess || '').trim();\nconst companyLc = companyName.toLowerCase();\nconst companyTokens = companyLc.split(/\\s+/).filter((token) => token.length > 2);\n\nlet verificationScore = 0;\nconst reasons = [];\n\nif (companyLc && lc.includes(companyLc)) {\n  verificationScore += 35;\n  reasons.push('exact_company_name_match');\n} else {\n  const matchedTokens = companyTokens.filter((token) => lc.includes(token)).length;\n  if (matchedTokens >= Math.max(1, Math.min(2, companyTokens.length))) {\n    verificationScore += 20;\n    reasons.push('partial_company_token_match');\n  }\n}\n\nconst hqCountry = String(seed.hqCountry || '').toLowerCase();\nif (hqCountry && lc.includes(hqCountry)) {\n  verificationScore += 10;\n  reasons.push('country_match');\n}\n\nif (lc.includes('about') || lc.includes('company') || lc.includes('products') || lc.includes('services')) {\n  verificationScore += 8;\n  reasons.push('corporate_page_signal');\n}\n\nif (content.length > 500) {\n  verificationScore += 5;\n  reasons.push('sufficient_content_length');\n} else if (content.length < 120) {\n  verificationScore -= 20;\n  reasons.push('thin_content_penalty');\n}\n\nif (rawError) {\n  verificationScore -= 50;\n  reasons.push('request_error_penalty');\n}\n\nconst negativePatterns = [\n  'linkedin', 'crunchbase', 'wikipedia', 'facebook', 'instagram', 'x.com', 'twitter',\n  'domain for sale', 'buy this domain', 'parked domain', 'coming soon', 'not found', '404'\n];\nfor (const pattern of negativePatterns) {\n  if (lc.includes(pattern)) {\n    verificationScore -= 25;\n    reasons.push(`negative:${pattern}`);\n  }\n}\n\nconst finalScore = Math.max(0, Math.min(100, Number(candidate.baseScore || 0) + verificationScore));\nlet verifyStatus = 'reject';\nif (finalScore >= 80) verifyStatus = 'verified';\nelse if (finalScore >= 55) verifyStatus = 'probable';\n\nreturn [{\n  json: {\n    ...candidate,\n    verificationScore,\n    finalScore,\n    verifyStatus,\n    verifyPreview: content.slice(0, 1200),\n    resolutionReason: reasons.length ? reasons.join(',') : (candidate.resolutionReason || 'low_confidence_match'),\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "32d67839-fc2c-4d3c-bcff-f7a14ca650cf",
      "name": "Collect Domain Scores",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        1040,
        -688
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData",
        "destinationFieldName": "domainCandidates"
      },
      "typeVersion": 1
    },
    {
      "id": "740c3c57-7a60-4d32-b04b-505821b6c5fa",
      "name": "Select Canonical Domain",
      "type": "n8n-nodes-base.code",
      "position": [
        1216,
        -688
      ],
      "parameters": {
        "jsCode": "const rows = Array.isArray($json.domainCandidates) ? $json.domainCandidates : [];\nconst fallback = $('Derive Company Seed').first().json || {};\n\nif (rows.length === 0) {\n  return [{\n    json: {\n      dealId: fallback.dealId || 'unknown',\n      companyNameGuess: fallback.companyNameGuess || 'Target Company',\n      hqCountry: fallback.hqCountry || 'United States',\n      canonicalDomain: '',\n      canonicalUrl: '',\n      finalScore: 0,\n      canonicalStatus: 'reject',\n      resolutionReason: 'no_candidates_found',\n      targetUrl: ''\n    }\n  }];\n}\n\nconst sortedRows = [...rows].sort((a, b) => (b.finalScore || 0) - (a.finalScore || 0));\nconst best = sortedRows[0];\nconst canonicalStatus = best.verifyStatus || ((best.finalScore || 0) >= 80 ? 'verified' : ((best.finalScore || 0) >= 55 ? 'probable' : 'reject'));\nconst baseUrl = String(best.url || '').replace(/\\/$/, '');\nconst pathSet = canonicalStatus === 'verified' ? ['', '/about'] : [''];\n\nreturn pathSet.map((path, index) => ({\n  json: {\n    dealId: best.dealId || fallback.dealId,\n    companyNameGuess: best.companyNameGuess || fallback.companyNameGuess,\n    hqCountry: best.hqCountry || fallback.hqCountry || 'United States',\n    canonicalDomain: best.candidateDomain || '',\n    canonicalUrl: best.url || '',\n    finalScore: best.finalScore || 0,\n    canonicalStatus,\n    resolutionReason: best.resolutionReason || 'best_scoring_candidate',\n    sourceSignals: best.sourceSignals || [],\n    targetUrl: `${baseUrl}${path}`,\n    targetIndex: index + 1,\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "1d00006f-d9c1-4a5d-877e-92567f304b7f",
      "name": "Iterate Enrichment URLs",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        1680,
        -592
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "196fdbb1-8c82-4420-a794-75cf6297ecbb",
      "name": "Profile Page?",
      "type": "n8n-nodes-base.if",
      "position": [
        1920,
        -432
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "or",
          "conditions": [
            {
              "id": "condition-1771924304230-x47ljneew",
              "operator": {
                "type": "string",
                "operation": "contains"
              },
              "leftValue": "={{ $json.targetUrl }}",
              "rightValue": "about"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "cc8961fc-4457-4106-94d7-3de024d77aa6",
      "name": "Decodo Scrape Company Profile",
      "type": "@decodo/n8n-nodes-decodo.decodo",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        2112,
        -448
      ],
      "parameters": {
        "geo": "={{ $json.hqCountry || 'United States' }}",
        "url": "={{ $json.targetUrl }}",
        "headless": false,
        "markdown": true
      },
      "credentials": {
        "decodoApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "waitBetweenTries": 5000
    },
    {
      "id": "c44deabd-9988-466e-adad-83b841cffe38",
      "name": "Decodo Scrape Commercial and Risk",
      "type": "@decodo/n8n-nodes-decodo.decodo",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        2112,
        -256
      ],
      "parameters": {
        "geo": "={{ $json.hqCountry || 'United States' }}",
        "url": "={{ $json.targetUrl }}",
        "headless": false,
        "markdown": true
      },
      "credentials": {
        "decodoApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1,
      "waitBetweenTries": 5000
    },
    {
      "id": "7c053d89-5fd6-48de-ad3d-0c45db398124",
      "name": "Normalize External Evidence",
      "type": "n8n-nodes-base.code",
      "position": [
        2304,
        -368
      ],
      "parameters": {
        "jsCode": "const src = $('Iterate Enrichment URLs').item.json;\nconst payload = $json || {};\nconst rawError = payload.error ? String(payload.error) : '';\nconst content = payload.results?.[0]?.content || payload.content || payload.markdown || payload.html || payload.text || '';\nconst text = typeof content === 'string' ? content : JSON.stringify(content);\n\nconst url = src.targetUrl || src.canonicalUrl || '';\nlet evidenceType = 'commercial_risk';\nif (url.includes('/about') || url.includes('/company') || url.includes('/team')) evidenceType = 'company_profile';\n\nlet confidence = 0.3;\nif ((src.canonicalStatus || '') === 'verified') confidence = 0.8;\nelse if ((src.canonicalStatus || '') === 'probable') confidence = 0.55;\nif (text.length > 1000) confidence += 0.1;\nconfidence = Math.max(0.2, Math.min(0.95, confidence));\n\nreturn [{\n  json: {\n    dealId: src.dealId,\n    canonicalUrl: src.canonicalUrl,\n    canonicalStatus: src.canonicalStatus || 'reject',\n    resolutionReason: src.resolutionReason || 'unclassified',\n    sourceSignals: src.sourceSignals || [],\n    sourceUrl: url,\n    evidenceType,\n    claim: text.slice(0, 4000),\n    confidence: Number(confidence.toFixed(2)),\n    geoUsed: src.hqCountry || 'United States',\n    capturedAt: new Date().toISOString(),\n    status: rawError ? 'error' : (text.length > 0 ? 'success' : 'failed'),\n    rawError,\n    markdown: text\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "93178829-b1c2-4d89-b412-d6d680712411",
      "name": "Collect External Evidence",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        1904,
        -608
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData",
        "destinationFieldName": "externalEvidence"
      },
      "typeVersion": 1
    },
    {
      "id": "de844c02-d42b-476a-a7cd-0d2aa5c3021c",
      "name": "Evidence Coverage Metrics",
      "type": "n8n-nodes-base.code",
      "position": [
        2112,
        -608
      ],
      "parameters": {
        "jsCode": "const items = $json.externalEvidence || [];\nconst canonicalStatus = items[0]?.canonicalStatus || $('Select Canonical Domain').first().json.canonicalStatus || 'reject';\nif (canonicalStatus === 'reject') {\n  return [{\n    json: {\n      dealId: items[0]?.dealId || $('Prepare Analysis Context').first().json.dealId,\n      externalEvidence: [],\n      failedEvidence: items,\n      externalSourcesCount: 0,\n      failedEvidenceCount: items.length,\n      evidenceCoverage: 0,\n      confidenceSummary: 'low',\n      evidenceGatePass: false,\n      evidenceCategories: { profileCount: 0, riskCount: 0 },\n      canonicalStatus,\n      generatedAt: new Date().toISOString()\n    }\n  }];\n}\nconst success = items.filter((item) => item.status === 'success');\nconst failed = items.filter((item) => item.status !== 'success');\nconst coverage = items.length === 0 ? 0 : success.length / items.length;\nlet confidenceSummary = 'low';\nif (coverage >= 0.75) confidenceSummary = 'high';\nelse if (coverage >= 0.4) confidenceSummary = 'medium';\nconst profileCount = success.filter((item) => item.evidenceType.includes('profile')).length;\nconst riskCount = success.filter((item) => item.evidenceType.includes('risk') || item.evidenceType.includes('commercial')).length;\nreturn [{\n  json: {\n    dealId: items[0]?.dealId || $('Prepare Analysis Context').first().json.dealId,\n    externalEvidence: success,\n    failedEvidence: failed,\n    externalSourcesCount: success.length,\n    failedEvidenceCount: failed.length,\n    evidenceCoverage: Number(coverage.toFixed(2)),\n    confidenceSummary,\n    evidenceGatePass: success.length >= 1,\n    evidenceCategories: { profileCount, riskCount },\n    canonicalStatus,\n    generatedAt: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "5f353e85-edd6-4c8e-a4bb-cc6e05d89285",
      "name": "Has External Evidence?",
      "type": "n8n-nodes-base.if",
      "position": [
        2336,
        -608
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-external-evidence-gate",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.evidenceGatePass }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "3183ddf5-6572-4f60-8553-069cb972d747",
      "name": "Prepare External Evidence Document",
      "type": "@n8n/n8n-nodes-langchain.documentDefaultDataLoader",
      "position": [
        2800,
        -512
      ],
      "parameters": {
        "options": {
          "metadata": {
            "metadataValues": [
              {
                "name": "deal_id",
                "value": "={{ $json.dealId }}"
              },
              {
                "name": "source_type",
                "value": "external_web"
              },
              {
                "name": "evidence_count",
                "value": "={{ $json.externalSourcesCount }}"
              },
              {
                "name": "coverage",
                "value": "={{ $json.evidenceCoverage }}"
              },
              {
                "name": "timestamp",
                "value": "={{ $now.toUTC() }}"
              }
            ]
          }
        },
        "jsonData": "={{ ($json.externalEvidence || []).map((item, index) => [\n  'Source ' + (index + 1) + ': ' + (item.sourceUrl || 'unknown'),\n  'Evidence type: ' + (item.evidenceType || 'unknown'),\n  'Confidence: ' + (item.confidence || 0),\n  item.markdown || item.claim || ''\n].join('\\n')).join('\\n\\n---\\n\\n') }}",
        "jsonMode": "expressionData"
      },
      "typeVersion": 1.1
    },
    {
      "id": "b91a09d6-6c79-4f5b-b545-a6c946e45e72",
      "name": "Generate Embeddings (External)",
      "type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
      "position": [
        2608,
        -512
      ],
      

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

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

About this workflow

Turn unstructured pitch decks and investment memos into polished Due Diligence PDF reports automatically. This n8n workflow handles everything from document ingestion to final delivery, combining internal document analysis with live web research to produce analyst-grade output…

Source: https://n8n.io/workflows/13500/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

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

AI & RAG

Transform raw investment memorandums and financial decks into comprehensive, professional Due Diligence (DD) PDF reports. This workflow automates document parsing via LlamaParse, enriches internal dat

HTTP Request, Pinecone Vector Store, OpenAI Embeddings +7
AI & RAG

Streamline M&A due diligence with AI. This n8n workflow automatically parses financial documents using LlamaIndex, embeds data into Pinecone, and generates comprehensive, AI-driven reports with GPT-5-

HTTP Request, Pinecone Vector Store, OpenAI Embeddings +6
AI & RAG

This Workflow simulates an AI-powered phone agent with RetellAI with two main functions: 📅 Appointment Booking – It can schedule appointments directly into Google Calendar. 🧠 RAG-based Information Ret

OpenAI Chat, Output Parser Structured, Qdrant Vector Store +10
AI & RAG

AI Phone Agent with RetellAI. Uses lmChatOpenAi, outputParserStructured, vectorStoreQdrant, embeddingsOpenAi. Webhook trigger; 36 nodes.

OpenAI Chat, Output Parser Structured, Qdrant Vector Store +10
AI & RAG

Indoor Farming Agent. Uses lmChatOpenAi, documentDefaultDataLoader, embeddingsOpenAi, toolVectorStore. Webhook trigger; 36 nodes.

OpenAI Chat, Document Default Data Loader, OpenAI Embeddings +16