← back to Pattern Vault

wpb-uploaders/lib/base.js

68 lines

/*
 * Shared uploader base for every WPB marketplace. Each platform module supplies a
 * config { key, name, sessionFile, healthUrl, uploadUrl, uploadFn } and calls run().
 *
 * HARD SAFETY (matches the Fernwick lessons):
 *  - DISARMED by default: an upload only fires live if the platform's ARMED file exists
 *    (wpb-uploaders/platforms/<key>.ARMED). Absent => dry-run (no live action). Arming is
 *    Steve-gated.
 *  - ANTI-BOT CADENCE: at most MAX_PER_DAY (4) designs per run; human-typed fields.
 *  - FAIL-LOUD: a design only counts as uploaded if the platform's uploadFn returns a real
 *    id/url. No id => it goes to failed[] with the reason — never a false-green.
 */
const path = require('path'), fs = require('fs'), os = require('os');
const CHROMIUM = '/Users/macstudio3/.npm-global/lib/node_modules/playwright';
const { chromium } = require(CHROMIUM);
const { rnd, sleep } = require('./human');

const MAX_PER_DAY = 4; // anti-bot cadence hard cap
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML,like Gecko) Chrome/126 Safari/537.36';

async function run(cfg, manifestPath) {
  const HERE = __dirname;
  const armedFile = path.join(HERE, '..', 'platforms', `${cfg.key}.ARMED`);
  const ARMED = fs.existsSync(armedFile);
  const result = { platform: cfg.key, armed: ARMED, uploaded: [], failed: [], skipped: 0, sessionExpired: false };

  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
  let designs = (manifest.designs || []).slice(0, MAX_PER_DAY); // enforce cadence cap
  if ((manifest.designs || []).length > MAX_PER_DAY) result.skipped = manifest.designs.length - MAX_PER_DAY;

  if (!fs.existsSync(cfg.sessionFile)) {
    result.sessionExpired = true;
    result.error = `no session file for ${cfg.name} (${cfg.sessionFile}) — capture login first`;
    console.log(JSON.stringify(result, null, 2));
    return result;
  }

  const b = await chromium.launch({ headless: process.env.WPB_HEADLESS === '1' ? true : !ARMED });
  const ctx = await b.newContext({ storageState: cfg.sessionFile, userAgent: UA });
  const pg = await ctx.newPage();
  try {
    // session health
    await pg.goto(cfg.healthUrl, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
    await sleep(rnd(2000, 3500));
    if (pg.url().includes('/login') || pg.url().includes('signin')) {
      result.sessionExpired = true; console.log(JSON.stringify(result, null, 2)); await b.close(); return result;
    }

    for (const d of designs) {
      try {
        if (!ARMED) { result.uploaded.push({ title: d.title, dryRun: true, id: null }); await sleep(rnd(400, 900)); continue; }
        // platform-specific upload → must return { id, url } or throw / return null
        const r = await cfg.uploadFn(pg, d, { rnd, sleep });
        if (r && r.id) result.uploaded.push({ title: d.title, id: r.id, url: r.url || null, private: true });
        else result.failed.push({ title: d.title, reason: (r && r.reason) || 'no design id returned (fail-loud)' });
        await sleep(rnd(30000, 90000)); // sporadic human gap between uploads (anti-bot)
      } catch (e) {
        result.failed.push({ title: d.title, reason: String(e).slice(0, 180) });
      }
    }
  } catch (e) { result.error = String(e).slice(0, 200); }
  await b.close();
  console.log(JSON.stringify(result, null, 2));
  return result;
}

module.exports = { run, MAX_PER_DAY, UA };