Bladeren bron

Reapply "Competitor Intelligence - Items 33 & 34"

This reverts commit 5ac509d9d51a1b8183b72546febddce9d3459cfa.
Benjamin Harris 1 maand geleden
bovenliggende
commit
ef2b254221

+ 268 - 2
services/gateway/server.js

@@ -635,9 +635,12 @@ app.get('/ai/models', async (request, reply) => {
 });
 
 app.post('/ai/generate', async (request, reply) => {
-  const { prompt, system, model: reqModel } = request.body || {};
+  const { prompt, system: rawSystem, model: reqModel, useCompetitorContext } = request.body || {};
   if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
 
+  const competitorSuffix = useCompetitorContext ? await buildCompetitorSystemSuffix() : '';
+  const system = rawSystem ? rawSystem + competitorSuffix : (competitorSuffix || undefined);
+
   const pconf = await getActiveProviderConfig();
   const model = reqModel || pconf.model;
 
@@ -744,9 +747,12 @@ app.post('/ai/caption', async (request, reply) => {
 
 // SSE streaming endpoint — normalized data: { token, done } format for all providers
 app.post('/ai/stream', async (request, reply) => {
-  const { prompt, system, model: reqModel } = request.body || {};
+  const { prompt, system: rawSystem, model: reqModel, useCompetitorContext } = request.body || {};
   if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
 
+  const competitorSuffix = useCompetitorContext ? await buildCompetitorSystemSuffix() : '';
+  const system = rawSystem ? rawSystem + competitorSuffix : (competitorSuffix || undefined);
+
   const pconf = await getActiveProviderConfig();
   const model = reqModel || pconf.model;
 
@@ -2153,4 +2159,264 @@ app.post('/hashtags/ai-suggest', async (request, reply) => {
   }
 });
 
+// ─── Competitor Intelligence ──────────────────────────────────────────────────
+
+async function extractTextFromUrl(url) {
+  try {
+    const res = await axios.get(url, { timeout: 15000, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SocialManager/1.0)' } });
+    const html = res.data || '';
+    const title = (html.match(/<title[^>]*>([^<]+)<\/title>/i) || [])[1] || '';
+    const desc = (html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) || [])[1] || '';
+    const headings = [...html.matchAll(/<h[1-3][^>]*>(.*?)<\/h[1-3]>/gi)].map((m) => m[1].replace(/<[^>]+>/g, '').trim()).filter(Boolean).slice(0, 8);
+    const paras = [...html.matchAll(/<p[^>]*>(.*?)<\/p>/gis)].map((m) => m[1].replace(/<[^>]+>/g, '').trim()).filter((t) => t.length > 40).slice(0, 5);
+    return [title, desc, ...headings, ...paras].filter(Boolean).join('\n').slice(0, 3000);
+  } catch {
+    return '';
+  }
+}
+
+async function scrapeBluesky(profileUrl) {
+  try {
+    const handle = profileUrl.replace(/^https?:\/\/bsky\.app\/profile\//i, '').replace(/\/$/, '');
+    if (!handle) return '';
+    const res = await axios.get(`https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(handle)}&limit=10`, { timeout: 10000 });
+    const posts = (res.data.feed || []).map((f) => f.post?.record?.text || '').filter(Boolean);
+    return posts.join('\n').slice(0, 3000);
+  } catch {
+    return '';
+  }
+}
+
+async function scrapeMastodon(profileUrl) {
+  try {
+    const m = profileUrl.match(/^https?:\/\/([^/]+)\/@(.+)$/);
+    if (!m) return '';
+    const [, instance, username] = m;
+    const lookupRes = await axios.get(`https://${instance}/api/v1/accounts/lookup?acct=${encodeURIComponent(username)}`, { timeout: 10000 });
+    const accountId = lookupRes.data?.id;
+    if (!accountId) return '';
+    const statusRes = await axios.get(`https://${instance}/api/v1/accounts/${accountId}/statuses?limit=10&exclude_replies=true`, { timeout: 10000 });
+    const posts = (statusRes.data || []).map((s) => s.content?.replace(/<[^>]+>/g, '').trim() || '').filter(Boolean);
+    return posts.join('\n').slice(0, 3000);
+  } catch {
+    return '';
+  }
+}
+
+async function runCompetitorScrape(competitorId) {
+  const db = await getDb();
+  const competitor = await db.collection('competitors').findOne({ _id: new ObjectId(competitorId) });
+  if (!competitor) return { ok: false, message: 'Not found', sources: 0 };
+
+  const newItems = [];
+
+  if (competitor.websiteUrl) {
+    const text = await extractTextFromUrl(competitor.websiteUrl);
+    if (text) newItems.push({ source: 'website', url: competitor.websiteUrl, text, scrapedAt: new Date() });
+  }
+
+  const socialEntries = Object.entries(competitor.socialUrls || {});
+  for (const [platform, url] of socialEntries) {
+    if (!url) continue;
+    let text = '';
+    if (platform === 'bluesky') text = await scrapeBluesky(url);
+    else if (platform === 'mastodon') text = await scrapeMastodon(url);
+    else text = await extractTextFromUrl(url);
+    if (text) newItems.push({ source: platform, url, text, scrapedAt: new Date() });
+  }
+
+  const existing = competitor.scrapedContent || [];
+  const combined = [...newItems, ...existing].slice(0, 20);
+
+  await db.collection('competitors').updateOne(
+    { _id: new ObjectId(competitorId) },
+    { $set: { scrapedContent: combined, lastScraped: new Date(), updatedAt: new Date() } },
+  );
+
+  return { ok: true, sources: newItems.length, message: newItems.length ? `Scraped ${newItems.length} source(s)` : 'No content found' };
+}
+
+async function buildCompetitorSystemSuffix() {
+  try {
+    const db = await getDb();
+    const competitors = await db.collection('competitors').find({ aiSummary: { $nin: ['', null] } }).toArray();
+    if (!competitors.length) return '';
+    const lines = competitors.map((c) => `- ${c.name}: ${c.aiSummary}`).join('\n');
+    return `\n\nCOMPETITOR CONTEXT (for differentiation — do not copy, use to contrast):\n${lines}\nEmphasise what makes this brand unique compared to the above.`;
+  } catch {
+    return '';
+  }
+}
+
+// List competitors
+app.get('/competitors', async (request, reply) => {
+  const db = await getDb();
+  const list = await db.collection('competitors').find({}).sort({ createdAt: 1 }).toArray();
+  return list;
+});
+
+// Add competitor (max 2)
+app.post('/competitors', async (request, reply) => {
+  const db = await getDb();
+  const count = await db.collection('competitors').countDocuments();
+  if (count >= 2) return reply.code(400).send({ error: 'Maximum 2 competitors allowed' });
+  const { name, websiteUrl, socialUrls = {} } = request.body || {};
+  if (!name || !websiteUrl) return reply.code(400).send({ error: 'name and websiteUrl are required' });
+  const now = new Date();
+  const result = await db.collection('competitors').insertOne({
+    name, websiteUrl, socialUrls, scrapedContent: [], aiSummary: '', keywords: [], lastScraped: null, createdAt: now, updatedAt: now,
+  });
+  const doc = await db.collection('competitors').findOne({ _id: result.insertedId });
+  return doc;
+});
+
+// Update competitor
+app.put('/competitors/:id', async (request, reply) => {
+  const db = await getDb();
+  const { name, websiteUrl, socialUrls } = request.body || {};
+  const updates = { updatedAt: new Date() };
+  if (name !== undefined) updates.name = name;
+  if (websiteUrl !== undefined) updates.websiteUrl = websiteUrl;
+  if (socialUrls !== undefined) updates.socialUrls = socialUrls;
+  await db.collection('competitors').updateOne({ _id: new ObjectId(request.params.id) }, { $set: updates });
+  const doc = await db.collection('competitors').findOne({ _id: new ObjectId(request.params.id) });
+  return doc;
+});
+
+// Delete competitor
+app.delete('/competitors/:id', async (request, reply) => {
+  const db = await getDb();
+  await db.collection('competitors').deleteOne({ _id: new ObjectId(request.params.id) });
+  return { success: true };
+});
+
+// Scrape one competitor
+app.post('/competitors/:id/scrape', async (request, reply) => {
+  try {
+    const result = await runCompetitorScrape(request.params.id);
+    return { success: result.ok, sources: result.sources, message: result.message };
+  } catch (err) {
+    return reply.code(500).send({ error: 'Scrape failed', detail: err.message });
+  }
+});
+
+// Scrape all competitors (called by scheduler)
+app.post('/competitors/scrape-all', async (request, reply) => {
+  const db = await getDb();
+  const all = await db.collection('competitors').find({}).toArray();
+  const results = [];
+  for (const c of all) {
+    const r = await runCompetitorScrape(c._id.toString());
+    results.push({ id: c._id.toString(), name: c.name, ...r });
+  }
+  return { success: true, results };
+});
+
+// Summarize competitor content with AI
+app.post('/competitors/:id/summarize', 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 content = (competitor.scrapedContent || []).map((s) => `[${s.source}] ${s.text}`).join('\n\n').slice(0, 6000);
+  if (!content) return reply.code(400).send({ error: 'No scraped content to summarize' });
+
+  const system = 'You are a competitive intelligence analyst. Be concise.';
+  const prompt = `Analyse the following content from "${competitor.name}" and summarise their key themes, messaging style, and content strategy in 2-3 sentences. Focus on topics, tone, and positioning.\n\n${content}`;
+
+  try {
+    const pconf = await getActiveProviderConfig();
+    const model = pconf.model;
+    let summary = '';
+
+    if (pconf.provider === 'ollama') {
+      const res = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 180000 });
+      summary = 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 });
+      summary = 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 },
+      );
+      summary = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+    } else {
+      return reply.code(400).send({ error: 'AI not configured' });
+    }
+
+    summary = summary.trim();
+    await db.collection('competitors').updateOne(
+      { _id: new ObjectId(request.params.id) },
+      { $set: { aiSummary: summary, updatedAt: new Date() } },
+    );
+    return { success: true, aiSummary: summary };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Summarization failed', detail: err.message });
+  }
+});
+
+// Extract keywords from scraped content using AI (item 34)
+app.post('/competitors/:id/extract-keywords', 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 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.`;
+
+  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: 120000 });
+      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: 120000 });
+      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: 120000 },
+      );
+      text = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+    } else {
+      return reply.code(400).send({ error: 'AI not configured' });
+    }
+
+    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);
+    } catch {
+      keywords = [];
+    }
+
+    await db.collection('competitors').updateOne(
+      { _id: new ObjectId(request.params.id) },
+      { $set: { keywords, updatedAt: new Date() } },
+    );
+    return { success: true, keywords };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Keyword extraction failed', detail: err.message });
+  }
+});
+
 module.exports = app;

+ 13 - 0
services/scheduler/index.js

@@ -115,6 +115,12 @@ async function processSystemJob(job) {
     log.info({ action: 'metrics_crawl', trigger: 'scheduled', outcome: 'success', total: res.data.total });
     return res.data;
   }
+  if (job.name === 'competitor-scrape') {
+    log.info({ action: 'competitor_scrape', trigger: 'scheduled', outcome: 'start' });
+    const res = await axios.post(`${GATEWAY_URL}/competitors/scrape-all`, {}, { timeout: 120000 });
+    log.info({ action: 'competitor_scrape', trigger: 'scheduled', outcome: 'success', results: res.data.results?.length });
+    return res.data;
+  }
 }
 
 // ─── HTTP Endpoints ──────────────────────────────────────────────────────────
@@ -226,6 +232,13 @@ async function start() {
   );
   log.info({ action: 'system_job_register', job: 'metrics-crawl', interval: '24h', outcome: 'success' });
 
+  await systemQueue.add(
+    'competitor-scrape',
+    {},
+    { repeat: { every: 7 * 24 * 60 * 60 * 1000 }, removeOnComplete: 5, removeOnFail: 5 }
+  );
+  log.info({ action: 'system_job_register', job: 'competitor-scrape', interval: '7d', outcome: 'success' });
+
   await app.listen({ port: process.env.PORT || 3011, host: '0.0.0.0' });
   log.info({ action: 'service_start', port: 3011, outcome: 'success' }, 'Scheduler started');
 }

+ 3 - 2
ui/src/components/NavBar.vue

@@ -65,8 +65,9 @@ const navLinks = [
   { to: '/compose',   label: 'nav.compose' },
   { to: '/media',     label: 'nav.media' },
   { to: '/scheduler', label: 'nav.scheduler' },
-  { to: '/analytics', label: 'nav.analytics' },
-  { to: '/settings',  label: 'nav.settings' },
+  { to: '/analytics',    label: 'nav.analytics' },
+  { to: '/competitors',  label: 'nav.competitors' },
+  { to: '/settings',     label: 'nav.settings' },
 ]
 
 const currentLocale = computed(

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

@@ -5,6 +5,7 @@ export default {
     media: 'Media',
     scheduler: 'Scheduler',
     analytics: 'Analytics',
+    competitors: 'Competitors',
     settings: 'Settings',
   },
 
@@ -179,6 +180,8 @@ export default {
     aiNoContext: 'No profile — set one in Settings',
     aiNotConfigured: 'AI not configured — check Settings → AI Integration',
     aiError: 'Generation failed',
+    aiUseCompetitors: 'Use competitor context',
+    aiUseCompetitorsHint: 'differentiate from {names}',
 
     captionGenerate: '✨ Generate caption',
     captionGenerating: 'Generating caption…',
@@ -456,6 +459,34 @@ export default {
     openOriginal: '↗ Open',
   },
 
+  competitors: {
+    sectionTitle: 'Competitor Intelligence',
+    sectionSubtitle: 'Track up to 2 competitors and use their content to improve your AI-generated posts.',
+    addCompetitor: 'Add Competitor',
+    addButton: 'Add',
+    namePlaceholder: 'Competitor name',
+    websitePlaceholder: 'https://competitor.com',
+    socialUrls: 'Social profile URLs',
+    scrapeNow: 'Scrape Now',
+    scraping: 'Scraping…',
+    summarizeAi: 'Summarise with AI',
+    summarizing: 'Summarising…',
+    extractKeywords: 'Extract Keywords',
+    extractingKeywords: 'Extracting…',
+    aiSummaryLabel: 'AI Summary',
+    keywordsLabel: 'Competitor Keywords',
+    lastScraped: 'Last scraped',
+    scrapeSuccess: 'Scraped {count} source(s) successfully',
+    scrapeNoContent: 'No content found — check the URL and try again',
+    emptyState: 'No competitors added yet. Add up to 2 to track their content.',
+    maxReached: 'Maximum 2 competitors reached.',
+    edit: 'Edit',
+    save: 'Save',
+    cancel: 'Cancel',
+    delete: 'Remove',
+    confirmDelete: 'Remove this competitor?',
+  },
+
   platforms: {
     twitter: 'Twitter/X',
     linkedin: 'LinkedIn',

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

@@ -5,6 +5,7 @@ export default {
     media: 'Medya',
     scheduler: 'Zamanlama',
     analytics: 'Analitik',
+    competitors: 'Rakipler',
     settings: 'Ayarlar',
   },
 
@@ -179,6 +180,8 @@ export default {
     aiNoContext: 'Profil yok — Ayarlar\'dan ekle',
     aiNotConfigured: 'YZ yapılandırılmamış — Ayarlar → YZ Entegrasyonu',
     aiError: 'Oluşturma başarısız',
+    aiUseCompetitors: 'Rakip bağlamını kullan',
+    aiUseCompetitorsHint: '{names} ile farklılaş',
 
     captionGenerate: '✨ Açıklama oluştur',
     captionGenerating: 'Açıklama oluşturuluyor…',
@@ -456,6 +459,34 @@ export default {
     openOriginal: '↗ Aç',
   },
 
+  competitors: {
+    sectionTitle: 'Rakip İstihbaratı',
+    sectionSubtitle: 'En fazla 2 rakibi takip edin ve içeriklerini YZ gönderilerinizi geliştirmek için kullanın.',
+    addCompetitor: 'Rakip Ekle',
+    addButton: 'Ekle',
+    namePlaceholder: 'Rakip adı',
+    websitePlaceholder: 'https://rakip.com',
+    socialUrls: 'Sosyal profil URL\'leri',
+    scrapeNow: 'Şimdi Tara',
+    scraping: 'Taranıyor…',
+    summarizeAi: 'YZ ile Özetle',
+    summarizing: 'Özetleniyor…',
+    extractKeywords: 'Anahtar Kelime Çıkar',
+    extractingKeywords: 'Çıkarılıyor…',
+    aiSummaryLabel: 'YZ Özeti',
+    keywordsLabel: 'Rakip Anahtar Kelimeleri',
+    lastScraped: 'Son tarama',
+    scrapeSuccess: '{count} kaynak başarıyla tarandı',
+    scrapeNoContent: 'İçerik bulunamadı — URL\'yi kontrol edip tekrar deneyin',
+    emptyState: 'Henüz rakip eklenmedi. İçeriklerini takip etmek için en fazla 2 rakip ekleyin.',
+    maxReached: 'Maksimum 2 rakibe ulaşıldı.',
+    edit: 'Düzenle',
+    save: 'Kaydet',
+    cancel: 'İptal',
+    delete: 'Kaldır',
+    confirmDelete: 'Bu rakip kaldırılsın mı?',
+  },
+
   platforms: {
     twitter: 'Twitter/X',
     linkedin: 'LinkedIn',

+ 5 - 0
ui/src/router/index.ts

@@ -32,6 +32,11 @@ const router = createRouter({
       name: 'analytics',
       component: () => import('../views/Analytics.vue'),
     },
+    {
+      path: '/competitors',
+      name: 'competitors',
+      component: () => import('../views/Competitors.vue'),
+    },
     {
       path: '/settings',
       name: 'settings',

+ 2 - 1
ui/src/stores/ai.ts

@@ -173,11 +173,12 @@ export const useAiStore = defineStore('ai', () => {
     system?: string,
     model?: string,
     signal?: AbortSignal,
+    useCompetitorContext?: boolean,
   ): AsyncGenerator<string> {
     const response = await fetch('/api/ai/stream', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ prompt, system, model }),
+      body: JSON.stringify({ prompt, system, model, useCompetitorContext }),
       signal,
     })
 

+ 127 - 0
ui/src/stores/competitors.ts

@@ -0,0 +1,127 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import axios from 'axios'
+
+export interface Competitor {
+  _id: string
+  name: string
+  websiteUrl: string
+  socialUrls: Partial<Record<string, string>>
+  scrapedContent: { source: string; url: string; text: string; scrapedAt: string }[]
+  aiSummary: string
+  keywords: string[]
+  lastScraped: string | null
+  createdAt: string
+  updatedAt: string
+}
+
+export const useCompetitorStore = defineStore('competitors', () => {
+  const competitors = ref<Competitor[]>([])
+  const loading = ref(false)
+  const scraping = ref<Record<string, boolean>>({})
+  const summarizing = ref<Record<string, boolean>>({})
+  const extractingKeywords = ref<Record<string, boolean>>({})
+  const scrapeResults = ref<Record<string, { sources: number; ok: boolean; message: string }>>({})
+  const error = ref<string | null>(null)
+
+  async function fetchCompetitors() {
+    loading.value = true
+    error.value = null
+    try {
+      const res = await axios.get('/api/competitors')
+      competitors.value = res.data
+    } catch (err: any) {
+      error.value = err.response?.data?.error || 'Failed to load competitors'
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function addCompetitor(data: { name: string; websiteUrl: string; socialUrls?: Record<string, string> }): Promise<boolean> {
+    error.value = null
+    try {
+      const res = await axios.post('/api/competitors', data)
+      competitors.value.push(res.data)
+      return true
+    } catch (err: any) {
+      error.value = err.response?.data?.error || 'Failed to add competitor'
+      return false
+    }
+  }
+
+  async function updateCompetitor(id: string, data: Partial<Pick<Competitor, 'name' | 'websiteUrl' | 'socialUrls'>>): Promise<boolean> {
+    error.value = null
+    try {
+      const res = await axios.put(`/api/competitors/${id}`, data)
+      const idx = competitors.value.findIndex((c) => c._id === id)
+      if (idx !== -1) competitors.value[idx] = res.data
+      return true
+    } catch (err: any) {
+      error.value = err.response?.data?.error || 'Failed to update competitor'
+      return false
+    }
+  }
+
+  async function deleteCompetitor(id: string): Promise<boolean> {
+    error.value = null
+    try {
+      await axios.delete(`/api/competitors/${id}`)
+      competitors.value = competitors.value.filter((c) => c._id !== id)
+      return true
+    } catch (err: any) {
+      error.value = err.response?.data?.error || 'Failed to delete competitor'
+      return false
+    }
+  }
+
+  async function scrapeCompetitor(id: string): Promise<void> {
+    scraping.value = { ...scraping.value, [id]: true }
+    try {
+      const res = await axios.post(`/api/competitors/${id}/scrape`)
+      scrapeResults.value = {
+        ...scrapeResults.value,
+        [id]: { sources: res.data.sources ?? 0, ok: res.data.success, message: res.data.message || '' },
+      }
+      await fetchCompetitors()
+    } catch (err: any) {
+      const msg = err.response?.data?.detail || err.response?.data?.error || err.message
+      scrapeResults.value = { ...scrapeResults.value, [id]: { sources: 0, ok: false, message: msg } }
+    } finally {
+      scraping.value = { ...scraping.value, [id]: false }
+    }
+  }
+
+  async function summarizeCompetitor(id: string): Promise<void> {
+    summarizing.value = { ...summarizing.value, [id]: true }
+    error.value = null
+    try {
+      const res = await axios.post(`/api/competitors/${id}/summarize`)
+      const idx = competitors.value.findIndex((c) => c._id === id)
+      if (idx !== -1) competitors.value[idx].aiSummary = res.data.aiSummary
+    } catch (err: any) {
+      error.value = err.response?.data?.detail || err.response?.data?.error || 'Summarization failed'
+    } finally {
+      summarizing.value = { ...summarizing.value, [id]: false }
+    }
+  }
+
+  async function extractKeywords(id: string): Promise<void> {
+    extractingKeywords.value = { ...extractingKeywords.value, [id]: true }
+    error.value = null
+    try {
+      const res = await axios.post(`/api/competitors/${id}/extract-keywords`)
+      const idx = competitors.value.findIndex((c) => c._id === id)
+      if (idx !== -1) competitors.value[idx].keywords = res.data.keywords || []
+    } catch (err: any) {
+      error.value = err.response?.data?.detail || err.response?.data?.error || 'Keyword extraction failed'
+    } finally {
+      extractingKeywords.value = { ...extractingKeywords.value, [id]: false }
+    }
+  }
+
+  return {
+    competitors, loading, scraping, summarizing, extractingKeywords, scrapeResults, error,
+    fetchCompetitors, addCompetitor, updateCompetitor, deleteCompetitor,
+    scrapeCompetitor, summarizeCompetitor, extractKeywords,
+  }
+})

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

@@ -0,0 +1,239 @@
+<template>
+  <div class="p-6 max-w-3xl mx-auto">
+    <div class="mb-6">
+      <h1 class="text-2xl font-bold text-white">{{ t('competitors.sectionTitle') }}</h1>
+      <p class="text-gray-400 mt-1">{{ t('competitors.sectionSubtitle') }}</p>
+    </div>
+
+    <div v-if="competitorStore.error" class="mb-4 p-3 bg-red-900/40 border border-red-700 rounded text-red-300 text-sm">
+      {{ competitorStore.error }}
+    </div>
+
+    <!-- Competitor cards -->
+    <div v-if="competitorStore.competitors.length" class="space-y-4 mb-6">
+      <div
+        v-for="competitor in competitorStore.competitors"
+        :key="competitor._id"
+        class="bg-gray-800 border border-gray-700 rounded-lg p-5"
+      >
+        <!-- Header row -->
+        <div class="flex items-start justify-between gap-3 mb-3">
+          <div class="flex-1 min-w-0">
+            <template v-if="editingId === competitor._id">
+              <input
+                v-model="editForm.name"
+                class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-white text-sm mb-2 focus:outline-none focus:border-violet-500"
+                :placeholder="t('competitors.namePlaceholder')"
+              />
+              <input
+                v-model="editForm.websiteUrl"
+                class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-white text-sm focus:outline-none focus:border-violet-500"
+                :placeholder="t('competitors.websitePlaceholder')"
+              />
+            </template>
+            <template v-else>
+              <div class="font-semibold text-white">{{ competitor.name }}</div>
+              <a :href="competitor.websiteUrl" target="_blank" rel="noopener" class="text-violet-400 text-sm hover:underline truncate block">{{ competitor.websiteUrl }}</a>
+            </template>
+          </div>
+          <div class="flex gap-2 shrink-0">
+            <template v-if="editingId === competitor._id">
+              <button @click="saveEdit(competitor._id)" class="text-xs px-3 py-1 bg-violet-600 hover:bg-violet-500 text-white rounded">{{ t('competitors.save') }}</button>
+              <button @click="cancelEdit" class="text-xs px-3 py-1 bg-gray-600 hover:bg-gray-500 text-white rounded">{{ t('competitors.cancel') }}</button>
+            </template>
+            <template v-else>
+              <button @click="startEdit(competitor)" class="text-xs px-3 py-1 bg-gray-600 hover:bg-gray-500 text-white rounded">{{ t('competitors.edit') }}</button>
+              <button @click="confirmDelete(competitor._id)" class="text-xs px-3 py-1 bg-red-800 hover:bg-red-700 text-white rounded">{{ t('competitors.delete') }}</button>
+            </template>
+          </div>
+        </div>
+
+        <!-- Social URLs collapsible -->
+        <details class="mb-3">
+          <summary class="text-sm text-gray-400 cursor-pointer hover:text-gray-200 select-none">{{ t('competitors.socialUrls') }}</summary>
+          <div class="mt-2 space-y-1.5">
+            <div v-for="platform in socialPlatforms" :key="platform.key" class="flex items-center gap-2">
+              <i :class="platform.icon" class="w-4 text-center text-gray-400 text-sm"></i>
+              <input
+                :value="getEditSocialUrl(competitor, platform.key)"
+                @change="setSocialUrl(competitor, platform.key, ($event.target as HTMLInputElement).value)"
+                @blur="saveSocialUrl(competitor)"
+                class="flex-1 bg-gray-700 border border-gray-600 rounded px-2.5 py-1 text-white text-xs focus:outline-none focus:border-violet-500"
+                :placeholder="platform.placeholder"
+              />
+            </div>
+          </div>
+        </details>
+
+        <!-- Action buttons -->
+        <div class="flex flex-wrap gap-2 mb-3">
+          <button
+            @click="competitorStore.scrapeCompetitor(competitor._id)"
+            :disabled="competitorStore.scraping[competitor._id]"
+            class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-gray-600 hover:bg-gray-500 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            <i class="fa-solid fa-rotate" :class="{ 'animate-spin': competitorStore.scraping[competitor._id] }"></i>
+            {{ competitorStore.scraping[competitor._id] ? t('competitors.scraping') : t('competitors.scrapeNow') }}
+          </button>
+          <button
+            @click="competitorStore.summarizeCompetitor(competitor._id)"
+            :disabled="competitorStore.summarizing[competitor._id] || !competitor.scrapedContent.length"
+            class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-violet-700 hover:bg-violet-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            <i class="fa-solid fa-wand-magic-sparkles" :class="{ 'animate-pulse': competitorStore.summarizing[competitor._id] }"></i>
+            {{ competitorStore.summarizing[competitor._id] ? t('competitors.summarizing') : t('competitors.summarizeAi') }}
+          </button>
+          <button
+            @click="competitorStore.extractKeywords(competitor._id)"
+            :disabled="competitorStore.extractingKeywords[competitor._id] || !competitor.scrapedContent.length"
+            class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-blue-700 hover:bg-blue-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            <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>
+        </div>
+
+        <!-- Scrape result message -->
+        <div v-if="competitorStore.scrapeResults[competitor._id]" class="mb-3 text-xs px-3 py-1.5 rounded" :class="competitorStore.scrapeResults[competitor._id].ok ? 'bg-green-900/40 text-green-300' : 'bg-amber-900/40 text-amber-300'">
+          {{ competitorStore.scrapeResults[competitor._id].ok
+            ? (competitorStore.scrapeResults[competitor._id].sources > 0
+                ? t('competitors.scrapeSuccess', { count: competitorStore.scrapeResults[competitor._id].sources })
+                : t('competitors.scrapeNoContent'))
+            : competitorStore.scrapeResults[competitor._id].message }}
+        </div>
+
+        <!-- Last scraped -->
+        <div v-if="competitor.lastScraped" class="text-xs text-gray-500 mb-3">
+          {{ t('competitors.lastScraped') }}: {{ new Date(competitor.lastScraped).toLocaleString() }}
+        </div>
+
+        <!-- AI Summary -->
+        <div v-if="competitor.aiSummary" class="mb-3 p-3 bg-gray-700/50 rounded border border-gray-600 text-sm text-gray-200">
+          <div class="text-xs text-violet-400 font-medium mb-1">{{ t('competitors.aiSummaryLabel') }}</div>
+          {{ competitor.aiSummary }}
+        </div>
+
+        <!-- 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 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>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- Empty state -->
+    <div v-else-if="!competitorStore.loading" class="mb-6 p-8 text-center bg-gray-800 border border-gray-700 rounded-lg text-gray-400">
+      {{ t('competitors.emptyState') }}
+    </div>
+
+    <!-- Add competitor form -->
+    <div v-if="competitorStore.competitors.length < 2" class="bg-gray-800 border border-gray-700 rounded-lg p-5">
+      <h2 class="text-sm font-semibold text-white mb-3">{{ t('competitors.addCompetitor') }}</h2>
+      <div class="space-y-2">
+        <input
+          v-model="newForm.name"
+          class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-violet-500"
+          :placeholder="t('competitors.namePlaceholder')"
+        />
+        <input
+          v-model="newForm.websiteUrl"
+          class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-violet-500"
+          :placeholder="t('competitors.websitePlaceholder')"
+        />
+      </div>
+      <button
+        @click="createCompetitor"
+        :disabled="!newForm.name.trim() || !newForm.websiteUrl.trim()"
+        class="mt-3 px-4 py-2 bg-violet-600 hover:bg-violet-500 text-white text-sm rounded disabled:opacity-50 disabled:cursor-not-allowed"
+      >
+        {{ t('competitors.addButton') }}
+      </button>
+    </div>
+    <p v-else class="text-xs text-gray-500 text-center">{{ t('competitors.maxReached') }}</p>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useCompetitorStore, type Competitor } from '../stores/competitors'
+
+const { t } = useI18n()
+const competitorStore = useCompetitorStore()
+
+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' },
+  { key: 'instagram', icon: 'fa-brands fa-instagram',  placeholder: 'https://instagram.com/username' },
+  { key: 'linkedin',  icon: 'fa-brands fa-linkedin',   placeholder: 'https://linkedin.com/company/name' },
+  { key: 'bluesky',   icon: 'fa-brands fa-bluesky',    placeholder: 'https://bsky.app/profile/handle.bsky.social' },
+  { key: 'mastodon',  icon: 'fa-brands fa-mastodon',   placeholder: 'https://mastodon.social/@username' },
+  { key: 'tiktok',    icon: 'fa-brands fa-tiktok',     placeholder: 'https://tiktok.com/@username' },
+  { key: 'youtube',   icon: 'fa-brands fa-youtube',    placeholder: 'https://youtube.com/@channel' },
+  { key: 'pinterest', icon: 'fa-brands fa-pinterest',  placeholder: 'https://pinterest.com/username' },
+]
+
+const newForm = reactive({ name: '', websiteUrl: '' })
+const editingId = ref<string | null>(null)
+const editForm = reactive({ name: '', websiteUrl: '' })
+const pendingSocialUrls = reactive<Record<string, Record<string, string>>>({})
+
+function getEditSocialUrl(competitor: Competitor, platform: string): string {
+  return pendingSocialUrls[competitor._id]?.[platform] ?? competitor.socialUrls?.[platform] ?? ''
+}
+
+function setSocialUrl(competitor: Competitor, platform: string, value: string) {
+  if (!pendingSocialUrls[competitor._id]) pendingSocialUrls[competitor._id] = {}
+  pendingSocialUrls[competitor._id][platform] = value
+}
+
+async function saveSocialUrl(competitor: Competitor) {
+  if (!pendingSocialUrls[competitor._id]) return
+  const merged = { ...competitor.socialUrls }
+  for (const [k, v] of Object.entries(pendingSocialUrls[competitor._id])) {
+    if (v) merged[k] = v
+    else delete merged[k]
+  }
+  await competitorStore.updateCompetitor(competitor._id, { socialUrls: merged })
+}
+
+async function createCompetitor() {
+  if (!newForm.name.trim() || !newForm.websiteUrl.trim()) return
+  const ok = await competitorStore.addCompetitor({ name: newForm.name.trim(), websiteUrl: newForm.websiteUrl.trim() })
+  if (ok) {
+    newForm.name = ''
+    newForm.websiteUrl = ''
+  }
+}
+
+function startEdit(competitor: Competitor) {
+  editingId.value = competitor._id
+  editForm.name = competitor.name
+  editForm.websiteUrl = competitor.websiteUrl
+}
+
+function cancelEdit() {
+  editingId.value = null
+}
+
+async function saveEdit(id: string) {
+  await competitorStore.updateCompetitor(id, { name: editForm.name.trim(), websiteUrl: editForm.websiteUrl.trim() })
+  editingId.value = null
+}
+
+async function confirmDelete(id: string) {
+  if (confirm(t('competitors.confirmDelete'))) {
+    await competitorStore.deleteCompetitor(id)
+  }
+}
+
+onMounted(() => {
+  competitorStore.fetchCompetitors()
+})
+</script>

+ 26 - 1
ui/src/views/Compose.vue

@@ -267,6 +267,20 @@
                   </button>
                 </div>
               </div>
+
+              <!-- Competitor context checkbox (only when summaries exist) -->
+              <div v-if="hasCompetitorSummaries" class="flex items-center gap-2 text-xs text-gray-400">
+                <input
+                  id="useCompetitorCtx"
+                  v-model="useCompetitorContext"
+                  type="checkbox"
+                  class="accent-violet-500"
+                />
+                <label for="useCompetitorCtx" class="cursor-pointer select-none">
+                  🔍 {{ $t('compose.aiUseCompetitors') }}
+                </label>
+                <span class="text-gray-600">— {{ $t('compose.aiUseCompetitorsHint', { names: competitorNames }) }}</span>
+              </div>
             </template>
           </div>
         </div>
@@ -459,6 +473,7 @@ import { useComposeStore } from '../stores/compose'
 import { usePlatformsStore } from '../stores/platforms'
 import { useAiStore } from '../stores/ai'
 import { useHashtagStore } from '../stores/hashtags'
+import { useCompetitorStore } from '../stores/competitors'
 import PostPreview from '../components/compose/PostPreview.vue'
 import { COMMON_TIMEZONES, getBrowserTimezone, getTimezoneAbbr, utcToNaiveDatetimeString } from '../utils/timezone'
 
@@ -467,6 +482,7 @@ const composeStore = useComposeStore()
 const platformsStore = usePlatformsStore()
 const aiStore = useAiStore()
 const hashtagStore = useHashtagStore()
+const competitorStore = useCompetitorStore()
 const router = useRouter()
 const route = useRoute()
 
@@ -487,6 +503,7 @@ onMounted(async () => {
     platformsStore.fetchMetaConnections(),
     aiStore.fetchConfig(),
     hashtagStore.fetchGroups(),
+    competitorStore.fetchCompetitors(),
   ])
   composeStore.initDestinations()
 
@@ -687,6 +704,14 @@ const generating = ref(false)
 const aiError = ref(false)
 const aiContextAccount = ref('')
 const abortController = ref<AbortController | null>(null)
+const useCompetitorContext = ref(false)
+
+const hasCompetitorSummaries = computed(() =>
+  competitorStore.competitors.some((c) => c.aiSummary?.trim())
+)
+const competitorNames = computed(() =>
+  competitorStore.competitors.filter((c) => c.aiSummary?.trim()).map((c) => c.name).join(', ')
+)
 
 const aiConfigured = computed(() => aiStore.config.enabled && !!aiStore.config.endpoint)
 
@@ -759,7 +784,7 @@ async function generatePost() {
   composeStore.content = ''
 
   try {
-    const gen = aiStore.streamGenerate(prompt, system, undefined, abortController.value.signal)
+    const gen = aiStore.streamGenerate(prompt, system, undefined, abortController.value.signal, useCompetitorContext.value)
     for await (const token of gen) {
       composeStore.content += token
     }