← 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
A .gitignoreA tag-contact-for-price.mjs
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)