import { parse } from "csv-parse/sync"; import { readFileSync } from "node:fs"; import type { PostAnalytics, AnalyticsBatch, PostMetrics } from "../models/types.js"; /** * Detects delimiter (comma vs semicolon) by checking first line */ function detectDelimiter(content: string): string { const firstLine = content.split("\n")[0]; const commaCount = (firstLine.match(/,/g) || []).length; const semicolonCount = (firstLine.match(/;/g) || []).length; return semicolonCount > commaCount ? ";" : ","; } /** * Finds column value using fuzzy pattern matching */ function findColumn(record: Record, patterns: string[]): string { const keys = Object.keys(record); for (const pattern of patterns) { const key = keys.find((k) => k.toLowerCase().includes(pattern.toLowerCase()) ); if (key) { return record[key]; } } return ""; } /** * Parses metric value, handling both US (4,523) and EU (4.523) thousand separators * Clamps negative values to 0 */ function parseMetric(value: string): number { if (!value) return 0; // Remove quotes and trim const cleaned = value.replace(/"/g, "").trim(); // Check if it looks like EU format (4.523) or US format (4,523) // EU format has dots as thousand separators, US has commas // If there's both comma and dot, the last one is decimal separator const lastComma = cleaned.lastIndexOf(","); const lastDot = cleaned.lastIndexOf("."); let normalized = cleaned; if (lastComma > lastDot) { // US format: remove commas (thousand separator), keep dots normalized = cleaned.replace(/,/g, ""); } else { // EU format: remove dots (thousand separator), replace comma with dot normalized = cleaned.replace(/\./g, "").replace(/,/g, "."); } const parsed = parseFloat(normalized) || 0; // Clamp negative values to 0 return Math.max(0, parsed); } /** * Parse an OPTIONAL manually-entered count (saves). Unlike parseMetric — which * coerces blanks, garbage, and negatives to 0 — this preserves the "unknown vs * zero" distinction the saves contract requires: * - blank / absent → undefined ("unknown", never 0) * - non-numeric ("n/a", …) → undefined ("unknown", never 0) * - negative → undefined (not a real save count) * - a genuine number ("0") → that number (an explicit 0 is a real reading) * Reuses the same EU/US thousand-separator normalization as parseMetric so a * "1.234"/"1,234" Saves cell parses consistently with the other columns. */ function parseOptionalCount(value: string): number | undefined { if (!value) return undefined; const cleaned = value.replace(/"/g, "").trim(); if (cleaned === "") return undefined; const lastComma = cleaned.lastIndexOf(","); const lastDot = cleaned.lastIndexOf("."); const normalized = lastComma > lastDot ? cleaned.replace(/,/g, "") : cleaned.replace(/\./g, "").replace(/,/g, "."); const parsed = Number(normalized); if (!Number.isFinite(parsed) || parsed < 0) return undefined; return parsed; } /** * Rounding slack, in percentage points, allowed between the two halves of the * reach split. LinkedIn rounds each half independently for display, so a * transcribed "63% / 37%" can legitimately sum to 99 or 101. */ const REACH_SPLIT_TOLERANCE = 1; /** Round to one decimal — the UI reading is itself a rounded percentage. */ function round1(value: number): number { return Math.round(value * 10) / 10; } /** * Parse an OPTIONAL manually-entered PERCENTAGE share (out-of-network reach). * Distinct from parseOptionalCount in two ways that matter: * - a share never carries a thousands separator, so a comma is always the * DECIMAL mark here ("36,5" → 36.5). parseOptionalCount's US-thousands rule * would read that as 365. * - a share above 100 is not a share. It is most likely an absolute * impression count pasted into a percent column, and one column cannot tell * a count from a share — so the honest answer is unknown, never a guess. * Otherwise the same contract as the saves field: * - blank / absent / non-numeric / negative → undefined ("unknown", never 0) * - a genuine "0" → 0 (nothing left the network) * - "37%", "37 %", "37" → 37 */ function parseOptionalPercent(value: string): number | undefined { if (!value) return undefined; const cleaned = value.replace(/"/g, "").replace(/%/g, "").trim(); if (cleaned === "") return undefined; const parsed = Number(cleaned.replace(/,/g, ".")); if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) return undefined; return round1(parsed); } /** * Reduce whichever halves of the reach split the user transcribed to the single * stored value: the out-of-network share. * - out-of-network only → that value * - in-network only → its complement (they describe one split) * - both, consistent → the out-of-network reading * - both, contradictory → undefined. One cell is a misreading and we cannot * tell which, so the record stays unknown rather than silently trusting one. */ function resolveOutOfNetworkPct( outOfNetwork: number | undefined, inNetwork: number | undefined ): number | undefined { if (outOfNetwork !== undefined && inNetwork !== undefined) { const sum = outOfNetwork + inNetwork; return Math.abs(sum - 100) <= REACH_SPLIT_TOLERANCE ? outOfNetwork : undefined; } if (outOfNetwork !== undefined) return outOfNetwork; if (inNetwork !== undefined) return round1(100 - inNetwork); return undefined; } /** * Normalizes date to YYYY-MM-DD format * Handles: DD.MM.YYYY, MM/DD/YYYY, YYYY-MM-DD * Returns null if date is invalid */ function normalizeDate(dateStr: string): string | null { if (!dateStr) return null; const cleaned = dateStr.replace(/"/g, "").trim(); // Already in YYYY-MM-DD format if (/^\d{4}-\d{2}-\d{2}$/.test(cleaned)) { return cleaned; } // DD.MM.YYYY format if (/^\d{2}\.\d{2}\.\d{4}$/.test(cleaned)) { const [day, month, year] = cleaned.split("."); return `${year}-${month}-${day}`; } // MM/DD/YYYY format if (/^\d{2}\/\d{2}\/\d{4}$/.test(cleaned)) { const [month, day, year] = cleaned.split("/"); return `${year}-${month}-${day}`; } // YYYY/MM/DD format if (/^\d{4}\/\d{2}\/\d{2}$/.test(cleaned)) { return cleaned.replace(/\//g, "-"); } // Invalid date format return null; } /** * Simple string hash function for generating deterministic post IDs */ function simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash).toString(36); } /** * Generates deterministic post ID from title and date */ function generatePostId(title: string, date: string): string { return simpleHash(`${title}:${date}`); } /** * Generates batch ID using timestamp */ function generateBatchId(): string { const now = new Date(); const timestamp = now.getTime(); return `batch-${timestamp}-${simpleHash(timestamp.toString())}`; } /** * Parses LinkedIn CSV export into structured AnalyticsBatch */ export function parseLinkedInCSV( filePath: string, filename: string ): AnalyticsBatch { // Read file let content = readFileSync(filePath, "utf-8"); // Strip BOM if present if (content.charCodeAt(0) === 0xfeff) { content = content.slice(1); } // Detect delimiter const delimiter = detectDelimiter(content); // Parse CSV const records = parse(content, { columns: true, skip_empty_lines: true, delimiter, quote: '"', trim: true, }) as Record[]; // Normalize records into PostAnalytics, skipping invalid records const posts: PostAnalytics[] = records .map((record, index) => { const title = findColumn(record, ["content", "title", "post"]); const dateStr = findColumn(record, ["date", "published", "posted"]); const date = normalizeDate(dateStr); // Skip records with empty titles if (!title || title.trim() === "") { console.warn(`Warning: Skipping record at line ${index + 2}: empty title`); return null; } // Skip records with invalid dates if (!date) { console.warn(`Warning: Skipping record at line ${index + 2}: invalid date "${dateStr}"`); return null; } const impressions = parseMetric(findColumn(record, ["impression", "view"])); const reactions = parseMetric(findColumn(record, ["reaction", "like"])); const comments = parseMetric(findColumn(record, ["comment"])); const shares = parseMetric(findColumn(record, ["share", "repost"])); const clicks = parseMetric(findColumn(record, ["click"])); // Calculate engagement rate — saves is deliberately NOT in the numerator, // so this stays comparable to historical, saves-free imports. const totalEngagement = reactions + comments + shares + clicks; const engagementRate = impressions > 0 ? (totalEngagement / impressions) * 100 : 0; const metrics: PostMetrics = { impressions, reactions, comments, shares, clicks, engagementRate, }; // Optional manual-entry saves: only when the user augmented this CSV with a // Saves column (read off native LinkedIn analytics, ~Sept 2025+). A missing // column, a blank cell, or a non-numeric/negative cell stays undefined — // "unknown", never coerced to 0; a genuine 0 is kept as 0. const saves = parseOptionalCount(findColumn(record, ["saves", "bookmark"])); if (saves !== undefined) { metrics.saves = saves; } // Optional manual-entry reach split: only when the user augmented this CSV // with an Out-of-network (or In-network) column, read off the native // Discovery panel in post analytics — LinkedIn does not export it. Either // half is accepted and reduced to the out-of-network share; anything // unreadable or self-contradicting stays undefined ("unknown", never 0). const outOfNetworkPct = resolveOutOfNetworkPct( parseOptionalPercent(findColumn(record, ["out-of-network", "out of network", "outofnetwork"])), parseOptionalPercent(findColumn(record, ["in-network", "in network", "innetwork"])) ); if (outOfNetworkPct !== undefined) { metrics.outOfNetworkPct = outOfNetworkPct; } return { id: generatePostId(title, date), title, publishedDate: date, metrics, importedAt: new Date().toISOString(), exportSource: filename, }; }) .filter((post): post is PostAnalytics => post !== null); // Find date range const dates = posts.map((p) => p.publishedDate).filter((d) => d); const sortedDates = dates.sort(); const dateRange = { from: sortedDates[0] || "", to: sortedDates[sortedDates.length - 1] || "", }; // Build AnalyticsBatch const batch: AnalyticsBatch = { batchId: generateBatchId(), importedAt: new Date().toISOString(), exportFilename: filename, dateRange, postCount: posts.length, posts, }; return batch; }