← back to Designerwallcoverings
TK-11673: June SEO title_tag de-dup/de-truncate map builder + gated apply/rollback
4a15ac17a8e800c3bd3c1be2827928030782d73b · 2026-09-14 00:42:28 -0700 · Steve Abrams
Rebuilds the 2026-06-15/16 defective title_tag population from the local dw_unified
mirror by the job's value signature (' Wallcovering by '), applies collapse-doubled-
Wallcovering + strip-trailing-ellipsis + word-boundary truncate <=60, and emits a
reversible old->new map (14,156 affected ACTIVE). apply.mjs/rollback.mjs are DRY-RUN
by default; the live Shopify write is GATED (Steve go only). Map blob gitignored;
it is the on-disk rollback record and apply also ledgers each old value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePUgpi4TWMrHo68U8ckpT
Files touched
A scripts/tk11673-june-titletag/.gitignoreA scripts/tk11673-june-titletag/apply.mjsA scripts/tk11673-june-titletag/build-map.mjsA scripts/tk11673-june-titletag/rollback.mjs
Diff
commit 4a15ac17a8e800c3bd3c1be2827928030782d73b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Sep 14 00:42:28 2026 -0700
TK-11673: June SEO title_tag de-dup/de-truncate map builder + gated apply/rollback
Rebuilds the 2026-06-15/16 defective title_tag population from the local dw_unified
mirror by the job's value signature (' Wallcovering by '), applies collapse-doubled-
Wallcovering + strip-trailing-ellipsis + word-boundary truncate <=60, and emits a
reversible old->new map (14,156 affected ACTIVE). apply.mjs/rollback.mjs are DRY-RUN
by default; the live Shopify write is GATED (Steve go only). Map blob gitignored;
it is the on-disk rollback record and apply also ledgers each old value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RePUgpi4TWMrHo68U8ckpT
---
scripts/tk11673-june-titletag/.gitignore | 1 +
scripts/tk11673-june-titletag/apply.mjs | 89 +++++++++++++++
scripts/tk11673-june-titletag/build-map.mjs | 162 ++++++++++++++++++++++++++++
scripts/tk11673-june-titletag/rollback.mjs | 51 +++++++++
4 files changed, 303 insertions(+)
diff --git a/scripts/tk11673-june-titletag/.gitignore b/scripts/tk11673-june-titletag/.gitignore
new file mode 100644
index 0000000..cec189c
--- /dev/null
+++ b/scripts/tk11673-june-titletag/.gitignore
@@ -0,0 +1 @@
+map.json
diff --git a/scripts/tk11673-june-titletag/apply.mjs b/scripts/tk11673-june-titletag/apply.mjs
new file mode 100644
index 0000000..8e99387
--- /dev/null
+++ b/scripts/tk11673-june-titletag/apply.mjs
@@ -0,0 +1,89 @@
+#!/usr/bin/env node
+// TK-11673 — June title_tag REWRITE (de-duplicate + de-truncate). GATED live customer-facing write.
+// DRY-RUN BY DEFAULT. --apply is the LIVE write to designer-laboratory-sandbox (Steve go ONLY).
+//
+// Reads ./map.json (built by build-map.mjs = the rollback map: old -> new per product), then for
+// each row LIVE-verifies the current global.title_tag STILL equals old_title_tag (skip if changed
+// since the map was built — never clobber a manual fix), asserts the new value is sane, and on
+// --apply does metafieldsSet(global.title_tag=new) and appends a per-item undo to the
+// executed-reversible ledger (undo = re-set global.title_tag=old_value on that product).
+//
+// SCOPE (which buckets to rewrite):
+// --scope=defect (DEFAULT) : doubling + trailing-ellipsis + whitespace micro-fixes (conservative)
+// --scope=all : also re-truncate clean over-60 titles to <=60 (drops trailing brand words)
+// --scope=over60_only : only the length re-truncations
+//
+// Usage: node apply.mjs # DRY-RUN, scope=defect
+// node apply.mjs --scope=all # DRY-RUN, full scope
+// node apply.mjs --scope=all --apply # LIVE WRITE (GATED — Steve only)
+//
+// ROLLBACK (after an --apply): node rollback.mjs [--apply] (re-sets old_title_tag from the ledger/map)
+
+import { gql } from '../lib/shopify.mjs';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const SCOPE = (args.find(a => a.startsWith('--scope=')) || '--scope=defect').split('=')[1];
+const BUCKETS = SCOPE === 'all' ? ['defect', 'whitespace', 'over60_only']
+ : SCOPE === 'over60_only' ? ['over60_only']
+ : ['defect', 'whitespace']; // default: conservative
+
+const map = JSON.parse(fs.readFileSync(path.join(HERE, 'map.json'), 'utf8'));
+let set = map.rows.filter(r => BUCKETS.includes(r.bucket) && r.status === 'ACTIVE');
+console.error(`scope=${SCOPE} buckets=[${BUCKETS}] rows=${set.length} mode=${APPLY ? 'APPLY (LIVE WRITE)' : 'DRY-RUN'}`);
+
+// ---- LIVE verify current value == old_title_tag (batches of 100) ----
+const byId = new Map(set.map(r => [r.product_id, r]));
+const ids = [...byId.keys()];
+const ready = [], changed = [], missing = [];
+for (let i = 0; i < ids.length; i += 100) {
+ const batch = ids.slice(i, i + 100);
+ const q = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id status metafield(namespace:"global",key:"title_tag"){ id value } } } }`;
+ const d = await gql(q, { ids: batch });
+ for (const n of d.nodes) {
+ if (!n) { continue; }
+ const r = byId.get(n.id);
+ const cur = (n.metafield?.value || '').trim();
+ if (!n.metafield) { missing.push({ product_id: r.product_id, handle: r.handle }); continue; }
+ if (cur !== r.old_title_tag.trim()) { changed.push({ product_id: r.product_id, handle: r.handle, was: r.old_title_tag, now: cur }); continue; }
+ ready.push({ ...r, mf_id: n.metafield.id });
+ }
+ process.stderr.write(`\r ready ${ready.length} changed-since-build ${changed.length} no-tag ${missing.length} `);
+}
+process.stderr.write('\n');
+
+console.log(`WILL REWRITE: ${ready.length}`);
+console.log(`SKIP (changed since build): ${changed.length} (don't clobber a manual edit)`);
+console.log(`SKIP (title_tag gone): ${missing.length}`);
+console.log('\nSample old -> new:');
+for (const r of ready.slice(0, 8)) console.log(` [${r.bucket}] ${r.handle}\n old(${r.old_len}): ${JSON.stringify(r.old_title_tag)}\n new(${r.new_len}): ${JSON.stringify(r.new_title_tag)}`);
+
+if (!APPLY) { console.log('\nDRY-RUN. No write fired. Re-run with --apply (GATED) to rewrite.'); process.exit(0); }
+
+// ---- LIVE WRITE PATH (gated) ----
+console.error('APPLYING: rewriting title_tag on', ready.length, 'products ...');
+const ledger = process.env.HOME + '/.claude/yolo-queue/executed-reversible/ledger.jsonl';
+let ok = 0, err = 0;
+for (let i = 0; i < ready.length; i += 25) {
+ const chunk = ready.slice(i, i + 25);
+ const metafields = chunk.map(r => ({ ownerId: r.product_id, namespace: 'global', key: 'title_tag', type: 'single_line_text_field', value: r.new_title_tag }));
+ const m = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ id } userErrors{ field message } } }`;
+ const d = await gql(m, { mf: metafields });
+ const ue = d.metafieldsSet.userErrors;
+ if (ue.length) { console.error('userErrors', ue); err += chunk.length; continue; }
+ ok += d.metafieldsSet.metafields.length;
+ for (const r of chunk) fs.appendFileSync(ledger, JSON.stringify({
+ ts: new Date().toISOString(), agent: 'vp-dw-commerce-11673', ticket: 'TK-11673',
+ action: `rewrite global.title_tag (${r.bucket})`, pid: r.product_id, handle: r.handle, blast_radius: 1,
+ old_value: r.old_title_tag, new_value: r.new_title_tag,
+ undo_cmd: `re-set global.title_tag=${JSON.stringify(r.old_title_tag)} on ${r.product_id}`,
+ verify: `GET product ${r.product_id} global.title_tag == ${JSON.stringify(r.new_title_tag)}`,
+ }) + '\n');
+ process.stderr.write(`\r rewritten ${ok}/${ready.length} err ${err} `);
+}
+process.stderr.write('\n');
+console.log(`APPLY done. rewritten=${ok} err=${err}. Rollback map in map.json + executed-reversible ledger.`);
diff --git a/scripts/tk11673-june-titletag/build-map.mjs b/scripts/tk11673-june-titletag/build-map.mjs
new file mode 100644
index 0000000..6342341
--- /dev/null
+++ b/scripts/tk11673-june-titletag/build-map.mjs
@@ -0,0 +1,162 @@
+#!/usr/bin/env node
+// TK-11673 — June 2026 (2026-06-15/16) SECOND defective SEO title_tag job.
+// MAP BUILDER (reversible, LOCAL, READ-ONLY — no Shopify write).
+//
+// The June job wrote global.title_tag = "{product title} Wallcovering by {vendor}[ ...truncated]"
+// on ~21,210 products. Defects: doubled "Wallcovering Wallcovering", literal trailing "...",
+// and >60 chars. UNLIKE the April job these tags name the RIGHT product, so the fix is a
+// de-duplicate + de-truncate REWRITE (never delete-to-fallback).
+//
+// This script REBUILDS the affected ACTIVE set from the LIVE local dw_unified mirror
+// (host=/tmp socket) by the job's distinctive VALUE signature — " Wallcovering by " — because
+// the mirror carries no per-metafield updatedAt to time-slice on. It then applies the
+// transform and emits a corrected-title MAP (old -> new per product) which IS the rollback map.
+//
+// OUTPUT: ./map.json (rows: {product_id, variant_id, handle, vendor, title, old_title_tag,
+// new_title_tag, classes[], old_len, new_len})
+// Re-runnable + deterministic. Writes NOTHING to Shopify or to dw_unified.
+//
+// Usage: node build-map.mjs # ACTIVE only (default)
+// node build-map.mjs --all # include ARCHIVED/DRAFT too (report parity; not for apply)
+
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const ALL = process.argv.includes('--all');
+const MAX = 60; // SEO title target length (hard cap)
+
+// --- June-job value signature + defect predicates (kept in sync with the memo) -------------
+const JOB_RE = / wallcovering by /i; // job template middle
+const DUP_RE = /\b(wallcoverings?)\s+(wallcoverings?)\b/i; // doubling
+const ELL_RE = /\.{2,}\s*$/; // literal trailing ellipsis
+
+// --- transform ------------------------------------------------------------------------------
+function collapseDupWc(s) {
+ let prev;
+ do { prev = s; s = s.replace(/\b(wallcoverings?)\s+(wallcoverings?)\b/gi, '$1'); }
+ while (s !== prev);
+ return s;
+}
+// trailing function/stop words we never want a title to END on (case-insensitive)
+const TAIL_STOP = new Set(['by', 'at', 'of', 'for', 'the', 'and', 'in', 'on', 'with', 'a', 'an', 'to', 'from']);
+function tidyTail(s) {
+ s = s.replace(/[\s.,\-–—|]+$/g, '').replace(/\s+/g, ' ').trim();
+ // strip trailing dangling function words repeatedly (e.g. "...Wallcovering by" -> "...Wallcovering")
+ let prev;
+ do { prev = s;
+ s = s.replace(/\s+([A-Za-z]+)$/,(m,w)=> TAIL_STOP.has(w.toLowerCase()) ? '' : m).replace(/[\s.,\-–—|]+$/g,'').trim();
+ } while (s !== prev);
+ return s;
+}
+function wordTruncate(s, max) {
+ if (s.length <= max) return s;
+ let cut = s.slice(0, max);
+ const sp = cut.lastIndexOf(' ');
+ if (sp > 0) cut = cut.slice(0, sp);
+ return tidyTail(cut);
+}
+function fix(tt) {
+ let s = tt;
+ s = collapseDupWc(s);
+ const hadEllipsis = ELL_RE.test(s.trimEnd());
+ s = s.replace(/[\s.]*\.{2,}\s*$/g, '').trimEnd(); // strip trailing "..."
+ s = s.replace(/\s*\.\s*$/g, '').trimEnd(); // stray lone trailing dot
+ if (hadEllipsis) s = s.replace(/\s+\S{1,2}$/g, '').trimEnd(); // drop 1-2 char dangling fragment
+ if (s.length > MAX) s = wordTruncate(s, MAX);
+ return tidyTail(s);
+}
+function classes(tt) {
+ const c = [];
+ if (DUP_RE.test(tt)) c.push('doubling');
+ if (ELL_RE.test(tt.trimEnd())) c.push('ellipsis');
+ if (tt.length > MAX) c.push('over60');
+ return c;
+}
+
+// --- pull candidate rows from the LIVE local mirror (READ-ONLY) -----------------------------
+const statusFilter = ALL ? '' : "AND status = 'ACTIVE'";
+const sql = `
+ SELECT COALESCE(json_agg(row_to_json(r)), '[]'::json)::text FROM (
+ SELECT shopify_id AS product_id, variant_id, handle, vendor, status, title,
+ metafields->'global'->'title_tag'->>'value' AS old_title_tag
+ FROM shopify_products
+ WHERE metafields->'global'->'title_tag'->>'value' ~* ' wallcovering by '
+ ${statusFilter}
+ ) r;`;
+const raw = execFileSync('psql', ['-h', '/tmp', '-d', 'dw_unified', '-tAc', sql], { maxBuffer: 512 * 1024 * 1024 }).toString().trim();
+const cand = JSON.parse(raw);
+
+// --- build the map --------------------------------------------------------------------------
+const rows = [];
+const held = []; // transform produced empty/too-short — never emit (safety)
+const stats = { doubling: 0, ellipsis: 0, over60: 0, unchanged: 0 };
+for (const r of cand) {
+ const old = (r.old_title_tag || '').trim();
+ if (!old) continue;
+ if (!JOB_RE.test(old)) continue; // defensive: signature must hold
+ const cls = classes(old);
+ const nw = fix(old);
+ if (nw === old) { stats.unchanged++; continue; } // clean already -> not affected
+ if (!nw || nw.length < 3) { held.push({ product_id: r.product_id, handle: r.handle, old, nw, reason: 'empty/too-short output' }); continue; }
+ // HARD RULE: never emit the banned word "Wallpaper". These carry it in a pattern-name token
+ // (e.g. KAZAK_WALLPAPER) — hold for manual normalization, do NOT auto-guess.
+ if (/wallpaper/i.test(nw)) { held.push({ product_id: r.product_id, handle: r.handle, old, nw, reason: 'banned-word-wallpaper (pre-existing in source)' }); continue; }
+ for (const c of cls) stats[c]++;
+ // bucket for scope filtering in the apply step:
+ // defect = doubling and/or trailing-ellipsis (unambiguous June-job defect)
+ // over60_only = clean long title, length re-truncation only (debatable — drops trailing brand words)
+ // whitespace = internal double-space / trailing-artifact micro-fix only
+ const bucket = (cls.includes('doubling') || cls.includes('ellipsis')) ? 'defect'
+ : (cls.includes('over60') ? 'over60_only' : 'whitespace');
+ rows.push({
+ product_id: r.product_id, variant_id: r.variant_id, handle: r.handle,
+ vendor: r.vendor, status: r.status, title: r.title,
+ old_title_tag: old, new_title_tag: nw,
+ classes: cls, bucket, old_len: old.length, new_len: nw.length,
+ });
+}
+
+// scope buckets: A = the unambiguous defects (doubling and/or ellipsis); B = full (adds pure length re-truncation)
+const scopeA = rows.filter(r => r.classes.includes('doubling') || r.classes.includes('ellipsis'));
+const pureOver60 = rows.filter(r => r.classes.length === 1 && r.classes[0] === 'over60');
+const manifest = {
+ ticket: 'TK-11673',
+ generated_at: new Date().toISOString(),
+ source: 'dw_unified local mirror (host=/tmp) READ-ONLY',
+ scope: ALL ? 'all statuses' : 'ACTIVE only',
+ job_signature: ' Wallcovering by ',
+ candidates_matching_signature: cand.length,
+ affected_rows: rows.length,
+ scope_A_doubling_or_ellipsis: scopeA.length,
+ scope_B_full: rows.length,
+ pure_over60_only: pureOver60.length,
+ held_bad_output: held.length,
+ class_counts_among_affected: stats,
+ transform: 'collapse consecutive duplicate Wallcovering tokens; strip literal trailing "..."; drop 1-2 char dangling fragment left by mid-word truncation; word-boundary truncate to <=60 chars',
+ rows,
+ held,
+};
+const out = path.join(HERE, 'map.json');
+fs.writeFileSync(out, JSON.stringify(manifest, null, 1));
+
+// --- report ---------------------------------------------------------------------------------
+console.log(`scope: ${manifest.scope}`);
+console.log(`signature candidates: ${cand.length}`);
+console.log(`AFFECTED (new!=old): ${rows.length} (scope B / full)`);
+console.log(` scope A (dbl|ellip): ${scopeA.length} (conservative — unambiguous defects)`);
+console.log(` pure over60 only: ${pureOver60.length} (length-only re-truncation)`);
+console.log(` class doubling: ${stats.doubling}`);
+console.log(` class ellipsis: ${stats.ellipsis}`);
+console.log(` class over60: ${stats.over60}`);
+console.log(`already-clean (skip): ${stats.unchanged}`);
+console.log(`held (bad output): ${held.length}`);
+console.log(`\nsample before -> after:`);
+for (const r of rows.slice(0, 8)) {
+ console.log(` [${r.classes.join(',')}] ${r.handle}`);
+ console.log(` old(${r.old_len}): ${JSON.stringify(r.old_title_tag)}`);
+ console.log(` new(${r.new_len}): ${JSON.stringify(r.new_title_tag)}`);
+}
+console.log(`\nmap + rollback record -> ${out}`);
diff --git a/scripts/tk11673-june-titletag/rollback.mjs b/scripts/tk11673-june-titletag/rollback.mjs
new file mode 100644
index 0000000..aeab564
--- /dev/null
+++ b/scripts/tk11673-june-titletag/rollback.mjs
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+// TK-11673 — ROLLBACK of the June title_tag rewrite. Re-sets global.title_tag back to old_title_tag
+// from map.json. DRY-RUN BY DEFAULT. --apply is the GATED live write (Steve go only).
+// Safety: only reverts a product whose CURRENT title_tag == new_title_tag (the value we wrote) —
+// if it has since changed, we skip it (don't clobber a newer edit).
+//
+// Usage: node rollback.mjs # dry-run
+// node rollback.mjs --scope=all
+// node rollback.mjs --apply # LIVE revert (GATED)
+
+import { gql } from '../lib/shopify.mjs';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const SCOPE = (args.find(a => a.startsWith('--scope=')) || '--scope=all').split('=')[1];
+const BUCKETS = SCOPE === 'all' ? ['defect', 'whitespace', 'over60_only']
+ : SCOPE === 'over60_only' ? ['over60_only'] : ['defect', 'whitespace'];
+
+const map = JSON.parse(fs.readFileSync(path.join(HERE, 'map.json'), 'utf8'));
+const set = map.rows.filter(r => BUCKETS.includes(r.bucket) && r.status === 'ACTIVE');
+console.error(`ROLLBACK scope=${SCOPE} rows=${set.length} mode=${APPLY ? 'APPLY (LIVE REVERT)' : 'DRY-RUN'}`);
+
+const byId = new Map(set.map(r => [r.product_id, r]));
+const ids = [...byId.keys()];
+const ready = [], skipped = [];
+for (let i = 0; i < ids.length; i += 100) {
+ const q = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id metafield(namespace:"global",key:"title_tag"){ value } } } }`;
+ const d = await gql(q, { ids: ids.slice(i, i + 100) });
+ for (const n of d.nodes) {
+ if (!n) continue; const r = byId.get(n.id);
+ const cur = (n.metafield?.value || '').trim();
+ if (cur === r.new_title_tag.trim()) ready.push(r); else skipped.push({ handle: r.handle, now: cur });
+ }
+}
+console.log(`WILL REVERT: ${ready.length} SKIP (changed since apply): ${skipped.length}`);
+if (!APPLY) { console.log('DRY-RUN. Re-run with --apply (GATED) to revert.'); process.exit(0); }
+
+let ok = 0, err = 0;
+for (let i = 0; i < ready.length; i += 25) {
+ const chunk = ready.slice(i, i + 25);
+ const mf = chunk.map(r => ({ ownerId: r.product_id, namespace: 'global', key: 'title_tag', type: 'single_line_text_field', value: r.old_title_tag }));
+ const d = await gql(`mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ id } userErrors{ field message } } }`, { mf });
+ if (d.metafieldsSet.userErrors.length) { console.error(d.metafieldsSet.userErrors); err += chunk.length; continue; }
+ ok += d.metafieldsSet.metafields.length;
+ process.stderr.write(`\r reverted ${ok}/${ready.length} err ${err} `);
+}
+console.log(`\nROLLBACK done. reverted=${ok} err=${err}.`);
← 8544b4b TK-11679: curated colorway-facet un-truncation list (GET-ver
·
back to Designerwallcoverings
·
TK-11673: extract June title_tag corrector to transform.mjs bb1980f →