← 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); });