← back to Koroseal Quote Only
scripts/gmc-exclude-koroseal.js
131 lines
#!/usr/bin/env node
/**
* gmc-exclude-koroseal.js — TK-11106 (Koroseal Option A, Steve-approved 2026-09-02)
*
* Fix the ~212 ACTIVE Koroseal products whose ONLY sellable price is the $4.25
* sample (sample-price leak → GMC price-mismatch disapproval risk) by
* unpublishing them from the "Google & YouTube" channel. Mirrors the executed
* Fentucci Option-A precedent (2026-08-19) and gmc-exclude-majilite.js.
*
* SAFETY:
* - Phase 1 VERIFY: live-fetches every target; a product is only eligible if
* live status=ACTIVE, min variant price <= 4.25, no real-priced variant, and
* currently published on Google & YouTube. Writes data/rollback-map.json
* BEFORE any write (undo = publishablePublish each id back).
* - Phase 2 APPLY (--apply): batches of 50, ~350ms between mutations, >=90s gap
* between batches (store-wide bulk-push rule; sibling agents write
* concurrently), per-batch re-verify isPublished=false.
*
* Usage:
* node scripts/gmc-exclude-koroseal.js # DRY-RUN: verify + rollback map only
* node scripts/gmc-exclude-koroseal.js --apply # approved live unpublish
*
* Cost: $0 (Shopify Admin API only).
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const STORE = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
return (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].replace(/["']/g, '').trim();
})();
const URL = `https://${STORE}/admin/api/${API}/graphql.json`;
const GOOGLE_PUB = 'gid://shopify/Publication/29646651457'; // Google & YouTube
const APPLY = process.argv.includes('--apply');
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function gql(query, variables) {
for (let attempt = 0; attempt < 6; attempt++) {
const res = await fetch(URL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
const j = await res.json();
if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (attempt + 1)); continue; } throw new Error(JSON.stringify(j.errors).slice(0, 300)); }
return j.data;
}
throw new Error('throttled after retries');
}
function targetIds() {
// The approved 212 set: local mirror, vendor=Koroseal, ACTIVE, price=4.25
const out = execSync(
`psql "host=/tmp dbname=dw_unified" -Atc "SELECT shopify_id FROM shopify_products WHERE vendor='Koroseal' AND status='ACTIVE' AND price=4.25 ORDER BY shopify_id"`,
{ encoding: 'utf8' });
return out.split('\n').filter(Boolean);
}
async function verifyOne(gid) {
const d = await gql(`query($id:ID!){ product(id:$id){ id handle title status vendor
variants(first:10){ nodes { price title sku } }
resourcePublicationsV2(first:25){ nodes { publication { id name } isPublished } } } }`, { id: gid });
const p = d.product;
if (!p) return { id: gid, eligible: false, reason: 'not found live' };
const prices = p.variants.nodes.map(v => Number(v.price));
const minP = Math.min(...prices), maxP = Math.max(...prices);
const onGoogle = p.resourcePublicationsV2.nodes.some(n => n.publication.id === GOOGLE_PUB && n.isPublished);
const eligible = p.status === 'ACTIVE' && p.vendor === 'Koroseal' && maxP <= 4.25 && onGoogle;
return { id: gid, handle: p.handle, status: p.status, vendor: p.vendor, minPrice: minP, maxPrice: maxP, onGoogle, eligible,
reason: eligible ? 'ok' : (!onGoogle ? 'not on Google (already excluded)' : p.status !== 'ACTIVE' ? 'not active' : p.vendor !== 'Koroseal' ? 'vendor mismatch' : 'has real-priced variant — SKIP (do not hide a sellable product)') };
}
(async () => {
console.log(`gmc-exclude-koroseal ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'} → ${STORE}`);
const ids = targetIds();
console.log(`mirror targets: ${ids.length}`);
// ---- Phase 1: VERIFY every target live ----
const verified = [];
for (let i = 0; i < ids.length; i++) {
verified.push(await verifyOne(ids[i]));
if (i % 25 === 24) { process.stdout.write(` verified ${i + 1}/${ids.length}\n`); await sleep(800); }
else await sleep(150);
}
const eligible = verified.filter(v => v.eligible);
const skipped = verified.filter(v => !v.eligible);
fs.writeFileSync(path.join(ROOT, 'data/verify-report.json'), JSON.stringify({ at: new Date().toISOString(), total: ids.length, eligible: eligible.length, skipped }, null, 2));
// ---- Rollback map BEFORE any write ----
const rollback = {
at: new Date().toISOString(), ticket: 'TK-11106', channel: 'Google & YouTube', publicationId: GOOGLE_PUB,
undo: 'for each id: mutation { publishablePublish(id:$id, input:[{publicationId:"' + GOOGLE_PUB + '"}]) } — see scripts/rollback-gmc-exclude.js',
ids: eligible.map(e => ({ id: e.id, handle: e.handle, wasPublishedGoogle: true })),
};
fs.writeFileSync(path.join(ROOT, 'data/rollback-map.json'), JSON.stringify(rollback, null, 2));
console.log(`eligible=${eligible.length} skipped=${skipped.length} → data/verify-report.json + data/rollback-map.json`);
if (!APPLY) { console.log('DRY-RUN complete. No writes.'); return; }
// ---- Phase 2: APPLY in batches of 50, >=90s between batches ----
const BATCH = 50;
let done = 0, failed = 0; const runs = [];
for (let b = 0; b * BATCH < eligible.length; b++) {
const batch = eligible.slice(b * BATCH, (b + 1) * BATCH);
console.log(`batch ${b + 1}: ${batch.length} products`);
for (const w of batch) {
try {
const d = await gql(`mutation($id:ID!,$pid:ID!){ publishableUnpublish(id:$id, input:[{publicationId:$pid}]){ userErrors{ field message } } }`, { id: w.id, pid: GOOGLE_PUB });
const ue = d.publishableUnpublish.userErrors;
if (ue && ue.length) { failed++; runs.push({ id: w.id, ok: false, err: ue }); }
else { done++; runs.push({ id: w.id, ok: true }); }
} catch (e) { failed++; runs.push({ id: w.id, ok: false, err: e.message.slice(0, 200) }); }
await sleep(350);
}
// per-batch verify
let stillOn = 0;
for (const w of batch) {
const d = await gql(`query($id:ID!){ product(id:$id){ resourcePublicationsV2(first:25){ nodes { publication { id } isPublished } } } }`, { id: w.id });
const on = d.product && d.product.resourcePublicationsV2.nodes.some(n => n.publication.id === GOOGLE_PUB && n.isPublished);
if (on) stillOn++;
await sleep(150);
}
console.log(` batch ${b + 1} verify: still-on-Google=${stillOn} (expect 0)`);
if ((b + 1) * BATCH < eligible.length) { console.log(' 90s inter-batch gap…'); await sleep(90000); }
}
fs.mkdirSync(path.join(ROOT, 'data/runs'), { recursive: true });
fs.writeFileSync(path.join(ROOT, 'data/runs', `gmc-${new Date().toISOString().replace(/[:.]/g, '-')}.json`), JSON.stringify({ done, failed, runs }, null, 2));
console.log(`DONE. unpublished=${done} failed=${failed}`);
})();