← back to Marketing Command Center

scripts/linkedin-feed-harvest.mjs

150 lines

#!/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.
//
// ⚠ STATUS (2026-08-12, TK-10504): EXPERIMENTAL SCAFFOLD — currently returns 0
// posts against LinkedIn's live feed. Empirically probed: LinkedIn serves the
// CDP/automation-controlled Chrome a stripped feed with (1) hashed/randomized
// post class names (.feed-shared-update-v2 / [data-urn] match nothing), (2) no
// urn:li:activity in any DOM attribute, (3) blob:/MSE video (data-sources gone,
// nothing downloadable in the DOM), and (4) no fetch/XHR feed calls on scroll.
// The next path (Voyager API via session cookie + CSRF) is forbidden token-
// harvesting + TOS-violating and is intentionally NOT taken. Kept as a scaffold
// in case a viable, compliant source appears. The selectors below are the
// last-known-good shape and will need reworking if LinkedIn re-exposes the feed.
//
// 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; });