← back to Norma
agents/instagram-agent/hashtag-reshare.js
140 lines
#!/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);
// Competitor blocklist — NEVER reshare or credit these on Steve's accounts, even if one
// slips into dw-vendors.txt (Spoonflower is a print-on-demand COMPETITOR, not a DW vendor).
// Steve, 2026-08-17. Add competitors here to block them fleet-wide across both reshare tools.
const BLOCKLIST = ['spoonflower'];
const isBlocked = (s) => {
const a = String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
return BLOCKLIST.some((b) => a.includes(b.replace(/[^a-z0-9]/g, '')));
};
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) || []))];
// Blocklist gate: refuse to attribute a competitor → reshare loop skips it (no creditName).
if (isBlocked(cap) || mentions.some(isBlocked)) return { mentions: [], vendor: null, creditName: null };
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); });