← back to Goldleafwallpaper
scripts/ig-poster/repost-all.js
102 lines
#!/usr/bin/env node
/*
* repost-all.js — repost the ENTIRE RML collection header-free to @goldleafwallpaper.
*
* One-shot bulk companion to post-next.js (the 4h cron). Walks every RML SKU in
* data/products.json, crops the Phillipe Romano header off (Shopify CDN), builds
* the info caption, and publishes — with a delay between posts and graceful
* back-off on Instagram's rate limit (code 9 "too many actions" / code 4).
*
* RESUMABLE: progress is tracked in data/ig/repost-state.json, so re-running
* continues where it stopped (after a rate-limit window or a token refresh).
* SKUs already posted in this batch are skipped.
*
* Requires GOLDLEAF_IG_TOKEN + GOLDLEAF_IG_USER_ID in env. Cost: $0.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { buildCaption } = require('./caption');
const ROOT = path.resolve(__dirname, '../..');
const PRODUCTS = path.join(ROOT, 'data/products.json');
const IG_DIR = path.join(ROOT, 'data/ig');
const STATE = path.join(IG_DIR, 'repost-state.json');
const LOG = path.join(IG_DIR, 'post-log.jsonl');
const GRAPH = 'https://graph.facebook.com/v21.0';
const TOKEN = process.env.GOLDLEAF_IG_TOKEN || '';
const IG_USER = process.env.GOLDLEAF_IG_USER_ID || '';
const DELAY_MS = Number(process.env.REPOST_DELAY_MS || 45000); // between posts
const MAX_BACKOFFS = Number(process.env.REPOST_MAX_BACKOFFS || 6);
if (!TOKEN || !IG_USER) { console.error('need GOLDLEAF_IG_TOKEN + GOLDLEAF_IG_USER_ID'); process.exit(1); }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cropHeaderUrl = (url) => `${String(url).split('?')[0]}?width=500&height=400&crop=bottom`;
function loadState() {
if (fs.existsSync(STATE)) { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch (_) {} }
return { posted: [] };
}
function saveState(s) { fs.mkdirSync(IG_DIR, { recursive: true }); fs.writeFileSync(STATE, JSON.stringify(s, null, 2)); }
function appendLog(e) { fs.mkdirSync(IG_DIR, { recursive: true }); fs.appendFileSync(LOG, JSON.stringify(e) + '\n'); }
async function tokenValid() {
const d = (await (await fetch(`${GRAPH}/debug_token?input_token=${TOKEN}&access_token=${TOKEN}`)).json()).data || {};
return d.is_valid === true;
}
async function publish(imageUrl, caption) {
const c = await (await fetch(`${GRAPH}/${IG_USER}/media`, { method: 'POST', body: new URLSearchParams({ image_url: imageUrl, caption, access_token: TOKEN }) })).json();
if (!c.id) { const e = new Error(JSON.stringify(c.error || c)); e.code = c.error && c.error.code; throw e; }
for (let i = 0; i < 15; i++) {
const s = await (await fetch(`${GRAPH}/${c.id}?fields=status_code&access_token=${TOKEN}`)).json();
if (s.status_code === 'FINISHED') break;
if (s.status_code === 'ERROR' || s.status_code === 'EXPIRED') throw new Error('container ' + s.status_code);
await sleep(2000);
}
const p = await (await fetch(`${GRAPH}/${IG_USER}/media_publish`, { method: 'POST', body: new URLSearchParams({ creation_id: c.id, access_token: TOKEN }) })).json();
if (!p.id) { const e = new Error(JSON.stringify(p.error || p)); e.code = p.error && p.error.code; throw e; }
return p.id;
}
(async () => {
const products = JSON.parse(fs.readFileSync(PRODUCTS, 'utf8'))
.filter((p) => p.image_url && /^https?:/.test(p.image_url) && p.dw_sku);
const state = loadState();
const posted = new Set(state.posted);
const todo = products.filter((p) => !posted.has(p.dw_sku));
console.log(`repost-all: ${products.length} total, ${posted.size} already done, ${todo.length} to go`);
let backoffs = 0;
for (let i = 0; i < todo.length; i++) {
const p = todo[i];
if (!(await tokenValid())) { console.log(`[${new Date().toISOString()}] TOKEN EXPIRED — stopping. Re-run with a fresh token to resume (${posted.size}/${products.length} done).`); break; }
const img = cropHeaderUrl(p.image_url);
const caption = buildCaption(p, products.indexOf(p));
try {
const mediaId = await publish(img, caption);
posted.add(p.dw_sku); state.posted = [...posted]; saveState(state);
appendLog({ ts: new Date().toISOString(), mode: 'LIVE-BATCH', dw_sku: p.dw_sku, image_url: img, media_id: mediaId, result: 'ok' });
console.log(`✓ ${posted.size}/${products.length} ${p.dw_sku} media_id ${mediaId}`);
backoffs = 0;
await sleep(DELAY_MS);
} catch (err) {
const code = err.code;
appendLog({ ts: new Date().toISOString(), mode: 'LIVE-BATCH', dw_sku: p.dw_sku, result: 'error', error: String(err.message).slice(0, 160) });
if (code === 9 || code === 4 || code === 17 || code === 32) { // rate limit family
backoffs++;
if (backoffs > MAX_BACKOFFS) { console.log(`Rate limit persists after ${MAX_BACKOFFS} backoffs — stopping at ${posted.size}/${products.length}. IG's 24h window needs to reset; re-run later to resume.`); break; }
const wait = Math.min(30, 5 * backoffs); // minutes
console.log(`[${new Date().toISOString()}] rate-limited on ${p.dw_sku} (code ${code}) — backing off ${wait}m (${backoffs}/${MAX_BACKOFFS})`);
i--; // retry same SKU after backoff
await sleep(wait * 60000);
} else {
console.log(`✗ ${p.dw_sku} failed (non-rate error): ${String(err.message).slice(0, 120)} — skipping`);
}
}
}
console.log(`repost-all done: ${posted.size}/${products.length} posted.`);
})();