[object Object]

← back to Norma

IG fabric-friday reshare: match #fabricfriday posts against DW vendor DB (FileMaker Supplier + registry, 505 names), normalized matching for correct credit, posts via Graph API

9cc01edead33dcb122334fe819feac21e3c34cac · 2026-08-14 13:37:11 -0700 · Steve

Files touched

Diff

commit 9cc01edead33dcb122334fe819feac21e3c34cac
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 14 13:37:11 2026 -0700

    IG fabric-friday reshare: match #fabricfriday posts against DW vendor DB (FileMaker Supplier + registry, 505 names), normalized matching for correct credit, posts via Graph API
---
 agents/instagram-agent/fabric-friday-reshare.js | 91 ++++++++++++++-----------
 1 file changed, 52 insertions(+), 39 deletions(-)

diff --git a/agents/instagram-agent/fabric-friday-reshare.js b/agents/instagram-agent/fabric-friday-reshare.js
index 913c6c8..dd1b931 100644
--- a/agents/instagram-agent/fabric-friday-reshare.js
+++ b/agents/instagram-agent/fabric-friday-reshare.js
@@ -1,16 +1,16 @@
 #!/usr/bin/env node
 /**
- * fabric-friday-reshare.js — find REAL accounts posting to #fabricfriday / #fabricfridays
+ * fabric-friday-reshare.js — find REAL vendors posting to #fabricfriday / #fabricfridays
  * and reshare the good ones to @fabric_fridays with a sassy, complimentary credit caption.
  *
  *   node fabric-friday-reshare.js            # DRAFT: show picks + sassy captions, post nothing
- *   node fabric-friday-reshare.js --go       # LIVE: reshare the picks to @fabric_fridays
+ *   node fabric-friday-reshare.js --go       # LIVE: reshare the picks to @fabric_fridays (Graph API)
  *   node fabric-friday-reshare.js --n 3      # cap number of reshares (default 3)
  *
- * How it works: IG Graph hashtag search -> recent_media (image/carousel only, has media_url),
- * skip our own posts + obvious non-vendors, resolve the poster's @handle via oEmbed so we can
- * CREDIT them (reposting without credit reads as theft), then compose a playful "regram" caption.
- * Reposting = re-upload their media_url as our own post + credit + tasteful sass — never mean.
+ * "Real vendor" = the post's caption names a vendor DW actually carries (matched against
+ * data/dw-vendors.txt — merged from the FileMaker Supplier/Line fields + vendor_registry) OR
+ * the poster tagged a brand with @mention. Both give us a CREDIT so the reshare isn't theft.
+ * Reshare = re-upload their image via the Graph API (works without a browser) + credit + sass.
  */
 const accounts = require('./accounts');
 const fs = require('fs');
@@ -24,64 +24,77 @@ const acct = accounts.resolve('fabric_fridays');
 const { ig_user_id: ID, access_token: T, graph_host: H, graph_version: V } = acct;
 const HASHTAG_IDS = { fabricfriday: '17841548773109912', fabricfridays: '17843754310062659' };
 
+// DW vendor DB (FileMaker Supplier/Line + vendor_registry). Longer names first = prefer specific match.
+const VENDORS = fs.readFileSync(path.join(__dirname, 'data', 'dw-vendors.txt'), 'utf8')
+  .split('\n').map((s) => s.trim().toLowerCase()).filter((s) => s.length >= 5)
+  .sort((a, b) => b.length - a.length);
+
 // Tasteful, complimentary sass — hypes the maker, never mocks. Varied so reshares aren't identical.
 const SASS = [
   (u) => `Okay ${u}, this is doing WAY too much — in the exact right way. 🧵 That's how you Fabric Friday. 👏`,
   (u) => `We saw ${u} pull up to Fabric Friday with THIS and had to share. The audacity of good taste. ✨`,
-  (u) => `${u} said "let me just casually drop a masterpiece on Fabric Friday" — and we're not okay. 🫠 Gorgeous.`,
-  (u) => `Fabric Friday flex of the week goes to ${u}. Show-off. (Please keep showing off.) 💅`,
-  (u) => `${u} understood the assignment and then some. This is the Fabric Friday content we signed up for. 🔥`,
+  (u) => `${u} said "let me casually drop a masterpiece on Fabric Friday" — and we're not okay. 🫠 Gorgeous.`,
+  (u) => `Fabric Friday flex of the week: ${u}. Show-off. (Please keep showing off.) 💅`,
+  (u) => `${u} understood the assignment and then some. THIS is the Fabric Friday content we signed up for. 🔥`,
 ];
 
-async function g(url) { const r = await fetch(url); return r.json(); }
+const g = async (url) => (await fetch(url)).json();
+const titleCase = (s) => s.replace(/\b([a-z])/g, (m) => m.toUpperCase());
 
-async function authorHandle(permalink) {
-  // oEmbed returns author_name = the @handle; hashtag media hides username, so this is how we credit.
-  try {
-    const j = await g(`${H}/${V}/instagram_oembed?url=${encodeURIComponent(permalink)}&fields=author_name&access_token=${encodeURIComponent(T)}`);
-    if (j.author_name) return j.author_name.startsWith('@') ? j.author_name : `@${j.author_name}`;
-  } catch { /* fall through */ }
-  return null;
+function attribute(caption) {
+  const cap = caption || '';
+  const mentions = [...new Set((cap.match(/@[a-zA-Z0-9_.]{2,}/g) || []))];
+  const capL = cap.toLowerCase();
+  const capAlnum = capL.replace(/[^a-z0-9]/g, '');        // "by brunschwigfils" -> "bybrunschwigfils"
+  // Match vendors both loosely AND normalized (so "brunschwig & fils" aligns to "brunschwigfils"),
+  // then keep the MOST SPECIFIC (longest) match — the poster's actual brand beats a parent-company hit.
+  const hits = [];
+  for (const v of VENDORS) {
+    const vn = v.replace(/[^a-z0-9]/g, '');
+    if (capL.includes(v) || (vn.length >= 6 && capAlnum.includes(vn))) hits.push(v);
+  }
+  hits.sort((a, b) => b.replace(/[^a-z0-9]/g, '').length - a.replace(/[^a-z0-9]/g, '').length);
+  const vendor = hits[0] || null;
+  const creditName = mentions[0] || (vendor ? titleCase(vendor) : null);
+  return { mentions, vendor: vendor ? titleCase(vendor) : null, creditName };
 }
 
 async function publish(imageUrl, caption) {
-  const create = await (await fetch(`${H}/${V}/${ID}/media`, { method: 'POST', body: new URLSearchParams({ image_url: imageUrl, caption, access_token: T }) })).json();
-  if (!create.id) throw new Error('container: ' + JSON.stringify(create).slice(0, 160));
-  for (let i = 0; i < 15; i++) { const s = await g(`${H}/${V}/${create.id}?fields=status_code&access_token=${encodeURIComponent(T)}`); if (s.status_code === 'FINISHED') break; if (s.status_code === 'ERROR') throw new Error('container ERROR'); await new Promise((r) => setTimeout(r, 2000)); }
-  const pub = await (await fetch(`${H}/${V}/${ID}/media_publish`, { method: 'POST', body: new URLSearchParams({ creation_id: create.id, access_token: T }) })).json();
-  if (!pub.id) throw new Error('publish: ' + JSON.stringify(pub).slice(0, 160));
+  const c = await (await fetch(`${H}/${V}/${ID}/media`, { method: 'POST', body: new URLSearchParams({ image_url: imageUrl, caption, access_token: T }) })).json();
+  if (!c.id) throw new Error('container: ' + JSON.stringify(c).slice(0, 140));
+  for (let i = 0; i < 15; i++) { const s = await g(`${H}/${V}/${c.id}?fields=status_code&access_token=${encodeURIComponent(T)}`); if (s.status_code === 'FINISHED') break; if (s.status_code === 'ERROR') throw new Error('container ERROR'); await new Promise((r) => setTimeout(r, 2000)); }
+  const pub = await (await fetch(`${H}/${V}/${ID}/media_publish`, { method: 'POST', body: new URLSearchParams({ creation_id: c.id, access_token: T }) })).json();
+  if (!pub.id) throw new Error('publish: ' + JSON.stringify(pub).slice(0, 140));
   return pub.id;
 }
 
 (async () => {
-  // Gather candidates from both hashtags
   const seen = new Set(); const cands = [];
   for (const [tag, hid] of Object.entries(HASHTAG_IDS)) {
-    const j = await g(`${H}/${V}/${hid}/recent_media?user_id=${ID}&fields=id,caption,media_type,media_url,permalink,like_count,comments_count&limit=20&access_token=${encodeURIComponent(T)}`);
+    const j = await g(`${H}/${V}/${hid}/recent_media?user_id=${ID}&fields=caption,media_type,media_url,permalink,like_count,comments_count&limit=25&access_token=${encodeURIComponent(T)}`);
     for (const m of (j.data || [])) {
       if (seen.has(m.permalink)) continue; seen.add(m.permalink);
-      // reshareable = single image OR carousel with a fetchable cover image
-      if (!m.media_url || !/^https?:/.test(m.media_url)) continue;
+      if (!m.media_url || !/^https?:/.test(m.media_url)) continue;                 // need a fetchable image
       if (!['IMAGE', 'CAROUSEL_ALBUM'].includes(m.media_type)) continue;
-      const cap = (m.caption || '').toLowerCase();
-      // skip obvious non-vendor / spam / giveaway noise
-      if (/follow to win|giveaway|link in bio to win|dm to buy|onlyfans|crypto/i.test(cap)) continue;
-      cands.push({ ...m, tag, score: (m.like_count || 0) + 3 * (m.comments_count || 0) });
+      if (/follow to win|giveaway|dm to buy|onlyfans|crypto/i.test(m.caption || '')) continue;
+      const attr = attribute(m.caption);
+      if (!attr.creditName) continue;                                             // only reshare ATTRIBUTABLE posts
+      cands.push({ ...m, tag, attr, score: (m.like_count || 0) + 3 * (m.comments_count || 0) + (attr.vendor ? 25 : 0) });
     }
   }
   cands.sort((a, b) => b.score - a.score);
   const picks = cands.slice(0, N);
 
-  console.log(`${GO ? 'LIVE RESHARE' : 'DRAFT'} — ${picks.length} pick(s) from #fabricfriday(s), ranked by engagement:\n`);
+  console.log(`${GO ? 'LIVE RESHARE' : 'DRAFT'} — ${picks.length} attributable pick(s), DW-vendor matches ranked first:\n`);
   const results = [];
   for (let i = 0; i < picks.length; i++) {
     const p = picks[i];
-    const handle = (await authorHandle(p.permalink)) || 'this maker';
-    const cred = handle === 'this maker' ? '(tap through to the original)' : `📸 ${handle}`;
-    const sass = SASS[i % SASS.length](handle);
-    const caption = `${sass}\n\n${cred} · via #${p.tag}\n#fabricfriday #fabric #textiles #interiordesign #designtrade #designerwallcoverings`;
-    console.log(`── pick ${i + 1}  ❤${p.like_count || '?'} 💬${p.comments_count || '?'}  ${p.permalink}`);
-    console.log(`   source cap: "${(p.caption || '').replace(/\n/g, ' ').slice(0, 80)}"`);
+    const u = p.attr.creditName;
+    const credit = p.attr.mentions[0] ? `📸 ${p.attr.mentions[0]}` : `featuring ${p.attr.vendor}`;
+    const vtag = p.attr.vendor ? `  [DW carries: ${p.attr.vendor}]` : '';
+    const caption = `${SASS[i % SASS.length](u)}\n\n${credit} · via #${p.tag}\n#fabricfriday #fabric #textiles #interiordesign #designtrade #designerwallcoverings`;
+    console.log(`── pick ${i + 1}  ❤${p.like_count || '?'}${vtag}  ${p.permalink}`);
+    console.log(`   source: "${(p.caption || '').replace(/\n/g, ' ').slice(0, 78)}"`);
     console.log(`   OUR caption:\n     ${caption.replace(/\n/g, '\n     ')}\n`);
     if (GO) {
       try { const id = await publish(p.media_url, caption); console.log(`   ✓ reshared → media_id ${id}\n`); results.push({ ...p, our_media_id: id, caption }); }
@@ -89,6 +102,6 @@ async function publish(imageUrl, caption) {
       if (i < picks.length - 1) await new Promise((r) => setTimeout(r, 5000));
     }
   }
-  if (GO && results.length) fs.appendFileSync(path.join(__dirname, 'data', 'fabric-friday-reshares.jsonl'), results.map((r) => JSON.stringify({ ts: new Date().toISOString(), ...r })).join('\n') + '\n');
-  console.log(GO ? `Reshared ${results.length}.` : 'DRAFT only — review the sass, then run with --go to reshare.');
+  if (GO && results.length) fs.appendFileSync(path.join(__dirname, 'data', 'fabric-friday-reshares.jsonl'), results.map((r) => JSON.stringify({ ts: new Date().toISOString(), permalink: r.permalink, our_media_id: r.our_media_id })).join('\n') + '\n');
+  console.log(GO ? `Reshared ${results.length}.` : 'DRAFT only — review, then run with --go to reshare via the Graph API.');
 })().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });

← 5dec32d IG: #fabricfriday reshare tool (hashtag-search picks + sassy  ·  back to Norma  ·  IG: delete-viewer cockpit generator — visual selectable boar 05e1786 →