Reach-splitten (in/out-of-network) er native i LinkedIns post-analytics siden juni 2026, men vises som PROSENT og finnes ikke i CSV-eksporten. Planen antok to manuelle antall; verifiseringen viste prosent, så modellen er ett felt — outOfNetworkPct — og in-network er komplementet. - parseOptionalPercent: egen parser, ikke parseOptionalCount. Komma er desimal (36,5 -> 36.5, aldri 365), og verdi >100 avvises: i én kolonne kan ikke et absolutt antall skilles fra en andel, så svaret er unknown, ikke en gjetning. Blank/ikke-numerisk/negativ -> unknown; ekte 0 beholdes. - Ett lagret halvpart, kryssjekket: In-network godtas og lagres som komplement; et transkribert par som ikke summerer til ~100 (±1 avrunding) forkastes som unknown i stedet for å bli halvveis trodd. - weightedOutOfNetworkPct: impressions-vektet roll-up (avgOutOfNetworkPct, uke + måned). Flatt snitt lar en 50-visnings-post slå en på 10 000; poster uten avlesning ekskluderes, og null vekt gir undefined — aldri 0, aldri NaN. - Reach inngår ALDRI i engagementRate (distribusjon != engasjement). Rapporten leser den som akvisisjon (ut) vs resonans (inn), og sier «ikke ført for denne perioden» framfor å estimere. En reach-innsikt går inn i N15s do-next-kanal. - Step 7c (A2-F11): rapporten tilbyr diff mot brukerens engagement-patterns.md med eksplisitt go — aldri stille skriving, aldri inn i den shippede malen. - Boundary-map (E#9): dwell eksplisitt umålbar, saves partner-gated, reach native men CSV-eksport uverifisert. - Reach-frie importer er byte-identiske med før, på skjerm og på disk. TDD: rødt bevist først (10 feilende), analytics 119 -> 144 tester, tsc ren. test-runner 232 -> 247 (Section 16w, gulv 213 -> 228). Alle suiter grønne. CHANGELOG: N15-oppføringen manglet og er backfilt sammen med N16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
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<string, string>, 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<string, string>[];
|
|
|
|
// 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;
|
|
}
|