[object Object]

← 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

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 →