← back to Norma
IG redo: caption polish (quote-strip colors, broaden SKU suffix, 429 retry) + redo-last5 driver (dry-run + gated --go)
6e373043e4fef532f4f19887cd618fbf37f76ec7 · 2026-08-14 09:48:35 -0700 · Steve
Files touched
M agents/instagram-agent/content.jsA agents/instagram-agent/redo-last5.js
Diff
commit 6e373043e4fef532f4f19887cd618fbf37f76ec7
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 14 09:48:35 2026 -0700
IG redo: caption polish (quote-strip colors, broaden SKU suffix, 429 retry) + redo-last5 driver (dry-run + gated --go)
---
agents/instagram-agent/content.js | 21 ++++++++--
agents/instagram-agent/redo-last5.js | 79 ++++++++++++++++++++++++++++++++++++
2 files changed, 96 insertions(+), 4 deletions(-)
diff --git a/agents/instagram-agent/content.js b/agents/instagram-agent/content.js
index 0e262ac..4b9f952 100644
--- a/agents/instagram-agent/content.js
+++ b/agents/instagram-agent/content.js
@@ -32,6 +32,7 @@ const SERVICE_LINES = [
];
const titleCase = (s) => String(s || '')
+ .replace(/["']/g, '')
.toLowerCase()
.replace(/\b([a-z])/g, (m) => m.toUpperCase())
.replace(/\b(And|Of|On|The)\b/g, (m) => m.toLowerCase())
@@ -59,7 +60,8 @@ const CONNECT = /^(and|&|on|light|dark|deep|warm|cool|pale|soft|muted|bright|ant
// Material / pattern words to strip out of a title-derived colorway so only the color remains.
const NOISE = /\b(wallcovering|wallpaper|fabric|residential|commercial|damask|geometric|texture|textured|type\s*\d|luxury|suede|sueded|silk|vinyl|grasscloth|linen|cork|mylar|leaf|modern|mid-century|transitional|traditional|contemporary|print|mural|weave|woven|faux|solid)\b/ig;
function extractColor(p) {
- const tags = String(p.tags || '').split(',').map((s) => s.trim()).filter(Boolean);
+ const tags = String(p.tags || '').split(',')
+ .map((s) => s.trim().replace(/^["'\s]+|["'\s]+$/g, '')).filter(Boolean); // strip stray quotes on tags
const isColorWord = (w) => COLOR.test(w) || GENERIC.test(w) || CONNECT.test(w);
const hasRealColor = (s) => s.split(/[\s/&]+/).filter(Boolean).some((w) => COLOR.test(w));
@@ -90,8 +92,9 @@ function extractColor(p) {
function extractSku(p) {
const skus = (p.variants || []).map((v) => String(v.sku || '').trim()).filter(Boolean);
let sku = skus.find((s) => !/sample|memo|swatch/i.test(s)) || skus[0] || '';
- // Strip any trailing size/unit descriptor (hyphen OR space separated): "-sample", "-PER YARD", " - Roll", etc.
- sku = sku.replace(/[\s-]+(sample|memo|swatch|per[\s-]*roll|per[\s-]*yard|roll|yard|yd)\s*$/i, '').trim();
+ // Strip any trailing size/unit/finish descriptor (hyphen OR space separated) and anything after it:
+ // "-sample", "-PER YARD", "-SMOOTH REMOVEABLE", " - Roll", etc. -> keep the clean model number.
+ sku = sku.replace(/[\s-]+(sample|memo|swatch|per[\s-]*roll|per[\s-]*yard|roll|yard|yd|smooth|remove?able|textured|unpasted|prepasted|peel|stick|matte|satin|gloss)\b.*$/i, '').trim();
return sku ? sku.toUpperCase() : '';
}
@@ -124,11 +127,21 @@ function leakToken(s) {
return PL_LEAK.find((v) => t.includes(v)) || null;
}
+/** GET the product JSON, backing off on 429 (storefront rate-limit) up to a few tries. */
+async function fetchProduct(url) {
+ for (let attempt = 0; attempt < 4; attempt++) {
+ const r = await fetch(url);
+ if (r.status !== 429) return r;
+ await new Promise((res) => setTimeout(res, 1500 * (attempt + 1))); // 1.5s, 3s, 4.5s backoff
+ }
+ return fetch(url); // final attempt; caller handles a non-ok status
+}
+
/** Resolve a product → { image_url, caption, title, url }. Throws if not found/imageless/leaky. */
async function resolveProduct(input) {
const { handle, store } = handleFrom(input);
const url = `${store}/products/${handle}.json`;
- const r = await fetch(url);
+ const r = await fetchProduct(url);
if (!r.ok) throw new Error(`product "${handle}" not found (${r.status}) at ${store}`);
const { product } = await r.json();
if (!product) throw new Error(`no product payload for "${handle}"`);
diff --git a/agents/instagram-agent/redo-last5.js b/agents/instagram-agent/redo-last5.js
new file mode 100644
index 0000000..5e91a1f
--- /dev/null
+++ b/agents/instagram-agent/redo-last5.js
@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+/**
+ * redo-last5.js — re-publish the last-5-days fleet posts with the CORRECTED caption
+ * (Name + Color + Model/SKU, no http link). TK-10574.
+ *
+ * Reads the FROZEN delete-list (data/redo-originals-<date>.jsonl) — the 85 originals
+ * captured before any repost — and, for each, reposts the SAME product via post-to.js,
+ * which now composes the fixed caption from content.js.
+ *
+ * node redo-last5.js # DRY: preview every corrected caption, publish nothing
+ * node redo-last5.js --go # LIVE: repost each (customer-facing — run this yourself)
+ * node redo-last5.js --go --only designerwallcoverings # just one account
+ *
+ * On --go it writes data/redo-results-<date>.jsonl mapping each ORIGINAL
+ * (handle, old media_id, old permalink) -> the NEW permalink, so the openclaw
+ * deletion step deletes exactly the right originals.
+ */
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const content = require('./content');
+
+const DATE = '20260814';
+const SRC = path.join(__dirname, 'data', `redo-originals-${DATE}.jsonl`);
+const OUT = path.join(__dirname, 'data', `redo-results-${DATE}.jsonl`);
+const PACE_MS = 4000; // gentle spacing so a fleet-wide sweep never hammers the Graph API
+
+const args = process.argv.slice(2);
+const GO = args.includes('--go');
+const only = (() => { const i = args.indexOf('--only'); return i >= 0 ? args[i + 1] : null; })();
+
+const rows = fs.readFileSync(SRC, 'utf8').trim().split('\n')
+ .map((l) => JSON.parse(l))
+ .filter((r) => (only ? r.handle === only : true));
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+(async () => {
+ console.log(`${GO ? 'LIVE REPOST' : 'DRY PREVIEW'} — ${rows.length} post(s)${only ? ` (@${only})` : ' across the fleet'}\n`);
+ let ok = 0, fail = 0, skip = 0;
+ for (let i = 0; i < rows.length; i++) {
+ const r = rows[i];
+ const tag = `[${i + 1}/${rows.length}] @${r.handle} ${r.product_handle}`;
+ // Preview the corrected caption (also surfaces products that now fail to resolve)
+ let preview;
+ try { preview = await content.resolveProduct(r.product_handle); }
+ catch (e) { console.log(` ⚠ SKIP ${tag} — ${e.message}`); skip++; continue; }
+
+ if (!GO) {
+ console.log(`── ${tag}`);
+ console.log(preview.caption.split('\n').slice(0, 3).join('\n')); // name/color/sku lines
+ console.log('');
+ await sleep(700); // pace storefront reads so a full sweep doesn't hit the rate limiter
+ continue;
+ }
+
+ // LIVE: hand off to the sanctioned publisher (its own --confirm gate + ledger append)
+ try {
+ const out = execFileSync(process.execPath,
+ ['post-to.js', r.handle, '--product', r.product_handle, '--confirm'],
+ { cwd: __dirname, encoding: 'utf8' });
+ const m = out.match(/posted IMAGE (\S+)/);
+ const newPermalink = m ? m[1] : null;
+ fs.appendFileSync(OUT, JSON.stringify({
+ handle: r.handle, product_handle: r.product_handle,
+ old_media_id: r.media_id, old_permalink: r.permalink,
+ new_permalink: newPermalink, ts: new Date().toISOString(),
+ }) + '\n');
+ console.log(` ✓ ${tag} — new ${newPermalink || '(permalink pending)'}`);
+ ok++;
+ } catch (e) {
+ console.log(` ✗ ${tag} — ${String(e.stderr || e.message).trim().split('\n').pop()}`);
+ fail++;
+ }
+ if (i < rows.length - 1) await sleep(PACE_MS);
+ }
+ console.log(`\n${GO ? 'Reposted' : 'Previewed'}: ${ok || rows.length - skip} ok, ${fail} failed, ${skip} skipped.`);
+ if (GO) console.log(`Mapping written -> ${OUT}\nNext: delete the ${ok} originals (openclaw), keyed by old_permalink.`);
+})();
← 285fb06 IG captions: show name+color+SKU (DW model), remove http lin
·
back to Norma
·
IG redo: make driver idempotent/resumable (skip already-repo 70e6d07 →