← back to Marketing Command Center
MCC LinkedIn: Network Feed — openclaw harvest of own feed + gated reshare + download-to-assets
46e18c8e5442a7e283cf6d63afd2fa3c59e290a0 · 2026-08-12 14:27:29 -0700 · Steve Abrams
- scripts/linkedin-feed-harvest.mjs: manual openclaw real-Chrome harvest of Steve's
own LinkedIn home feed -> data/linkedin-feed.json (video posts + progressive mp4
sources). Login-guarded single-run research aid; never an auto-poster.
- modules/linkedin: GET /feed, POST /feed/harvest(+status), /feed/download
(licdn-only SSRF guard -> shared Asset Library), /feed/reshare (gated: confirm+
approved+connected, honest API result).
- panel: third 'Network feed' mode — poster cards, Download to assets, gated
Reshare from DW Page.
TK-10504. LinkedIn has no feed-read API; reads Steve's logged-in session locally
per his authorization. Deploy to Kamatera is gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreM modules/linkedin/index.jsM public/panels/linkedin.htmlM public/panels/linkedin.jsA scripts/linkedin-feed-harvest.mjs
Diff
commit 46e18c8e5442a7e283cf6d63afd2fa3c59e290a0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 14:27:29 2026 -0700
MCC LinkedIn: Network Feed — openclaw harvest of own feed + gated reshare + download-to-assets
- scripts/linkedin-feed-harvest.mjs: manual openclaw real-Chrome harvest of Steve's
own LinkedIn home feed -> data/linkedin-feed.json (video posts + progressive mp4
sources). Login-guarded single-run research aid; never an auto-poster.
- modules/linkedin: GET /feed, POST /feed/harvest(+status), /feed/download
(licdn-only SSRF guard -> shared Asset Library), /feed/reshare (gated: confirm+
approved+connected, honest API result).
- panel: third 'Network feed' mode — poster cards, Download to assets, gated
Reshare from DW Page.
TK-10504. LinkedIn has no feed-read API; reads Steve's logged-in session locally
per his authorization. Deploy to Kamatera is gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 1 +
modules/linkedin/index.js | 106 +++++++++++++++++++++++++++++
public/panels/linkedin.html | 18 +++++
public/panels/linkedin.js | 90 +++++++++++++++++++++++--
scripts/linkedin-feed-harvest.mjs | 138 ++++++++++++++++++++++++++++++++++++++
5 files changed, 346 insertions(+), 7 deletions(-)
diff --git a/.gitignore b/.gitignore
index f75b75b..eb49f7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,3 +61,4 @@ screenrecord/rec/
# locally-cached remote post media (durable copies of expiring IG/fbcdn urls)
public/cache/
+data/linkedin-feed.json
diff --git a/modules/linkedin/index.js b/modules/linkedin/index.js
index 99640f3..9e5c665 100644
--- a/modules/linkedin/index.js
+++ b/modules/linkedin/index.js
@@ -108,6 +108,74 @@ async function liPost({ text, media }) {
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 }; } };
+
+// 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',
@@ -153,5 +221,43 @@ module.exports = {
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 }); }
+ });
},
};
diff --git a/public/panels/linkedin.html b/public/panels/linkedin.html
index 135d706..f4d87c2 100644
--- a/public/panels/linkedin.html
+++ b/public/panels/linkedin.html
@@ -4,6 +4,7 @@
<div class="row" id="li-modebar" style="gap:8px;margin-bottom:14px;">
<button class="btn gold" id="li-mode-compose-btn">✍️ Compose a post</button>
<button class="btn ghost" id="li-mode-follow-btn">👥 Follow list</button>
+ <button class="btn ghost" id="li-mode-feed-btn">📰 Network feed</button>
</div>
<div id="li-mode-compose">
@@ -68,4 +69,21 @@
<div id="li-fl-sections"></div>
<p class="muted" id="li-fl-updated" style="font-size:11px;text-align:center;margin:18px 0;"></p>
</div><!-- /li-mode-follow -->
+
+ <div id="li-mode-feed" hidden>
+ <div class="card">
+ <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
+ <h2 style="margin:0;">Network feed</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>
+ <button class="btn gold" id="li-feed-refresh">↻ Refresh from LinkedIn</button>
+ </div>
+ <div class="muted" style="font-size:12.5px;margin:10px 0 0;">
+ Pulls video posts from <b>your own LinkedIn home feed</b> through openclaw real Chrome — LinkedIn has no feed API, so this reads your logged-in session locally. For each post you can <b>⬇ Download to assets</b> (re-cut as original DW content) or <b>↻ Reshare from the DW Page</b> (gated). Requires Chrome signed into LinkedIn on the <code>openclaw</code> profile.
+ </div>
+ <div id="li-feed-status" class="muted" style="font-size:11.5px;margin-top:8px;"></div>
+ </div>
+ <div id="li-feed-cards"><div class="card muted">No feed harvested yet — click <b>Refresh from LinkedIn</b>.</div></div>
+ </div><!-- /li-mode-feed -->
</div>
diff --git a/public/panels/linkedin.js b/public/panels/linkedin.js
index 4fcb728..dacbaf6 100644
--- a/public/panels/linkedin.js
+++ b/public/panels/linkedin.js
@@ -96,18 +96,21 @@ window.MCC_PANELS['linkedin'] = {
msg(d.error ? '✗ ' + d.error : (d.posted ? '✓ Posted to LinkedIn.' : (d.message || 'Staged.')), !!d.error);
};
- // ── Mode toggle: Compose ⇄ Follow list ──────────────────────────────────
+ // ── Mode toggle: Compose ⇄ Follow list ⇄ Network feed ───────────────────
+ const MODES = ['compose', 'follow', 'feed'];
function setMode(m) {
- const follow = m === 'follow';
- $('#li-mode-compose').hidden = follow;
- $('#li-mode-follow').hidden = !follow;
- $('#li-mode-compose-btn').className = 'btn ' + (follow ? 'ghost' : 'gold');
- $('#li-mode-follow-btn').className = 'btn ' + (follow ? 'gold' : 'ghost');
+ if (!MODES.includes(m)) m = 'compose';
+ MODES.forEach(x => {
+ $('#li-mode-' + x).hidden = x !== m;
+ $('#li-mode-' + x + '-btn').className = 'btn ' + (x === m ? 'gold' : 'ghost');
+ });
try { localStorage.setItem('mcc_li_mode', m); } catch {}
- if (follow) initFollowList();
+ if (m === 'follow') initFollowList();
+ if (m === 'feed') loadFeed();
}
$('#li-mode-compose-btn').onclick = () => setMode('compose');
$('#li-mode-follow-btn').onclick = () => setMode('follow');
+ $('#li-mode-feed-btn').onclick = () => setMode('feed');
// ── Follow list (from public/data/linkedin-followlist.json) ─────────────
const FL_KEY = 'mcc_li_followed'; // localStorage Set of followed hrefs
@@ -184,6 +187,79 @@ window.MCC_PANELS['linkedin'] = {
$('#li-fl-bar').style.width = total ? (100 * doneCount / total).toFixed(1) + '%' : '0';
}
+ // ── 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() {
+ const all = $('#li-feed-all') && $('#li-feed-all').checked ? '?all=1' : '';
+ let f; try { f = await jget('/api/linkedin/feed' + all); } catch { feedStatus('Could not load the feed.', true); return; }
+ $('#li-feed-count').textContent = f.count || 0;
+ if (f.harvestedAt) feedStatus('Harvested ' + fmtWhen(f.harvestedAt) + ' — ' + (f.videos || 0) + ' video / ' + (f.total || 0) + ' posts. licdn video URLs expire; re-refresh if a download 502s.');
+ renderFeed(f.posts || []);
+ }
+ function renderFeed(posts) {
+ const wrap = $('#li-feed-cards');
+ if (!posts.length) { wrap.innerHTML = '<div class="card muted">No posts harvested yet — click <b>Refresh from LinkedIn</b>.</div>'; return; }
+ wrap.innerHTML = posts.map(p => {
+ const poster = p.video && p.video.poster ? `<img src="${esc(p.video.poster)}" alt="" style="width:120px;height:auto;border-radius:8px;object-fit:cover;flex:none;">` : '';
+ const vid = p.isVideo ? '<span class="pill" style="font-size:9px;">▶ video</span>' : '';
+ const dl = p.isVideo && p.video && p.video.best
+ ? `<button class="btn ghost" data-dl="${esc(p.video.best)}" data-name="${esc((p.author || 'LinkedIn') + ' — network video')}">⬇ Download to assets</button>` : '';
+ return `<div class="card" style="display:flex;gap:12px;">
+ ${poster}
+ <div style="flex:1;min-width:0;">
+ <div style="display:flex;align-items:center;gap:8px;"><b>${esc(p.author || 'Unknown')}</b> ${vid}</div>
+ ${p.sub ? `<div class="muted" style="font-size:11px;">${esc(p.sub)}</div>` : ''}
+ <div style="font-size:12.5px;margin:6px 0;white-space:pre-wrap;">${esc((p.text || '').slice(0, 280))}${(p.text || '').length > 280 ? '…' : ''}</div>
+ <div class="row" style="gap:8px;align-items:center;flex-wrap:wrap;">
+ <a class="btn" href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer">View on LinkedIn ↗</a>
+ ${dl}
+ <button class="btn gated" data-reshare="${esc(p.urn)}">↻ Reshare from DW Page…</button>
+ </div>
+ </div></div>`;
+ }).join('');
+ wrap.querySelectorAll('[data-dl]').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.dl, name: b.dataset.name }) });
+ const d = await r.json();
+ if (d.ok) { b.textContent = '✓ In Asset Library'; feedStatus('Saved ' + Math.round((d.asset.size || 0) / 1024) + ' KB to the Asset Library.'); }
+ else { b.textContent = old; b.disabled = false; feedStatus('Download failed: ' + (d.error || 'error'), true); }
+ } catch (e) { b.textContent = old; b.disabled = false; feedStatus('Download failed: ' + e.message, true); }
+ });
+ wrap.querySelectorAll('[data-reshare]').forEach(b => b.onclick = async () => {
+ const note = window.prompt('Add your comment for the reshare (optional) — then OK to post from the DW Page:', '');
+ if (note === null) return; // cancelled
+ 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.reshare, commentary: deWP(note), confirm: true, approved: true }) });
+ const d = await r.json();
+ if (d.posted) { b.textContent = '✓ Reshared'; feedStatus('Reshared from the DW Page (id ' + (d.id || '?') + ').'); }
+ else if (d.staged) { b.textContent = old; b.disabled = false; feedStatus(d.message, true); }
+ else { b.textContent = old; b.disabled = false; feedStatus('Reshare not posted: ' + (d.error || 'error') + '. (LinkedIn can restrict third-party reshares.)', true); }
+ } catch (e) { b.textContent = old; b.disabled = false; feedStatus('Reshare failed: ' + e.message, true); }
+ });
+ }
+ $('#li-feed-all') && ($('#li-feed-all').onchange = loadFeed);
+ $('#li-feed-refresh').onclick = async () => {
+ const btn = $('#li-feed-refresh'); btn.disabled = true; feedStatus('Starting openclaw harvest of your feed…');
+ try {
+ const r = await fetch(ORIGIN + '/api/linkedin/feed/harvest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({}) });
+ const d = await r.json();
+ if (!d.started) { feedStatus(d.message || 'A harvest is already running.', true); btn.disabled = false; return; }
+ } catch (e) { feedStatus('Could not start harvest: ' + e.message, true); btn.disabled = false; return; }
+ // poll status until the child process exits
+ const poll = setInterval(async () => {
+ let s; try { s = await jget('/api/linkedin/feed/harvest/status'); } catch { return; }
+ const tail = (s.log || '').split('\n').filter(Boolean).pop() || '';
+ if (s.running) { feedStatus('Harvesting… ' + tail); return; }
+ clearInterval(poll); btn.disabled = false;
+ if (s.error || s.code) feedStatus('Harvest finished with an issue — ' + (s.error || tail || ('exit ' + s.code)), true);
+ else feedStatus('Harvest complete. ' + tail);
+ loadFeed();
+ }, 2500);
+ };
+
await loadConn(); await loadTemplates(); await loadDrafts(); updateCount();
// Restore last mode (default compose).
setMode((() => { try { return localStorage.getItem('mcc_li_mode') || 'compose'; } catch { return 'compose'; } })());
diff --git a/scripts/linkedin-feed-harvest.mjs b/scripts/linkedin-feed-harvest.mjs
new file mode 100644
index 0000000..e5998fd
--- /dev/null
+++ b/scripts/linkedin-feed-harvest.mjs
@@ -0,0 +1,138 @@
+#!/usr/bin/env node
+// linkedin-feed-harvest.mjs — LOCAL research aid for the Marketing Command Center.
+//
+// LinkedIn has NO feed-read API (the Community Management API the MCC uses only
+// PUBLISHES + reads DW's own org posts). Steve authorized (2026-08-12, TK-10504)
+// harvesting HIS OWN home feed through openclaw's real logged-in Chrome so the
+// "Network feed" panel can surface video posts worth resharing/repurposing.
+//
+// This is a MANUAL, human-triggered tool — it is NEVER an always-on auto-poster.
+// It only READS the feed and writes data/linkedin-feed.json. Every outward action
+// (reshare / download) stays gated in the panel. Scraping bypasses bot-detection,
+// not gates: dw-legal-compliance + the reshare/publish confirm gates still apply.
+//
+// Usage:
+// node scripts/linkedin-feed-harvest.mjs # video posts, 8 scrolls
+// node scripts/linkedin-feed-harvest.mjs --scrolls=12 # scroll deeper
+// node scripts/linkedin-feed-harvest.mjs --all # keep non-video posts too
+//
+// Cost: $0 (openclaw real browser + local). Requires openclaw enabled + Chrome
+// already logged into LinkedIn on the `openclaw` profile.
+import { execSync } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(__dirname, '..', 'data', 'linkedin-feed.json');
+const kv = Object.fromEntries(process.argv.slice(2).filter(a => a.includes('=')).map(a => a.slice(2).split('=')));
+const FLAGS = new Set(process.argv.slice(2).filter(a => !a.includes('=')).map(a => a.replace(/^--/, '')));
+const SCROLLS = Math.max(1, Math.min(40, Number(kv.scrolls) || 8));
+const VIDEO_ONLY = !FLAGS.has('all');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function oc(args) {
+ return execSync(`openclaw browser ${args}`, { encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'ignore'] });
+}
+// openclaw evaluate returns a JSON-encoded string; a fn that returns
+// JSON.stringify(x) comes back double-encoded → parse up to twice.
+function unwrap(s) {
+ s = (s || '').trim().split('\n').filter(Boolean).pop() || 'null';
+ try { let v = JSON.parse(s); if (typeof v === 'string') { try { v = JSON.parse(v); } catch { /* plain string */ } } return v; }
+ catch { return null; }
+}
+
+// Extraction runs INSIDE the page. Written with string concat + .includes()/
+// .endsWith() so it carries no `$` or backticks (shell-hazard chars when inlined
+// into the openclaw --fn argument under double quotes).
+const EXTRACT_FN = "() => {" +
+ "var out=[];" +
+ "var nodes=document.querySelectorAll('[data-urn]');" +
+ "for(var i=0;i<nodes.length;i++){var n=nodes[i];" +
+ "var urn=n.getAttribute('data-urn')||'';" +
+ "if(urn.indexOf('urn:li:activity:')!==0)continue;" +
+ "var authEl=n.querySelector('.update-components-actor__title, .update-components-actor__name, span.feed-shared-actor__name');" +
+ "var author=authEl?authEl.innerText.trim().split('\\n')[0]:'';" +
+ "var subEl=n.querySelector('.update-components-actor__description');" +
+ "var sub=subEl?subEl.innerText.trim().split('\\n')[0]:'';" +
+ "var txtEl=n.querySelector('.update-components-update-v2__commentary, .feed-shared-update-v2__description, .update-components-text');" +
+ "var text=txtEl?txtEl.innerText.trim():'';" +
+ // gather progressive mp4 sources from any descendant data-sources blob
+ "var srcs=[];var ds=n.querySelectorAll('[data-sources]');" +
+ "for(var j=0;j<ds.length;j++){try{var arr=JSON.parse(ds[j].getAttribute('data-sources'));" +
+ "for(var k=0;k<arr.length;k++){var s=arr[k]||{};var u=s.src||'';" +
+ "if(u.indexOf('licdn')!==-1&&(u.indexOf('/mp4-')!==-1||u.endsWith('.mp4'))){" +
+ "srcs.push({src:u,bitrate:Number(s['data-bitrate']||s.bitrate||0)});}}}catch(e){}}" +
+ // fallback: a bare <video src>
+ "if(!srcs.length){var v=n.querySelector('video');if(v&&v.src&&v.src.indexOf('licdn')!==-1&&v.src.indexOf('blob:')!==0){srcs.push({src:v.src,bitrate:0});}}" +
+ "srcs.sort(function(a,b){return b.bitrate-a.bitrate;});" +
+ "var vEl=n.querySelector('video');" +
+ "var poster=vEl?vEl.getAttribute('poster'):'';" +
+ "if(!poster){var img=n.querySelector('.update-components-linkedin-video img, .ivm-image-view-model img');poster=img?img.src:'';}" +
+ "out.push({urn:urn,author:author,sub:sub,text:text.slice(0,600),isVideo:srcs.length>0," +
+ "video:srcs.length?{sources:srcs,best:srcs[0].src,poster:poster}:null," +
+ "permalink:'https://www.linkedin.com/feed/update/'+urn+'/'});}" +
+ // de-dup by urn, preserving order
+ "var seen={};var uniq=[];for(var m=0;m<out.length;m++){if(!seen[out[m].urn]){seen[out[m].urn]=1;uniq.push(out[m]);}}" +
+ "return JSON.stringify(uniq);}";
+
+function ensureOpenclaw() {
+ let status;
+ try { status = oc('status'); } catch (e) { throw new Error('openclaw not reachable — is the CLI installed + Chrome available? (' + e.message + ')'); }
+ if (!/enabled/i.test(status) || /enabled\s*[:=]\s*false/i.test(status)) {
+ throw new Error('openclaw browser is not enabled. Run: openclaw browser status (expect enabled:true)');
+ }
+}
+
+async function main() {
+ ensureOpenclaw();
+ console.log('Opening LinkedIn feed in openclaw real Chrome…');
+ const openOut = oc('open "https://www.linkedin.com/feed/" --timeout 40000');
+ const tab = (openOut.match(/id:\s*([A-F0-9]+)/i) || [])[1];
+ if (!tab) throw new Error('could not obtain a tab id from openclaw open');
+
+ await sleep(3500);
+ // login guard — an authwall/login redirect means the openclaw Chrome profile
+ // isn't signed into LinkedIn. Don't hammer; tell Steve to log in once.
+ const href = unwrap(oc(`evaluate --target-id ${tab} --fn ${JSON.stringify('() => location.href')}`)) || '';
+ if (/\/(login|authwall|uas\/login|checkpoint)/i.test(String(href))) {
+ try { oc(`close --target-id ${tab}`); } catch {}
+ throw new Error('LinkedIn is not logged in on the openclaw Chrome profile (landed on ' + href + '). Log into linkedin.com once in that Chrome, then re-run.');
+ }
+
+ // scroll to lazy-load posts
+ let count = 0;
+ for (let i = 0; i < SCROLLS; i++) {
+ try { count = unwrap(oc(`evaluate --target-id ${tab} --fn ${JSON.stringify('() => { window.scrollBy(0, document.body.scrollHeight); return document.querySelectorAll("[data-urn]").length; }')}`)) || count; } catch {}
+ process.stdout.write(` scroll ${i + 1}/${SCROLLS} — ${count} update containers loaded\r`);
+ await sleep(1600);
+ }
+ console.log('');
+
+ let posts = [];
+ for (let i = 0; i < 4 && !posts.length; i++) {
+ await sleep(1200);
+ const v = unwrap(oc(`evaluate --target-id ${tab} --fn ${JSON.stringify(EXTRACT_FN)}`));
+ if (Array.isArray(v)) posts = v;
+ }
+ try { oc(`close --target-id ${tab}`); } catch {}
+
+ const videoPosts = posts.filter(p => p.isVideo);
+ const kept = VIDEO_ONLY ? videoPosts : posts;
+ const payload = {
+ harvestedAt: new Date().toISOString(),
+ source: 'openclaw-real-chrome',
+ scrolls: SCROLLS,
+ videoOnly: VIDEO_ONLY,
+ total: posts.length,
+ videos: videoPosts.length,
+ count: kept.length,
+ posts: kept,
+ };
+ fs.mkdirSync(path.dirname(OUT), { recursive: true });
+ fs.writeFileSync(OUT, JSON.stringify(payload, null, 2));
+ console.log(`Harvested ${posts.length} posts (${videoPosts.length} with video). Wrote ${kept.length} → ${path.relative(process.cwd(), OUT)}`);
+ console.log('$0 (openclaw real browser + local). Note: licdn video URLs are signed + expire — download soon.');
+}
+
+main().catch(e => { console.error('linkedin-feed-harvest error:', e.message); process.exitCode = 1; });
← 454644c deploy: Insights hub live on Kamatera (v1.9.1)
·
back to Marketing Command Center
·
MCC deploy: protect remote linkedin-feed.json harvest output 189b7f7 →