[object Object]

← back to Designer Wallcoverings

Schumacher recrawl cracked: authenticated product API (browser-free)

51a55b8a9fc5eb95d83594b5c90e0c60eae39886 · 2026-06-11 10:23:33 -0700 · SteveStudio2

api.schumacher.com/products/{SKU} returns price + full specs with a Bearer JWT from the
logged-in trade session (.schu-token, gitignored, ~92d expiry). No browser/Browserbase/
Mac2 Chrome. Designer Net (discount 0.00) → priceUsd = cost. Verified 25/25 priced.
NOTE: schu-browserbase-scrape.js / schu-price-probe.js still hold a plaintext password —
NOT committed; pending secret-strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 51a55b8a9fc5eb95d83594b5c90e0c60eae39886
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu Jun 11 10:23:33 2026 -0700

    Schumacher recrawl cracked: authenticated product API (browser-free)
    
    api.schumacher.com/products/{SKU} returns price + full specs with a Bearer JWT from the
    logged-in trade session (.schu-token, gitignored, ~92d expiry). No browser/Browserbase/
    Mac2 Chrome. Designer Net (discount 0.00) → priceUsd = cost. Verified 25/25 priced.
    NOTE: schu-browserbase-scrape.js / schu-price-probe.js still hold a plaintext password —
    NOT committed; pending secret-strip.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore                          |   4 +
 shopify/scripts/schu-api-recrawl.js | 146 ++++++++++++++++++++++++++++++++++++
 2 files changed, 150 insertions(+)

diff --git a/.gitignore b/.gitignore
index 4f934aca..332fdba9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,7 @@ __pycache__/
 *.old
 copy-of-*
 *.backup
+
+# Schumacher trade session token (sensitive, ~92d expiry)
+.schu-token
+shopify/scripts/.schu-token
diff --git a/shopify/scripts/schu-api-recrawl.js b/shopify/scripts/schu-api-recrawl.js
new file mode 100644
index 00000000..44dd6182
--- /dev/null
+++ b/shopify/scripts/schu-api-recrawl.js
@@ -0,0 +1,146 @@
+#!/usr/bin/env node
+/**
+ * Schumacher RECRAWL via the authenticated product API (browser-free).
+ *
+ * Cracked 2026-06-11: schumacher.com gates pricing behind trade login, and the price
+ * is NOT in __NEXT_DATA__ (always null) — it lives in api.schumacher.com/products/{SKU},
+ * returned with a Bearer JWT. Steve's logged-in trade session token (sub=466516,
+ * TRADE_ACCOUNT_OWNER, ~92d expiry) is read from .schu-token. With it we fetch price +
+ * full specs for every SKU server-side. No browser, no Browserbase, no Mac2 Chrome.
+ *
+ * Schumacher = "Designer Net": vendor_registry discount 0.00 → priceUsd IS our net cost.
+ *   retail_price = trade_price = cost = priceUsd.   (our DW retail = cost/0.65/0.85, computed at import)
+ *
+ *   node schu-api-recrawl.js --test            # 3 SKUs incl R11358658, parse+print, NO write
+ *   node schu-api-recrawl.js --limit 50 --commit
+ *   node schu-api-recrawl.js --all --commit     # all ~4,977 wallpaper rows needing data (resume-safe)
+ */
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const TOKEN = fs.readFileSync(path.join(__dirname, '.schu-token'), 'utf8').trim();
+const args = process.argv.slice(2);
+const TEST = args.includes('--test');
+const COMMIT = args.includes('--commit');
+const ALL = args.includes('--all');
+const lIdx = args.indexOf('--limit');
+const LIMIT = TEST ? 3 : (lIdx >= 0 ? parseInt(args[lIdx + 1], 10) : (ALL ? 100000 : 25));
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function psql(sql) { return execFileSync('psql', ['-At', '-F', '\t', '-d', 'dw_unified', '-c', sql], { encoding: 'utf8', maxBuffer: 1 << 28 }).trim(); }
+const q = s => "'" + String(s).replace(/'/g, "''") + "'";          // sql string literal
+const qn = v => (v == null || v === '' || isNaN(parseFloat(v))) ? 'NULL' : parseFloat(v);
+
+// token expiry guard
+function tokenExp() { try { const p = JSON.parse(Buffer.from(TOKEN.split('.')[1], 'base64').toString()); return p.exp; } catch { return 0; } }
+
+async function fetchProduct(sku) {
+  const url = `https://api.schumacher.com/products/${encodeURIComponent(sku)}?skipAnalytics=true`;
+  const r = await fetch(url, { headers: {
+    'authorization': 'Bearer ' + TOKEN,
+    'x-version': '1.2',
+    'x-domain': 'PDP',
+    'origin': 'https://schumacher.com',
+    'referer': 'https://schumacher.com/',
+    'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
+    'accept': '*/*',
+  }});
+  if (r.status === 401 || r.status === 403) { const e = new Error('AUTH_' + r.status); e.auth = true; throw e; }
+  if (!r.ok) throw new Error('HTTP_' + r.status);
+  return r.json();
+}
+
+// attributes[] → { slug: firstValue }   (each attr.value is [{value}])
+function attrMap(j) {
+  const m = {};
+  for (const a of (j.attributes || [])) {
+    const vals = (a.value || []).map(v => v.value).filter(Boolean);
+    if (vals.length) m[a.slug] = vals.length === 1 ? vals[0] : vals;
+  }
+  return m;
+}
+
+function parse(j) {
+  const a = attrMap(j);
+  const img = j.imageUrl || (j.images && j.images[0] && j.images[0].largeUrl) || null;
+  const gallery = (j.images || []).map(x => x.largeUrl).filter(Boolean);
+  return {
+    sku: j.itemNumber,
+    priceUsd: (typeof j.priceUsd === 'number') ? j.priceUsd : null,
+    priceCad: (typeof j.priceCad === 'number') ? j.priceCad : null,
+    name: j.name || null,
+    color: j.colorName || null,
+    collection: (j.collection && j.collection.name) || a['collection'] || null,
+    category: (j.category && j.category.name) || null,
+    description: j.description || null,
+    composition: a['content'] || a['content1'] || null,       // "100% WOOL"
+    country: a['country-of-origin'] || a['country-of-finish'] || null,
+    size: a['size'] || null,
+    width: a['item-width-imperial-fraction'] || null,
+    repeat_v: a['vertical-repeat'] || a['repeat-vertical'] || null,
+    repeat_h: a['horizontal-repeat'] || a['repeat-horizontal'] || null,
+    image_url: img,
+    gallery,
+    specs: a,
+  };
+}
+
+(async () => {
+  const exp = tokenExp();
+  const daysLeft = Math.round((exp - Date.now() / 1000) / 86400);
+  if (exp && exp < Date.now() / 1000) { console.error('TOKEN EXPIRED — refresh .schu-token from a logged-in browser session.'); process.exit(2); }
+  console.log(`token ok (~${daysLeft}d left) | mode: ${TEST ? 'TEST' : COMMIT ? 'COMMIT' : 'DRY'} | limit ${LIMIT}`);
+
+  let skus;
+  if (TEST) {
+    skus = ['R11358658', ...psql(`SELECT mfr_sku FROM schumacher_catalog WHERE product_type IN ('Wallcovering','Commercial Wallcovering') AND retail_price IS NULL ORDER BY mfr_sku LIMIT 2;`).split('\n').filter(Boolean)];
+  } else {
+    // resume-safe: only rows still missing price
+    skus = psql(`SELECT mfr_sku FROM schumacher_catalog WHERE product_type IN ('Wallcovering','Commercial Wallcovering') AND retail_price IS NULL AND mfr_sku IS NOT NULL AND mfr_sku<>'' ORDER BY mfr_sku LIMIT ${LIMIT};`).split('\n').filter(Boolean);
+  }
+  console.log(`SKUs to fetch: ${skus.length}`);
+
+  let ok = 0, priced = 0, miss = 0, err = 0;
+  for (let i = 0; i < skus.length; i++) {
+    const sku = skus[i];
+    try {
+      const j = await fetchProduct(sku);
+      const p = parse(j);
+      if (p.priceUsd == null) miss++;
+      if (TEST || !COMMIT) {
+        console.log(`  ${sku}  $${p.priceUsd}  ${p.composition || ''} | ${p.country || ''} | ${p.size || p.width || ''} | ${(p.name||'').slice(0,30)}`);
+      } else {
+        const our = p.priceUsd != null ? (p.priceUsd / 0.65 / 0.85) : null;       // DW retail (kept in `price` text for now)
+        const sets = [
+          `retail_price=${qn(p.priceUsd)}`,
+          `trade_price=${qn(p.priceUsd)}`,            // discount 0.00 → net = listed
+          `cost=${qn(p.priceUsd)}`,
+          `price=${p.priceUsd != null ? q('$' + p.priceUsd.toFixed(2)) : 'NULL'}`,
+          `price_fetched_at=now()`, `updated_at=now()`,
+          p.name ? `pattern_name=COALESCE(NULLIF(pattern_name,''),${q(p.name)})` : null,
+          p.color ? `color_name=COALESCE(NULLIF(color_name,''),${q(p.color)})` : null,
+          p.collection ? `collection=COALESCE(NULLIF(collection,''),${q(p.collection)})` : null,
+          p.description ? `description=COALESCE(NULLIF(description,''),${q(p.description)})` : null,
+          p.composition ? `composition=${q(p.composition)}` : null,
+          p.composition ? `material=COALESCE(NULLIF(material,''),${q(p.composition)})` : null,
+          p.country ? `country_of_origin=${q(p.country)}` : null,
+          p.width ? `width=COALESCE(NULLIF(width,''),${q(p.width)})` : null,
+          p.image_url ? `image_url=COALESCE(NULLIF(image_url,''),${q(p.image_url)})` : null,
+          p.gallery.length ? `gallery_images=${q(JSON.stringify(p.gallery))}::jsonb` : null,
+          `specs=${q(JSON.stringify(p.specs))}::jsonb`,
+        ].filter(Boolean).join(', ');
+        psql(`UPDATE schumacher_catalog SET ${sets} WHERE mfr_sku=${q(sku)};`);
+        if (p.priceUsd != null) priced++;
+      }
+      ok++;
+    } catch (e) {
+      err++;
+      if (e.auth) { console.error(`\nAUTH FAILURE on ${sku} (${e.message}) — token likely expired/invalid. Stopping.`); break; }
+      console.log(`  ${sku} ERR ${e.message}`);
+    }
+    if (!TEST && ok % 50 === 0) process.stdout.write(`\r  progress ${i + 1}/${skus.length} | priced ${priced} | miss ${miss} | err ${err}`);
+    await sleep(350);
+  }
+  console.log(`\nDONE: fetched ${ok}, priced ${priced}, no-price ${miss}, errors ${err}${COMMIT && !TEST ? ' (written to schumacher_catalog)' : ' (dry — no write)'}`);
+})();

← a51fa0c8 Add launchd auto-resume agent (com.steve.dw-pricing-resume):  ·  back to Designer Wallcoverings  ·  Scrub plaintext Schumacher password from browser-login scrip a51f9ed8 →