← back to Dw Validator Debug TK11314
fabricut poster: never activate an image-less product (TK-10807 recurrence guard)
283b8478fdf0dc4bc11dc12f89299057764dcff7 · 2026-08-24 11:26:30 -0700 · Steve
GET /products/:id/images.json before flipping to active; if 0 images landed on
Shopify (silent 500GB storage-cap upload failure), keep DRAFT + ledger instead of
publishing an empty customer-facing page. Prevents repeat of the DWFC-23001x class.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A DW-Programming/fabricut-daily-post.js
Diff
commit 283b8478fdf0dc4bc11dc12f89299057764dcff7
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Aug 24 11:26:30 2026 -0700
fabricut poster: never activate an image-less product (TK-10807 recurrence guard)
GET /products/:id/images.json before flipping to active; if 0 images landed on
Shopify (silent 500GB storage-cap upload failure), keep DRAFT + ledger instead of
publishing an empty customer-facing page. Prevents repeat of the DWFC-23001x class.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
DW-Programming/fabricut-daily-post.js | 104 ++++++++++++++++++++++++++++++++++
1 file changed, 104 insertions(+)
diff --git a/DW-Programming/fabricut-daily-post.js b/DW-Programming/fabricut-daily-post.js
new file mode 100644
index 00000000..971fd3ae
--- /dev/null
+++ b/DW-Programming/fabricut-daily-post.js
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+/**
+ * Fabricut DAILY POST (TK-10513) — Steve 2026-08-20:
+ * "post at least 10 new SKUs a day for fabricut, always FULL patterns and their colors —
+ * don't cut off after 1 color if there are more."
+ *
+ * Each run activates COMPLETE patterns (every colorway of a pattern together, never partial),
+ * accumulating whole patterns until >= DAILY_MIN (10) SKUs are posted. GMC-leak-safe:
+ * publishes to all channels EXCEPT Google & YouTube (pub 29646651457). validate-before-activate
+ * keeps any incomplete product a DRAFT. Progress tracked in data/fabricut-posted-patterns.json so
+ * each day continues where the last left off (never re-posts, never cuts a pattern in half).
+ *
+ * Usage: node fabricut-daily-post.js [--dry-run] [--min N]
+ */
+const { Pool } = require('pg');
+const https = require('https');
+const fs = require('fs');
+const path = require('path');
+const { validateBeforeActivate } = require('/Users/macstudio3/Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js');
+
+const STORE='designer-laboratory-sandbox.myshopify.com', TOKEN=process.env.SHOPIFY_ADMIN_TOKEN, VER='2024-10';
+const GYT_PUB='gid://shopify/Publication/29646651457'; // Google & YouTube — EXCLUDE
+const pool=new Pool({host:'/tmp',database:'dw_unified'});
+const DELAY=500;
+const STATE=path.join(__dirname,'data','fabricut-posted-patterns.json');
+const LEDGER='/Users/macstudio3/.claude/yolo-queue/executed-reversible/fabricut-daily-post-ledger.jsonl';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+
+function toTitleCase(s){if(!s)return'';const m=new Set(['a','an','the','in','on','at','for','and','but','or','of','to','is']);return String(s).toLowerCase().split(/\s+/).filter(Boolean).map((w,i)=>i===0||!m.has(w)?w.charAt(0).toUpperCase()+w.slice(1):w).join(' ');}
+function cleanWP(s){if(!s)return s;const B=['Apartment','Boråstapeter','China Seas','DW Exclusive','Edge','Fentucci','Grasscloth','Laura Ashley','Malibu','MC Escher','Missoni','Nicolette Mayer','PS Removable','Ralph Lauren','Roberto Cavalli','Scalamandre','Schumacher'];const br=new RegExp('('+B.map(b=>b.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).join('|')+')\\s+Wallpaper','gi');const S=' ',h=[];let o=String(s).replace(br,m=>{h.push(m);return S+(h.length-1)+S;});o=o.replace(/\bWallpaper(s?)\b(?!ed|ing)/gi,(m,p)=>{const b=p?'wallcoverings':'wallcovering';return m===m.toUpperCase()?b.toUpperCase():m[0]===m[0].toUpperCase()?b[0].toUpperCase()+b.slice(1):b;});h.forEach((x,i)=>{o=o.split(S+i+S).join(x);});return o;}
+
+function rest(method,p,body){return new Promise((res,rej)=>{const d=body?JSON.stringify(body):null;
+ const req=https.request({hostname:STORE,path:`/admin/api/${VER}${p}`,method,headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json',...(d?{'Content-Length':Buffer.byteLength(d)}:{})},timeout:30000},
+ r=>{let b='';r.on('data',c=>b+=c);r.on('end',()=>{if(r.statusCode===429){res({rl:true});return;}if(r.statusCode>=400){rej(new Error(`${r.statusCode}:${b.slice(0,200)}`));return;}try{res(JSON.parse(b));}catch{res({});}});});
+ req.on('error',rej);req.on('timeout',()=>{req.destroy();rej(new Error('timeout'));});if(d)req.write(d);req.end();});}
+async function restR(m,p,b,n){n=n||0;try{const r=await rest(m,p,b);if(r.rl){await sleep(1500);return restR(m,p,b,n);}return r;}catch(e){if(n<2){await sleep(1500*(n+1));return restR(m,p,b,n+1);}throw e;}}
+function gql(query,variables){return new Promise(res=>{const d=JSON.stringify({query,variables});const req=https.request({host:STORE,path:`/admin/api/${VER}/graphql.json`,method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json','Content-Length':Buffer.byteLength(d)}},r=>{let b='';r.on('data',c=>b+=c);r.on('end',()=>{try{res(JSON.parse(b));}catch{res(null);}});});req.on('error',()=>res(null));req.write(d);req.end();});}
+let _pubs=null;
+async function loadPubs(){if(_pubs)return _pubs;const r=await gql(`{publications(first:50){edges{node{id name}}}}`,{});_pubs=(r?.data?.publications?.edges||[]).map(e=>e.node).filter(p=>p.id!==GYT_PUB);return _pubs;}
+async function publishExGYT(gid){const pubs=await loadPubs();if(!pubs.length)return false;await gql(`mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{field message}}}`,{id:gid,input:pubs.map(p=>({publicationId:p.id}))});await sleep(300);return true;}
+
+function validate(row){
+ const title=cleanWP(`${toTitleCase(row.pattern_name)} ${toTitleCase(row.color_name)} | Fabricut`);
+ return validateBeforeActivate({title,vendor:'Fabricut',tags:['Fabricut'],dwSku:row.dw_sku,descriptionHtml:row.ai_description||'',
+ specs:{width:row.width,length:row.length,repeat:row.repeat_v,material:row.material,unitOfMeasure:'Priced Per Yard'},
+ vendorSpecs:{width:row.width,length:row.length,repeat:row.repeat_v,material:row.material,unitOfMeasure:'x'},
+ images:[row.image_url],vendorImages:[row.image_url],variants:[{sku:row.dw_sku},{sku:row.dw_sku+'-Sample'}]});
+}
+async function activateOne(row,dry,st){
+ const result=validate(row);
+ if(!result.ok){ st.kept_draft++; result.reasons.forEach(r=>st.reasons[r]=(st.reasons[r]||0)+1);
+ if(!dry&&result.tags.length){const gid=`gid://shopify/Product/${row.shopify_product_id}`;await gql(`mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{message}}}`,{id:gid,tags:result.tags});}
+ return {dw:row.dw_sku,kept_draft:true}; }
+ if(dry){ st.activated++; return {dw:row.dw_sku,would_activate:true}; }
+ const pid=row.shopify_product_id, gid=`gid://shopify/Product/${pid}`;
+ // TK-10807 guard: never activate an image-less product. validate() checks the SOURCE image, but
+ // the upload to Shopify can fail silently at the 500GB storage cap (dw-shopify-storage-canary),
+ // leaving an ACTIVE product with 0 images (empty customer-facing page — the DWFC-23001x class).
+ const imgResp=await restR('GET',`/products/${pid}/images.json`);
+ const imgCount=(imgResp&&imgResp.images&&imgResp.images.length)||0;
+ if(imgCount<1){ st.kept_draft++; st.reasons['no_image_on_shopify']=(st.reasons['no_image_on_shopify']||0)+1;
+ fs.appendFileSync(LEDGER,JSON.stringify({ts:new Date().toISOString(),dw:row.dw_sku,pid,pattern:row.pattern_name,action:'kept_draft:0_images_on_shopify'})+'\n');
+ return {dw:row.dw_sku,kept_draft:true,reason:'no_image_on_shopify'}; }
+ await restR('PUT',`/products/${pid}.json`,{product:{id:Number(pid),status:'active'}});
+ await publishExGYT(gid);
+ st.activated++;
+ fs.appendFileSync(LEDGER,JSON.stringify({ts:new Date().toISOString(),dw:row.dw_sku,pid,pattern:row.pattern_name,action:'activated+exGYT'})+'\n');
+ return {dw:row.dw_sku,activated:true};
+}
+
+const loadState=()=>{try{return new Set(JSON.parse(fs.readFileSync(STATE,'utf8')).posted||[]);}catch{return new Set();}};
+const saveState=set=>{fs.mkdirSync(path.dirname(STATE),{recursive:true});fs.writeFileSync(STATE,JSON.stringify({updated:new Date().toISOString(),posted:[...set]},null,0));};
+
+async function main(){
+ const args=process.argv.slice(2),dry=args.includes('--dry-run');
+ const mi=args.indexOf('--min'); const MIN=mi>=0&&args[mi+1]?parseInt(args[mi+1]):(parseInt(process.env.DAILY_MIN,10)||10);
+ if(!TOKEN){console.error('no SHOPIFY_ADMIN_TOKEN');process.exit(1);}
+ console.log(`=== Fabricut DAILY POST ===${dry?' [DRY]':''} min=${MIN} SKUs, FULL patterns`);
+ // all eligible products, grouped by pattern, patterns ordered by their smallest dw_sku (stable progression)
+ const {rows}=await pool.query(`SELECT * FROM fabricut_catalog WHERE product_url ~ 'fabricut.com' AND on_shopify=true AND shopify_product_id IS NOT NULL AND full_scraped=true AND NULLIF(pattern_name,'') IS NOT NULL ORDER BY pattern_name, dw_sku`);
+ const byPattern=new Map();
+ for(const r of rows){ if(!byPattern.has(r.pattern_name)) byPattern.set(r.pattern_name,[]); byPattern.get(r.pattern_name).push(r); }
+ const order=[...byPattern.keys()].sort((a,b)=>{const am=byPattern.get(a)[0].dw_sku||'',bm=byPattern.get(b)[0].dw_sku||'';return am<bm?-1:am>bm?1:0;});
+ const posted=loadState();
+ // pick whole patterns not yet posted until we reach >= MIN skus (never split a pattern)
+ const todaysPatterns=[]; let count=0;
+ for(const pat of order){ if(posted.has(pat)) continue; todaysPatterns.push(pat); count+=byPattern.get(pat).length; if(count>=MIN) break; }
+ if(!todaysPatterns.length){ console.log('Nothing left to post — all patterns already posted.'); await pool.end(); return; }
+ console.log(`Today: ${todaysPatterns.length} FULL pattern(s) = ${count} SKUs`);
+ todaysPatterns.forEach(p=>console.log(` · ${p} (${byPattern.get(p).length} colorway${byPattern.get(p).length>1?'s':''})`));
+ const st={activated:0,kept_draft:0,failed:0,reasons:{}};
+ for(const pat of todaysPatterns){
+ const items=byPattern.get(pat);
+ for(const row of items){ try{await activateOne(row,dry,st);}catch(e){st.failed++;console.error(`✗ ${row.dw_sku}: ${e.message}`);} if(!dry)await sleep(DELAY); }
+ if(!dry) posted.add(pat); // mark posted only after the WHOLE pattern is done
+ }
+ if(!dry) saveState(posted);
+ console.log(`\n=== ${dry?'DRY — would post':'POSTED'} ${st.activated} SKUs (${todaysPatterns.length} full patterns) | kept_draft:${st.kept_draft} fail:${st.failed} ===`);
+ if(Object.keys(st.reasons).length){console.log('kept-draft reasons:');Object.entries(st.reasons).sort((a,b)=>b[1]-a[1]).forEach(([r,n])=>console.log(` ${n}x ${r}`));}
+ const remaining=order.filter(p=>!posted.has(p)).length;
+ console.log(`Patterns remaining after today: ${remaining} (of ${order.length})`);
+ await pool.end();
+}
+main().catch(e=>{console.error('Fatal:',e);pool.end();process.exit(1);});
← 6a79282f auto-data-snapshot: 2026-08-24T10:08:33 (1 data files) — DW-
·
back to Dw Validator Debug TK11314
·
fabricut-activate: same image-less-activation guard as daily 7a2091d0 →