[object Object]

← back to Dw Yolo Loop

Trending 2026 tag reconciler: newest-500 active + all Artmura, daily launchd cron (trimmed 5130→500)

7f7c9f1f88e37291a538bbe0637a20dc31d8cea1 · 2026-06-15 10:49:38 -0700 · Steve Abrams

Files touched

Diff

commit 7f7c9f1f88e37291a538bbe0637a20dc31d8cea1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 15 10:49:38 2026 -0700

    Trending 2026 tag reconciler: newest-500 active + all Artmura, daily launchd cron (trimmed 5130→500)
---
 .gitignore                                  |   3 +
 scripts/trending-2026/README.md             |  41 ++++++++
 scripts/trending-2026/trending-tag-sync.cjs | 150 ++++++++++++++++++++++++++++
 3 files changed, 194 insertions(+)

diff --git a/.gitignore b/.gitignore
index ee1c4e7..23cb7b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,6 @@ data/google-feed/report.json
 data/kravet-cost/
 data/schumacher-cost/
 data/thibaut-cost/
+scripts/trending-2026/sync.log
+scripts/trending-2026/cron.out.log
+scripts/trending-2026/cron.err.log
diff --git a/scripts/trending-2026/README.md b/scripts/trending-2026/README.md
new file mode 100644
index 0000000..8c403b3
--- /dev/null
+++ b/scripts/trending-2026/README.md
@@ -0,0 +1,41 @@
+# Trending Wallcovering Collection 2026 — daily tag reconciler
+
+Keeps the Shopify tag **`Trending Wallcovering Collection 2026`** on exactly the right
+products, every day.
+
+## Rule
+Target set (who carries the tag) =
+- the **newest 500 active products** by created date, **∪**
+- **all active Artmura products** (pinned in, so they never fall out)
+
+Today all 161 Artmura sit inside the newest 500, so the target is exactly 500.
+As newer products arrive and Artmura ages past rank 500, Artmura stays pinned and
+the set grows slightly above 500 — by design ("make sure Artmura has it").
+
+## What each run does
+Re-derives the target via GraphQL search (no full-catalog scan), then:
+- **adds** the tag to target products missing 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 (aged out / archived)
+
+Idempotent and safe to re-run. Tag search is eventually-consistent, so the
+post-run count may lag for a minute before settling.
+
+## Run manually
+```sh
+node trending-tag-sync.cjs --dry-run      # preview +add / -remove counts
+node trending-tag-sync.cjs                # apply
+node trending-tag-sync.cjs --top 500 --conc 6
+```
+
+## Cron
+LaunchAgent `com.steve.dw-trending-2026` (Mac2) runs it **daily at 05:00**.
+- plist: `~/Library/LaunchAgents/com.steve.dw-trending-2026.plist`
+- logs: `sync.log` (per-run summary), `cron.out.log` / `cron.err.log` (launchd)
+- reload: `launchctl kickstart -k gui/$(id -u)/com.steve.dw-trending-2026`
+
+## Cost
+$0 — Shopify Admin API has no per-call charge.
+
+## Token
+Reads `SHOPIFY_ADMIN_TOKEN` from `~/Projects/secrets-manager/.env` (has write_products).
diff --git a/scripts/trending-2026/trending-tag-sync.cjs b/scripts/trending-2026/trending-tag-sync.cjs
new file mode 100644
index 0000000..52cf0de
--- /dev/null
+++ b/scripts/trending-2026/trending-tag-sync.cjs
@@ -0,0 +1,150 @@
+#!/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');
+
+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') || '500', 10) || 500;
+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} ---`);
+
+  // 1. newest TOP_N active products by created date
+  const newest = await fetchAll('status:active', { sortKey: 'CREATED_AT', reverse: true, cap: TOP_N });
+  // 2. all active Artmura
+  const artmura = await fetchAll(`status:active vendor:${ARTMURA_VENDOR}`);
+  // 3. everything currently carrying the tag (any status)
+  const tagged = await fetchAll(`tag:${JSON.stringify(TAG)}`);
+
+  const target = new Set([...newest, ...artmura].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));
+
+  const newestCutoff = newest.length ? newest[newest.length - 1].createdAt : 'n/a';
+  logline(`newest active=${newest.length} (oldest in set created ${newestCutoff}) | artmura active=${artmura.length} | 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); });

← 2cde42a Add Schumacher add-roll-variant builder (532 targets, invent  ·  back to Dw Yolo Loop  ·  Kravet dedup verifier: handle-driven keeper selection + redi cf0c7f0 →