← back to Dw Launches

ingest-browserbase.js

166 lines

'use strict';
/*
 * Vendor-IG ingest via Browserbase (cloud Chromium, residential IP, defeats the
 * local-IP block). Loads each vendor's public Instagram profile, scrapes recent
 * post thumbnails + permalinks, caches the images locally, and upserts into the
 * vendor-ig store so the Media → Vendor IG tab shows real, cached images.
 *
 * Usage:
 *   node ingest-browserbase.js                 # all seeded handles
 *   node ingest-browserbase.js 3               # first 3 handles (pilot)
 *   node ingest-browserbase.js schumacher1889 thibaut1886   # specific handles
 *
 * PAID: ~$0.01–0.05 per profile (one Browserbase session each). Prints a cost
 * estimate + per-vendor + batch total.
 */
const os = require('os');
const SK = os.homedir() + '/.claude/skills/browserbase';
const Browserbase = require(SK + '/node_modules/@browserbasehq/sdk').default;
const { chromium } = require(SK + '/node_modules/playwright-core');
require(SK + '/node_modules/dotenv').config({ path: SK + '/.env' });
const vendorIg = require('./vendor-ig');

const PER_PROFILE_COST = 0.03; // rough Browserbase session cost, for the ledger
const MAX_POSTS = parseInt(process.env.MAX_POSTS || '12', 10);

function pickHandles(argv) {
  const all = vendorIg.handles();
  if (!argv.length) return all;
  if (argv.length === 1 && /^\d+$/.test(argv[0])) return all.slice(0, parseInt(argv[0], 10));
  const want = new Set(argv.map(s => s.replace(/^@/, '').toLowerCase()));
  return all.filter(h => want.has(h.handle.toLowerCase()));
}

// IG login-walls public profiles; Picuki-family mirrors render them without
// login and are scrapable via the cloud browser (they proxy the real IG image).
// They're clones with the same DOM, so one extractor works across all of them.
// We try each mirror until one yields posts.
const MIRRORS = [
  h => `https://www.picuki.com/profile/${h}`,
  h => `https://www.picnob.com/profile/${h}/`,
  h => `https://www.pixwox.com/profile/${h}/`
];
async function extractPosts(page, max) {
  try { await page.click('text=/Accept|Agree|Consent/i', { timeout: 1500 }); } catch {}
  try { await page.evaluate(() => window.scrollBy(0, 1600)); } catch {}
  await page.waitForTimeout(2200);
  return await page.evaluate((max) => {
    const out = []; const seen = new Set();
    // picuki uses /media/<id>; picnob/pixwox use /post/<id> or /p/<code>
    document.querySelectorAll('a[href*="/media/"], a[href*="/post/"], a[href*="/p/"]').forEach(a => {
      const href = a.href;
      const id = (href.match(/\/(?:media|post|p)\/([A-Za-z0-9_-]+)/) || [])[1];
      if (!id || seen.has(id)) return;
      const img = a.querySelector('img') || a.parentElement.querySelector('img');
      const src = img && (img.getAttribute('src') || (img.getAttribute('srcset') || '').split(' ')[0]);
      if (!src || !/^https?:/.test(src)) return;
      const box = a.closest('.box-photo, .item, li, article, div');
      const desc = box && box.querySelector('.photo-description, .description, .caption');
      seen.add(id);
      out.push({
        shortcode: 'pk' + id, img: src,
        alt: (desc && desc.textContent.trim().slice(0, 300)) || (img && img.getAttribute('alt')) || '',
        permalink: href
      });
    });
    return out.slice(0, max);
  }, max);
}
// CAPTURE=shot → grab each post thumbnail as a rendered element-screenshot
// inside the cloud browser (bypasses mirrors' signed/expiring image URLs).
// Returns posts with a `shot` Buffer instead of relying on a fetchable img url.
async function captureShots(page, max) {
  try { await page.click('text=/Accept|Agree|Consent/i', { timeout: 1500 }); } catch {}
  try { await page.evaluate(() => window.scrollBy(0, 1600)); } catch {}
  await page.waitForTimeout(2500);
  const anchors = await page.$$('a[href*="/media/"], a[href*="/post/"], a[href*="/p/"]');
  const out = []; const seen = new Set();
  for (const a of anchors) {
    if (out.length >= max) break;
    try {
      const href = await a.getAttribute('href');
      const id = (href && href.match(/\/(?:media|post|p)\/([A-Za-z0-9_-]+)/) || [])[1];
      if (!id || seen.has(id)) continue;
      const img = await a.$('img');
      if (!img) continue;
      const box = await img.boundingBox();
      if (!box || box.width < 80) continue; // skip icons/avatars
      const alt = (await img.getAttribute('alt')) || '';
      const shot = await img.screenshot({ type: 'jpeg', quality: 88 });
      seen.add(id);
      out.push({ shortcode: 'pk' + id, alt: alt.slice(0, 300), permalink: href, shot });
    } catch {}
  }
  return out;
}
async function scrapeProfile(page, handle) {
  let lastErr = 'no mirror yielded posts';
  const useShot = process.env.CAPTURE === 'shot';
  for (const m of MIRRORS) {
    try {
      const resp = await page.goto(m(handle), { waitUntil: 'domcontentloaded', timeout: 40000 });
      if (resp && resp.status() >= 400) { lastErr = 'mirror ' + resp.status(); continue; }
      await page.waitForTimeout(2800);
      if (/no posts|user has no|not found/i.test(await page.evaluate(() => document.body.innerText.slice(0, 400)))) {
        lastErr = 'empty profile'; continue;
      }
      const posts = useShot ? await captureShots(page, MAX_POSTS) : await extractPosts(page, MAX_POSTS);
      if (posts.length) return posts;
      lastErr = '0 posts extracted';
    } catch (e) { lastErr = e.message.slice(0, 50); }
  }
  throw new Error(lastErr);
}

(async () => {
  const handles = pickHandles(process.argv.slice(2));
  if (!handles.length) { console.error('no handles matched'); process.exit(1); }
  const estLow = (handles.length * 0.01).toFixed(2), estHigh = (handles.length * 0.05).toFixed(2);
  console.log(`[browserbase] ${handles.length} profile(s) — est cost $${estLow}–$${estHigh}`);

  const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY });
  const summary = [];
  let spend = 0;

  for (const h of handles) {
    let session, browser, posts = 0, imgs = 0, err = null;
    try {
      session = await bb.sessions.create({ projectId: process.env.BROWSERBASE_PROJECT_ID });
      browser = await chromium.connectOverCDP(session.connectUrl);
      const ctx = browser.contexts()[0];
      const page = ctx.pages()[0] || await ctx.newPage();
      const found = await scrapeProfile(page, h.handle);
      posts = found.length;
      const store = vendorIg.load();
      const fs = require('fs'), path = require('path');
      const CACHE = path.join(__dirname, 'data', 'img-cache');
      if (!fs.existsSync(CACHE)) fs.mkdirSync(CACHE, { recursive: true });
      for (const f of found) {
        let cached = null, image_url = f.img || f.permalink;
        if (f.shot) {
          // in-session screenshot → save locally, serve via /api/vendor-ig/img/:id
          try { fs.writeFileSync(path.join(CACHE, 'vig_' + f.shortcode + '.jpg'), f.shot); cached = '/api/vendor-ig/img/' + f.shortcode; imgs++; } catch {}
        } else {
          try { cached = await vendorIg.cacheImage(f.img); imgs++; } catch {}
        }
        const i = store.posts.findIndex(p => p.id === 'ig_' + f.shortcode);
        const rec = {
          id: 'ig_' + f.shortcode, handle: h.handle, vendor: h.vendor || h.name,
          image_url, cached, caption: f.alt || '',
          permalink: f.permalink || '', posted_at: null
        };
        if (i >= 0) store.posts[i] = { ...store.posts[i], ...rec }; else store.posts.push(rec);
      }
      vendorIg.save(store);
    } catch (e) { err = e.message; }
    finally { try { await browser?.close(); } catch {} }
    spend += PER_PROFILE_COST;
    summary.push({ handle: h.handle, posts, imgs, err });
    console.log(`  @${h.handle.padEnd(20)} posts:${posts} cached:${imgs}${err ? ' ERR:' + err.slice(0, 60) : ''}  [batch $${spend.toFixed(2)}]`);
  }

  const okV = summary.filter(s => s.imgs > 0).length;
  const totalImgs = summary.reduce((a, s) => a + s.imgs, 0);
  console.log(`\n[done] ${okV}/${handles.length} vendors yielded images · ${totalImgs} images cached · ~$${spend.toFixed(2)} spent · store now ${vendorIg.count()} posts`);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });