{
  "name": "LinkedIn CSV: explicit mapping, clean and review download",
  "nodes": [
    {
      "id": "10a5fe38-b2f1-4df4-a578-7d5359db8761",
      "name": "Upload CSV and select column",
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.4,
      "position": [
        0,
        0
      ],
      "webhookId": "322d8879-c17e-4c1b-a6f0-7c56d3e7a585",
      "parameters": {
        "authentication": "none",
        "formTitle": "Prepare a LinkedIn prospect CSV",
        "formDescription": "Upload a UTF-8 CSV to your own n8n instance. Select the public LinkedIn profile URL column by its position, starting at 1. This workflow does not extract from LinkedIn, resolve Sales Navigator IDs or send file contents to third-party services. It returns clean and review CSVs in one ZIP. Template file limit: 5 MiB.",
        "formFields": {
          "values": [
            {
              "fieldLabel": "CSV file",
              "fieldName": "csv_file",
              "fieldType": "file",
              "multipleFiles": false,
              "acceptFileTypes": ".csv,.tsv,.txt",
              "requiredField": true
            },
            {
              "fieldLabel": "Profile URL column number (first column = 1)",
              "fieldName": "profile_column",
              "fieldType": "number",
              "placeholder": "2",
              "requiredField": true
            },
            {
              "fieldLabel": "Input delimiter",
              "fieldName": "delimiter",
              "fieldType": "dropdown",
              "fieldOptions": {
                "values": [
                  {
                    "option": "Comma"
                  },
                  {
                    "option": "Semicolon"
                  },
                  {
                    "option": "Tab"
                  }
                ]
              },
              "requiredField": true
            }
          ]
        },
        "responseMode": "lastNode",
        "options": {
          "buttonLabel": "Create clean and review files",
          "appendAttribution": false
        }
      }
    },
    {
      "id": "45c25170-e24d-4314-a647-bd72b3c9f3ed",
      "name": "Validate CSV without losing originals",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        320,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// n8n Code node: JavaScript, Run Once for All Items. No modules or outbound requests.\n// The downloadable workflow embeds this entire source. Column mapping is one-based.\nconst MAX_BYTES = 5 * 1024 * 1024;\nconst items = $input.all();\nif (items.length !== 1) throw new Error('Submit exactly one CSV file per execution.');\nconst input = items[0];\nconst binaryKeys = Object.keys(input.binary || {});\nif (!binaryKeys.length && input.json && input.json.csv_file && typeof input.json.csv_file === 'object' && (input.json.csv_file.data || input.json.csv_file.id || input.json.csv_file.mimeType)) {\n  throw new Error('This template requires workflow settings.binaryMode = separate. The upload is in combined JSON binary mode; change the workflow binary mode to separate and submit the CSV again.');\n}\nif (binaryKeys.length !== 1 || binaryKeys[0] !== 'csv_file') {\n  throw new Error('A single CSV upload in the csv_file field is required.');\n}\nconst uploaded = await this.helpers.getBinaryDataBuffer(0, 'csv_file');\nif (uploaded.length > MAX_BYTES) throw new Error('This template accepts at most 5 MiB per file. The upload remains in the trigger input; split the file before retrying.');\nconst sourceFile = String(input.binary.csv_file.fileName || 'uploaded.csv');\nconst mappingRaw = input.json && input.json.profile_column;\nconst delimiterName = input.json && input.json.delimiter;\nconst delimiters = { Comma: ',', Semicolon: ';', Tab: '\\t' };\nconst separator = delimiters[delimiterName];\n\nfunction parseCSV(text, sep) {\n  text = text.replace(/^\\uFEFF/, '');\n  const records = [], issues = [];\n  let cells = [], cell = '', quoted = false, closed = false;\n  let line = 1, startLine = 1, startOffset = 0, touched = false;\n  function issue(code) { issues.push({ code, record: records.length + 1, line }); }\n  function endCell() { cells.push(cell); cell = ''; closed = false; }\n  function endRecord(endOffset) {\n    endCell();\n    records.push({ record: records.length + 1, lineStart: startLine, lineEnd: line, cells, raw: text.slice(startOffset, endOffset) });\n    cells = []; touched = false;\n  }\n  for (let i = 0; i < text.length; i++) {\n    const c = text[i]; touched = true;\n    if (quoted) {\n      if (c === '\"') {\n        if (text[i + 1] === '\"') { cell += '\"'; i++; }\n        else { quoted = false; closed = true; }\n      } else if (c === '\\r' || c === '\\n') {\n        if (c === '\\r' && text[i + 1] === '\\n') { cell += '\\r\\n'; i++; }\n        else cell += c;\n        line++;\n      } else cell += c;\n    } else if (c === sep) endCell();\n    else if (c === '\\r' || c === '\\n') {\n      endRecord(i);\n      if (c === '\\r' && text[i + 1] === '\\n') i++;\n      line++; startLine = line; startOffset = i + 1;\n    } else if (c === '\"' && cell === '' && !closed) quoted = true;\n    else {\n      if (c === '\"' || closed) issue('unexpected_character');\n      cell += c;\n    }\n  }\n  if (quoted) issue('unclosed_quote');\n  if (touched || cells.length || cell !== '') endRecord(text.length);\n  return { records, issues };\n}\n\n// Intentionally lexical: do not resolve redirects, Sales Navigator IDs or URL aliases.\nfunction normalizeProfile(value) {\n  let raw = String(value || '').trim();\n  if (!raw || /[\\u0000-\\u0020\\u007f\\\\]/.test(raw)) return null;\n  if (!/^https?:\\/\\//i.test(raw)) {\n    if (!/^(?:(?:www|[a-z]{2})\\.)?linkedin\\.com\\//i.test(raw)) return null;\n    raw = 'https://' + raw;\n  }\n  const match = /^https?:\\/\\/((?:(?:www|[a-z]{2})\\.)?linkedin\\.com)(\\/in\\/([^/?#]+)\\/?)(?:[?#].*)?$/i.exec(raw);\n  if (!match || !/^\\/in\\//.test(match[2])) return null;\n  const slug = match[3];\n  try {\n    const decoded = decodeURIComponent(slug);\n    if (!decoded || decoded === '.' || decoded === '..' || /[\\/\\\\\\s\\u0000-\\u001f\\u007f]/.test(decoded)) return null;\n    // Do not merge case variants or different percent encodings by guessing aliases.\n    return 'https://www.linkedin.com/in/' + slug;\n  } catch (_) { return null; }\n}\n\nlet escapedCells = 0;\nfunction csv(rows) {\n  return rows.map(row => row.map(value => {\n    let text = value == null ? '' : String(value);\n    if (/^[\\s\\u0000-\\u001f\\u007f]*[=+\\-@]/.test(text)) { text = \"'\" + text; escapedCells++; }\n    return /[,\"\\r\\n]/.test(text) ? '\"' + text.replace(/\"/g, '\"\"') + '\"' : text;\n  }).join(',')).join('\\r\\n') + '\\r\\n';\n}\n\nlet decoded = '', encodingIssue = null;\ntry { decoded = new TextDecoder('utf-8', { fatal: true }).decode(uploaded); }\ncatch (_) { encodingIssue = 'file_encoding_error'; }\nconst parsed = encodingIssue || !separator ? { records: [], issues: [] } : parseCSV(decoded, separator);\nconst headerRecord = parsed.records[0];\nconst headers = headerRecord ? headerRecord.cells.slice() : [];\nconst records = parsed.records.slice(1);\nconst column = Number(mappingRaw) - 1;\nconst validMapping = /^\\d+$/.test(String(mappingRaw).trim()) && Number.isInteger(column) && column >= 0 && column < headers.length;\nconst headerWarnings = [];\nconst seenHeaders = new Map();\nheaders.forEach((header, i) => {\n  if (!header.trim()) headerWarnings.push({ code: 'blank_header', column: i + 1 });\n  if (seenHeaders.has(header)) headerWarnings.push({ code: 'duplicate_header', column: i + 1, firstColumn: seenHeaders.get(header) });\n  else seenHeaders.set(header, i + 1);\n});\nlet blockingIssue = encodingIssue || (!separator ? 'invalid_delimiter' : null) || (!headers.length ? 'empty_file' : null) || (!validMapping ? 'invalid_column_mapping' : null) || (parsed.issues.length ? 'file_parse_error' : null);\nconst maxWidth = records.reduce((width, record) => Math.max(width, record.cells.length), headers.length);\nconst outputLabels = Array.from({ length: maxWidth }, (_, i) => 'column_' + (i + 1) + ':' + (headers[i] === undefined ? '[unmapped extra cell]' : headers[i]));\nconst clean = [], review = [], dispositions = new Map(), candidates = new Map();\nfunction padded(cells) { const result = cells.slice(); while (result.length < maxWidth) result.push(''); return result; }\nfunction reviewRecord(record, reason, normalized) {\n  dispositions.set(record.record, reason);\n  review.push([reason, sourceFile, record.record, record.lineStart, record.lineEnd, normalized || '', record.raw].concat(padded(record.cells)));\n}\nfor (const record of records) {\n  if (blockingIssue) { reviewRecord(record, blockingIssue); continue; }\n  if (record.cells.length !== headers.length) { reviewRecord(record, 'row_width_mismatch'); continue; }\n  const originalURL = record.cells[column];\n  const normalized = normalizeProfile(originalURL);\n  if (!String(originalURL || '').trim()) { reviewRecord(record, 'blank_profile_url'); continue; }\n  if (!normalized) { reviewRecord(record, 'unsupported_or_invalid_profile_url'); continue; }\n  if (!candidates.has(normalized)) candidates.set(normalized, []);\n  candidates.get(normalized).push(record);\n}\nlet mergedDuplicateRows = 0;\nfor (const [key, group] of candidates) {\n  const first = group[0];\n  const conflict = group.some(record => record.cells.some((value, i) => i !== column && value !== first.cells[i]));\n  if (conflict) { group.forEach(record => reviewRecord(record, 'duplicate_profile_conflict', key)); continue; }\n  clean.push([key, sourceFile, JSON.stringify(group.map(record => ({ record: record.record, lineStart: record.lineStart, lineEnd: record.lineEnd })))].concat(padded(first.cells)));\n  group.forEach((record, i) => dispositions.set(record.record, i ? 'duplicate_folded_into_clean' : 'clean'));\n  mergedDuplicateRows += group.length - 1;\n}\nreview.sort((left, right) => left[2] - right[2]);\nconst cleanCSV = csv([['_normalized_profile_url', '_source_file', '_source_records'].concat(outputLabels)].concat(clean));\nconst reviewCSV = csv([['_review_reason', '_source_file', '_source_record', '_line_start', '_line_end', '_normalized_profile_url', '_original_record_text'].concat(outputLabels)].concat(review));\nconst report = {\n  formatVersion: 1, status: blockingIssue ? 'blocked' : 'completed', blockingIssue,\n  sourceFile, bytes: uploaded.length, encoding: 'UTF-8 only', delimiter: delimiterName || null,\n  selectedProfileColumn: validMapping ? column + 1 : null,\n  selectedProfileHeader: validMapping ? headers[column] : null,\n  headerCells: headers, headerWarnings, parseIssues: parsed.issues,\n  parsedDataRecords: records.length, cleanRows: clean.length, reviewRows: review.length,\n  mergedDuplicateRows, accountedForDataRecords: clean.length + review.length + mergedDuplicateRows,\n  formulaEscapedCells: escapedCells,\n  notes: [\n    'All examples and fixture people are synthetic. This workflow does not contact LinkedIn or resolve Sales Navigator IDs.',\n    'clean.csv and review.csv prefix formula-like cells with an apostrophe. Use originals.json or input-original.csv.txt to recover unchanged values.',\n    'A CSV syntax error blocks all clean output because later record boundaries may be unreliable. Raw input bytes remain in input-original.csv.txt.',\n    'Profile slug case and percent encoding are preserved. Different spellings are not assumed to be the same person.',\n    'Header names are retained in headerCells and originals.json. Positional CSV labels prevent duplicate-header overwrites.'\n  ]\n};\nconst originals = {\n  sourceFile, headerRecord: headerRecord || null,\n  records: records.map(record => ({ ...record, disposition: dispositions.get(record.record) || 'unclassified' })),\n  note: 'Exact input bytes are in input-original.csv.txt. Syntax-damaged records are provisional parsed segments, not verified person boundaries.'\n};\n\n// Portable, uncompressed ZIP (stored entries). Buffer is exposed by the n8n JavaScript runner.\nfunction crc32(buffer) {\n  let crc = 0xffffffff;\n  for (const byte of buffer) {\n    crc ^= byte;\n    for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);\n  }\n  return (crc ^ 0xffffffff) >>> 0;\n}\nfunction zip(files) {\n  const local = [], directory = []; let offset = 0;\n  for (const [name, content] of files) {\n    const filename = Buffer.from(name, 'utf8');\n    const data = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');\n    const checksum = crc32(data);\n    const h = Buffer.alloc(30);\n    h.writeUInt32LE(0x04034b50, 0); h.writeUInt16LE(20, 4); h.writeUInt16LE(0x0800, 6);\n    h.writeUInt16LE(0x0021, 12); h.writeUInt32LE(checksum, 14);\n    h.writeUInt32LE(data.length, 18); h.writeUInt32LE(data.length, 22); h.writeUInt16LE(filename.length, 26);\n    local.push(h, filename, data);\n    const d = Buffer.alloc(46);\n    d.writeUInt32LE(0x02014b50, 0); d.writeUInt16LE(20, 4); d.writeUInt16LE(20, 6); d.writeUInt16LE(0x0800, 8);\n    d.writeUInt16LE(0x0021, 14); d.writeUInt32LE(checksum, 16);\n    d.writeUInt32LE(data.length, 20); d.writeUInt32LE(data.length, 24); d.writeUInt16LE(filename.length, 28); d.writeUInt32LE(offset, 42);\n    directory.push(d, filename); offset += h.length + filename.length + data.length;\n  }\n  const central = Buffer.concat(directory), end = Buffer.alloc(22);\n  end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10);\n  end.writeUInt32LE(central.length, 12); end.writeUInt32LE(offset, 16);\n  return Buffer.concat(local.concat([central, end]));\n}\nconst archive = zip([\n  ['clean.csv', cleanCSV], ['review.csv', reviewCSV], ['input-original.csv.txt', uploaded],\n  ['originals.json', JSON.stringify(originals, null, 2) + '\\n'], ['report.json', JSON.stringify(report, null, 2) + '\\n']\n]);\nreturn [{\n  json: report,\n  binary: { bundle: { data: archive.toString('base64'), mimeType: 'application/zip', fileName: 'linkedin-csv-clean-review.zip', fileExtension: 'zip' } },\n  pairedItem: { item: 0 }\n}];\n"
      }
    },
    {
      "id": "ccf72cbe-6fb3-4ac9-9e84-5ce922a09a87",
      "name": "Download clean and review ZIP",
      "type": "n8n-nodes-base.form",
      "typeVersion": 2.3,
      "position": [
        640,
        0
      ],
      "parameters": {
        "operation": "completion",
        "respondWith": "returnBinary",
        "inputDataFieldName": "bundle",
        "completionTitle": "CSV review package ready",
        "completionMessage": "Download the ZIP and inspect report.json and review.csv before using clean.csv. A blocked report means the clean file contains no data rows. Original values and source positions are in originals.json; the original upload is input-original.csv.txt.",
        "options": {
          "appendAttribution": false
        }
      }
    }
  ],
  "connections": {
    "Upload CSV and select column": {
      "main": [
        [
          {
            "node": "Validate CSV without losing originals",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate CSV without losing originals": {
      "main": [
        [
          {
            "node": "Download clean and review ZIP",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "saveDataSuccessExecution": "none",
    "saveDataErrorExecution": "none",
    "saveManualExecutions": false,
    "saveExecutionProgress": false
  },
  "tags": []
}
