You found the perfect influencer. Great engagement. Right niche. Perfect fit.
Now what? Save their username in a Google Sheet? Forget about them in 3 days?
In this tutorial, we'll build an Influencer Outreach CRM that:
- Auto-enriches influencer profiles from any social link
- Tracks outreach status and conversations
- Ranks influencers by partnership potential
Stop losing leads in spreadsheets. Start closing partnerships.
The Problem with Manual Outreach
Here's how most brands track influencers:
- Screenshot profile
- Copy-paste into spreadsheet
- Forget they exist
- Repeat
You need:
- Centralized influencer database
- Auto-updated stats (followers change daily)
- Outreach pipeline tracking
- Partnership potential scoring
The Stack
- Node.js: Runtime
- SociaVault API: Profile enrichment
- SQLite: Simple database (no setup)
- Express: API server
- OpenAI API: Fit scoring
Step 1: Setup
mkdir influencer-crm
cd influencer-crm
npm init -y
npm install axios openai dotenv express better-sqlite3
Create .env:
SOCIAVAULT_API_KEY=your_sociavault_key
OPENAI_API_KEY=your_openai_key
PORT=3000
Step 2: Database Schema
Create database.js:
const Database = require('better-sqlite3');
const db = new Database('influencers.db');
// Initialize tables
db.exec(`
CREATE TABLE IF NOT EXISTS influencers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
handle TEXT,
platform TEXT,
profile_url TEXT UNIQUE,
avatar_url TEXT,
bio TEXT,
followers INTEGER DEFAULT 0,
following INTEGER DEFAULT 0,
avg_engagement REAL DEFAULT 0,
niche TEXT,
email TEXT,
location TEXT,
fit_score INTEGER DEFAULT 0,
fit_reasons TEXT,
status TEXT DEFAULT 'discovered',
notes TEXT,
last_enriched TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS outreach (
id INTEGER PRIMARY KEY AUTOINCREMENT,
influencer_id INTEGER,
type TEXT,
subject TEXT,
message TEXT,
sent_at TEXT,
response_at TEXT,
response TEXT,
status TEXT DEFAULT 'draft',
FOREIGN KEY (influencer_id) REFERENCES influencers(id)
);
CREATE TABLE IF NOT EXISTS campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
description TEXT,
target_niche TEXT,
budget REAL,
status TEXT DEFAULT 'active',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS campaign_influencers (
campaign_id INTEGER,
influencer_id INTEGER,
status TEXT DEFAULT 'pending',
deal_value REAL,
PRIMARY KEY (campaign_id, influencer_id),
FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
FOREIGN KEY (influencer_id) REFERENCES influencers(id)
);
CREATE INDEX IF NOT EXISTS idx_influencers_platform ON influencers(platform);
CREATE INDEX IF NOT EXISTS idx_influencers_status ON influencers(status);
CREATE INDEX IF NOT EXISTS idx_influencers_fit_score ON influencers(fit_score);
`);
module.exports = db;
Step 3: Profile Enrichment Engine
Create enrichment.js:
require('dotenv').config();
const axios = require('axios');
const OpenAI = require('openai');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const SOCIAVAULT_BASE = 'https://api.sociavault.com';
const headers = { 'Authorization': `Bearer ${process.env.SOCIAVAULT_API_KEY}` };
async function enrichTikTokProfile(handle) {
console.log(`📱 Enriching TikTok profile: @${handle}`);
try {
// Get profile
const profileRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/tiktok/profile`, {
params: { handle },
headers
});
const profile = profileRes.data.data;
// Get recent videos for engagement calculation
const videosRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/tiktok/videos`, {
params: { handle, limit: 10 },
headers
});
const videos = videosRes.data.data || [];
// Calculate average engagement
const avgEngagement = calculateTikTokEngagement(videos, profile.followerCount || profile.fans);
return {
platform: 'tiktok',
handle: profile.uniqueId || handle,
name: profile.nickname || profile.name,
avatar_url: profile.avatarLarger || profile.avatar,
bio: profile.signature || profile.bio,
followers: profile.followerCount || profile.fans || 0,
following: profile.followingCount || profile.following || 0,
avg_engagement: avgEngagement,
profile_url: `https://tiktok.com/@${handle}`,
verified: profile.verified || false,
extraData: {
likes: profile.heartCount || profile.heart || 0,
videos: profile.videoCount || videos.length,
recentViews: videos.reduce((sum, v) => sum + (v.playCount || 0), 0)
}
};
} catch (error) {
console.error('TikTok enrichment error:', error.message);
return null;
}
}
async function enrichInstagramProfile(handle) {
console.log(`📸 Enriching Instagram profile: @${handle}`);
try {
const profileRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/instagram/profile`, {
params: { handle },
headers
});
const profile = profileRes.data.data;
// Get recent posts
const postsRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/instagram/posts`, {
params: { handle, limit: 10 },
headers
});
const posts = postsRes.data.data || [];
const avgEngagement = calculateInstagramEngagement(posts, profile.follower_count || profile.followers);
return {
platform: 'instagram',
handle: profile.username || handle,
name: profile.full_name || profile.name,
avatar_url: profile.profile_pic_url || profile.avatar,
bio: profile.biography || profile.bio,
followers: profile.follower_count || profile.followers || 0,
following: profile.following_count || profile.following || 0,
avg_engagement: avgEngagement,
profile_url: `https://instagram.com/${handle}`,
verified: profile.is_verified || false,
email: extractEmail(profile.biography || ''),
extraData: {
posts: profile.media_count || posts.length,
isBusinessAccount: profile.is_business_account,
category: profile.category_name
}
};
} catch (error) {
console.error('Instagram enrichment error:', error.message);
return null;
}
}
async function enrichTwitterProfile(handle) {
console.log(`🐦 Enriching Twitter profile: @${handle}`);
try {
const profileRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/twitter/profile`, {
params: { handle },
headers
});
const profile = profileRes.data.data;
// Get recent tweets
const tweetsRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/twitter/user-tweets`, {
params: { handle, limit: 10 },
headers
});
const tweets = tweetsRes.data.data || [];
const avgEngagement = calculateTwitterEngagement(tweets, profile.followers_count || profile.followers);
return {
platform: 'twitter',
handle: profile.screen_name || handle,
name: profile.name,
avatar_url: profile.profile_image_url_https || profile.avatar,
bio: profile.description || profile.bio,
followers: profile.followers_count || profile.followers || 0,
following: profile.friends_count || profile.following || 0,
avg_engagement: avgEngagement,
profile_url: `https://twitter.com/${handle}`,
verified: profile.verified || false,
location: profile.location,
email: extractEmail(profile.description || ''),
extraData: {
tweets: profile.statuses_count,
listed: profile.listed_count,
joined: profile.created_at
}
};
} catch (error) {
console.error('Twitter enrichment error:', error.message);
return null;
}
}
async function enrichYouTubeChannel(handle) {
console.log(`🎬 Enriching YouTube channel: ${handle}`);
try {
const channelRes = await axios.get(`${SOCIAVAULT_BASE}/v1/scrape/youtube/channel`, {
params: { handle },
headers
});
const channel = channelRes.data.data;
return {
platform: 'youtube',
handle: channel.customUrl || handle,
name: channel.title || channel.name,
avatar_url: channel.thumbnail || channel.avatar,
bio: channel.description,
followers: channel.subscriberCount || channel.subscribers || 0,
avg_engagement: 0, // Would need video data
profile_url: channel.url || `https://youtube.com/${handle}`,
verified: channel.isVerified || false,
extraData: {
videos: channel.videoCount,
views: channel.viewCount
}
};
} catch (error) {
console.error('YouTube enrichment error:', error.message);
return null;
}
}
function calculateTikTokEngagement(videos, followers) {
if (!videos.length || !followers) return 0;
const totalEngagement = videos.reduce((sum, v) => {
const likes = v.diggCount || v.stats?.diggCount || 0;
const comments = v.commentCount || v.stats?.commentCount || 0;
const shares = v.shareCount || v.stats?.shareCount || 0;
return sum + likes + comments + shares;
}, 0);
return ((totalEngagement / videos.length) / followers) * 100;
}
function calculateInstagramEngagement(posts, followers) {
if (!posts.length || !followers) return 0;
const totalEngagement = posts.reduce((sum, p) => {
const likes = p.like_count || p.likes || 0;
const comments = p.comment_count || p.comments || 0;
return sum + likes + comments;
}, 0);
return ((totalEngagement / posts.length) / followers) * 100;
}
function calculateTwitterEngagement(tweets, followers) {
if (!tweets.length || !followers) return 0;
const totalEngagement = tweets.reduce((sum, t) => {
const likes = t.favorite_count || t.likes || 0;
const retweets = t.retweet_count || 0;
const replies = t.reply_count || 0;
return sum + likes + retweets + replies;
}, 0);
return ((totalEngagement / tweets.length) / followers) * 100;
}
function extractEmail(text) {
const emailRegex = /[\w.-]+@[\w.-]+\.\w+/g;
const matches = text.match(emailRegex);
return matches ? matches[0] : null;
}
// Auto-detect platform from URL
function detectPlatform(url) {
if (url.includes('tiktok.com')) return 'tiktok';
if (url.includes('instagram.com')) return 'instagram';
if (url.includes('twitter.com') || url.includes('x.com')) return 'twitter';
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
return null;
}
function extractHandle(url, platform) {
try {
const urlObj = new URL(url);
const path = urlObj.pathname;
switch (platform) {
case 'tiktok':
return path.split('@')[1]?.split('/')[0] || path.split('/')[1];
case 'instagram':
return path.split('/')[1];
case 'twitter':
return path.split('/')[1];
case 'youtube':
if (path.includes('@')) return path.split('@')[1]?.split('/')[0];
if (path.includes('/c/')) return path.split('/c/')[1]?.split('/')[0];
if (path.includes('/channel/')) return path.split('/channel/')[1]?.split('/')[0];
return path.split('/')[1];
default:
return null;
}
} catch {
return url; // Assume it's already a handle
}
}
async function enrichFromUrl(url) {
const platform = detectPlatform(url);
if (!platform) return null;
const handle = extractHandle(url, platform);
if (!handle) return null;
switch (platform) {
case 'tiktok': return enrichTikTokProfile(handle);
case 'instagram': return enrichInstagramProfile(handle);
case 'twitter': return enrichTwitterProfile(handle);
case 'youtube': return enrichYouTubeChannel(handle);
default: return null;
}
}
module.exports = {
enrichTikTokProfile,
enrichInstagramProfile,
enrichTwitterProfile,
enrichYouTubeChannel,
enrichFromUrl,
detectPlatform,
extractHandle
};
Step 4: Fit Scoring Engine
Create scoring.js:
const OpenAI = require('openai');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function calculateFitScore(influencer, brandContext) {
const prompt = `Score this influencer's fit for a brand partnership on a scale of 0-100.
BRAND CONTEXT:
${brandContext.name ? `Brand: ${brandContext.name}` : ''}
Target Niche: ${brandContext.niche || 'general'}
Target Audience: ${brandContext.audience || 'general consumers'}
Campaign Goals: ${brandContext.goals || 'brand awareness'}
Budget Range: ${brandContext.budget || 'flexible'}
INFLUENCER PROFILE:
Platform: ${influencer.platform}
Name: ${influencer.name || influencer.handle}
Followers: ${influencer.followers?.toLocaleString()}
Engagement Rate: ${influencer.avg_engagement?.toFixed(2)}%
Bio: ${influencer.bio || 'N/A'}
Niche: ${influencer.niche || 'Unknown'}
Scoring Criteria:
1. Audience Size Fit (0-20): Do their followers match the campaign scale?
2. Engagement Quality (0-25): Is their engagement rate healthy for their size?
3. Niche Relevance (0-25): Does their content align with the brand?
4. Authenticity Signals (0-15): Does their profile look genuine?
5. Partnership Potential (0-15): Would they likely accept a collaboration?
Return JSON with:
- score: overall fit score (0-100)
- breakdown: object with individual scores for each criterion
- reasons: array of 3 key reasons for the score
- recommendation: "strongly recommend", "recommend", "consider", or "skip"
- estimated_rate: rough estimate of their partnership rate`;
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' }
});
return JSON.parse(response.choices[0].message.content);
} catch (error) {
console.error('Scoring error:', error.message);
return {
score: calculateBasicScore(influencer),
breakdown: {},
reasons: ['Score calculated using basic metrics'],
recommendation: 'consider'
};
}
}
function calculateBasicScore(influencer) {
let score = 50;
// Engagement bonus
const engagement = influencer.avg_engagement || 0;
if (engagement > 5) score += 20;
else if (engagement > 3) score += 15;
else if (engagement > 1) score += 10;
else score -= 10;
// Follower size (sweet spot is 10K-500K for most brands)
const followers = influencer.followers || 0;
if (followers >= 10000 && followers <= 500000) score += 15;
else if (followers >= 1000 && followers <= 1000000) score += 10;
else if (followers > 1000000) score += 5;
else score -= 10;
return Math.max(0, Math.min(100, score));
}
function rankInfluencers(influencers) {
return influencers
.map(inf => ({
...inf,
rankScore: calculateRankScore(inf)
}))
.sort((a, b) => b.rankScore - a.rankScore);
}
function calculateRankScore(influencer) {
const fitWeight = 0.4;
const engagementWeight = 0.3;
const followersWeight = 0.2;
const recencyWeight = 0.1;
const fitScore = influencer.fit_score || 50;
const engagementScore = Math.min(100, (influencer.avg_engagement || 0) * 20);
const followerScore = Math.min(100, Math.log10(influencer.followers || 1) * 15);
const lastEnriched = new Date(influencer.last_enriched || 0);
const daysSinceEnriched = (Date.now() - lastEnriched.getTime()) / (1000 * 60 * 60 * 24);
const recencyScore = Math.max(0, 100 - daysSinceEnriched * 5);
return (
fitScore * fitWeight +
engagementScore * engagementWeight +
followerScore * followersWeight +
recencyScore * recencyWeight
);
}
module.exports = {
calculateFitScore,
calculateBasicScore,
rankInfluencers
};
Step 5: CRM API Server
Create server.js:
require('dotenv').config();
const express = require('express');
const db = require('./database');
const { enrichFromUrl, enrichTikTokProfile, enrichInstagramProfile, enrichTwitterProfile } = require('./enrichment');
const { calculateFitScore, rankInfluencers } = require('./scoring');
const app = express();
app.use(express.json());
// Brand context for scoring (would be configurable)
let brandContext = {
name: 'Your Brand',
niche: 'lifestyle',
audience: 'millennials and gen-z',
goals: 'brand awareness and sales',
budget: '$500-$5000 per creator'
};
// Add influencer from URL
app.post('/api/influencers', async (req, res) => {
const { url, handle, platform } = req.body;
try {
let enrichedData;
if (url) {
enrichedData = await enrichFromUrl(url);
} else if (handle && platform) {
switch (platform) {
case 'tiktok':
enrichedData = await enrichTikTokProfile(handle);
break;
case 'instagram':
enrichedData = await enrichInstagramProfile(handle);
break;
case 'twitter':
enrichedData = await enrichTwitterProfile(handle);
break;
}
}
if (!enrichedData) {
return res.status(400).json({ error: 'Could not enrich profile' });
}
// Calculate fit score
const fitResult = await calculateFitScore(enrichedData, brandContext);
// Insert into database
const stmt = db.prepare(`
INSERT INTO influencers (
name, handle, platform, profile_url, avatar_url, bio,
followers, following, avg_engagement, email, location,
fit_score, fit_reasons, status, last_enriched
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', ?)
ON CONFLICT(profile_url) DO UPDATE SET
name = excluded.name,
followers = excluded.followers,
following = excluded.following,
avg_engagement = excluded.avg_engagement,
fit_score = excluded.fit_score,
fit_reasons = excluded.fit_reasons,
last_enriched = excluded.last_enriched,
updated_at = CURRENT_TIMESTAMP
`);
const result = stmt.run(
enrichedData.name,
enrichedData.handle,
enrichedData.platform,
enrichedData.profile_url,
enrichedData.avatar_url,
enrichedData.bio,
enrichedData.followers,
enrichedData.following,
enrichedData.avg_engagement,
enrichedData.email,
enrichedData.location,
fitResult.score,
JSON.stringify(fitResult.reasons),
new Date().toISOString()
);
res.json({
success: true,
id: result.lastInsertRowid,
influencer: enrichedData,
fitScore: fitResult
});
} catch (error) {
console.error('Add influencer error:', error);
res.status(500).json({ error: error.message });
}
});
// Get all influencers
app.get('/api/influencers', (req, res) => {
const { status, platform, minFollowers, minScore, sortBy } = req.query;
let query = 'SELECT * FROM influencers WHERE 1=1';
const params = [];
if (status) {
query += ' AND status = ?';
params.push(status);
}
if (platform) {
query += ' AND platform = ?';
params.push(platform);
}
if (minFollowers) {
query += ' AND followers >= ?';
params.push(parseInt(minFollowers));
}
if (minScore) {
query += ' AND fit_score >= ?';
params.push(parseInt(minScore));
}
switch (sortBy) {
case 'followers':
query += ' ORDER BY followers DESC';
break;
case 'engagement':
query += ' ORDER BY avg_engagement DESC';
break;
case 'score':
query += ' ORDER BY fit_score DESC';
break;
case 'recent':
query += ' ORDER BY created_at DESC';
break;
default:
query += ' ORDER BY fit_score DESC, followers DESC';
}
const influencers = db.prepare(query).all(...params);
res.json(influencers);
});
// Get single influencer
app.get('/api/influencers/:id', (req, res) => {
const influencer = db.prepare('SELECT * FROM influencers WHERE id = ?').get(req.params.id);
if (!influencer) {
return res.status(404).json({ error: 'Influencer not found' });
}
// Get outreach history
const outreach = db.prepare('SELECT * FROM outreach WHERE influencer_id = ? ORDER BY sent_at DESC').all(req.params.id);
res.json({ ...influencer, outreach });
});
// Update influencer status
app.patch('/api/influencers/:id', (req, res) => {
const { status, notes, niche } = req.body;
const updates = [];
const params = [];
if (status) {
updates.push('status = ?');
params.push(status);
}
if (notes !== undefined) {
updates.push('notes = ?');
params.push(notes);
}
if (niche) {
updates.push('niche = ?');
params.push(niche);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No updates provided' });
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(req.params.id);
db.prepare(`UPDATE influencers SET ${updates.join(', ')} WHERE id = ?`).run(...params);
const updated = db.prepare('SELECT * FROM influencers WHERE id = ?').get(req.params.id);
res.json(updated);
});
// Re-enrich influencer
app.post('/api/influencers/:id/enrich', async (req, res) => {
const influencer = db.prepare('SELECT * FROM influencers WHERE id = ?').get(req.params.id);
if (!influencer) {
return res.status(404).json({ error: 'Influencer not found' });
}
const enrichedData = await enrichFromUrl(influencer.profile_url);
if (!enrichedData) {
return res.status(400).json({ error: 'Could not re-enrich profile' });
}
const fitResult = await calculateFitScore(enrichedData, brandContext);
db.prepare(`
UPDATE influencers SET
name = ?, followers = ?, following = ?, avg_engagement = ?,
fit_score = ?, fit_reasons = ?, last_enriched = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
enrichedData.name,
enrichedData.followers,
enrichedData.following,
enrichedData.avg_engagement,
fitResult.score,
JSON.stringify(fitResult.reasons),
new Date().toISOString(),
req.params.id
);
res.json({ success: true, influencer: enrichedData, fitScore: fitResult });
});
// Create outreach
app.post('/api/influencers/:id/outreach', (req, res) => {
const { type, subject, message } = req.body;
const stmt = db.prepare(`
INSERT INTO outreach (influencer_id, type, subject, message, status)
VALUES (?, ?, ?, ?, 'draft')
`);
const result = stmt.run(req.params.id, type, subject, message);
// Update influencer status
db.prepare(`UPDATE influencers SET status = 'contacted', updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
.run(req.params.id);
res.json({ success: true, id: result.lastInsertRowid });
});
// Mark outreach as sent
app.patch('/api/outreach/:id/send', (req, res) => {
db.prepare(`UPDATE outreach SET status = 'sent', sent_at = ? WHERE id = ?`)
.run(new Date().toISOString(), req.params.id);
res.json({ success: true });
});
// Record response
app.patch('/api/outreach/:id/response', (req, res) => {
const { response, status } = req.body;
db.prepare(`UPDATE outreach SET response = ?, status = ?, response_at = ? WHERE id = ?`)
.run(response, status || 'responded', new Date().toISOString(), req.params.id);
// Update influencer status based on response
const outreach = db.prepare('SELECT influencer_id FROM outreach WHERE id = ?').get(req.params.id);
const newStatus = status === 'accepted' ? 'negotiating' :
status === 'declined' ? 'declined' : 'responded';
db.prepare(`UPDATE influencers SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
.run(newStatus, outreach.influencer_id);
res.json({ success: true });
});
// Pipeline stats
app.get('/api/stats', (req, res) => {
const stats = {
total: db.prepare('SELECT COUNT(*) as count FROM influencers').get().count,
byStatus: db.prepare(`
SELECT status, COUNT(*) as count
FROM influencers
GROUP BY status
`).all(),
byPlatform: db.prepare(`
SELECT platform, COUNT(*) as count, AVG(followers) as avgFollowers, AVG(avg_engagement) as avgEngagement
FROM influencers
GROUP BY platform
`).all(),
topScored: db.prepare(`
SELECT id, name, handle, platform, followers, fit_score
FROM influencers
ORDER BY fit_score DESC
LIMIT 5
`).all(),
recentlyAdded: db.prepare(`
SELECT id, name, handle, platform, followers, created_at
FROM influencers
ORDER BY created_at DESC
LIMIT 5
`).all()
};
res.json(stats);
});
// Update brand context
app.put('/api/brand-context', (req, res) => {
brandContext = { ...brandContext, ...req.body };
res.json(brandContext);
});
// Bulk import from URLs
app.post('/api/influencers/bulk', async (req, res) => {
const { urls } = req.body;
const results = [];
for (const url of urls) {
try {
const enrichedData = await enrichFromUrl(url);
if (enrichedData) {
const fitResult = await calculateFitScore(enrichedData, brandContext);
const stmt = db.prepare(`
INSERT INTO influencers (name, handle, platform, profile_url, avatar_url, bio,
followers, following, avg_engagement, email, fit_score, fit_reasons, last_enriched)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(profile_url) DO UPDATE SET followers = excluded.followers, last_enriched = excluded.last_enriched
`);
stmt.run(
enrichedData.name, enrichedData.handle, enrichedData.platform,
enrichedData.profile_url, enrichedData.avatar_url, enrichedData.bio,
enrichedData.followers, enrichedData.following, enrichedData.avg_engagement,
enrichedData.email, fitResult.score, JSON.stringify(fitResult.reasons),
new Date().toISOString()
);
results.push({ url, success: true, handle: enrichedData.handle });
} else {
results.push({ url, success: false, error: 'Could not enrich' });
}
} catch (error) {
results.push({ url, success: false, error: error.message });
}
// Rate limiting
await new Promise(r => setTimeout(r, 500));
}
res.json({ results, imported: results.filter(r => r.success).length });
});
// Dashboard HTML
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Influencer CRM</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
h1 { font-size: 1.5rem; margin-bottom: 20px; }
.add-form { display: flex; gap: 10px; margin-bottom: 30px; }
input[type="text"] { flex: 1; padding: 12px; border: 1px solid #334155; border-radius: 8px; background: #1e293b; color: #fff; }
button { padding: 12px 24px; background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; }
button:hover { background: #2563eb; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 30px; }
.stat-card { background: #1e293b; padding: 20px; border-radius: 12px; }
.stat-value { font-size: 2rem; font-weight: bold; }
.stat-label { color: #94a3b8; font-size: 0.875rem; }
.filters { display: flex; gap: 10px; margin-bottom: 20px; }
select { padding: 8px 12px; border: 1px solid #334155; border-radius: 6px; background: #1e293b; color: #fff; }
.influencer-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; }
.influencer-card { background: #1e293b; border-radius: 12px; padding: 20px; }
.influencer-header { display: flex; gap: 15px; margin-bottom: 15px; }
.avatar { width: 60px; height: 60px; border-radius: 50%; object-fit: cover; }
.influencer-info h3 { font-size: 1rem; margin-bottom: 4px; }
.influencer-info .handle { color: #94a3b8; font-size: 0.875rem; }
.influencer-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 15px; }
.influencer-stat { text-align: center; }
.influencer-stat .value { font-weight: bold; }
.influencer-stat .label { font-size: 0.75rem; color: #94a3b8; }
.fit-score { display: flex; align-items: center; gap: 10px; padding: 10px; background: #0f172a; border-radius: 8px; }
.score-badge { font-size: 1.5rem; font-weight: bold; }
.score-high { color: #22c55e; }
.score-medium { color: #f59e0b; }
.score-low { color: #ef4444; }
.status-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; background: #334155; }
.status-contacted { background: #3b82f6; }
.status-responded { background: #f59e0b; }
.status-negotiating { background: #8b5cf6; }
.status-partnered { background: #22c55e; }
.platform-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; }
.platform-tiktok { background: #000; }
.platform-instagram { background: #E1306C; }
.platform-twitter { background: #1DA1F2; }
.platform-youtube { background: #FF0000; }
</style>
</head>
<body>
<div class="container">
<h1>🎯 Influencer CRM</h1>
<div class="add-form">
<input type="text" id="urlInput" placeholder="Paste influencer profile URL (TikTok, Instagram, Twitter, YouTube)...">
<button onclick="addInfluencer()">Add Influencer</button>
</div>
<div class="stats" id="statsContainer"></div>
<div class="filters">
<select id="statusFilter" onchange="loadInfluencers()">
<option value="">All Statuses</option>
<option value="discovered">Discovered</option>
<option value="contacted">Contacted</option>
<option value="responded">Responded</option>
<option value="negotiating">Negotiating</option>
<option value="partnered">Partnered</option>
<option value="declined">Declined</option>
</select>
<select id="platformFilter" onchange="loadInfluencers()">
<option value="">All Platforms</option>
<option value="tiktok">TikTok</option>
<option value="instagram">Instagram</option>
<option value="twitter">Twitter</option>
<option value="youtube">YouTube</option>
</select>
<select id="sortFilter" onchange="loadInfluencers()">
<option value="score">Sort by Fit Score</option>
<option value="followers">Sort by Followers</option>
<option value="engagement">Sort by Engagement</option>
<option value="recent">Sort by Recent</option>
</select>
</div>
<div class="influencer-grid" id="influencerGrid"></div>
</div>
<script>
async function loadStats() {
const res = await fetch('/api/stats');
const stats = await res.json();
document.getElementById('statsContainer').innerHTML = \`
<div class="stat-card"><div class="stat-value">\${stats.total}</div><div class="stat-label">Total Influencers</div></div>
<div class="stat-card"><div class="stat-value">\${stats.byStatus.find(s=>s.status==='partnered')?.count || 0}</div><div class="stat-label">Partnered</div></div>
<div class="stat-card"><div class="stat-value">\${stats.byStatus.find(s=>s.status==='negotiating')?.count || 0}</div><div class="stat-label">Negotiating</div></div>
<div class="stat-card"><div class="stat-value">\${stats.byStatus.find(s=>s.status==='contacted')?.count || 0}</div><div class="stat-label">Contacted</div></div>
\`;
}
async function loadInfluencers() {
const status = document.getElementById('statusFilter').value;
const platform = document.getElementById('platformFilter').value;
const sortBy = document.getElementById('sortFilter').value;
let url = '/api/influencers?';
if (status) url += 'status=' + status + '&';
if (platform) url += 'platform=' + platform + '&';
url += 'sortBy=' + sortBy;
const res = await fetch(url);
const influencers = await res.json();
document.getElementById('influencerGrid').innerHTML = influencers.map(inf => \`
<div class="influencer-card">
<div class="influencer-header">
<img src="\${inf.avatar_url || 'https://via.placeholder.com/60'}" class="avatar" onerror="this.src='https://via.placeholder.com/60'">
<div class="influencer-info">
<h3>\${inf.name || inf.handle}</h3>
<div class="handle">@\${inf.handle}</div>
<span class="platform-badge platform-\${inf.platform}">\${inf.platform}</span>
<span class="status-badge status-\${inf.status}">\${inf.status}</span>
</div>
</div>
<div class="influencer-stats">
<div class="influencer-stat"><div class="value">\${formatNumber(inf.followers)}</div><div class="label">Followers</div></div>
<div class="influencer-stat"><div class="value">\${(inf.avg_engagement || 0).toFixed(1)}%</div><div class="label">Engagement</div></div>
<div class="influencer-stat"><div class="value">\${inf.fit_score || 0}</div><div class="label">Fit Score</div></div>
</div>
<div class="fit-score">
<span class="score-badge \${inf.fit_score >= 70 ? 'score-high' : inf.fit_score >= 40 ? 'score-medium' : 'score-low'}">\${inf.fit_score || 0}</span>
<span style="font-size:0.875rem;color:#94a3b8">/ 100 fit score</span>
</div>
</div>
\`).join('');
}
async function addInfluencer() {
const url = document.getElementById('urlInput').value;
if (!url) return;
const res = await fetch('/api/influencers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
if (res.ok) {
document.getElementById('urlInput').value = '';
loadInfluencers();
loadStats();
}
}
function formatNumber(n) {
if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
if (n >= 1000) return (n/1000).toFixed(1) + 'K';
return n || 0;
}
loadStats();
loadInfluencers();
</script>
</body>
</html>
`);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`\n🎯 Influencer CRM running at http://localhost:${PORT}\n`);
});
Step 6: Run It
node server.js
Open http://localhost:3000 and start adding influencers!
What You Just Built
Influencer CRM tools are expensive:
- Grin: $2500+/month
- CreatorIQ: Enterprise pricing
- Upfluence: $795+/month
- Aspire: $1000+/month
Your version has auto-enrichment for cents per profile.
Features
- Auto-enrichment from any social URL
- Fit scoring based on your brand context
- Pipeline tracking (discovered → contacted → partnered)
- Outreach history for each influencer
- Bulk import support
- Multi-platform (TikTok, Instagram, Twitter, YouTube)
Get Started
- Get your SociaVault API Key
- Run the CRM
- Start building your influencer database
Stop losing partnerships to disorganization. Start closing deals.
The best influencers are already talking about your niche. Find them.
Top comments (0)