← back to Marketing Command Center
MCC LinkedIn Network Feed: paste-URL curation board (the working populate path) — TK-10504
79e179829e7b11066936376346d1431270baa4c8 · 2026-08-12 14:55:45 -0700 · Steve Abrams
Paste a LinkedIn post URL -> official LinkedIn embed iframe (video plays inline) +
gated Reshare-from-DW-Page + Download-to-assets (when the public post exposes an
og:video licdn URL). Backend: urnFromUrl parser (activity/ugcPost/share, incl.
url-encoded), best-effort OG enrichment (6s timeout, graceful degrade), curated
store data/linkedin-feed-curated.json (gitignored + rsync-protected). Reuses the
existing gated reshare + licdn-guarded download routes. The experimental openclaw
auto-harvest scaffold stays below, clearly labeled. TOS-clean: no scraping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .deploy.confM .gitignoreM modules/linkedin/index.jsM public/panels/linkedin.htmlM public/panels/linkedin.js
Diff
commit 79e179829e7b11066936376346d1431270baa4c8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 14:55:45 2026 -0700
MCC LinkedIn Network Feed: paste-URL curation board (the working populate path) — TK-10504
Paste a LinkedIn post URL -> official LinkedIn embed iframe (video plays inline) +
gated Reshare-from-DW-Page + Download-to-assets (when the public post exposes an
og:video licdn URL). Backend: urnFromUrl parser (activity/ugcPost/share, incl.
url-encoded), best-effort OG enrichment (6s timeout, graceful degrade), curated
store data/linkedin-feed-curated.json (gitignored + rsync-protected). Reuses the
existing gated reshare + licdn-guarded download routes. The experimental openclaw
auto-harvest scaffold stays below, clearly labeled. TOS-clean: no scraping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.deploy.conf | 2 +-
.gitignore | 1 +
modules/linkedin/index.js | 69 +++++++++++++++++++++++++++++++++++++++++++
public/panels/linkedin.html | 20 ++++++++++++-
public/panels/linkedin.js | 72 ++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 161 insertions(+), 3 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
index 30ce8de..40f9ae0 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -23,4 +23,4 @@ HEALTH_URL=http://127.0.0.1:9662/api/health
# the deploy-side guard regardless.)
# linkedin-feed.json is the openclaw harvest output (TK-10504) — generated on the
# remote by the running MCC; a stale/absent local copy must never clobber it.
-RSYNC_EXTRA_EXCLUDES="follow-counts-history.json follow-counts-accounts.json follow-counts-snapshot.out follow-counts-snapshot.err clients-notes.json channels-outbox.json channels-tokens.json meta-pages.json assets.json assets-catalog.cache.json engine-queue.json engine-config.json linkedin-feed.json"
+RSYNC_EXTRA_EXCLUDES="follow-counts-history.json follow-counts-accounts.json follow-counts-snapshot.out follow-counts-snapshot.err clients-notes.json channels-outbox.json channels-tokens.json meta-pages.json assets.json assets-catalog.cache.json engine-queue.json engine-config.json linkedin-feed.json linkedin-feed-curated.json"
diff --git a/.gitignore b/.gitignore
index b31d18e..e152803 100644
--- a/.gitignore
+++ b/.gitignore
@@ -68,3 +68,4 @@ videos/**/node_modules/
videos/**/renders/
videos/**/.media/
videos/**/capture/
+data/linkedin-feed-curated.json
diff --git a/modules/linkedin/index.js b/modules/linkedin/index.js
index 9e5c665..c7b79ed 100644
--- a/modules/linkedin/index.js
+++ b/modules/linkedin/index.js
@@ -119,6 +119,45 @@ 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) {
@@ -259,5 +298,35 @@ module.exports = {
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 });
+ });
},
};
diff --git a/public/panels/linkedin.html b/public/panels/linkedin.html
index 098a113..af74f2c 100644
--- a/public/panels/linkedin.html
+++ b/public/panels/linkedin.html
@@ -71,9 +71,27 @@
</div><!-- /li-mode-follow -->
<div id="li-mode-feed" hidden>
+ <div class="card">
+ <h2 style="margin:0 0 4px;">Amplify a post from your network</h2>
+ <div class="muted" style="font-size:12.5px;margin-bottom:10px;">Saw a post worth resharing? Paste its LinkedIn URL — it renders as the <b>official LinkedIn embed</b> (video plays inline) with a one-click <b>Reshare from the DW Page</b> (gated) and, when the post is public, <b>Download to assets</b>. TOS-clean, no scraping.</div>
+ <div class="row" style="gap:8px;align-items:flex-end;flex-wrap:wrap;">
+ <div style="flex:1;min-width:240px;">
+ <label for="li-cur-url">LinkedIn post URL</label>
+ <input id="li-cur-url" type="text" placeholder="https://www.linkedin.com/posts/…-activity-7… or /feed/update/urn:li:activity:7…">
+ </div>
+ <div style="flex:1;min-width:180px;">
+ <label for="li-cur-note">Your reshare note <span class="muted" style="font-weight:400;">(optional)</span></label>
+ <input id="li-cur-note" type="text" placeholder="Why this matters for DW…">
+ </div>
+ <button class="btn gold" id="li-cur-add">+ Add to board</button>
+ </div>
+ <div id="li-cur-msg" class="muted" style="font-size:11.5px;margin-top:8px;"></div>
+ </div>
+ <div id="li-curated-cards"></div>
+
<div class="card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
- <h2 style="margin:0;">Network feed <span class="pill" style="background:#b8860b;color:#fff;font-size:9px;">EXPERIMENTAL</span></h2>
+ <h2 style="margin:0;">Auto-harvest <span class="pill" style="background:#b8860b;color:#fff;font-size:9px;">EXPERIMENTAL</span></h2>
<span class="pill" id="li-feed-count">0</span>
<span style="flex:1;"></span>
<label style="display:flex;align-items:center;gap:6px;font-size:11.5px;" class="muted"><input type="checkbox" id="li-feed-all"> show non-video too</label>
diff --git a/public/panels/linkedin.js b/public/panels/linkedin.js
index dacbaf6..a7a3fea 100644
--- a/public/panels/linkedin.js
+++ b/public/panels/linkedin.js
@@ -106,7 +106,7 @@ window.MCC_PANELS['linkedin'] = {
});
try { localStorage.setItem('mcc_li_mode', m); } catch {}
if (m === 'follow') initFollowList();
- if (m === 'feed') loadFeed();
+ if (m === 'feed') { loadCurated(); loadFeed(); }
}
$('#li-mode-compose-btn').onclick = () => setMode('compose');
$('#li-mode-follow-btn').onclick = () => setMode('follow');
@@ -187,6 +187,76 @@ window.MCC_PANELS['linkedin'] = {
$('#li-fl-bar').style.width = total ? (100 * doneCount / total).toFixed(1) + '%' : '0';
}
+ // ── Curated amplify board (the working populate path) ───────────────────
+ const curMsg = (t, err) => { const el = $('#li-cur-msg'); if (el) { el.textContent = t || ''; el.style.color = err ? '#c0563f' : 'var(--mut)'; } };
+ async function loadCurated() {
+ let c; try { c = await jget('/api/linkedin/feed/curated'); } catch { return; }
+ renderCurated(c.items || []);
+ }
+ function renderCurated(items) {
+ const wrap = $('#li-curated-cards');
+ if (!items.length) { wrap.innerHTML = '<div class="card muted">No posts on the board yet — paste a LinkedIn post URL above.</div>'; return; }
+ wrap.innerHTML = items.map(it => {
+ const dl = it.videoUrl
+ ? `<button class="btn ghost" data-cdl="${esc(it.videoUrl)}" data-name="${esc((it.title || 'LinkedIn') + ' — amplify video')}">⬇ Download to assets</button>`
+ : '<span class="muted" style="font-size:11px;">(no public video to download)</span>';
+ return `<div class="card">
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
+ <b>${esc(it.title || 'LinkedIn post')}</b>
+ <span class="muted" style="font-size:11px;">added ${esc(fmtWhen(it.addedAt))}</span>
+ </div>
+ ${it.note ? `<div class="muted" style="font-size:12px;margin:4px 0;">↳ ${esc(it.note)}</div>` : ''}
+ <iframe src="${esc(it.embedUrl)}" style="width:100%;height:480px;border:1px solid var(--line,#e6e6e6);border-radius:8px;margin:8px 0;" frameborder="0" allowfullscreen title="Embedded LinkedIn post" loading="lazy"></iframe>
+ <div class="row" style="gap:8px;align-items:center;flex-wrap:wrap;">
+ <a class="btn" href="${esc(it.permalink)}" target="_blank" rel="noopener noreferrer">Open ↗</a>
+ ${dl}
+ <button class="btn gated" data-creshare="${esc(it.urn)}" data-cnote="${esc(it.note || '')}">↻ Reshare from DW Page…</button>
+ <span style="flex:1;"></span>
+ <button class="btn ghost" data-cdel="${esc(it.id)}" style="font-size:11px;">Remove</button>
+ </div></div>`;
+ }).join('');
+ wrap.querySelectorAll('[data-cdl]').forEach(b => b.onclick = async () => {
+ b.disabled = true; const old = b.textContent; b.textContent = 'Downloading…';
+ try {
+ const r = await fetch(ORIGIN + '/api/linkedin/feed/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ src: b.dataset.cdl, name: b.dataset.name }) });
+ const d = await r.json();
+ if (d.ok) { b.textContent = '✓ In Asset Library'; curMsg('Saved ' + Math.round((d.asset.size || 0) / 1024) + ' KB to the Asset Library.'); }
+ else { b.textContent = old; b.disabled = false; curMsg('Download failed: ' + (d.error || 'error'), true); }
+ } catch (e) { b.textContent = old; b.disabled = false; curMsg('Download failed: ' + e.message, true); }
+ });
+ wrap.querySelectorAll('[data-creshare]').forEach(b => b.onclick = async () => {
+ const note = window.prompt('Comment for the reshare (optional) — OK to post from the DW Page:', b.dataset.cnote || '');
+ if (note === null) return;
+ if (!window.confirm('Reshare this post live from the DW LinkedIn Page now?')) return;
+ b.disabled = true; const old = b.textContent; b.textContent = 'Resharing…';
+ try {
+ const r = await fetch(ORIGIN + '/api/linkedin/feed/reshare', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ urn: b.dataset.creshare, commentary: deWP(note), confirm: true, approved: true }) });
+ const d = await r.json();
+ if (d.posted) { b.textContent = '✓ Reshared'; curMsg('Reshared from the DW Page (id ' + (d.id || '?') + ').'); }
+ else if (d.staged) { b.textContent = old; b.disabled = false; curMsg(d.message, true); }
+ else { b.textContent = old; b.disabled = false; curMsg('Reshare not posted: ' + (d.error || 'error') + '. (LinkedIn can restrict third-party reshares.)', true); }
+ } catch (e) { b.textContent = old; b.disabled = false; curMsg('Reshare failed: ' + e.message, true); }
+ });
+ wrap.querySelectorAll('[data-cdel]').forEach(b => b.onclick = async () => {
+ if (!window.confirm('Remove this post from the board?')) return;
+ await fetch(ORIGIN + '/api/linkedin/feed/curated/' + encodeURIComponent(b.dataset.cdel), { method: 'DELETE', credentials: 'same-origin' });
+ loadCurated();
+ });
+ }
+ $('#li-cur-add').onclick = async () => {
+ const url = $('#li-cur-url').value.trim();
+ if (!url) return curMsg('Paste a LinkedIn post URL first.', true);
+ const btn = $('#li-cur-add'); btn.disabled = true; curMsg('Adding…');
+ try {
+ const r = await fetch(ORIGIN + '/api/linkedin/feed/curated', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ url, note: $('#li-cur-note').value.trim() }) });
+ const d = await r.json();
+ if (d.ok) { $('#li-cur-url').value = ''; $('#li-cur-note').value = ''; curMsg('Added to the board.'); loadCurated(); }
+ else curMsg(d.error || 'Could not add that URL.', true);
+ } catch (e) { curMsg('Add failed: ' + e.message, true); }
+ btn.disabled = false;
+ };
+ $('#li-cur-url').addEventListener('keydown', e => { if (e.key === 'Enter') $('#li-cur-add').click(); });
+
// ── Network feed (harvested from Steve's own LinkedIn via openclaw) ──────
const feedStatus = (t, err) => { const el = $('#li-feed-status'); if (el) { el.textContent = t || ''; el.style.color = err ? '#c0563f' : 'var(--mut)'; } };
async function loadFeed() {
← 1749ce4 MCC LinkedIn Network Feed: mark EXPERIMENTAL — scrape non-fu
·
back to Marketing Command Center
·
MCC promo v2: hook-first, tighter (15.9s), new hook line; 9: dee3f1f →