Benjamin Harris 3 недель назад
Родитель
Сommit
d4df88405a

+ 49 - 0
services/gateway/server.js

@@ -2498,6 +2498,55 @@ No explanation, no markdown.`;
   }
 });
 
+// Analyse content gaps — compare competitor keywords against user's hashtag_stats
+app.post('/competitors/:id/analyze-gaps', async (request, reply) => {
+  const db = await getDb();
+  const competitor = await db.collection('competitors').findOne({ _id: new ObjectId(request.params.id) });
+  if (!competitor) return reply.code(404).send({ error: 'Competitor not found' });
+
+  const keywords = (competitor.keywords || []);
+  if (!keywords.length) return reply.code(400).send({ error: 'Extract keywords first before analysing gaps' });
+
+  const hashtagDocs = await db.collection('hashtag_stats').find({}, { projection: { _id: 1 } }).toArray();
+  const hashtagStatsEmpty = hashtagDocs.length === 0;
+
+  // Strip '#' prefix and lowercase each hashtag for substring matching
+  const hashtagTexts = hashtagDocs.map((h) => ({ id: h._id, text: h._id.replace(/^#/, '').toLowerCase() }));
+
+  const INTENT_ORDER = { transactional: 0, commercial: 1, informational: 2, navigational: 3 };
+
+  function findMatchingHashtags(term) {
+    const words = term.toLowerCase().split(/\s+/).filter((w) => w.length >= 4);
+    return hashtagTexts
+      .filter(({ text }) => words.some((w) => text.includes(w)))
+      .map(({ id }) => id);
+  }
+
+  const gaps = [];
+  const covered = [];
+
+  for (const kw of keywords) {
+    const term = typeof kw === 'string' ? kw : kw.term;
+    const intent = typeof kw === 'string' ? 'informational' : (kw.intent || 'informational');
+    const matched = findMatchingHashtags(term);
+    if (matched.length) {
+      covered.push({ term, intent, matchedHashtags: matched.slice(0, 4) });
+    } else {
+      gaps.push({ term, intent });
+    }
+  }
+
+  gaps.sort((a, b) => (INTENT_ORDER[a.intent] ?? 99) - (INTENT_ORDER[b.intent] ?? 99));
+
+  const gapAnalysis = { gaps, covered, totalKeywords: keywords.length, hashtagStatsEmpty, lastAnalyzed: new Date() };
+
+  await db.collection('competitors').updateOne(
+    { _id: new ObjectId(request.params.id) },
+    { $set: { gapAnalysis, updatedAt: new Date() } },
+  );
+  return { success: true, ...gapAnalysis };
+});
+
 // Generate a 5-post content roadmap from competitor keywords and gaps
 app.post('/competitors/:id/content-roadmap', async (request, reply) => {
   const db = await getDb();

+ 9 - 0
ui/src/locales/en.ts

@@ -485,6 +485,15 @@ export default {
     intent_commercial: 'Commercial',
     intent_transactional: 'Transactional',
     intent_navigational: 'Navigational',
+    analyzeGaps: 'Gap Analysis',
+    analyzingGaps: 'Analysing…',
+    gapAnalysisLabel: 'Content Gaps',
+    gapCount: 'gaps',
+    coveredCount: 'covered',
+    gapMissing: 'Keywords you\'re missing',
+    gapNoneFound: 'No gaps — your content already covers all competitor keywords.',
+    gapCoveredToggle: '{count} covered keywords',
+    gapNoHashtagStats: 'No hashtag data yet — run "Scan Posts" in Settings → Hashtag Groups for accurate results.',
     generateRoadmap: 'Content Roadmap',
     generatingRoadmap: 'Generating…',
     roadmapLabel: 'Content Roadmap',

+ 9 - 0
ui/src/locales/tr.ts

@@ -485,6 +485,15 @@ export default {
     intent_commercial: 'Ticari',
     intent_transactional: 'İşlemsel',
     intent_navigational: 'Yönlendirici',
+    analyzeGaps: 'Boşluk Analizi',
+    analyzingGaps: 'Analiz ediliyor…',
+    gapAnalysisLabel: 'İçerik Boşlukları',
+    gapCount: 'boşluk',
+    coveredCount: 'kapsanan',
+    gapMissing: 'Eksik olduğunuz anahtar kelimeler',
+    gapNoneFound: 'Boşluk yok — içeriğiniz rakibin tüm anahtar kelimelerini kapsıyor.',
+    gapCoveredToggle: '{count} kapsanan anahtar kelime',
+    gapNoHashtagStats: 'Henüz hashtag verisi yok — doğru sonuçlar için Ayarlar → Hashtag Grupları\'nda "Gönderileri Tara"yı çalıştırın.',
     generateRoadmap: 'İçerik Yol Haritası',
     generatingRoadmap: 'Oluşturuluyor…',
     roadmapLabel: 'İçerik Yol Haritası',

+ 35 - 2
ui/src/stores/competitors.ts

@@ -18,6 +18,23 @@ export interface CompetitorKeyword {
   extractedAt?: string
 }
 
+export interface GapItem {
+  term: string
+  intent: KeywordIntent
+}
+
+export interface CoveredItem extends GapItem {
+  matchedHashtags: string[]
+}
+
+export interface GapAnalysis {
+  gaps: GapItem[]
+  covered: CoveredItem[]
+  totalKeywords: number
+  hashtagStatsEmpty: boolean
+  lastAnalyzed: string
+}
+
 export interface RoadmapPost {
   topic: string
   headline: string
@@ -34,6 +51,7 @@ export interface Competitor {
   aiSummary: string
   aiAnalysis?: AiAnalysis
   keywords: CompetitorKeyword[]
+  gapAnalysis?: GapAnalysis
   contentRoadmap?: RoadmapPost[]
   lastScraped: string | null
   createdAt: string
@@ -46,6 +64,7 @@ export const useCompetitorStore = defineStore('competitors', () => {
   const scraping = ref<Record<string, boolean>>({})
   const summarizing = ref<Record<string, boolean>>({})
   const extractingKeywords = ref<Record<string, boolean>>({})
+  const analyzingGaps = ref<Record<string, boolean>>({})
   const generatingRoadmap = ref<Record<string, boolean>>({})
   const scrapeResults = ref<Record<string, { sources: number; ok: boolean; message: string }>>({})
   const error = ref<string | null>(null)
@@ -166,6 +185,20 @@ export const useCompetitorStore = defineStore('competitors', () => {
     }
   }
 
+  async function analyzeGaps(id: string): Promise<void> {
+    analyzingGaps.value = { ...analyzingGaps.value, [id]: true }
+    error.value = null
+    try {
+      const res = await axios.post(`/api/competitors/${id}/analyze-gaps`)
+      const idx = competitors.value.findIndex((c) => c._id === id)
+      if (idx !== -1) competitors.value[idx].gapAnalysis = res.data
+    } catch (err: any) {
+      error.value = err.response?.data?.detail || err.response?.data?.error || 'Gap analysis failed'
+    } finally {
+      analyzingGaps.value = { ...analyzingGaps.value, [id]: false }
+    }
+  }
+
   async function generateRoadmap(id: string): Promise<void> {
     generatingRoadmap.value = { ...generatingRoadmap.value, [id]: true }
     error.value = null
@@ -181,8 +214,8 @@ export const useCompetitorStore = defineStore('competitors', () => {
   }
 
   return {
-    competitors, loading, scraping, summarizing, extractingKeywords, generatingRoadmap, scrapeResults, error,
+    competitors, loading, scraping, summarizing, extractingKeywords, analyzingGaps, generatingRoadmap, scrapeResults, error,
     fetchCompetitors, addCompetitor, updateCompetitor, deleteCompetitor,
-    scrapeCompetitor, summarizeCompetitor, extractKeywords, generateRoadmap,
+    scrapeCompetitor, summarizeCompetitor, extractKeywords, analyzeGaps, generateRoadmap,
   }
 })

+ 61 - 0
ui/src/views/Competitors.vue

@@ -91,6 +91,14 @@
             <i class="fa-solid fa-tags" :class="{ 'animate-pulse': competitorStore.extractingKeywords[competitor._id] }"></i>
             {{ competitorStore.extractingKeywords[competitor._id] ? t('competitors.extractingKeywords') : t('competitors.extractKeywords') }}
           </button>
+          <button
+            @click="competitorStore.analyzeGaps(competitor._id)"
+            :disabled="competitorStore.analyzingGaps[competitor._id] || !competitor.keywords?.length"
+            class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-orange-700 hover:bg-orange-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            <i class="fa-solid fa-code-compare" :class="{ 'animate-pulse': competitorStore.analyzingGaps[competitor._id] }"></i>
+            {{ competitorStore.analyzingGaps[competitor._id] ? t('competitors.analyzingGaps') : t('competitors.analyzeGaps') }}
+          </button>
           <button
             @click="competitorStore.generateRoadmap(competitor._id)"
             :disabled="competitorStore.generatingRoadmap[competitor._id] || (!competitor.keywords?.length && !competitor.scrapedContent.length)"
@@ -198,6 +206,59 @@
           </div>
         </div>
 
+        <!-- Gap Analysis -->
+        <div v-if="competitor.gapAnalysis" class="mt-4">
+          <div class="flex items-center justify-between mb-2">
+            <div class="text-xs text-orange-400 font-medium">{{ t('competitors.gapAnalysisLabel') }}</div>
+            <div class="flex items-center gap-2 text-xs text-gray-500">
+              <span class="text-orange-300">{{ competitor.gapAnalysis.gaps.length }} {{ t('competitors.gapCount') }}</span>
+              <span>·</span>
+              <span class="text-green-400">{{ competitor.gapAnalysis.covered.length }} {{ t('competitors.coveredCount') }}</span>
+              <span>·</span>
+              <span>{{ new Date(competitor.gapAnalysis.lastAnalyzed).toLocaleDateString() }}</span>
+            </div>
+          </div>
+
+          <!-- Warning when no hashtag data exists -->
+          <div v-if="competitor.gapAnalysis.hashtagStatsEmpty" class="mb-2 p-2.5 bg-amber-900/30 border border-amber-700/50 rounded text-xs text-amber-300">
+            {{ t('competitors.gapNoHashtagStats') }}
+          </div>
+
+          <!-- Gap keywords (missing from your content) -->
+          <div v-if="competitor.gapAnalysis.gaps.length" class="mb-3">
+            <div class="text-xs text-gray-400 mb-1.5">{{ t('competitors.gapMissing') }}</div>
+            <div class="flex flex-wrap gap-1.5">
+              <span
+                v-for="gap in competitor.gapAnalysis.gaps"
+                :key="gap.term"
+                :class="intentChipClass(gap.intent)"
+                :title="t(`competitors.intent_${gap.intent}`)"
+                class="inline-flex items-center gap-1 text-xs px-2 py-0.5 border rounded-full"
+              >
+                <span class="opacity-60 text-[10px]">{{ gap.intent[0].toUpperCase() }}</span>{{ gap.term }}
+              </span>
+            </div>
+          </div>
+          <div v-else class="mb-3 text-xs text-green-400">{{ t('competitors.gapNoneFound') }}</div>
+
+          <!-- Covered keywords (you already have these) -->
+          <details v-if="competitor.gapAnalysis.covered.length" class="group">
+            <summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-200 select-none">
+              {{ t('competitors.gapCoveredToggle', { count: competitor.gapAnalysis.covered.length }) }}
+            </summary>
+            <div class="mt-2 flex flex-wrap gap-1.5">
+              <span
+                v-for="item in competitor.gapAnalysis.covered"
+                :key="item.term"
+                class="inline-flex items-center gap-1 text-xs px-2 py-0.5 bg-green-900/30 border border-green-700/40 text-green-300 rounded-full"
+                :title="item.matchedHashtags.join(', ')"
+              >
+                <i class="fa-solid fa-check text-[9px]"></i>{{ item.term }}
+              </span>
+            </div>
+          </details>
+        </div>
+
         <!-- Content Roadmap -->
         <div v-if="competitor.contentRoadmap?.length" class="mt-4">
           <div class="text-xs text-emerald-400 font-medium mb-2">{{ t('competitors.roadmapLabel') }}</div>