← back to NationalPaperHangers
scripts/generate-ig-dm-drafts.js
204 lines
#!/usr/bin/env node
// Generate Instagram DM drafts for unclaimed studios that are running paid ads.
//
// Why IG DM (not email) for this cohort?
// The dream-team-fast panel ruled IG DM is the right channel for the studios
// we caught running paid ads:
// - It's a business channel, not commercial email — zero CAN-SPAM exposure.
// - Higher response rate than cold email in the small luxury-trade niche.
// - The recipient sees Steve's IG profile (signal of legitimacy) before
// the message text — the message lands as one trade peer to another.
//
// IMPORTANT — this script is READ-ONLY.
// - No DB writes.
// - No IG API calls. Meta TOS forbids unauthorized automation; Steve sends
// each DM by hand from his own IG account.
// - We just generate the personalized text he'll paste.
//
// Usage:
// node scripts/generate-ig-dm-drafts.js # CSV to stdout
// node scripts/generate-ig-dm-drafts.js --out=batch.csv # CSV to file
// node scripts/generate-ig-dm-drafts.js --min=2 # studios on 2+ paid platforms
// node scripts/generate-ig-dm-drafts.js --state=CA # state filter
// node scripts/generate-ig-dm-drafts.js --limit=5 # cap for the day's batch
//
// CSV columns (7):
// business_name, ig_handle, city, state, paid_platforms, dm_message, claim_url
//
// The dm_message ends with the claim_url so Steve just pastes the whole field
// into the IG conversation and hits send.
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const db = require('../lib/db');
const ARGS = process.argv.slice(2);
const arg = (k, def = null) => {
const a = ARGS.find(x => x.startsWith(`--${k}=`));
return a ? a.split('=').slice(1).join('=') : def;
};
const OUT = arg('out');
const MIN = parseInt(arg('min', '1'), 10);
const STATE = (arg('state') || '').toUpperCase() || null;
const LIMIT = parseInt(arg('limit', '0'), 10); // 0 = no cap
const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://nationalpaperhangers.com').replace(/\/+$/, '');
// Map raw signal keys to human-readable platform names.
const PLATFORM_LABEL = {
google_ads: 'Google Ads',
meta_pixel: 'Meta',
tiktok_pixel: 'TikTok',
pinterest_tag: 'Pinterest',
linkedin_insight: 'LinkedIn',
twitter_pixel: 'Twitter/X',
reddit_pixel: 'Reddit',
bing_uet: 'Bing',
snap_pixel: 'Snap'
};
function paidPlatforms(signals) {
if (!signals) return [];
return Object.entries(PLATFORM_LABEL)
.filter(([k]) => signals[k] === true)
.map(([, v]) => v);
}
// Format the platforms list as a readable English clause.
// ['Google Ads'] → 'Google Ads'
// ['Google Ads', 'Pinterest'] → 'Google Ads + Pinterest'
// ['Google Ads','Meta','TikTok'] → 'Google Ads, Meta, and TikTok'
function platformsClause(list) {
if (list.length === 0) return '';
if (list.length === 1) return list[0];
if (list.length === 2) return `${list[0]} + ${list[1]}`;
return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1];
}
function locationClause(city, state) {
if (city && state) return `over in ${city}`;
if (city) return `over in ${city}`;
if (state) return `out in ${state}`;
return 'on your end';
}
// Build the personalized 4-6 sentence DM. Hand-paste-ready.
//
// Voice notes:
// - Trade-peer tone, not sales-pitch tone.
// - Reference one specific signal we caught (the platforms they're spending on).
// - Mention the Designer Wallcoverings → NPH context (DW already routes
// trade installs to the directory — Steve owns DW).
// - Soft CTA: "if it's interesting, here's the claim link" — no pressure.
// - End with the claim URL so the whole field can be pasted at once.
function buildDmMessage({ businessName, city, state, paidPlatforms: plats, claimUrl }) {
const platformsBit = plats.length
? `noticed you're running ${platformsClause(plats)} — that tells me you're actively chasing the trade buyer`
: `saw you doing real marketing work`;
const where = locationClause(city, state);
// 5 sentences, ~75 words. Designed to read in 10 seconds.
return [
`Hey — Steve from National Paper Hangers (also Designer Wallcoverings).`,
`${platformsBit}, which is exactly the cohort our directory is built for.`,
`I listed ${businessName} ${where} so designers and hospitality buyers searching outside LA can find you, and DW is already routing trade installs to NPH listings.`,
`If you want to take control of the page — bio, portfolio, availability, accept self-booked consults — claim it here:`,
`${claimUrl}`,
`Basic listing is free, no obligation either way. Happy to answer anything.`
].join(' ');
}
// CSV-escape a single field. RFC 4180.
function csv(v) {
if (v == null) return '';
const s = String(v);
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
return s;
}
function header() {
return [
'# IG DM drafts — National Paper Hangers',
'# Generated: ' + new Date().toISOString(),
'# How to use:',
'# 1. Open IG (web or phone) on Steve\'s personal/business account.',
'# 2. For each row, navigate to https://instagram.com/<ig_handle>',
'# 3. Click Message → paste the entire dm_message field → send.',
'# 4. Log the send in comms_send_audit (channel=\'ig_dm\').',
'# 5. 5/day cadence max — see outreach/IG_DM_PLAYBOOK.md.',
'# Rule: NEVER use the IG API to automate sends — Meta TOS forbids it.',
''
].join('\n');
}
async function main() {
const params = [MIN];
let where = `claim_status = 'unclaimed'
AND ad_signals IS NOT NULL
AND (ad_signals->>'paid_ads_count')::int >= $1
AND instagram_handle IS NOT NULL
AND instagram_handle <> ''
AND website IS NOT NULL
AND website <> ''`;
if (STATE) {
params.push(STATE);
where += ` AND state = $${params.length}`;
}
let query = `SELECT slug, business_name, city, state, website, instagram_handle, ad_signals
FROM installers
WHERE ${where}
ORDER BY (ad_signals->>'paid_ads_count')::int DESC, business_name ASC`;
if (LIMIT > 0) {
params.push(LIMIT);
query += ` LIMIT $${params.length}`;
}
const rows = await db.many(query, params);
const lines = [];
lines.push(header());
lines.push('business_name,ig_handle,city,state,paid_platforms,dm_message,claim_url');
for (const r of rows) {
const plats = paidPlatforms(r.ad_signals);
const claimUrl = `${PUBLIC_URL}/installer/${r.slug}/claim`;
const dm = buildDmMessage({
businessName: r.business_name,
city: r.city,
state: r.state,
paidPlatforms: plats,
claimUrl
});
lines.push([
csv(r.business_name),
csv('@' + r.instagram_handle),
csv(r.city),
csv(r.state),
csv(plats.join('|')),
csv(dm),
csv(claimUrl)
].join(','));
}
const output = lines.join('\n') + '\n';
if (OUT) {
const abs = path.resolve(OUT);
fs.writeFileSync(abs, output, 'utf8');
// Stderr summary so a redirected stdout stays clean if someone passes both.
process.stderr.write(`[ig-dm-drafts] wrote ${rows.length} drafts → ${abs}\n`);
} else {
process.stdout.write(output);
process.stderr.write(`[ig-dm-drafts] generated ${rows.length} drafts\n`);
}
await db.pool.end();
}
main().catch(err => {
console.error('[ig-dm-drafts] error:', err.message);
process.exit(1);
});