← back to Marketing Command Center
modules/linkedin/index.js
333 lines
'use strict';
// linkedin — LinkedIn composer + drafts + gated posting for the Marketing
// Command Center. DRAFT-FIRST (DTD verdict A 2026-06-13): compose with DW B2B
// best-practice templates, save drafts, copy/deep-link to post manually TODAY.
// The official LinkedIn API adapter (Community Management API — w_member_social
// for a personal profile, w_organization_social for the DW Company Page) is
// Phase 2: POSTING never auto-fires — it requires a connected token + confirm +
// approve, exactly like the social/channels gates.
//
// LinkedIn's User Agreement PROHIBITS third-party/browser automation, so there
// is intentionally NO scraping/headless posting here — the official API is the
// only programmatic path, and it stays human-approved.
const fs = require('fs');
const path = require('path');
const STORE = path.join(__dirname, '..', '..', 'data', 'linkedin-drafts.json');
const readDrafts = () => { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); } catch { return []; } };
const writeDrafts = a => { fs.mkdirSync(path.dirname(STORE), { recursive: true }); fs.writeFileSync(STORE, JSON.stringify(a, null, 2)); };
const env = k => (process.env[k] || '').trim();
// DW standing rule — "Wallpaper" is banned; "Wallcovering(s)" only.
const deWallpaper = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
let _seq = 0; const newId = () => 'li' + Date.now().toString(36) + ((_seq = (_seq + 1) % 1000).toString(36));
const TEMPLATES = [
{ id: 'reveal', name: 'Project reveal', body: 'Before → after: {room} transformed with {product}.\n\nThe brief: {goal}. The move: {why}.\n\nWhat would you have specified?' },
{ id: 'insight', name: 'Trade insight', body: 'One thing most {audience} get wrong about {topic}:\n\n{insight}\n\nHere’s how we approach it at Designer Wallcoverings.' },
{ id: 'feature', name: 'Product feature', body: 'Material spotlight: {product}.\n\nWhy it works: {benefit1}; {benefit2}; {benefit3}.\n\nSpecifying for a project? Let’s talk.' },
{ id: 'lesson', name: 'Lesson from an install', body: 'A lesson from a recent install:\n\n{lesson}\n\nSmall detail, big difference.' },
{ id: 'testimonial', name: 'Specifier testimonial', body: '“{quote}”\n\nThat’s from {role} on {project}, after specifying {product}.\n\nNothing means more than a designer trusting us on a project. Thank you, {name}.' },
];
// Connected only when a token + an author/org URN are set (Phase 2).
const liConfigured = () => !!(env('LINKEDIN_ACCESS_TOKEN') && (env('LINKEDIN_AUTHOR_URN') || env('LINKEDIN_ORG_URN')));
const LI_VER = '202401';
const liHeaders = token => ({ Authorization: `Bearer ${token}`, 'LinkedIn-Version': LI_VER, 'X-Restli-Protocol-Version': '2.0.0' });
// Resolve media bytes from either a local filesystem path or an http(s) URL
// (e.g. the reels app's /reels/<file>.mp4). Returns a Buffer.
async function mediaBytes(ref) {
if (/^https?:\/\//i.test(ref)) {
const r = await fetch(ref);
if (!r.ok) throw new Error(`fetch media ${r.status} for ${ref}`);
return Buffer.from(await r.arrayBuffer());
}
return fs.readFileSync(ref);
}
// Native image/video upload, ported from the linkedin-api CLI (post.py):
// initializeUpload → PUT part(s) → (video) finalizeUpload → returns the media URN.
async function liUploadMedia({ token, author, kind, ref }) {
const bytes = await mediaBytes(ref);
const init = kind === 'video'
? { initializeUploadRequest: { owner: author, fileSizeBytes: bytes.length, uploadCaptions: false, uploadThumbnail: false } }
: { initializeUploadRequest: { owner: author } };
const ir = await fetch(`https://api.linkedin.com/rest/${kind}s?action=initializeUpload`, {
method: 'POST', headers: { ...liHeaders(token), 'Content-Type': 'application/json' }, body: JSON.stringify(init),
});
if (!ir.ok) throw new Error(`initializeUpload ${ir.status}: ${(await ir.text()).slice(0, 200)}`);
const v = (await ir.json()).value;
const urn = v[kind];
if (kind === 'image') {
const pr = await fetch(v.uploadUrl, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: bytes });
if (!pr.ok) throw new Error(`image PUT ${pr.status}`);
return urn;
}
// video → PUT each byte-range part, collect ETags, then finalize
const etags = [];
for (const ins of v.uploadInstructions) {
const first = Number(ins.firstByte), last = Number(ins.lastByte);
const pr = await fetch(ins.uploadUrl, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: bytes.subarray(first, last + 1) });
if (!pr.ok) throw new Error(`video PUT part ${pr.status}`);
etags.push(pr.headers.get('etag'));
}
const fr = await fetch('https://api.linkedin.com/rest/videos?action=finalizeUpload', {
method: 'POST', headers: { ...liHeaders(token), 'Content-Type': 'application/json' },
body: JSON.stringify({ finalizeUploadRequest: { video: urn, uploadToken: v.uploadToken || '', uploadedPartIds: etags } }),
});
if (!fr.ok) throw new Error(`finalizeUpload ${fr.status}`);
return urn;
}
// Post text, or text + native image/video, or a link share, to the connected
// author (org page or personal). media = { video } | { image } | { url } where
// video/image is a local path or http(s) URL; title optional.
async function liPost({ text, media }) {
const token = env('LINKEDIN_ACCESS_TOKEN');
const author = env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN'); // urn:li:organization:… or urn:li:person:…
let content = null;
if (media && (media.video || media.image)) {
const kind = media.video ? 'video' : 'image';
const urn = await liUploadMedia({ token, author, kind, ref: media.video || media.image });
content = { media: { title: media.title || '', id: urn } }; // blank title, not the literal media-kind word
} else if (media && media.url) {
content = { article: { source: media.url, title: media.title || '', description: media.description || '' } };
}
const post = {
author, commentary: text, visibility: 'PUBLIC',
distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
};
if (content) post.content = content;
const r = await fetch('https://api.linkedin.com/rest/posts', {
method: 'POST', headers: { ...liHeaders(token), 'Content-Type': 'application/json' }, body: JSON.stringify(post),
});
const id = r.headers.get('x-restli-id') || r.headers.get('x-linkedin-id');
let body = null; try { body = await r.json(); } catch { /* may be empty */ }
return { ok: r.ok, status: r.status, id, error: r.ok ? null : ((body && body.message) || `HTTP ${r.status}`) };
}
// ── Network-feed harvest + reshare + download (TK-10504) ─────────────────────
// LinkedIn has NO feed-read API, so the "Network feed" is populated by a MANUAL,
// human-triggered openclaw harvest of Steve's OWN home feed (scripts/linkedin-
// feed-harvest.mjs) writing data/linkedin-feed.json. This is a research aid — it
// only READS the feed; every outward action below stays gated.
const { spawn } = require('child_process');
const FEED_STORE = path.join(__dirname, '..', '..', 'data', 'linkedin-feed.json');
const ASSETS_DIR = path.join(__dirname, '..', '..', 'data', 'assets');
const ASSETS_STORE = path.join(__dirname, '..', '..', 'data', 'assets.json');
const readFeed = () => { try { return JSON.parse(fs.readFileSync(FEED_STORE, 'utf8')); } catch { return { posts: [], harvestedAt: null }; } };
// ── Curated feed (TK-10504) — the WORKING populate path ──────────────────────
// LinkedIn's feed can't be scraped (see harvester header), so the panel is
// populated by pasting post URLs. Each becomes a card rendered via LinkedIn's
// OFFICIAL embed iframe (sanctioned, TOS-clean) + the same gated reshare/download.
const CURATED_STORE = path.join(__dirname, '..', '..', 'data', 'linkedin-feed-curated.json');
const readCurated = () => { try { return JSON.parse(fs.readFileSync(CURATED_STORE, 'utf8')); } catch { return { items: [] }; } };
const writeCurated = o => { fs.mkdirSync(path.dirname(CURATED_STORE), { recursive: true }); fs.writeFileSync(CURATED_STORE, JSON.stringify(o, null, 2)); };
// Extract urn:li:(activity|ugcPost|share):<id> from any LinkedIn post URL shape:
// /posts/<slug>-activity-7xxxx-yyyy /feed/update/urn:li:activity:7xxxx (url-encoded too)
function urnFromUrl(url) {
const raw = String(url || '').trim();
let dec = raw; try { dec = decodeURIComponent(raw); } catch { /* keep raw */ }
for (const s of [raw, dec]) {
let m = s.match(/urn:li:(activity|ugcPost|share):(\d{6,})/i);
if (m) return `urn:li:${m[1].toLowerCase() === 'ugcpost' ? 'ugcPost' : m[1].toLowerCase()}:${m[2]}`;
}
const m = dec.match(/activity[:\-](\d{6,})/i);
return m ? `urn:li:activity:${m[1]}` : null;
}
// Best-effort Open Graph pull from the PUBLIC post page — gives a thumbnail,
// title, and (when the post is public) a dms.licdn.com progressive video URL the
// existing /feed/download route can save. If LinkedIn blocks it, the embed iframe
// still renders the post, so this only enriches — it never gates adding a card.
async function fetchOg(permalink) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 6000);
try {
const r = await fetch(permalink, { headers: { 'User-Agent': 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)' }, redirect: 'follow', signal: ctrl.signal });
if (!r.ok) return {};
const html = (await r.text()).slice(0, 400000);
const og = k => { const m = html.match(new RegExp('<meta[^>]+(?:property|name)=["\\\']og:' + k + '["\\\'][^>]+content=["\\\']([^"\\\']+)', 'i')); return m ? m[1].replace(/&/g, '&') : ''; };
const video = og('video:url') || og('video:secure_url') || og('video');
return { title: og('title'), thumb: og('image'), videoUrl: /\.licdn\.com/i.test(video) ? video : '' };
} catch { return {}; }
finally { clearTimeout(timer); }
}
// Single-flight harvest job tracker — the harvester is never an auto-poster.
const harvestJob = { running: false, startedAt: null, finishedAt: null, code: null, error: null, log: '' };
function startHarvest(args) {
if (harvestJob.running) return false;
Object.assign(harvestJob, { running: true, startedAt: new Date().toISOString(), finishedAt: null, code: null, error: null, log: '' });
const script = path.join(__dirname, '..', '..', 'scripts', 'linkedin-feed-harvest.mjs');
const child = spawn(process.execPath, [script, ...args], { cwd: path.join(__dirname, '..', '..') });
const cap = d => { harvestJob.log = (harvestJob.log + d.toString()).slice(-4000); };
child.stdout.on('data', cap); child.stderr.on('data', cap);
child.on('error', e => { harvestJob.error = e.message; });
child.on('close', c => { harvestJob.running = false; harvestJob.finishedAt = new Date().toISOString(); harvestJob.code = c; });
return true;
}
// Reshare a network post from the connected DW author via the CMA Posts API.
// reshareContext.parent references the original activity/share URN. Third-party
// reshares can be restricted by LinkedIn — the raw API result is surfaced
// honestly (no false "posted" on a permissions error).
async function liReshare({ urn, commentary }) {
const token = env('LINKEDIN_ACCESS_TOKEN');
const author = env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN');
const post = {
author, commentary: commentary || '', visibility: 'PUBLIC',
distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
reshareContext: { parent: urn },
};
const r = await fetch('https://api.linkedin.com/rest/posts', {
method: 'POST', headers: { ...liHeaders(token), 'Content-Type': 'application/json' }, body: JSON.stringify(post),
});
const id = r.headers.get('x-restli-id') || r.headers.get('x-linkedin-id');
let body = null; try { body = await r.json(); } catch { /* may be empty */ }
return { ok: r.ok, status: r.status, id, error: r.ok ? null : ((body && body.message) || `HTTP ${r.status}`) };
}
// Download a licdn progressive-mp4 into the SHARED asset library (data/assets/*
// + a record in data/assets.json, matching the assets module's record shape so
// it appears in the Asset Library for re-cutting as original DW content).
async function downloadToAssets({ src, name }) {
let host; try { host = new URL(src).hostname; } catch { throw new Error('bad url'); }
if (!/\.licdn\.com$/i.test(host)) throw new Error('refusing to download from non-licdn host: ' + host);
const r = await fetch(src, { headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'video/mp4,*/*' } });
if (!r.ok) throw new Error(`fetch video ${r.status} (licdn URLs are signed + expire — re-harvest, then download)`);
const buf = Buffer.from(await r.arrayBuffer());
fs.mkdirSync(ASSETS_DIR, { recursive: true });
const id = 'a' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36);
const filename = id + '.mp4';
fs.writeFileSync(path.join(ASSETS_DIR, filename), buf);
let store = []; try { store = JSON.parse(fs.readFileSync(ASSETS_STORE, 'utf8')); } catch { store = []; }
const asset = {
id, name: (name || 'LinkedIn video').toString().slice(0, 120), kind: 'upload', filename,
mime: 'video/mp4', size: buf.length, tags: ['linkedin', 'video', 'network'], created_at: new Date().toISOString(),
};
store.push(asset); fs.writeFileSync(ASSETS_STORE, JSON.stringify(store, null, 2));
return { id, filename, size: buf.length, src: `/api/assets/file/${filename}` };
}
module.exports = {
id: 'linkedin',
title: 'LinkedIn',
icon: '💼',
mount(router) {
router.get('/connection', (_req, res) => res.json({
configured: liConfigured(),
surface: env('LINKEDIN_ORG_URN') ? 'Company Page' : (env('LINKEDIN_AUTHOR_URN') ? 'Personal profile' : null),
mode: liConfigured() ? 'live (gated)' : 'draft-only (manual post)',
}));
router.get('/templates', (_req, res) => res.json({ templates: TEMPLATES }));
router.get('/drafts', (_req, res) => res.json({ drafts: readDrafts().slice().sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) }));
router.post('/draft', (req, res) => {
const text = deWallpaper(String((req.body && req.body.text) || '').slice(0, 3000));
const hashtags = Array.isArray(req.body && req.body.hashtags) ? req.body.hashtags.slice(0, 8) : [];
if (!text.trim()) return res.status(400).json({ error: 'empty post' });
const d = { id: newId(), text, hashtags, chars: text.length, status: 'draft', created_at: new Date().toISOString() };
const arr = readDrafts(); arr.push(d); writeDrafts(arr);
res.json({ ok: true, draft: d });
});
router.delete('/draft/:id', (req, res) => {
const arr = readDrafts(); const i = arr.findIndex(d => d.id === req.params.id);
if (i === -1) return res.status(404).json({ error: 'not found' });
const [x] = arr.splice(i, 1); writeDrafts(arr); res.json({ ok: true, id: x.id });
});
// Gated publish — NEVER auto-fires. Stages unless connected + confirm + approve.
// Optional media: { video } | { image } | { url } (video/image = local path or
// http(s) URL, e.g. the reels app's /reels/<file>.mp4); title/description optional.
router.post('/publish', async (req, res) => {
const d = req.body || {};
const text = deWallpaper(String(d.text || '').slice(0, 3000));
if (!text.trim()) return res.status(400).json({ error: 'empty post' });
if (d.confirm !== true) return res.status(400).json({ error: 'A live post requires confirm:true.' });
if (d.approved !== true) return res.status(400).json({ error: 'This post is not approved. Set approved:true to clear it for live posting.' });
// normalize a media reference from either d.media or flat d.video/d.image/d.url
const media = d.media || (d.video ? { video: d.video, title: d.title } : d.image ? { image: d.image, title: d.title } : d.url ? { url: d.url, title: d.title, description: d.description } : null);
if (!liConfigured()) {
return res.json({ ok: true, staged: true, posted: false, media: media || null, message: 'Staged — LinkedIn isn’t connected. Add LINKEDIN_ACCESS_TOKEN + author/org URN (Phase 2) to post live. Nothing was sent.' });
}
try { const r = await liPost({ text, media }); return res.json({ ok: r.ok, posted: r.ok, id: r.id, error: r.error }); }
catch (e) { return res.status(502).json({ error: e.message }); }
});
// ── Network feed (TK-10504) ────────────────────────────────────────────
// Read harvested posts (video-only by default; ?all=1 for every post).
router.get('/feed', (req, res) => {
const f = readFeed();
const all = req.query.all === '1';
const posts = all ? (f.posts || []) : (f.posts || []).filter(p => p.isVideo);
res.json({ harvestedAt: f.harvestedAt || null, total: f.total ?? (f.posts || []).length, videos: f.videos, count: posts.length, posts });
});
// Manually trigger an openclaw harvest of Steve's own feed (research aid).
router.get('/feed/harvest/status', (_req, res) => res.json(harvestJob));
router.post('/feed/harvest', (req, res) => {
const args = [];
if (req.body && req.body.scrolls) args.push('--scrolls=' + Math.max(1, Math.min(40, Number(req.body.scrolls) || 8)));
if (req.body && req.body.all) args.push('--all');
const ok = startHarvest(args);
res.json({ ok, started: ok, running: harvestJob.running, message: ok ? 'Harvest started (openclaw real Chrome).' : 'A harvest is already running.' });
});
// Download a harvested video into the shared asset library (action: repurpose).
router.post('/feed/download', async (req, res) => {
const src = String((req.body && req.body.src) || '');
if (!src) return res.status(400).json({ error: 'src required' });
try { const a = await downloadToAssets({ src, name: (req.body && req.body.name) }); res.json({ ok: true, asset: a }); }
catch (e) { res.status(502).json({ error: e.message }); }
});
// Gated reshare from the DW Page — NEVER auto-fires (confirm + approved + connected).
router.post('/feed/reshare', async (req, res) => {
const d = req.body || {};
const urn = String(d.urn || '');
if (!/^urn:li:/.test(urn)) return res.status(400).json({ error: 'a valid post urn is required' });
if (d.confirm !== true) return res.status(400).json({ error: 'A live reshare requires confirm:true.' });
if (d.approved !== true) return res.status(400).json({ error: 'This reshare is not approved. Set approved:true to clear it for live posting.' });
if (!liConfigured()) {
return res.json({ ok: true, staged: true, posted: false, message: 'Staged — LinkedIn isn’t connected. Add LINKEDIN_ACCESS_TOKEN + author/org URN to reshare live. Nothing was sent.' });
}
try { const r = await liReshare({ urn, commentary: deWallpaper(String(d.commentary || '').slice(0, 3000)) }); return res.json({ ok: r.ok, posted: r.ok, id: r.id, status: r.status, error: r.error }); }
catch (e) { return res.status(502).json({ error: e.message }); }
});
// ── Curated feed (the WORKING populate path) — add posts by URL ─────────
router.get('/feed/curated', (_req, res) => {
const c = readCurated();
res.json({ count: (c.items || []).length, items: (c.items || []).slice().sort((a, b) => (b.addedAt || '').localeCompare(a.addedAt || '')) });
});
router.post('/feed/curated', async (req, res) => {
const url = String((req.body && req.body.url) || '').trim();
const note = deWallpaper(String((req.body && req.body.note) || '').slice(0, 600));
const urn = urnFromUrl(url);
if (!urn) return res.status(400).json({ error: 'Could not find a LinkedIn post id in that URL. Paste a post/activity URL (…/posts/…-activity-<id>… or …/feed/update/urn:li:activity:<id>).' });
const c = readCurated();
if ((c.items || []).some(it => it.urn === urn)) return res.status(409).json({ error: 'That post is already on the board.' });
const permalink = `https://www.linkedin.com/feed/update/${urn}/`;
const og = await fetchOg(permalink);
const item = {
id: 'lc' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36),
urn, permalink, sourceUrl: url,
embedUrl: `https://www.linkedin.com/embed/feed/update/${urn}`,
title: og.title || '', thumb: og.thumb || '', videoUrl: og.videoUrl || '',
note, addedAt: new Date().toISOString(),
};
c.items = c.items || []; c.items.push(item); writeCurated(c);
res.json({ ok: true, item });
});
router.delete('/feed/curated/:id', (req, res) => {
const c = readCurated(); const i = (c.items || []).findIndex(it => it.id === req.params.id);
if (i === -1) return res.status(404).json({ error: 'not found' });
const [x] = c.items.splice(i, 1); writeCurated(c); res.json({ ok: true, id: x.id });
});
},
};