← back to Dw Yolo Loop
scripts/google-feed/feed-eligibility.mjs
246 lines
#!/usr/bin/env node
/**
* feed-eligibility.mjs — Build a CLEAN Google Merchant Center feed candidate set
* from LIVE Shopify data (not the stale dw_unified mirror), applying Steve's hard
* rules + a price floor + sample-variant segregation + private-label leak guard.
*
* WHY live-driven: calibrate-price.mjs proved the mirror `price` column is stale
* (mirror NULL/$4.25 -> live roll $34–$205). Pricing the feed off the mirror would
* wrongly drop ~54k correctly-priced products and could advertise the $4.25 SAMPLE
* variant. So the feed price = the ROLL variant's price, read live.
*
* OUTPUT (local files only — NOTHING is submitted to Google):
* data/google-feed/feed-clean.tsv — Merchant Center TSV of ELIGIBLE products
* data/google-feed/exclusions.json — every excluded product + reason
* data/google-feed/report.json — counts + reason breakdown + samples
*
* READ-ONLY against Shopify (GraphQL queries only). No writes, no status changes,
* no channel publish. Submitting/uploading the feed is a separate Steve-gated step.
*
* USAGE: node feed-eligibility.mjs [--limit=N] [--floor=10]
*/
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
// Canonical showroom-vendor primitive (list + logic live in fix-live-board/config).
// Never hardcode a vendor name here — edit showroom-vendors.json to change the set. TK-11186.
const require = createRequire(import.meta.url);
const { isShowroomVendor } = require(process.env.HOME + '/Projects/fix-live-board/config/showroom-vendor.cjs');
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const VER = '2024-10';
const PUBLIC_DOMAIN = 'https://designerwallcoverings.com';
const GOOGLE_CATEGORY = 'Hardware > Building Materials > Wallpaper'; // Google taxonomy 503739
const args = Object.fromEntries(process.argv.slice(2).map(a => {
const [k, v] = a.replace(/^--/, '').split('='); return [k, v === undefined ? true : v];
}));
const LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity;
const FLOOR = args.floor ? parseFloat(args.floor) : 10;
const envTxt = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (envTxt.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1]?.trim();
if (!TOKEN) { console.error('no token'); process.exit(1); }
const URL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// ---- cost map for Kravet MAP floor (from mirror; sparse is OK -> flagged unverified) ----
// Tolerate an absent cost map: the file lives in /tmp and is GC-prone, and the
// script is DESIGNED to flag `kravet_map_unverified_no_cost` per-product when cost
// is missing. A hard readFileSync crash here defeats that intent (TK-10209).
const COST = new Map();
try {
for (const line of fs.readFileSync('/tmp/cost_map.tsv', 'utf8').trim().split('\n')) {
const [gid, cost] = line.split('\t');
if (gid) COST.set(gid, parseFloat(cost) || 0);
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
console.error('[warn] /tmp/cost_map.tsv absent -> Kravet MAP floor unverified for all (per-product flagged)');
}
// ---- rule constants ----
// Kravet-umbrella brands: must be >= wholesale x 1.5 (MAP). Showing the brand name is fine (DW is authorized).
const KRAVET_FAMILY = ['kravet','lee jofa','groundworks','brunschwig','cole and son','cole & son',
'gp j baker','gp & j baker','colefax','clarke and clarke','clarke & clarke','mulberry','threads',
'baker lifestyle','andrew martin','nicolette mayer','aerin','barclay butera','thom filicia'];
// PRIVATE-LABEL upstreams — these names must NEVER reach a customer surface (the feed).
// Source: dw-leak-scanner / MEMORY. NOT the repped brands (Kravet/Thibaut/etc are shown by name).
const PRIVATE_LABEL_LEAK = ['command54','command 54','wallquest','chesapeake','nextwall','next wall',
'seabrook','brewster','desima','carlsten','nicolette mayer'];
const norm = s => String(s == null ? '' : s).toLowerCase();
// A variant priced above this is a REAL sellable roll no matter what its option
// label says. Fixes the false `no_roll_variant_price` on products whose single
// full-roll-priced variant is mislabeled option "Size: Sample" (e.g. Osborne &
// Little W7351-01 @ $76.02). Genuine $4.25 memo/swatch samples stay < this and are
// still correctly treated as samples (TK-10209).
const ROLL_PRICE_MIN = 5;
const looksSample = v => /(sample|memo|swatch)/i.test([v.title, v.sku].join(' ')) || /-sample$/i.test(v.sku || '');
// Classify as sample only when it LOOKS like a sample AND isn't priced like a real
// roll. Price beats the label: a >$5 variant is a roll even if labeled "Sample".
const isSample = v => looksSample(v) && !(parseFloat(v.price) > ROLL_PRICE_MIN);
async function gql(query, variables) {
for (let attempt = 0; attempt < 8; attempt++) {
let res, j;
try {
res = await fetch(URL, { method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }) });
j = await res.json();
} catch (e) { await sleep(1500 * (attempt + 1)); continue; }
if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (attempt + 1)); continue; } throw new Error(JSON.stringify(j.errors)); }
const t = j.extensions?.cost?.throttleStatus;
if (t && t.currentlyAvailable < 400) await sleep(1200);
return j.data;
}
throw new Error('exhausted retries');
}
const QUERY = `
query($cursor:String){
products(first:50, after:$cursor, query:"status:active"){
pageInfo{ hasNextPage endCursor }
nodes{
id handle title status vendor tags productType
featuredImage{ url }
mediaCount{ count }
variants(first:30){ nodes{ title sku price } }
widthGlobal: metafield(namespace:"global", key:"width"){ value }
widthCustom: metafield(namespace:"custom", key:"width"){ value }
widthDwc: metafield(namespace:"dwc", key:"width"){ value }
}
}
}`;
function evaluate(p) {
const reasons = [];
const flags = [];
const title = p.title || '';
const tagsBlob = (p.tags || []).join(' ');
const ctx = norm([p.vendor, title, tagsBlob, p.handle, p.productType].join(' '));
// Leak check runs ONLY over fields the feed actually emits (title/handle/vendor).
// A private-label name buried in tags/productType never reaches Google via this feed,
// so it's a catalog-hygiene issue (tag scrub), not a feed blocker. Calibrated 2026-06-15:
// of 988 tag-matches only 29 were real feed-field leaks.
const feedCtx = norm([p.vendor, title, p.handle].join(' '));
const variants = p.variants?.nodes || [];
const rolls = variants.filter(v => !isSample(v)).map(v => parseFloat(v.price)).filter(Number.isFinite);
const rollPrice = rolls.length ? Math.max(...rolls) : null;
const img = p.featuredImage?.url || null;
const hasImg = !!img || (p.mediaCount?.count || 0) > 0;
const width = (p.widthGlobal?.value || p.widthCustom?.value || p.widthDwc?.value || '').trim();
// ---- hard gates (exclude) ----
// Showroom-only vendors (e.g. Phillip Jeffries) are addressable-but-not-discoverable —
// hard-exclude them from the Google feed regardless of price. PJ is sample-only ($4.25)
// today so it's excluded incidentally, but a future >$5 / roll-priced PJ variant would
// otherwise leak into the feed. Keyed off showroom-vendors.json via the shared primitive. TK-11186.
if (isShowroomVendor(p.vendor)) reasons.push('showroom_only_addressable_not_discoverable');
if (!hasImg) reasons.push('no_image');
if (rollPrice == null) reasons.push('no_roll_variant_price');
else if (rollPrice < FLOOR) reasons.push(`roll_price_below_floor_${rollPrice}`);
if (rollPrice === 4.25) reasons.push('roll_price_is_425_sampletrap');
// private-label leak -> only exclude if it reaches a FEED-emitted field
for (const tok of PRIVATE_LABEL_LEAK) {
if (feedCtx.includes(tok)) { reasons.push(`private_label_leak:${tok}`); break; }
}
// tags-only private-label name = feed-safe but a catalog-hygiene flag
if (!reasons.some(r => r.startsWith('private_label_leak'))) {
for (const tok of PRIVATE_LABEL_LEAK) {
if (ctx.includes(tok)) { flags.push(`tag_scrub_needed:${tok}`); break; }
}
}
// banned words
if (/\bwallpapers?\b/i.test(title)) reasons.push('title_says_wallpaper');
if (/\bunknown\b/i.test(title)) reasons.push('title_says_unknown');
// Kravet MAP floor (exclude only if we can PROVE it's below MAP; else flag)
const isKravet = KRAVET_FAMILY.some(k => ctx.includes(k));
if (isKravet && rollPrice != null) {
const cost = COST.get(p.id) || 0;
if (cost > 0) {
const mapFloor = cost * 1.5;
if (rollPrice + 1e-6 < mapFloor) reasons.push(`below_kravet_map_${rollPrice}_lt_${mapFloor.toFixed(2)}`);
} else {
flags.push('kravet_map_unverified_no_cost');
}
}
return { id: p.id.split('/').pop(), handle: p.handle, title, vendor: p.vendor || '',
rollPrice, img, width, eligible: reasons.length === 0, reasons, flags };
}
function tsvRow(r) {
// Merchant Center primary feed columns (tab-separated)
const desc = `${r.title}${r.width ? ' — ' + r.width + ' wide' : ''}`.replace(/\t|\n/g, ' ');
return [
r.id, // id
r.title.replace(/\t|\n/g, ' '), // title
desc, // description
`${PUBLIC_DOMAIN}/products/${r.handle}`, // link
r.img || '', // image_link
'in stock', // availability
`${r.rollPrice.toFixed(2)} USD`, // price (ROLL, not sample)
r.vendor, // brand
'new', // condition
r.id, // mpn
GOOGLE_CATEGORY, // google_product_category
'Wallcovering', // product_type
].join('\t');
}
(async () => {
const outDir = path.join(process.cwd(), 'data', 'google-feed');
fs.mkdirSync(outDir, { recursive: true });
const eligible = [], excluded = [];
const reasonTally = {}, flagTally = {}, vendorEligible = {};
let cursor = null, hasNext = true, scanned = 0;
while (hasNext && scanned < LIMIT) {
const data = await gql(QUERY, { cursor });
const page = data.products;
for (const p of page.nodes) {
if (scanned >= LIMIT) break;
scanned++;
const r = evaluate(p);
for (const f of r.flags) flagTally[f] = (flagTally[f] || 0) + 1;
if (r.eligible) {
eligible.push(r);
vendorEligible[r.vendor] = (vendorEligible[r.vendor] || 0) + 1;
} else {
excluded.push({ id: r.id, handle: r.handle, title: r.title, vendor: r.vendor, rollPrice: r.rollPrice, reasons: r.reasons });
for (const reason of r.reasons) {
const key = reason.replace(/:.+$/, '').replace(/_\d.+$/, '').replace(/_[0-9.]+_lt_[0-9.]+$/, '');
reasonTally[key] = (reasonTally[key] || 0) + 1;
}
}
}
hasNext = page.pageInfo.hasNextPage;
cursor = page.pageInfo.endCursor;
if (scanned % 1000 === 0) process.stderr.write(` scanned ${scanned} | eligible ${eligible.length} | excluded ${excluded.length}\n`);
}
// write feed TSV
const header = ['id','title','description','link','image_link','availability','price','brand','condition','mpn','google_product_category','product_type'].join('\t');
fs.writeFileSync(path.join(outDir, 'feed-clean.tsv'), header + '\n' + eligible.map(tsvRow).join('\n') + '\n');
fs.writeFileSync(path.join(outDir, 'exclusions.json'), JSON.stringify(excluded, null, 2));
const topVendors = Object.entries(vendorEligible).sort((a,b)=>b[1]-a[1]).slice(0,20).map(([v,c])=>({vendor:v,count:c}));
const report = {
generated_at_note: 'live Shopify pull, read-only',
shop: SHOP, api_version: VER, price_floor: FLOOR, public_domain: PUBLIC_DOMAIN,
scanned, eligible: eligible.length, excluded: excluded.length,
exclusion_reasons: Object.fromEntries(Object.entries(reasonTally).sort((a,b)=>b[1]-a[1])),
advisory_flags: flagTally,
top_eligible_vendors: topVendors,
sample_exclusions: excluded.slice(0, 25),
};
fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ scanned, eligible: eligible.length, excluded: excluded.length,
exclusion_reasons: report.exclusion_reasons, advisory_flags: flagTally }, null, 2));
process.stderr.write(`WROTE ${outDir}/{feed-clean.tsv, exclusions.json, report.json}\n`);
})();