← 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
M .gitignoreA scripts/trending-2026/README.mdA scripts/trending-2026/trending-tag-sync.cjs
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 →