Explorar o código

Content Roadmap

Benjamin Harris hai 3 semanas
pai
achega
b7cd42be25

+ 90 - 0
services/gateway/server.js

@@ -2476,4 +2476,94 @@ No explanation, no markdown.`;
   }
   }
 });
 });
 
 
+// Generate a 5-post content roadmap from competitor keywords and gaps
+app.post('/competitors/:id/content-roadmap', 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 || []);
+  const hasKeywords = keywords.length > 0;
+  const hasContent = (competitor.scrapedContent || []).length > 0;
+  if (!hasKeywords && !hasContent) return reply.code(400).send({ error: 'Extract keywords first before generating a roadmap' });
+
+  const kwList = hasKeywords
+    ? keywords.map((k) => (typeof k === 'string' ? k : k.term)).join(', ')
+    : '';
+  const gaps = competitor.aiAnalysis?.gaps || [];
+  const moves = competitor.aiAnalysis?.moves || [];
+
+  const gapsSection = gaps.length ? `\nCompetitor gaps/weaknesses:\n${gaps.map((g) => `- ${g}`).join('\n')}` : '';
+  const movesSection = moves.length ? `\nSuggested differentiation angles:\n${moves.map((m) => `- ${m}`).join('\n')}` : '';
+  const kwSection = kwList ? `\nCompetitor's keywords: ${kwList}` : '';
+
+  const system = 'You are a content strategist. Return only valid JSON with no explanation, no markdown code blocks.';
+  const prompt = `Create a 5-post content roadmap to compete against "${competitor.name}".
+${kwSection}${gapsSection}${movesSection}
+
+Generate 5 post ideas that exploit their weaknesses and differentiate clearly. For each post return:
+- topic: short topic name, max 8 words
+- headline: an engaging opening line or hook ready to use as post content (1-2 sentences)
+- keywords: array of 2-3 keywords from the competitor's list to target (use exact terms where available)
+- rationale: one sentence on why this post wins against ${competitor.name}
+
+Return ONLY a JSON array:
+[{"topic":"...","headline":"...","keywords":["..."],"rationale":"..."}]
+No explanation, no markdown.`;
+
+  try {
+    const pconf = await getActiveProviderConfig();
+    const model = pconf.model;
+    let text = '';
+
+    if (pconf.provider === 'ollama') {
+      const res = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 180000 });
+      text = res.data.response;
+    } else if (pconf.provider === 'openai' || pconf.provider === 'groq') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: `${pconf.provider} API key not configured` });
+      const res = await axios.post(`${pconf.baseUrl}/chat/completions`, {
+        model, messages: buildOpenAIMessages(prompt, system), stream: false,
+      }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, timeout: 180000 });
+      text = res.data.choices[0]?.message?.content || '';
+    } else if (pconf.provider === 'gemini') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: 'Gemini API key not configured' });
+      const res = await axios.post(
+        `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${pconf.apiKey}`,
+        { contents: buildGeminiContents(prompt, system) },
+        { timeout: 180000 },
+      );
+      text = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+    } else {
+      return reply.code(400).send({ error: 'AI not configured' });
+    }
+
+    let roadmap = [];
+    try {
+      const jsonStr = (text.match(/\[[\s\S]*\]/) || ['[]'])[0];
+      const parsed = JSON.parse(jsonStr);
+      if (!Array.isArray(parsed)) throw new Error();
+      roadmap = parsed
+        .filter((p) => p && typeof p.topic === 'string' && typeof p.headline === 'string')
+        .slice(0, 5)
+        .map((p) => ({
+          topic: p.topic.trim(),
+          headline: p.headline.trim(),
+          keywords: Array.isArray(p.keywords) ? p.keywords.filter((k) => typeof k === 'string').slice(0, 3) : [],
+          rationale: typeof p.rationale === 'string' ? p.rationale.trim() : '',
+        }));
+    } catch {
+      roadmap = [];
+    }
+    if (!roadmap.length) return reply.code(503).send({ error: 'AI returned invalid roadmap format — try again' });
+
+    await db.collection('competitors').updateOne(
+      { _id: new ObjectId(request.params.id) },
+      { $set: { contentRoadmap: roadmap, updatedAt: new Date() } },
+    );
+    return { success: true, contentRoadmap: roadmap };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Roadmap generation failed', detail: err.message });
+  }
+});
+
 module.exports = app;
 module.exports = app;

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

@@ -485,6 +485,10 @@ export default {
     intent_commercial: 'Commercial',
     intent_commercial: 'Commercial',
     intent_transactional: 'Transactional',
     intent_transactional: 'Transactional',
     intent_navigational: 'Navigational',
     intent_navigational: 'Navigational',
+    generateRoadmap: 'Content Roadmap',
+    generatingRoadmap: 'Generating…',
+    roadmapLabel: 'Content Roadmap',
+    roadmapDraft: 'Draft this post',
     lastScraped: 'Last scraped',
     lastScraped: 'Last scraped',
     scrapeSuccess: 'Scraped {count} source(s) successfully',
     scrapeSuccess: 'Scraped {count} source(s) successfully',
     scrapeNoContent: 'No content found — check the URL and try again',
     scrapeNoContent: 'No content found — check the URL and try again',

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

@@ -485,6 +485,10 @@ export default {
     intent_commercial: 'Ticari',
     intent_commercial: 'Ticari',
     intent_transactional: 'İşlemsel',
     intent_transactional: 'İşlemsel',
     intent_navigational: 'Yönlendirici',
     intent_navigational: 'Yönlendirici',
+    generateRoadmap: 'İçerik Yol Haritası',
+    generatingRoadmap: 'Oluşturuluyor…',
+    roadmapLabel: 'İçerik Yol Haritası',
+    roadmapDraft: 'Bu gönderiyi taslağa al',
     lastScraped: 'Son tarama',
     lastScraped: 'Son tarama',
     scrapeSuccess: '{count} kaynak başarıyla tarandı',
     scrapeSuccess: '{count} kaynak başarıyla tarandı',
     scrapeNoContent: 'İçerik bulunamadı — URL\'yi kontrol edip tekrar deneyin',
     scrapeNoContent: 'İçerik bulunamadı — URL\'yi kontrol edip tekrar deneyin',

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

@@ -18,6 +18,13 @@ export interface CompetitorKeyword {
   extractedAt?: string
   extractedAt?: string
 }
 }
 
 
+export interface RoadmapPost {
+  topic: string
+  headline: string
+  keywords: string[]
+  rationale: string
+}
+
 export interface Competitor {
 export interface Competitor {
   _id: string
   _id: string
   name: string
   name: string
@@ -27,6 +34,7 @@ export interface Competitor {
   aiSummary: string
   aiSummary: string
   aiAnalysis?: AiAnalysis
   aiAnalysis?: AiAnalysis
   keywords: CompetitorKeyword[]
   keywords: CompetitorKeyword[]
+  contentRoadmap?: RoadmapPost[]
   lastScraped: string | null
   lastScraped: string | null
   createdAt: string
   createdAt: string
   updatedAt: string
   updatedAt: string
@@ -38,6 +46,7 @@ export const useCompetitorStore = defineStore('competitors', () => {
   const scraping = ref<Record<string, boolean>>({})
   const scraping = ref<Record<string, boolean>>({})
   const summarizing = ref<Record<string, boolean>>({})
   const summarizing = ref<Record<string, boolean>>({})
   const extractingKeywords = ref<Record<string, boolean>>({})
   const extractingKeywords = ref<Record<string, boolean>>({})
+  const generatingRoadmap = ref<Record<string, boolean>>({})
   const scrapeResults = ref<Record<string, { sources: number; ok: boolean; message: string }>>({})
   const scrapeResults = ref<Record<string, { sources: number; ok: boolean; message: string }>>({})
   const error = ref<string | null>(null)
   const error = ref<string | null>(null)
 
 
@@ -139,9 +148,23 @@ export const useCompetitorStore = defineStore('competitors', () => {
     }
     }
   }
   }
 
 
+  async function generateRoadmap(id: string): Promise<void> {
+    generatingRoadmap.value = { ...generatingRoadmap.value, [id]: true }
+    error.value = null
+    try {
+      const res = await axios.post(`/api/competitors/${id}/content-roadmap`)
+      const idx = competitors.value.findIndex((c) => c._id === id)
+      if (idx !== -1) competitors.value[idx].contentRoadmap = res.data.contentRoadmap
+    } catch (err: any) {
+      error.value = err.response?.data?.detail || err.response?.data?.error || 'Roadmap generation failed'
+    } finally {
+      generatingRoadmap.value = { ...generatingRoadmap.value, [id]: false }
+    }
+  }
+
   return {
   return {
-    competitors, loading, scraping, summarizing, extractingKeywords, scrapeResults, error,
+    competitors, loading, scraping, summarizing, extractingKeywords, generatingRoadmap, scrapeResults, error,
     fetchCompetitors, addCompetitor, updateCompetitor, deleteCompetitor,
     fetchCompetitors, addCompetitor, updateCompetitor, deleteCompetitor,
-    scrapeCompetitor, summarizeCompetitor, extractKeywords,
+    scrapeCompetitor, summarizeCompetitor, extractKeywords, generateRoadmap,
   }
   }
 })
 })

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

@@ -91,6 +91,14 @@
             <i class="fa-solid fa-tags" :class="{ 'animate-pulse': competitorStore.extractingKeywords[competitor._id] }"></i>
             <i class="fa-solid fa-tags" :class="{ 'animate-pulse': competitorStore.extractingKeywords[competitor._id] }"></i>
             {{ competitorStore.extractingKeywords[competitor._id] ? t('competitors.extractingKeywords') : t('competitors.extractKeywords') }}
             {{ competitorStore.extractingKeywords[competitor._id] ? t('competitors.extractingKeywords') : t('competitors.extractKeywords') }}
           </button>
           </button>
+          <button
+            @click="competitorStore.generateRoadmap(competitor._id)"
+            :disabled="competitorStore.generatingRoadmap[competitor._id] || (!competitor.keywords?.length && !competitor.scrapedContent.length)"
+            class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            <i class="fa-solid fa-map" :class="{ 'animate-pulse': competitorStore.generatingRoadmap[competitor._id] }"></i>
+            {{ competitorStore.generatingRoadmap[competitor._id] ? t('competitors.generatingRoadmap') : t('competitors.generateRoadmap') }}
+          </button>
         </div>
         </div>
 
 
         <!-- Scrape result message -->
         <!-- Scrape result message -->
@@ -189,6 +197,38 @@
             >{{ typeof kw === 'string' ? kw : kw.term }}</span>
             >{{ typeof kw === 'string' ? kw : kw.term }}</span>
           </div>
           </div>
         </div>
         </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>
+          <div class="space-y-2">
+            <div
+              v-for="(post, idx) in competitor.contentRoadmap"
+              :key="idx"
+              class="p-3 bg-gray-700/50 rounded border border-gray-600"
+            >
+              <div class="flex items-start justify-between gap-3 mb-1.5">
+                <div class="text-xs font-semibold text-emerald-300">{{ post.topic }}</div>
+                <button
+                  @click="draftPost(post.headline)"
+                  class="shrink-0 flex items-center gap-1 text-xs px-2.5 py-1 bg-violet-700 hover:bg-violet-600 text-white rounded"
+                >
+                  <i class="fa-solid fa-pen-to-square"></i>
+                  {{ t('competitors.roadmapDraft') }}
+                </button>
+              </div>
+              <p class="text-sm text-gray-200 mb-2">{{ post.headline }}</p>
+              <div v-if="post.rationale" class="text-xs text-gray-400 italic mb-2">{{ post.rationale }}</div>
+              <div v-if="post.keywords?.length" class="flex flex-wrap gap-1">
+                <span
+                  v-for="kw in post.keywords"
+                  :key="kw"
+                  class="text-xs px-1.5 py-0.5 bg-emerald-900/40 border border-emerald-700/50 text-emerald-300 rounded"
+                >{{ kw }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
       </div>
       </div>
     </div>
     </div>
 
 
@@ -226,11 +266,20 @@
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue'
 import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
 import { useCompetitorStore, type Competitor, type KeywordIntent } from '../stores/competitors'
 import { useCompetitorStore, type Competitor, type KeywordIntent } from '../stores/competitors'
+import { useComposeStore } from '../stores/compose'
 
 
 const { t } = useI18n()
 const { t } = useI18n()
+const router = useRouter()
 const competitorStore = useCompetitorStore()
 const competitorStore = useCompetitorStore()
+const composeStore = useComposeStore()
+
+function draftPost(headline: string) {
+  composeStore.content = headline
+  router.push('/compose')
+}
 
 
 const KEYWORD_INTENTS = [
 const KEYWORD_INTENTS = [
   { key: 'informational', dot: 'bg-blue-400' },
   { key: 'informational', dot: 'bg-blue-400' },

+ 5 - 0
ui/src/views/Compose.vue

@@ -507,6 +507,11 @@ onMounted(async () => {
   ])
   ])
   composeStore.initDestinations()
   composeStore.initDestinations()
 
 
+  // Pre-fill content when arriving from Competitor Roadmap ("Draft this post")
+  if (route.query.prefill) {
+    composeStore.content = String(route.query.prefill)
+  }
+
   // Pre-fill media URL when arriving from the Media Library ("Use in Post")
   // Pre-fill media URL when arriving from the Media Library ("Use in Post")
   if (route.query.media) {
   if (route.query.media) {
     composeStore.mediaUrl = String(route.query.media)
     composeStore.mediaUrl = String(route.query.media)