← back to Marketing Command Center
scripts/ingest-linkedin-exports.mjs
93 lines
// Standing ingest checker — run periodically by launchd. Idempotently ingests the
// NEWEST LinkedIn export found on Desktop/Downloads: Company Follows (→ follows +
// slug-resolve for NEW firms) and Connections (→ contact sheet). Tracks last-ingested
// mtime so re-runs are no-ops until you drop a fresher export. Watcher-only (no reminders).
// node scripts/ingest-linkedin-exports.mjs
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync } from 'node:child_process';
const ROOT = path.join(path.dirname(new URL(import.meta.url).pathname), '..');
const STATE = path.join(ROOT, 'data', 'linkedin-ingest-state.json');
const roots = [path.join(os.homedir(), 'Downloads'), path.join(os.homedir(), 'Desktop')];
const log = (...a) => console.log(new Date().toISOString(), ...a);
function findNewest(nameRe) {
let best = null;
for (const r of roots) {
let stack = [{ d: r, depth: 0 }];
while (stack.length) {
const { d, depth } = stack.pop();
let ents; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
for (const e of ents) {
const p = path.join(d, e.name);
if (e.isDirectory() && depth < 3) stack.push({ d: p, depth: depth + 1 });
else if (e.isFile() && nameRe.test(e.name)) {
const m = fs.statSync(p).mtimeMs;
if (!best || m > best.mtime) best = { path: p, mtime: m };
}
}
}
}
return best;
}
const state = (() => { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return {}; } })();
const follows = findNewest(/^company follows\.csv$/i) || findNewest(/company.?follows.*\.csv$/i);
const conns = findNewest(/^connections\.csv$/i);
let did = false;
if (follows && follows.mtime > (state.followsMtime || 0)) {
log('Ingesting follows:', follows.path);
execFileSync('node', [path.join(ROOT, 'scripts', 'import-linkedin-follows.mjs'), follows.path], { stdio: 'inherit' });
execFileSync('node', [path.join(ROOT, 'scripts', 'resolve-follows.mjs')], { stdio: 'inherit' }); // resolve new slugs
state.followsMtime = follows.mtime; did = true;
} else log('follows: up to date');
if (conns && conns.mtime > (state.connsMtime || 0)) {
log('Ingesting connections:', conns.path);
execFileSync('node', [path.join(ROOT, 'scripts', 'import-linkedin-connections.mjs'), conns.path], { stdio: 'inherit' });
execFileSync('node', [path.join(ROOT, 'scripts', 'build-vendor-contact-sheet.mjs')], { stdio: 'inherit' });
state.connsMtime = conns.mtime; did = true;
} else log('connections: up to date');
// ── Staleness reminder — email steve-personal when the LinkedIn data needs a fresh
// export (LinkedIn has no follows/connections API, so re-export is manual). Fires when
// the newest-ingested export is older than STALE_DAYS, re-nagging at most every 7 days.
const STALE_DAYS = 30, RENAG_DAYS = 7, DAY = 864e5;
const newestIngest = Math.max(state.followsMtime || 0, state.connsMtime || 0);
const ageDays = newestIngest ? (Date.now() - newestIngest) / DAY : 999;
const lastRemind = state.reminderSentAt ? new Date(state.reminderSentAt).getTime() : 0;
// George Basic-Auth — env first, then macOS Keychain (dw-agents/admin). Never hardcoded.
function georgeAuth() {
if (process.env.GEORGE_BASIC_AUTH) return process.env.GEORGE_BASIC_AUTH;
try { const pw = execFileSync('security', ['find-generic-password', '-s', 'dw-agents', '-a', 'admin', '-w'], { encoding: 'utf8' }).trim();
if (pw) return Buffer.from('admin:' + pw).toString('base64'); } catch {}
return null;
}
if (!did && ageDays >= STALE_DAYS && (Date.now() - lastRemind) >= RENAG_DAYS * DAY) {
const AUTH = georgeAuth();
if (!AUTH) { log('reminder skipped: no George auth (set GEORGE_BASIC_AUTH or dw-agents keychain)'); }
else {
const body = `Your LinkedIn data feeding the DW Marketing Command Center is ${Math.round(ageDays)} days old.\n\n`
+ `LinkedIn has no API for your follows/connections, so a fresh pull is a manual export:\n`
+ ` 1. LinkedIn → Settings → Data Privacy → Get a copy of your data → tick "Connections" + "Company Follows"\n`
+ ` 2. Request archive (ready in ~minutes), download the .zip, unzip on your Mac's Desktop/Downloads.\n`
+ `That's it — the command center auto-ingests it within 6h (contacts + follows refresh, no other step).\n`;
try {
const r = execFileSync('curl', ['-s', '--max-time', '20',
'-X', 'POST', 'http://127.0.0.1:9850/api/send', '-H', 'content-type: application/json',
'-H', `authorization: Basic ${AUTH}`,
'--data', JSON.stringify({ account: 'steve-office', from: 'steve@designerwallcoverings.com',
to: 'steveabramsdesigns@gmail.com', subject: 'DW Marketing: time to re-export your LinkedIn data',
body, no_source_tag: false })], { encoding: 'utf8' });
log('reminder emailed steve-personal:', r.slice(0, 120));
state.reminderSentAt = new Date().toISOString();
} catch (e) { log('reminder email FAILED (non-fatal):', e.message); }
}
}
fs.writeFileSync(STATE, JSON.stringify({ ...state, lastCheck: new Date().toISOString(), dataAgeDays: Math.round(ageDays) }, null, 2));
log(did ? 'INGESTED new export(s).' : `nothing new (data ${Math.round(ageDays)}d old).`);