[object Object]

← back to Norma Platform

IG: generic per-account hashtag-reshare engine (all 32 niche accounts, per-account hashtags, DW-vendor credit, same 6/day+dedup+own-exclude rails)

1079fad7b8e936e40c243c0e11007063b2ed1b66 · 2026-08-14 14:06:22 -0700 · Steve

Files touched

Diff

commit 1079fad7b8e936e40c243c0e11007063b2ed1b66
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 14 14:06:22 2026 -0700

    IG: generic per-account hashtag-reshare engine (all 32 niche accounts, per-account hashtags, DW-vendor credit, same 6/day+dedup+own-exclude rails)
---
 agents/instagram-agent/hashtag-reshare.js | 128 ++++++++++++++++++++++++++++++
 1 file changed, 128 insertions(+)

diff --git a/agents/instagram-agent/hashtag-reshare.js b/agents/instagram-agent/hashtag-reshare.js
new file mode 100644
index 0000000..8b3dc01
--- /dev/null
+++ b/agents/instagram-agent/hashtag-reshare.js
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+/**
+ * hashtag-reshare.js — GENERIC per-account version of fabric-friday-reshare.js.
+ * Find REAL vendors posting to an account's niche hashtags and reshare the good ones with a
+ * sassy, credited caption. Works for ANY account via data/account-hashtags.json.
+ *
+ *   node hashtag-reshare.js <account>            # DRAFT: show picks, post nothing
+ *   node hashtag-reshare.js <account> --go       # LIVE reshare (Graph API)
+ *   node hashtag-reshare.js <account> --n 2      # per-run cap (daily hard-cap is 6)
+ *
+ * Same rails as the fabric-friday tool: DW-vendor-DB credit, dedup, exclude-own-posts,
+ * 6/day cap, business-hours gate. Hashtag ids are cached (Graph limits hashtag search to
+ * 30 unique tags / 7 days / account).
+ */
+const accounts = require('./accounts');
+const fs = require('fs');
+const path = require('path');
+
+const args = process.argv.slice(2);
+const ACCOUNT = (args.find((a) => !a.startsWith('--')) || 'fabric_fridays').replace(/^@/, '');
+const GO = args.includes('--go');
+const FORCE = args.includes('--force');
+const N = parseInt((() => { const i = args.indexOf('--n'); return i >= 0 ? args[i + 1] : '2'; })(), 10);
+const DAILY_CAP = 6;
+
+const acct = accounts.resolve(ACCOUNT);
+if (!acct) { console.error(`Unknown account "${ACCOUNT}".`); process.exit(1); }
+const { ig_user_id: ID, access_token: T, graph_host: H, graph_version: V } = acct;
+
+const HASHMAP = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'account-hashtags.json'), 'utf8'));
+const TAGS = HASHMAP[ACCOUNT] || [];
+if (!TAGS.length) { console.error(`No hashtags mapped for @${ACCOUNT} (skip/restricted account?).`); process.exit(1); }
+
+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);
+
+const SASS = [
+  (u) => `Okay ${u}, this is doing WAY too much — in the exact right way. 👏`,
+  (u) => `We saw ${u} pull up with THIS and had to share. The audacity of good taste. ✨`,
+  (u) => `${u} said "let me casually drop a masterpiece" — and we're not okay. 🫠 Gorgeous.`,
+  (u) => `Flex of the week: ${u}. Show-off. (Please keep showing off.) 💅`,
+  (u) => `${u} understood the assignment and then some. 🔥`,
+];
+
+const g = async (url) => (await fetch(url)).json();
+const titleCase = (s) => s.replace(/\b([a-z])/g, (m) => m.toUpperCase());
+
+// Cached hashtag_id resolver (respect the 30-tags/7-day search limit).
+async function hashtagIds() {
+  const CACHE = path.join(__dirname, 'data', 'hashtag-ids.json');
+  const cache = fs.existsSync(CACHE) ? JSON.parse(fs.readFileSync(CACHE, 'utf8')) : {};
+  let changed = false; const ids = [];
+  for (const tag of TAGS) {
+    if (cache[tag]) { ids.push([tag, cache[tag]]); continue; }
+    const j = await g(`${H}/${V}/ig_hashtag_search?user_id=${ID}&q=${encodeURIComponent(tag)}&access_token=${encodeURIComponent(T)}`);
+    if (j.data && j.data[0]) { cache[tag] = j.data[0].id; ids.push([tag, j.data[0].id]); changed = true; }
+  }
+  if (changed) fs.writeFileSync(CACHE, JSON.stringify(cache, null, 1));
+  return ids;
+}
+
+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, '');
+  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;
+  return { mentions, vendor: vendor ? titleCase(vendor) : null, creditName: mentions[0] || (vendor ? titleCase(vendor) : null) };
+}
+
+async function publish(imageUrl, caption) {
+  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 () => {
+  const LOG = path.join(__dirname, 'data', `reshares-${ACCOUNT}.jsonl`);
+  const logRows = fs.existsSync(LOG) ? fs.readFileSync(LOG, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean) : [];
+  const doneSrc = new Set(logRows.map((r) => r.permalink));
+  const today = new Date().toISOString().slice(0, 10);
+  const todayCount = logRows.filter((r) => (r.ts || '').slice(0, 10) === today).length;
+  const hour = new Date().getHours();
+  if (GO && !FORCE && (hour < 8 || hour >= 20)) { console.log(`@${ACCOUNT}: outside posting hours. Skip.`); return; }
+  const allowed = GO ? Math.max(0, Math.min(N, DAILY_CAP - todayCount)) : N;
+  if (GO && allowed <= 0) { console.log(`@${ACCOUNT}: daily cap ${todayCount}/${DAILY_CAP}. Skip.`); return; }
+
+  const mine = await g(`${H}/${V}/${ID}/media?fields=permalink&limit=50&access_token=${encodeURIComponent(T)}`);
+  const ownSet = new Set((mine.data || []).map((x) => x.permalink));
+
+  const ids = await hashtagIds();
+  const seen = new Set(); const cands = [];
+  for (const [tag, hid] of ids) {
+    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);
+      if (ownSet.has(m.permalink) || doneSrc.has(m.permalink)) continue;
+      if (!m.media_url || !/^https?:/.test(m.media_url) || !['IMAGE', 'CAROUSEL_ALBUM'].includes(m.media_type)) continue;
+      if (/follow to win|giveaway|dm to buy|onlyfans|crypto/i.test(m.caption || '')) continue;
+      const attr = attribute(m.caption);
+      if (!attr.creditName) continue;
+      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, allowed);
+
+  console.log(`@${ACCOUNT} — ${GO ? 'LIVE' : 'DRAFT'} — tags [${TAGS.join(', ')}] — ${picks.length} pick(s)${GO ? ` (${todayCount}/${DAILY_CAP} today)` : ''}:\n`);
+  const results = [];
+  for (let i = 0; i < picks.length; i++) {
+    const p = picks[i];
+    const credit = p.attr.mentions[0] ? `📸 ${p.attr.mentions[0]}` : `featuring ${p.attr.vendor}`;
+    const caption = `${SASS[i % SASS.length](p.attr.creditName)}\n\n${credit} · via #${p.tag}\n#${p.tag} #interiordesign #designtrade #designerwallcoverings`;
+    console.log(`── ${p.attr.vendor ? `[DW: ${p.attr.vendor}]` : ''} ${p.permalink}\n   "${(p.caption || '').replace(/\n/g, ' ').slice(0, 70)}"`);
+    if (GO) {
+      try { const id = await publish(p.media_url, caption); results.push({ ts: new Date().toISOString(), permalink: p.permalink, our_media_id: id }); console.log(`   ✓ ${id}`); }
+      catch (e) { console.log(`   ✗ ${e.message}`); }
+      if (i < picks.length - 1) await new Promise((r) => setTimeout(r, 5000));
+    }
+  }
+  if (GO && results.length) fs.appendFileSync(LOG, results.map((r) => JSON.stringify(r)).join('\n') + '\n');
+  console.log(GO ? `\n@${ACCOUNT}: reshared ${results.length}.` : `\n@${ACCOUNT}: DRAFT — run with --go to post.`);
+})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });

← 8ad73fc IG: fabric-friday reshare launchd plist (4x/day spaced, cap  ·  back to Norma Platform  ·  IG fleet interaction: phased reshare-fleet + comment auto-re ed1358f →