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

+ 25 - 5
services/gateway/server.js

@@ -2407,8 +2407,19 @@ app.post('/competitors/:id/extract-keywords', async (request, reply) => {
   const content = (competitor.scrapedContent || []).map((s) => s.text).join('\n\n').slice(0, 6000);
   if (!content) return reply.code(400).send({ error: 'No scraped content to extract keywords from' });
 
-  const system = 'You are an SEO and content strategist. Return only valid JSON.';
-  const prompt = `Analyse the following content from "${competitor.name}" and extract the top 20 keywords and key phrases they appear to be targeting or ranking for. Include both short-tail and long-tail keywords.\n\nContent:\n${content}\n\nReturn ONLY a JSON array of strings, e.g. ["keyword one", "keyword two"]. No explanation, no markdown.`;
+  const system = 'You are an SEO and content strategist. Return only valid JSON with no explanation, no markdown code blocks.';
+  const prompt = `Analyse the following content from "${competitor.name}" and extract the top 20 keywords and key phrases they appear to be targeting. For each keyword, classify its search intent using one of these four types:
+- informational: user wants to learn ("how to", "what is", "guide", "tips")
+- commercial: user is evaluating options ("best", "vs", "review", "top", "alternative")
+- transactional: user is ready to act ("buy", "free", "pricing", "download", "get started")
+- navigational: user is searching for a specific brand or tool by name
+
+Content:
+${content}
+
+Return ONLY a JSON array, e.g.:
+[{"term": "project management software", "intent": "commercial"}, {"term": "how to manage tasks", "intent": "informational"}]
+No explanation, no markdown.`;
 
   try {
     const pconf = await getActiveProviderConfig();
@@ -2436,12 +2447,21 @@ app.post('/competitors/:id/extract-keywords', async (request, reply) => {
       return reply.code(400).send({ error: 'AI not configured' });
     }
 
+    const VALID_INTENTS = new Set(['informational', 'commercial', 'transactional', 'navigational']);
     let keywords = [];
     try {
       const jsonStr = (text.match(/\[[\s\S]*\]/) || ['[]'])[0];
-      keywords = JSON.parse(jsonStr);
-      if (!Array.isArray(keywords)) keywords = [];
-      keywords = keywords.filter((k) => typeof k === 'string').slice(0, 20);
+      const parsed = JSON.parse(jsonStr);
+      if (!Array.isArray(parsed)) throw new Error();
+      const now = new Date();
+      keywords = parsed
+        .filter((k) => k && typeof k.term === 'string' && k.term.trim())
+        .slice(0, 20)
+        .map((k) => ({
+          term: k.term.trim(),
+          intent: VALID_INTENTS.has(k.intent) ? k.intent : 'informational',
+          extractedAt: now,
+        }));
     } catch {
       keywords = [];
     }

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

@@ -481,6 +481,10 @@ export default {
     analysisGaps: 'Gaps & opportunities',
     analysisMoves: 'Differentiation moves',
     keywordsLabel: 'Competitor Keywords',
+    intent_informational: 'Informational',
+    intent_commercial: 'Commercial',
+    intent_transactional: 'Transactional',
+    intent_navigational: 'Navigational',
     lastScraped: 'Last scraped',
     scrapeSuccess: 'Scraped {count} source(s) successfully',
     scrapeNoContent: 'No content found — check the URL and try again',

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

@@ -481,6 +481,10 @@ export default {
     analysisGaps: 'Boşluklar ve fırsatlar',
     analysisMoves: 'Farklılaşma hamleleri',
     keywordsLabel: 'Rakip Anahtar Kelimeleri',
+    intent_informational: 'Bilgilendirici',
+    intent_commercial: 'Ticari',
+    intent_transactional: 'İşlemsel',
+    intent_navigational: 'Yönlendirici',
     lastScraped: 'Son tarama',
     scrapeSuccess: '{count} kaynak başarıyla tarandı',
     scrapeNoContent: 'İçerik bulunamadı — URL\'yi kontrol edip tekrar deneyin',

+ 9 - 1
ui/src/stores/competitors.ts

@@ -10,6 +10,14 @@ export interface AiAnalysis {
   moves: string[]
 }
 
+export type KeywordIntent = 'informational' | 'commercial' | 'transactional' | 'navigational'
+
+export interface CompetitorKeyword {
+  term: string
+  intent: KeywordIntent
+  extractedAt?: string
+}
+
 export interface Competitor {
   _id: string
   name: string
@@ -18,7 +26,7 @@ export interface Competitor {
   scrapedContent: { source: string; url: string; text: string; scrapedAt: string }[]
   aiSummary: string
   aiAnalysis?: AiAnalysis
-  keywords: string[]
+  keywords: CompetitorKeyword[]
   lastScraped: string | null
   createdAt: string
   updatedAt: string

+ 32 - 5
ui/src/views/Competitors.vue

@@ -171,13 +171,22 @@
 
         <!-- Keywords -->
         <div v-if="competitor.keywords && competitor.keywords.length" class="mt-3">
-          <div class="text-xs text-blue-400 font-medium mb-2">{{ t('competitors.keywordsLabel') }}</div>
+          <div class="flex items-center justify-between mb-2">
+            <div class="text-xs text-blue-400 font-medium">{{ t('competitors.keywordsLabel') }}</div>
+            <div class="flex items-center gap-2.5">
+              <span v-for="intent in KEYWORD_INTENTS" :key="intent.key" class="flex items-center gap-1 text-xs text-gray-400">
+                <span :class="intent.dot" class="w-1.5 h-1.5 rounded-full shrink-0"></span>{{ t(`competitors.intent_${intent.key}`) }}
+              </span>
+            </div>
+          </div>
           <div class="flex flex-wrap gap-1.5">
             <span
               v-for="kw in competitor.keywords"
-              :key="kw"
-              class="inline-block text-xs px-2 py-0.5 bg-blue-900/40 border border-blue-700/50 text-blue-300 rounded-full"
-            >{{ kw }}</span>
+              :key="typeof kw === 'string' ? kw : kw.term"
+              :class="typeof kw === 'string' ? 'bg-blue-900/40 border-blue-700/50 text-blue-300' : intentChipClass(kw.intent)"
+              :title="typeof kw === 'string' ? '' : t(`competitors.intent_${kw.intent}`)"
+              class="inline-block text-xs px-2 py-0.5 border rounded-full cursor-default"
+            >{{ typeof kw === 'string' ? kw : kw.term }}</span>
           </div>
         </div>
       </div>
@@ -218,11 +227,29 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue'
 import { useI18n } from 'vue-i18n'
-import { useCompetitorStore, type Competitor } from '../stores/competitors'
+import { useCompetitorStore, type Competitor, type KeywordIntent } from '../stores/competitors'
 
 const { t } = useI18n()
 const competitorStore = useCompetitorStore()
 
+const KEYWORD_INTENTS = [
+  { key: 'informational', dot: 'bg-blue-400' },
+  { key: 'commercial',    dot: 'bg-violet-400' },
+  { key: 'transactional', dot: 'bg-green-400' },
+  { key: 'navigational',  dot: 'bg-gray-400' },
+]
+
+const INTENT_CHIP_CLASSES: Record<KeywordIntent, string> = {
+  informational: 'bg-blue-900/40 border-blue-700/50 text-blue-300',
+  commercial:    'bg-violet-900/40 border-violet-700/50 text-violet-300',
+  transactional: 'bg-green-900/40 border-green-700/50 text-green-300',
+  navigational:  'bg-gray-700 border-gray-600 text-gray-300',
+}
+
+function intentChipClass(intent: string): string {
+  return INTENT_CHIP_CLASSES[intent as KeywordIntent] ?? INTENT_CHIP_CLASSES.informational
+}
+
 const socialPlatforms = [
   { key: 'twitter',   icon: 'fa-brands fa-x-twitter',  placeholder: 'https://twitter.com/username' },
   { key: 'facebook',  icon: 'fa-brands fa-facebook',   placeholder: 'https://facebook.com/page' },