[object Object]

← back to Dw Product Manager

Add contact-for-price bulk tagger for command54-origin blank-price PR set

b84f6b52947d428dbe69a7ceaacb8a4885567436 · 2026-07-10 07:51:16 -0700 · Steve Abrams

Append-only tagsAdd mutation, idempotent + resumable ledger, rate-limited 2/sec
with 429/throttle backoff. Target = command54-origin (backup table) ACTIVE
blank-price (min_variant_price<=4.25, price NULL-or-<=4.25) PR private-label
vendors, excluding real-brand-vendor and real-price rows as skipped-ambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b84f6b52947d428dbe69a7ceaacb8a4885567436
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 10 07:51:16 2026 -0700

    Add contact-for-price bulk tagger for command54-origin blank-price PR set
    
    Append-only tagsAdd mutation, idempotent + resumable ledger, rate-limited 2/sec
    with 429/throttle backoff. Target = command54-origin (backup table) ACTIVE
    blank-price (min_variant_price<=4.25, price NULL-or-<=4.25) PR private-label
    vendors, excluding real-brand-vendor and real-price rows as skipped-ambiguous.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore                |   8 +++
 tag-contact-for-price.mjs | 148 ++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 156 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8935e44
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+cfp-bulk-ledger.jsonl
+cfp_target.tsv
+cfp_skipped.tsv
diff --git a/tag-contact-for-price.mjs b/tag-contact-for-price.mjs
new file mode 100644
index 0000000..1349062
--- /dev/null
+++ b/tag-contact-for-price.mjs
@@ -0,0 +1,148 @@
+#!/usr/bin/env node
+/**
+ * tag-contact-for-price.mjs
+ *
+ * Append-only bulk tagger: adds the tag `contact-for-price` to the
+ * command54-origin blank-price Phillipe Romano sibling set (proven quote-only
+ * switch that gates sections/contact-for-price.liquid line 13).
+ *
+ * SAFETY:
+ *  - Uses the tagsAdd GraphQL mutation, which is APPEND-ONLY by definition
+ *    (it merges the tag into the existing set; it never drops other tags).
+ *  - NEVER touches price, variants, or the theme.
+ *  - Idempotent: re-reads live tags per product; if contact-for-price is
+ *    already present it records `already-had-tag` and does not mutate.
+ *  - Resumable: appends one JSONL line per product to the ledger. On restart
+ *    it loads the ledger and skips any product already recorded as tagged /
+ *    already-had-tag.
+ *  - Rate-limited to ~2 mutations/sec with 429/throttle backoff.
+ *
+ * Target set was computed from dw_unified provenance
+ * (vendor_catalog_command54_relabel_bak_20260706, old_vendor_code='command54')
+ * joined to shopify_products, filtered to ACTIVE + blank-price
+ * (min_variant_price<=4.25 AND price NULL-or-<=4.25) + PR private-label vendor
+ * allowlist + not-already-tagged. See cfp_target.tsv.
+ */
+import fs from 'node:fs';
+import readline from 'node:readline';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const TAG = 'contact-for-price';
+const TARGET_TSV = new URL('./cfp_target.tsv', import.meta.url).pathname;
+const LEDGER = new URL('./cfp-bulk-ledger.jsonl', import.meta.url).pathname;
+const ENDPOINT = `https://${SHOP}/admin/api/${API}/graphql.json`;
+
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN not set'); process.exit(1); }
+
+const DRY_RUN = process.argv.includes('--dry-run');
+const PACE_MS = 500; // ~2/sec
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function gql(query, variables, attempt = 0) {
+  const res = await fetch(ENDPOINT, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  if (res.status === 429 || res.status >= 500) {
+    const wait = Math.min(2000 * (attempt + 1), 10000);
+    console.warn(`  HTTP ${res.status} — backoff ${wait}ms (attempt ${attempt + 1})`);
+    await sleep(wait);
+    if (attempt < 6) return gql(query, variables, attempt + 1);
+    throw new Error(`HTTP ${res.status} after retries`);
+  }
+  const json = await res.json();
+  // GraphQL-level throttle
+  const throttled = json.errors?.some((e) => e.extensions?.code === 'THROTTLED');
+  if (throttled) {
+    const wait = Math.min(2000 * (attempt + 1), 10000);
+    console.warn(`  GraphQL THROTTLED — backoff ${wait}ms`);
+    await sleep(wait);
+    if (attempt < 6) return gql(query, variables, attempt + 1);
+    throw new Error('THROTTLED after retries');
+  }
+  return json;
+}
+
+const READ_Q = `query($id: ID!){ product(id:$id){ id status tags } }`;
+const ADD_M = `mutation($id: ID!, $tags: [String!]!){
+  tagsAdd(id:$id, tags:$tags){ node{ id } userErrors{ field message } }
+}`;
+
+// Load ledger — skip products already recorded as done (tagged / already-had-tag)
+function loadDone() {
+  const done = new Set();
+  if (fs.existsSync(LEDGER)) {
+    for (const line of fs.readFileSync(LEDGER, 'utf8').split('\n')) {
+      if (!line.trim()) continue;
+      try {
+        const r = JSON.parse(line);
+        if (r.status === 'tagged' || r.status === 'already-had-tag') done.add(r.id);
+      } catch {}
+    }
+  }
+  return done;
+}
+
+function appendLedger(rec) {
+  fs.appendFileSync(LEDGER, JSON.stringify(rec) + '\n');
+}
+
+async function main() {
+  const done = loadDone();
+  const rows = fs.readFileSync(TARGET_TSV, 'utf8').split('\n').filter((l) => l.trim());
+  console.log(`Target rows: ${rows.length} | already-done in ledger: ${done.size} | DRY_RUN=${DRY_RUN}`);
+
+  let tagged = 0, already = 0, skipped = 0, errors = 0, processed = 0;
+
+  for (const line of rows) {
+    const [gid, handle, dwSku, vendor] = line.split('\t');
+    if (done.has(gid)) { continue; }
+    processed++;
+
+    try {
+      // Re-read live status + tags (idempotency + safety: confirm still ACTIVE)
+      const r = await gql(READ_Q, { id: gid });
+      const p = r.data?.product;
+      if (!p) {
+        appendLedger({ id: gid, handle, dw_sku: dwSku, status: 'skipped-not-found', ts: new Date().toISOString() });
+        skipped++; console.log(`  [skip] ${handle} — not found`); continue;
+      }
+      if (p.status !== 'ACTIVE') {
+        appendLedger({ id: gid, handle, dw_sku: dwSku, before_tag_count: p.tags.length, status: 'skipped-not-active', live_status: p.status, ts: new Date().toISOString() });
+        skipped++; console.log(`  [skip] ${handle} — status ${p.status}`); continue;
+      }
+      const before = p.tags.length;
+      if (p.tags.map((t) => t.toLowerCase()).includes(TAG)) {
+        appendLedger({ id: gid, handle, dw_sku: dwSku, before_tag_count: before, after_tag_count: before, status: 'already-had-tag', ts: new Date().toISOString() });
+        already++; continue;
+      }
+
+      if (DRY_RUN) {
+        appendLedger({ id: gid, handle, dw_sku: dwSku, before_tag_count: before, after_tag_count: before + 1, status: 'dry-run', ts: new Date().toISOString() });
+        tagged++;
+      } else {
+        const m = await gql(ADD_M, { id: gid, tags: [TAG] });
+        const errs = m.data?.tagsAdd?.userErrors;
+        if (errs && errs.length) {
+          appendLedger({ id: gid, handle, dw_sku: dwSku, before_tag_count: before, status: 'error', error: JSON.stringify(errs), ts: new Date().toISOString() });
+          errors++; console.log(`  [ERR] ${handle} — ${JSON.stringify(errs)}`); continue;
+        }
+        appendLedger({ id: gid, handle, dw_sku: dwSku, before_tag_count: before, after_tag_count: before + 1, status: 'tagged', ts: new Date().toISOString() });
+        tagged++;
+      }
+      if (processed % 100 === 0) console.log(`  ...${processed} processed (tagged=${tagged} already=${already} skip=${skipped} err=${errors})`);
+    } catch (e) {
+      appendLedger({ id: gid, handle, dw_sku: dwSku, status: 'error', error: String(e.message || e), ts: new Date().toISOString() });
+      errors++; console.log(`  [ERR] ${handle} — ${e.message}`);
+    }
+    await sleep(PACE_MS);
+  }
+
+  console.log(`\nDONE. tagged=${tagged} already-had-tag=${already} skipped=${skipped} errors=${errors} (processed this run=${processed})`);
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });

(oldest)  ·  back to Dw Product Manager  ·  (newest)