← back to Gmc Titlefix

gmc-dwla-peryard-primary.js

208 lines

// GMC DWLA PER-YARD PRIMARY-OFFER DEPLOY (Steve-APPROVED 2026-07-16).
//
// PROBLEM: The Justin David / "Los Angeles Fabrics" (Phillipe Romano, DWLA-) line
// is present on LIVE Merchant Center 146735262 ONLY as $4.25 "Sample" offers
// (offerIds shopify_US_<pid>_<vid> from the Shopify Google&YouTube channel).
// ZERO per-yard offers exist. A supplemental/update feed CANNOT create a missing
// offer, so we create the per-yard offers as NEW PRIMARY offers.
//
// STRATEGY (DTD verdict B, 2026-07-16): a DEDICATED NEW primary datasource
// "DW LA Fabrics Per-Yard" holds all per-yard productInputs, isolated from the
// Shopify-channel offers. FULLY REVERSIBLE: delete the datasource → every
// per-yard offer vanishes. FEED-ONLY: never touches Shopify variant/price/weight.
//
// Offer content comes from the controlled feed's DWLA-*-Yard rows (correct
// per-yard price, link, image, title). shipping_weight is ADDED here (feed-only):
//   Commercial Fabric / Drapery  -> 1 lb
//   Commercial Wallcovering      -> 4 lb
// offerId = the feed row id (DWLA-<sku>-Yard) — greppable, distinct from the
// shopify_US_* sample offers.
//
//   node gmc-dwla-peryard-primary.js                    # DRY-RUN: build the per-yard offer list from the feed, no writes
//   node gmc-dwla-peryard-primary.js --create-ds        # GATED: create the dedicated primary datasource (prints its name/id)
//   node gmc-dwla-peryard-primary.js --pilot 3 --ds <DS> # GATED: insert the first N per-yard offers as a pilot
//   node gmc-dwla-peryard-primary.js --scale --ds <DS>   # GATED: insert ALL remaining per-yard offers
//   node gmc-dwla-peryard-primary.js --verify --ds <DS> [--pilot 3]  # read back inserted offers from MC
//   node gmc-dwla-peryard-primary.js --delete-ds <DS>    # REVERT: delete the whole datasource (all per-yard offers vanish)
//
// Cost: $0 (Google Merchant API + local feed read; no per-call charge).
const { token, MERCHANT } = require('./_auth.js');
const fs = require('fs');

const FEED_TSV = '/Users/macstudio3/Projects/designerwallcoverings/today-viewer/data/google-merchant-feed.tsv';
const OFFERS_JSON = '/Users/macstudio3/.claude/yolo-queue/gmc-dwla-peryard-offers.json';
const LOG = '/Users/macstudio3/.claude/yolo-queue/gmc-dwla-peryard-deploy-log.json';

const CATEGORY = 'Home & Garden > Decor > Wallpaper'; // Google taxonomy (same as the rest of the feed)
const WEIGHT_FABRIC = 1;         // Commercial Fabric / Drapery  ≈ 1 lb (per Steve 2026-07-16)
const WEIGHT_WALLCOVERING = 4;   // Commercial Wallcovering       ≈ 4 lb

const args = process.argv.slice(2);
const has = f => args.includes(f);
const val = (f, d) => { const i = args.indexOf(f); return (i !== -1 && args[i + 1] && !args[i + 1].startsWith('--')) ? args[i + 1] : d; };
const sleep = ms => new Promise(r => setTimeout(r, ms));

// ---- 1) Build the per-yard offer list from the controlled feed ---------------
function buildOffers() {
  const lines = fs.readFileSync(FEED_TSV, 'utf8').split('\n');
  const cols = lines[0].split('\t');
  const ix = n => cols.indexOf(n);
  const c = {
    id: ix('id'), title: ix('title'), description: ix('description'), link: ix('link'),
    image: ix('image_link'), addl: ix('additional_image_link'), avail: ix('availability'),
    price: ix('price'), brand: ix('brand'), mpn: ix('mpn'), ptype: ix('product_type'),
  };
  const offers = [];
  for (let i = 1; i < lines.length; i++) {
    if (!lines[i]) continue;
    const f = lines[i].split('\t');
    const id = f[c.id];
    if (!/^DWLA-.*-Yard$/.test(id)) continue;                     // per-yard DWLA rows only
    const priceNum = parseFloat((f[c.price] || '').split(' ')[0]);
    if (!(priceNum > 4.26)) continue;                             // never advertise the $4.25 sample as per-yard
    const ptype = f[c.ptype] || '';
    const weight = /wallcovering/i.test(ptype) ? WEIGHT_WALLCOVERING : WEIGHT_FABRIC;
    offers.push({
      offerId: id,
      title: f[c.title] || '',
      description: f[c.description] || f[c.title] || '',
      link: f[c.link] || '',
      imageLink: f[c.image] || '',
      additionalImageLinks: (f[c.addl] || '').split(',').filter(Boolean),
      availability: f[c.avail] || 'in_stock',
      priceAmount: priceNum,
      brand: f[c.brand] || 'Los Angeles Fabrics',
      mpn: f[c.mpn] || id,
      productType: ptype || 'Commercial Fabric',
      shippingWeightLb: weight,
    });
  }
  return offers;
}

// ---- 2) Create the dedicated primary datasource ------------------------------
async function createDataSource() {
  const tok = await token();
  const url = `https://merchantapi.googleapis.com/datasources/v1/accounts/${MERCHANT}/dataSources`;
  const body = {
    displayName: 'DW LA Fabrics Per-Yard',
    primaryProductDataSource: { contentLanguage: 'en', feedLabel: 'US', countries: ['US'] },
  };
  const r = await fetch(url, { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
  const j = await r.json();
  if (r.status >= 200 && r.status < 300 && j.name) {
    console.log('DATASOURCE CREATED:\n' + j.name + '\n  id: ' + j.dataSourceId);
    appendLog({ action: 'create-ds', datasource: j.name, id: j.dataSourceId });
    return j.name;
  }
  console.error('create-ds FAILED', r.status, JSON.stringify(j.error || j).slice(0, 300));
  process.exit(1);
}

// ---- 3) Insert per-yard offers (pilot or scale) ------------------------------
async function insertOffers(ds, offers, label) {
  let tok = await token(), tokAt = Date.now(), ok = 0, fail = 0;
  const echoes = [];
  console.log(`[${label}] inserting ${offers.length} per-yard offers → ${ds}`);
  for (let i = 0; i < offers.length; i++) {
    if (Date.now() - tokAt > 50 * 60 * 1000) { tok = await token(); tokAt = Date.now(); }
    const o = offers[i];
    const url = `https://merchantapi.googleapis.com/products/v1/accounts/${MERCHANT}/productInputs:insert?dataSource=${encodeURIComponent(ds)}`;
    const productAttributes = {
      title: o.title,
      description: o.description,
      link: o.link,
      imageLink: o.imageLink,
      additionalImageLinks: o.additionalImageLinks,
      availability: o.availability,
      condition: 'new',
      price: { amountMicros: String(Math.round(o.priceAmount * 1e6)), currencyCode: 'USD' },
      brand: o.brand,
      mpn: o.mpn,
      productTypes: [o.productType],
      googleProductCategory: CATEGORY,
      shippingWeight: { value: o.shippingWeightLb, unit: 'lb' },
    };
    const body = { offerId: o.offerId, contentLanguage: 'en', feedLabel: 'US', productAttributes };
    const r = await fetch(url, { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
    const txt = await r.text(); let j = {}; try { j = JSON.parse(txt); } catch (e) {}
    if (r.ok && !j.error) {
      ok++;
      if (echoes.length < 5) echoes.push({ offerId: o.offerId, name: j.name, price: o.priceAmount, weight: o.shippingWeightLb });
    } else {
      fail++;
      if (fail <= 10) console.error('FAIL', o.offerId, r.status, txt.slice(0, 220));
    }
    if (i % 250 === 0 && i) console.log(`  ${i}/${offers.length} | ok ${ok} fail ${fail}`);
    if (r.status === 429) await sleep(3000);
  }
  console.log(`[${label}] DONE: ok ${ok} fail ${fail} of ${offers.length}`);
  if (echoes.length) console.log('  API echoes:\n' + echoes.map(e => `    ${e.offerId} → ${e.name} ($${e.price}, ${e.weight}lb)`).join('\n'));
  appendLog({ action: label, datasource: ds, attempted: offers.length, ok, fail, echoes });
  return { ok, fail, echoes };
}

// ---- 4) Verify offers are ingested (read back from MC) -----------------------
async function verify(ds, sample) {
  const tok = await token(); const H = { Authorization: 'Bearer ' + tok };
  console.log(`Verifying ${sample.length} per-yard offers on live MC (Content API products.get)...`);
  for (const o of sample) {
    const pid = `online:en:US:${o.offerId}`;
    const r = await fetch(`https://shoppingcontent.googleapis.com/content/v2.1/${MERCHANT}/products/${encodeURIComponent(pid)}`, { headers: H });
    const j = await r.json();
    if (j.error) { console.log(`  ${o.offerId}  NOT-FOUND-YET (${j.error.code}) — expected within Google's ~24h merge`); continue; }
    console.log(`  ${o.offerId}  price=${j.price?.value} ${j.price?.currency} | title="${(j.title || '').slice(0, 45)}" | shippingWeight=${JSON.stringify(j.shippingWeight || null)}`);
  }
}

// ---- 5) REVERT: delete the datasource ----------------------------------------
async function deleteDataSource(ds) {
  const tok = await token();
  const url = `https://merchantapi.googleapis.com/datasources/v1/accounts/${MERCHANT}/dataSources/${ds.split('/').pop()}`;
  const r = await fetch(url, { method: 'DELETE', headers: { Authorization: 'Bearer ' + tok } });
  console.log(r.ok ? `✓ DELETED datasource ${ds} — all per-yard offers under it are removed.` : `DELETE failed ${r.status} ${(await r.text()).slice(0, 200)}`);
  appendLog({ action: 'delete-ds', datasource: ds, http: r.status });
}

function appendLog(entry) {
  let log = [];
  try { log = JSON.parse(fs.readFileSync(LOG, 'utf8')); } catch (e) {}
  log.push({ at: new Date().toISOString(), ...entry });
  fs.writeFileSync(LOG, JSON.stringify(log, null, 2));
}

(async () => {
  const offers = buildOffers();
  fs.writeFileSync(OFFERS_JSON, JSON.stringify({ generated_at: new Date().toISOString(), count: offers.length, offers }, null, 2));

  if (has('--create-ds')) return createDataSource();

  const ds = val('--ds', null);
  if (has('--delete-ds')) return deleteDataSource(val('--delete-ds', args[args.indexOf('--delete-ds') + 1]));

  if (has('--pilot')) {
    if (!ds) { console.error('need --ds <datasource>'); process.exit(1); }
    const n = parseInt(val('--pilot', '3'), 10);
    return void await insertOffers(ds, offers.slice(0, n), `pilot-${n}`);
  }
  if (has('--scale')) {
    if (!ds) { console.error('need --ds <datasource>'); process.exit(1); }
    return void await insertOffers(ds, offers, 'scale');
  }
  if (has('--verify')) {
    if (!ds) { console.error('need --ds <datasource>'); process.exit(1); }
    const n = has('--pilot') ? parseInt(val('--pilot', '3'), 10) : 3;
    return verify(ds, offers.slice(0, n));
  }

  // DRY-RUN
  const wall = offers.filter(o => o.shippingWeightLb === WEIGHT_WALLCOVERING).length;
  const fab = offers.length - wall;
  console.log(`DWLA per-yard offers built from feed: ${offers.length}`);
  console.log(`  shipping_weight tiers: ${fab} @ 1lb (fabric/drapery), ${wall} @ 4lb (commercial wallcovering)`);
  console.log(`  price range: $${Math.min(...offers.map(o => o.priceAmount)).toFixed(2)} – $${Math.max(...offers.map(o => o.priceAmount)).toFixed(2)}`);
  console.log(`Wrote ${OFFERS_JSON}`);
  console.log('--- first 3 (pilot candidates) ---');
  offers.slice(0, 3).forEach(o => console.log(`  ${o.offerId}  $${o.priceAmount}  ${o.shippingWeightLb}lb  "${o.title.slice(0, 45)}"`));
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });