DEV Community

Cover image for Build a Competitor Hashtag Gap Analyzer with Node.js
Olamide Olaniyan
Olamide Olaniyan

Posted on

Build a Competitor Hashtag Gap Analyzer with Node.js

Your competitor is getting 3x your reach on TikTok. Same niche. Similar content. What are they doing differently?

Hashtags.

Not the obvious ones you both use. The ones they use that you don't. The gaps in your strategy that you don't even know exist.

I built a tool that compares hashtag strategies between any two creators and finds the exact gaps. Takes about 50 lines of actual logic. Here's the whole thing.

The Stack

  • Node.js – runtime
  • SociaVault API – fetch posts and captions across platforms
  • Set operations – difference, intersection, frequency analysis

The Concept

Your Hashtags:        #fitness #gym #workout #gains #fitfam
Competitor's:         #fitness #gym #gymtok #fitnesstips #homeworkout #formcheck

Gap (they use, you don't): #gymtok #fitnesstips #homeworkout #formcheck
Overlap (you both use):    #fitness #gym
Wasted (you use, they don't): #workout #gains #fitfam
Enter fullscreen mode Exit fullscreen mode

The gap is where the opportunity lives. Those are proven hashtags in your niche that you're ignoring.

Step 1: Fetch Post Data

const axios = require('axios');

const API_BASE = 'https://api.sociavault.com/v1';
const API_KEY = process.env.SOCIAVAULT_API_KEY;

const api = axios.create({
  baseURL: API_BASE,
  headers: { 'x-api-key': API_KEY },
});

async function getPosts(platform, username, limit = 50) {
  const { data } = await api.get(`/${platform}/posts/${username}`, {
    params: { limit },
  });
  return data.posts;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Extract Hashtag Profiles

A hashtag "profile" isn't just which tags someone uses — it's how often and how well they perform.

function buildHashtagProfile(posts, followerCount) {
  const hashtags = {};

  for (const post of posts) {
    const tags = (post.caption || '').match(/#[\w\u00C0-\u024F]+/g) || [];
    const engagement = (post.likeCount + post.commentCount) / Math.max(followerCount, 1);

    for (const tag of tags) {
      const normalized = tag.toLowerCase();

      if (!hashtags[normalized]) {
        hashtags[normalized] = {
          tag: normalized,
          count: 0,
          totalEngagement: 0,
          posts: [],
        };
      }

      hashtags[normalized].count++;
      hashtags[normalized].totalEngagement += engagement;
      hashtags[normalized].posts.push({
        id: post.id,
        likes: post.likeCount,
        comments: post.commentCount,
      });
    }
  }

  // Calculate average engagement per hashtag
  for (const tag of Object.values(hashtags)) {
    tag.avgEngagement = parseFloat(
      ((tag.totalEngagement / tag.count) * 100).toFixed(2)
    );
  }

  return hashtags;
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Compare Hashtag Strategies

function analyzeHashtagGap(myProfile, competitorProfile) {
  const myTags = new Set(Object.keys(myProfile));
  const compTags = new Set(Object.keys(competitorProfile));

  // Gap: competitor uses, I don't
  const gap = [];
  for (const tag of compTags) {
    if (!myTags.has(tag)) {
      gap.push({
        ...competitorProfile[tag],
        source: 'competitor-only',
      });
    }
  }

  // Overlap: both use
  const overlap = [];
  for (const tag of myTags) {
    if (compTags.has(tag)) {
      overlap.push({
        tag,
        myUsage: myProfile[tag].count,
        myEngagement: myProfile[tag].avgEngagement,
        compUsage: competitorProfile[tag].count,
        compEngagement: competitorProfile[tag].avgEngagement,
        engagementDiff: parseFloat(
          (competitorProfile[tag].avgEngagement - myProfile[tag].avgEngagement).toFixed(2)
        ),
      });
    }
  }

  // Wasted: I use, competitor doesn't
  const wasted = [];
  for (const tag of myTags) {
    if (!compTags.has(tag)) {
      wasted.push({
        ...myProfile[tag],
        source: 'my-only',
      });
    }
  }

  return { gap, overlap, wasted };
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Score and Rank Opportunities

Not all gaps are equal. A hashtag the competitor uses once with low engagement isn't worth stealing. One they use in 30/50 posts with 2x engagement? Gold.

function scoreOpportunities(gap) {
  return gap
    .map(tag => ({
      ...tag,
      opportunityScore: calculateOpportunityScore(tag),
    }))
    .sort((a, b) => b.opportunityScore - a.opportunityScore);
}

function calculateOpportunityScore(tag) {
  let score = 0;

  // Frequency: how consistently does the competitor use it?
  if (tag.count >= 20) score += 40;       // Staple hashtag
  else if (tag.count >= 10) score += 30;  // Regular
  else if (tag.count >= 5) score += 20;   // Occasional
  else score += 5;                         // Rare

  // Engagement: does it drive results?
  if (tag.avgEngagement > 5) score += 40;
  else if (tag.avgEngagement > 3) score += 30;
  else if (tag.avgEngagement > 1.5) score += 20;
  else score += 5;

  // Consistency bonus: used frequently AND performs well
  if (tag.count >= 10 && tag.avgEngagement > 3) score += 20;

  return Math.min(100, score);
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Multi-Competitor Analysis

One competitor might be an anomaly. Three competitors using the same hashtag? That's a pattern.

async function multiCompetitorGapAnalysis(platform, myUsername, competitorUsernames) {
  // Fetch my profile
  const myPosts = await getPosts(platform, myUsername, 50);
  const myFollowers = (await api.get(`/${platform}/profile/${myUsername}`)).data.followerCount;
  const myHashtags = buildHashtagProfile(myPosts, myFollowers);

  // Fetch all competitor profiles
  const competitorHashtags = {};
  for (const competitor of competitorUsernames) {
    const posts = await getPosts(platform, competitor, 50);
    const followers = (await api.get(`/${platform}/profile/${competitor}`)).data.followerCount;
    competitorHashtags[competitor] = buildHashtagProfile(posts, followers);
  }

  // Find tags used by multiple competitors that I don't use
  const tagCompetitorCount = {};
  const tagBestEngagement = {};

  for (const [competitor, profile] of Object.entries(competitorHashtags)) {
    for (const [tag, data] of Object.entries(profile)) {
      if (!myHashtags[tag]) {
        tagCompetitorCount[tag] = (tagCompetitorCount[tag] || 0) + 1;
        if (!tagBestEngagement[tag] || data.avgEngagement > tagBestEngagement[tag]) {
          tagBestEngagement[tag] = data.avgEngagement;
        }
      }
    }
  }

  // Rank by how many competitors use it
  const opportunities = Object.entries(tagCompetitorCount)
    .map(([tag, count]) => ({
      tag,
      usedByCompetitors: count,
      totalCompetitors: competitorUsernames.length,
      bestEngagement: tagBestEngagement[tag],
      confidence: parseFloat((count / competitorUsernames.length * 100).toFixed(0)),
    }))
    .sort((a, b) => b.usedByCompetitors - a.usedByCompetitors || b.bestEngagement - a.bestEngagement);

  return opportunities;
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Generate the Report

async function runGapAnalysis(platform, myUsername, competitors) {
  console.log(`\n=== HASHTAG GAP ANALYSIS ===`);
  console.log(`Your account: @${myUsername}`);
  console.log(`Competitors: ${competitors.map(c => '@' + c).join(', ')}\n`);

  const opportunities = await multiCompetitorGapAnalysis(platform, myUsername, competitors);

  // High-confidence gaps (used by 2+ competitors)
  const highConfidence = opportunities.filter(t => t.usedByCompetitors >= 2);

  console.log(`šŸŽÆ HIGH-CONFIDENCE GAPS (used by 2+ competitors):`);
  highConfidence.slice(0, 15).forEach((t, i) => {
    console.log(
      `  ${i + 1}. ${t.tag} — used by ${t.usedByCompetitors}/${t.totalCompetitors} competitors, ` +
      `best engagement: ${t.bestEngagement}%, confidence: ${t.confidence}%`
    );
  });

  // Single competitor gaps with high engagement
  const singleHigh = opportunities
    .filter(t => t.usedByCompetitors === 1 && t.bestEngagement > 3)
    .slice(0, 10);

  console.log(`\nšŸ’” WORTH TESTING (1 competitor, high engagement):`);
  singleHigh.forEach((t, i) => {
    console.log(
      `  ${i + 1}. ${t.tag} — engagement: ${t.bestEngagement}%`
    );
  });

  return { highConfidence, singleHigh, allOpportunities: opportunities };
}

// Run it
runGapAnalysis('tiktok', 'my_fitness_account', [
  'competitor_1',
  'competitor_2',
  'competitor_3',
]);
Enter fullscreen mode Exit fullscreen mode

Sample Output

=== HASHTAG GAP ANALYSIS ===
Your account: @my_fitness_account
Competitors: @competitor_1, @competitor_2, @competitor_3

šŸŽÆ HIGH-CONFIDENCE GAPS (used by 2+ competitors):
  1. #gymtok — used by 3/3 competitors, best engagement: 6.2%, confidence: 100%
  2. #formcheck — used by 3/3 competitors, best engagement: 5.8%, confidence: 100%
  3. #fitnesstips — used by 2/3 competitors, best engagement: 4.1%, confidence: 67%
  4. #homeworkout — used by 2/3 competitors, best engagement: 3.9%, confidence: 67%
  5. #personaltrainer — used by 2/3 competitors, best engagement: 3.5%, confidence: 67%

šŸ’” WORTH TESTING (1 competitor, high engagement):
  1. #calisthenics — engagement: 7.1%
  2. #functionalfitness — engagement: 4.8%
  3. #mobilitywork — engagement: 3.6%
Enter fullscreen mode Exit fullscreen mode

All 3 competitors use #gymtok and #formcheck. You don't. That's not a coincidence — those hashtags work in your niche.

Read the Full Guide

This is a condensed version. The full guide includes:

  • Time-based analysis (which hashtags trend when)
  • Hashtag clustering by theme
  • Automatic caption rewriting with gap hashtags
  • Tracking gap closure over time

Read the complete guide on SociaVault →


Building social media analytics tools? SociaVault provides social media data APIs for TikTok, Instagram, YouTube, and 10+ platforms. Fetch posts, captions, hashtags, and engagement data through one unified API.

Discussion

What's your hashtag strategy? Manual research, tools, or just vibes? Have you ever found a hashtag gap that completely changed your reach? šŸ‘‡

webdev #api #nodejs #socialmedia #javascript

Top comments (0)