← back to Goldleafwallpaper
ig-poster: add resumable bulk reposter (repost-all.js) — header-cropped, rate-limit backoff
b715c466da2a1006e0bcf973b7f2e52fad9a07bd · 2026-08-10 13:14:08 -0700 · Steve Abrams
Files touched
A scripts/ig-poster/repost-all.js
Diff
commit b715c466da2a1006e0bcf973b7f2e52fad9a07bd
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 10 13:14:08 2026 -0700
ig-poster: add resumable bulk reposter (repost-all.js) — header-cropped, rate-limit backoff
---
scripts/ig-poster/repost-all.js | 101 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 101 insertions(+)
diff --git a/scripts/ig-poster/repost-all.js b/scripts/ig-poster/repost-all.js
new file mode 100644
index 0000000..a9b502d
--- /dev/null
+++ b/scripts/ig-poster/repost-all.js
@@ -0,0 +1,101 @@
+#!/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') throw new Error('container ERROR');
+ 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.`);
+})();
← 49ec03e ig-poster: crop the Phillipe Romano header banner off each i
·
back to Goldleafwallpaper
·
chore: lint, refactor (dedup Bronze, EXPIRED guard), v1.2.0 493523d →