[object Object]

← 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

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 →