DEV Community

Olamide Olaniyan
Olamide Olaniyan

Posted on

Build an Influencer Outreach CRM with Auto-Enrichment

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:

  1. Auto-enriches influencer profiles from any social link
  2. Tracks outreach status and conversations
  3. 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:

  1. Screenshot profile
  2. Copy-paste into spreadsheet
  3. Forget they exist
  4. 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
Enter fullscreen mode Exit fullscreen mode

Create .env:

SOCIAVAULT_API_KEY=your_sociavault_key
OPENAI_API_KEY=your_openai_key
PORT=3000
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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`);
});
Enter fullscreen mode Exit fullscreen mode

Step 6: Run It

node server.js
Enter fullscreen mode Exit fullscreen mode

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

  1. Get your SociaVault API Key
  2. Run the CRM
  3. 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)