import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { parseArgs } from 'node:util'; import { parse } from 'csv-parse/sync'; import { stringify } from 'csv-stringify/sync'; import Influship, { APIError } from 'influship'; import type { CreatorEmailLookupResponse } from 'influship/resources/creator-emails'; import type { CreatorMatchResponse } from 'influship/resources/creators'; import type { ProfileResponseData } from 'influship/resources/profiles'; import { extractHandles, type Handle, pickHandleColumn, type Rejected } from './handles.js'; type Match = CreatorMatchResponse.Data['match']; type Email = CreatorEmailLookupResponse.Data.Result['emails'][number]; type Decision = Match['decision']; // ---------- configuration ---------- const { values: args } = parseArgs({ options: { input: { type: 'string', default: 'creators.csv' }, brief: { type: 'string', default: 'brief.md' }, out: { type: 'string', default: 'enriched.csv' }, 'max-credits': { type: 'string', default: '300' }, 'min-followers': { type: 'string', default: '1000' }, 'inactive-days': { type: 'string', default: '60' }, emails: { type: 'string', default: 'good' }, // "good", "good,neutral" or "none" 'ingest-missing': { type: 'boolean', default: false }, 'dry-run': { type: 'boolean', default: false }, }, }); const MAX_CREDITS = Number(args['max-credits']); const MIN_FOLLOWERS = Number(args['min-followers']); const INACTIVE_DAYS = Number(args['inactive-days']); const EMAIL_DECISIONS = new Set(args.emails === 'none' ? [] : args.emails.split(',')); // Published prices, in credits (1 credit = $0.01). The response headers are the source of truth. const PRICE = { lookup: 0.1, match: 1, email: 5, ingest: 5 }; // Reads INFLUSHIP_API_KEY. 429s and 503s are retried after the Retry-After delay. const client = new Influship({ timeout: 90_000, maxRetries: 5 }); // ---------- cost tracking and a disk cache ---------- let spent = 0; let reserved = 0; // worst-case cost of requests still in flight class BudgetExceeded extends Error {} /** * Runs a paid request once per unique payload. Re-running the script after a crash, * or after editing the brief, only pays for requests whose inputs changed. */ async function cached( step: string, payload: unknown, maxCost: number, request: () => Promise<{ data: T; response: Response }>, ): Promise { const key = createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16); const file = `.cache/${step}-${key}.json`; if (existsSync(file)) return JSON.parse(await readFile(file, 'utf8')) as T; if (spent + reserved + maxCost > MAX_CREDITS) { throw new BudgetExceeded( `${step} could cost up to ${maxCost} credits; ${(MAX_CREDITS - spent).toFixed(1)} left of --max-credits ${MAX_CREDITS}`, ); } reserved += maxCost; try { const { data, response } = await request(); spent += Number(response.headers.get('x-credits-charged') ?? 0); await mkdir('.cache', { recursive: true }); await writeFile(file, JSON.stringify(data)); return data; } finally { reserved -= maxCost; } } /** Runs `work` over `items` with at most `limit` in flight. Stops taking new items after a failure. */ async function inParallel(items: T[], limit: number, work: (item: T) => Promise) { let next = 0; let failed = false; const worker = async () => { while (!failed && next < items.length) { const item = items[next++]; try { await work(item); } catch (error) { failed = true; throw error; } } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); } function chunk(items: T[], size: number): T[][] { const chunks: T[][] = []; for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)); return chunks; } // ---------- step 2: resolve handles to profiles ---------- async function resolveProfiles(handles: Handle[]) { const profiles = new Map(); const notFound: string[] = []; for (const batch of chunk(handles, 100)) { const result = await cached('lookup', batch.map((h) => h.username), batch.length * PRICE.lookup, () => client.profiles .lookup({ profiles: batch.map((h) => ({ platform: 'instagram', username: h.username })) }) .withResponse(), ); for (const profile of result.data) profiles.set(profile.username.toLowerCase(), profile); for (const missing of result.not_found) notFound.push(missing.username.toLowerCase()); } return { profiles, notFound }; } // ---------- step 3: screen out obvious misses for free ---------- function screenOut(profile: ProfileResponseData): string | null { if (profile.is_private) return 'private account'; if (profile.metrics.followers < MIN_FOLLOWERS) return `under ${MIN_FOLLOWERS} followers`; // last_post_at is as of data_updated_at, when Influship last collected the profile. // Measure the gap at that moment, not against today, or older snapshots look inactive. // A null on either side means "unknown", not "inactive": only screen out what you can prove. const lastPost = profile.activity.last_post_at; const collected = profile.data_updated_at; if (lastPost && collected) { const days = (Date.parse(collected) - Date.parse(lastPost)) / 86_400_000; if (days > INACTIVE_DAYS) return `no posts in ${Math.floor(days)} days before ${collected.slice(0, 10)}`; } return null; } // ---------- step 4: score campaign fit ---------- // Scoring reads each creator's recent posts, which takes a few seconds per creator. // Small batches running side by side finish sooner than one big batch, and every request // stays well inside the client timeout. const MATCH_BATCH = 10; const MATCH_PARALLEL = 4; async function scoreFit(creatorIds: string[], brief: { query: string; context?: string }) { const matches = new Map(); await inParallel(chunk(creatorIds, MATCH_BATCH), MATCH_PARALLEL, async (batch) => { const result = await cached('match', { batch, brief }, batch.length * PRICE.match, () => client.creators .match({ creators: batch.map((id) => ({ creator_id: id })), intent: brief }) .withResponse(), ); for (const item of result.data) matches.set(item.creator.id, item.match); process.stdout.write(`\rScored ${matches.size} of ${creatorIds.length}`); }); process.stdout.write('\n'); return matches; } // ---------- step 5: emails for the creators worth contacting ---------- const EMAIL_RANK: Record = { creator_verified: 0, valid: 1, unvalidated: 2, risky: 3, }; async function findEmails(creatorIds: string[]) { const emails = new Map(); for (const batch of chunk(creatorIds, 100)) { const result = await cached('emails', batch, batch.length * PRICE.email, () => client.creatorEmails .lookup({ creators: batch.map((id) => ({ creator_id: id })) }) .withResponse(), ); for (const row of result.data.results) { const best = [...row.emails].sort( (a, b) => EMAIL_RANK[a.status] - EMAIL_RANK[b.status] || Number(b.is_primary) - Number(a.is_primary), )[0]; if (row.creator_id && best) emails.set(row.creator_id, best); } } return emails; } // ---------- optional: queue creators Influship has not indexed yet ---------- async function ingestMissing(usernames: string[]) { // The ingest endpoint is capped at 50 new creators per UTC day. for (const username of usernames.slice(0, 50)) { await cached('ingest', username, PRICE.ingest, () => client .post('/v1/creators/ingest', { body: { platform: 'instagram', username, source_query: 'list enrichment' }, }) .withResponse(), ); } } // ---------- step 6: write the ranked CSV ---------- const DECISION_ORDER: Record = { good: 0, neutral: 1, avoid: 2 }; const PROVENANCE_ORDER = { post_evidence: 0, profile_fact: 1, inferred: 2 } as const; type Row = Record; function scoredRow(handle: Handle, profile: ProfileResponseData, match?: Match, email?: Email): Row { const reasons = [...(match?.reasons ?? [])].sort( (a, b) => PROVENANCE_ORDER[a.provenance] - PROVENANCE_ORDER[b.provenance], ); const evidence = reasons.find((r) => r.evidence_quote)?.evidence_quote ?? null; return { username: handle.username, status: match ? 'scored' : 'not scored', decision: match?.decision ?? null, fit_score: match ? Number(match.score.toFixed(2)) : null, why: reasons.map((r) => r.text).join(' | ') || null, evidence_quote: evidence, email: email?.email ?? null, email_status: email?.status ?? null, followers: profile.metrics.followers, engagement_rate_pct: profile.metrics.engagement_rate, posts_last_30d: profile.metrics.posts_last_30d, follower_growth_30d_pct: profile.growth.followers_30d_pct, last_post_at: profile.activity.last_post_at, profile_url: profile.url, data_updated_at: profile.data_updated_at, note: null, source_row: handle.row, }; } function skippedRow(username: string, row: number, status: string, note: string): Row { return { username, status, note, source_row: row }; } function sortRows(rows: Row[]) { const rank = (row: Row) => row.status === 'scored' ? DECISION_ORDER[row.decision as Decision] : 3; return rows.sort( (a, b) => rank(a) - rank(b) || Number(b.fit_score ?? 0) - Number(a.fit_score ?? 0), ); } // ---------- main ---------- async function main() { const csv = await readFile(args.input, 'utf8'); const records = parse(csv, { columns: true, skip_empty_lines: true, bom: true }) as Record[]; const column = pickHandleColumn(Object.keys(records[0] ?? {})); const { handles, rejected } = extractHandles(records, column); const [query, ...rest] = (await readFile(args.brief, 'utf8')).trim().split(/\n\s*\n/); const brief = { query: query.slice(0, 500), context: rest.join('\n\n').slice(0, 2000) || undefined }; const worstCase = handles.length * PRICE.lookup + handles.length * PRICE.match + (EMAIL_DECISIONS.size ? handles.length * PRICE.email : 0); console.log(`${handles.length} usable handles from column "${column}", ${rejected.length} rejected`); console.log(`Worst case: ${worstCase.toFixed(1)} credits. Budget: ${MAX_CREDITS} credits.`); if (args['dry-run']) return; const rows: Row[] = rejected.map((r: Rejected) => skippedRow(r.raw, r.row, 'rejected', r.reason)); let stoppedEarly: string | null = null; try { const { profiles, notFound } = await resolveProfiles(handles); console.log(`Resolved ${profiles.size}, not indexed ${notFound.length}`); const eligible = new Map(); // creator_id -> first handle seen for (const handle of handles) { const profile = profiles.get(handle.username); if (!profile) continue; const reason = screenOut(profile); if (reason) rows.push({ ...scoredRow(handle, profile), status: 'screened out', note: reason }); else if (!eligible.has(profile.creator_id)) eligible.set(profile.creator_id, handle); else rows.push({ ...scoredRow(handle, profile), status: 'duplicate creator', note: 'same creator as another row' }); } let matches = new Map(); let emails = new Map(); try { matches = await scoreFit([...eligible.keys()], brief); const contactable = [...matches].filter(([, m]) => EMAIL_DECISIONS.has(m.decision)).map(([id]) => id); emails = await findEmails(contactable); } finally { for (const [creatorId, handle] of eligible) { const profile = profiles.get(handle.username); if (profile) rows.push(scoredRow(handle, profile, matches.get(creatorId), emails.get(creatorId))); } } for (const username of notFound) { const handle = handles.find((h) => h.username === username); rows.push(skippedRow(username, handle?.row ?? 0, 'not indexed', args['ingest-missing'] ? 'queued for indexing; re-run tomorrow' : 'use --ingest-missing to queue')); } if (args['ingest-missing'] && notFound.length) await ingestMissing(notFound); } catch (error) { if (error instanceof BudgetExceeded) stoppedEarly = error.message; else if (error instanceof APIError && error.status === 402) stoppedEarly = 'out of API credits'; else throw error; } await writeFile(args.out, stringify(sortRows(rows), { header: true, columns: COLUMNS })); const count = (decision: Decision) => rows.filter((r) => r.decision === decision).length; console.log(`good ${count('good')} · neutral ${count('neutral')} · avoid ${count('avoid')}`); console.log(`Emails found: ${rows.filter((r) => r.email).length}`); console.log(`Spent ${spent.toFixed(2)} credits ($${(spent / 100).toFixed(2)}). Wrote ${args.out}`); if (stoppedEarly) console.log(`Stopped early: ${stoppedEarly}. Re-run to resume; finished steps are cached.`); } const COLUMNS = [ 'username', 'status', 'decision', 'fit_score', 'why', 'evidence_quote', 'email', 'email_status', 'followers', 'engagement_rate_pct', 'posts_last_30d', 'follower_growth_30d_pct', 'last_post_at', 'profile_url', 'data_updated_at', 'note', 'source_row', ]; main().catch((error) => { if (error instanceof APIError) { console.error(`API error ${error.status}: ${error.message}`); console.error(`Request ID: ${error.headers?.get('x-request-id') ?? 'unknown'}`); } else { console.error(error); } process.exit(1); });