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