← back to Norma Platform
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
M agents/instagram-agent/fabric-friday-reshare.js
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 Platform
·
IG: delete-viewer cockpit generator — visual selectable boar 05e1786 →