← back to Dw Yolo Loop
scripts/trending-2026/trending-tag-sync.cjs
159 lines
#!/usr/bin/env node
/**
* Trending Wallcovering Collection 2026 — daily tag reconciler.
*
* Target set (who SHOULD carry the tag):
* (the newest TOP_N active products by created date) ∪ (all active Artmura products)
*
* Each run re-derives the target and reconciles:
* - adds the tag to products in the target that don't have it (new arrivals entering the top 500)
* - removes the tag from any product (any status) that has it but is no longer in the target
* (products that aged out of the top 500 / were archived)
*
* Idempotent + safe to re-run. Read path = GraphQL search (no full 74k scan).
* Cost: $0 (Shopify Admin API — no per-call charge).
*
* Usage: node trending-tag-sync.cjs [--dry-run] [--top N] [--conc 6]
*/
const fs = require('fs');
const path = require('path');
// Canonical showroom-vendor primitive (list + logic live in fix-live-board/config).
// Showroom-only vendors are addressable-not-discoverable: they must NEVER be added to
// the Trending discoverability flow. Never hardcode a vendor — edit showroom-vendors.json. TK-11186.
const { isShowroomVendor } = require(path.join(process.env.HOME, 'Projects/fix-live-board/config/showroom-vendor.cjs'));
const TAG = 'Trending Wallcovering Collection 2026';
const HOST = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const ARTMURA_VENDOR = 'Artmura';
const args = process.argv.slice(2);
const DRY = args.includes('--dry-run');
const TOP_N = parseInt(args.find((_, i, a) => a[i - 1] === '--top') || '1000', 10) || 1000;
const CONC = parseInt(args.find((_, i, a) => a[i - 1] === '--conc') || '6', 10) || 6;
const envPath = path.join(process.env.HOME, 'Projects/secrets-manager/.env');
const env = fs.readFileSync(envPath, 'utf8');
const getEnv = (k) => { const m = env.match(new RegExp('^' + k + '=(.*)$', 'm')); return m ? m[1].trim() : ''; };
const TOKEN = getEnv('SHOPIFY_ADMIN_TOKEN');
if (!TOKEN) { console.error('Missing SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
const LOG = path.join(__dirname, 'sync.log');
const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
function logline(msg) {
const line = `[${stamp()}] ${msg}`;
console.log(line);
try { fs.appendFileSync(LOG, line + '\n'); } catch (_) {}
}
function gql(query, variables) {
return new Promise((resolve, reject) => {
fetch(`https://${HOST}/admin/api/${API}/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
}).then(r => r.json()).then(resolve).catch(reject);
});
}
// Paginate a products search query, return array of {id, vendor, createdAt}
async function fetchAll(searchQuery, { sortKey, reverse, cap } = {}) {
const out = [];
let cursor = null;
while (true) {
const after = cursor ? `, after:"${cursor}"` : '';
const sort = sortKey ? `, sortKey:${sortKey}, reverse:${!!reverse}` : '';
const q = `{ products(first:100, query:${JSON.stringify(searchQuery)}${sort}${after}){
pageInfo{ hasNextPage endCursor }
nodes{ id vendor createdAt } } }`;
let r;
try { r = await gql(q); } catch (e) { await sleep(2500); continue; }
if (r.errors) {
if (JSON.stringify(r.errors).includes('Throttled')) { await sleep(3500); continue; }
throw new Error(JSON.stringify(r.errors).slice(0, 200));
}
const pg = r.data.products;
for (const n of pg.nodes) {
out.push(n);
if (cap && out.length >= cap) return out;
}
if (!pg.pageInfo.hasNextPage) break;
cursor = pg.pageInfo.endCursor;
await sleep(120);
}
return out;
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function mutateTag(kind, id) {
// kind = 'tagsAdd' | 'tagsRemove'
const r = await gql(
`mutation($id:ID!,$tags:[String!]!){ ${kind}(id:$id, tags:$tags){ userErrors{ message } } }`,
{ id, tags: [TAG] }
);
const ue = r?.data?.[kind]?.userErrors;
if (ue && ue.length) throw new Error(ue[0].message);
if (r?.errors) throw new Error(JSON.stringify(r.errors).slice(0, 120));
}
async function runPool(ids, kind) {
const queue = [...ids];
let done = 0, failed = 0;
async function worker() {
while (queue.length) {
const id = queue.shift();
try { if (!DRY) await mutateTag(kind, id); done++; }
catch (e) {
failed++;
if (String(e.message).match(/Throttled|429/)) { queue.push(id); await sleep(3000); }
else if (failed <= 5) logline(` ${kind} ERR ${id}: ${e.message}`);
}
}
}
await Promise.all(Array.from({ length: CONC }, worker));
return { done, failed };
}
(async () => {
logline(`--- Trending 2026 sync START ${DRY ? '(DRY-RUN)' : ''} top=${TOP_N} conc=${CONC} ---`);
// HARD 500 CAP: target = the newest TOP_N active products by created date, period.
// Artmura is included only when it naturally falls inside that newest TOP_N.
const newest = await fetchAll('status:active', { sortKey: 'CREATED_AT', reverse: true, cap: TOP_N });
// everything currently carrying the tag (any status)
const tagged = await fetchAll(`tag:${JSON.stringify(TAG)}`);
// Showroom-only vendors never enter the target (never get the tag added), and are
// excluded from this reconciler's remove path — the dedicated, ledgered TK-11186
// build-remove-tags.mjs owns the one-time showroom cleanup (restore-map + 90s gaps).
const showroomTaggedIds = new Set(tagged.filter(n => isShowroomVendor(n.vendor)).map(n => n.id));
const target = new Set(newest.filter(n => !isShowroomVendor(n.vendor)).map(n => n.id));
const have = new Set(tagged.map(n => n.id));
const toAdd = [...target].filter(id => !have.has(id));
const toRemove = [...have].filter(id => !target.has(id) && !showroomTaggedIds.has(id));
const artmuraInSet = newest.filter(n => n.vendor === ARTMURA_VENDOR).length;
const newestCutoff = newest.length ? newest[newest.length - 1].createdAt : 'n/a';
logline(`newest active=${newest.length} (oldest in set created ${newestCutoff}) | artmura within set=${artmuraInSet} | target=${target.size} | currently tagged=${have.size}`);
logline(`plan: +${toAdd.length} add, -${toRemove.length} remove`);
if (toAdd.length) {
const a = await runPool(toAdd, 'tagsAdd');
logline(`tagsAdd: ${a.done} done, ${a.failed} failed`);
}
if (toRemove.length) {
const r = await runPool(toRemove, 'tagsRemove');
logline(`tagsRemove: ${r.done} done, ${r.failed} failed`);
}
// verify final count (skip in dry-run)
if (!DRY) {
await sleep(1500);
const c = await gql(`{ productsCount(query:${JSON.stringify('tag:"' + TAG + '"')}){ count } }`);
logline(`final tagged count: ${c.data?.productsCount?.count}`);
}
logline(`--- Trending 2026 sync DONE ---`);
})().catch(e => { logline('FATAL ' + e.message); process.exit(1); });