[object Object]

← 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

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 →