Răsfoiți Sursa

Revert "Competitor Intelligence - Items 33 & 34"

This reverts commit 6bb54d909e3a6fa72f437927442a7e3915e94b3a.
Benjamin Harris 1 lună în urmă
părinte
comite
5ac509d9d5

+ 2 - 268
services/gateway/server.js

@@ -635,12 +635,9 @@ app.get('/ai/models', async (request, reply) => {
 });
 
 app.post('/ai/generate', async (request, reply) => {
-  const { prompt, system: rawSystem, model: reqModel, useCompetitorContext } = request.body || {};
+  const { prompt, system, model: reqModel } = 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;
 
@@ -747,12 +744,9 @@ 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: rawSystem, model: reqModel, useCompetitorContext } = request.body || {};
+  const { prompt, system, model: reqModel } = 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;
 
@@ -2159,264 +2153,4 @@ 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;

+ 0 - 13
services/scheduler/index.js

@@ -115,12 +115,6 @@ 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 ──────────────────────────────────────────────────────────
@@ -232,13 +226,6 @@ 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');
 }

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

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

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

@@ -5,7 +5,6 @@ export default {
     media: 'Media',
     scheduler: 'Scheduler',
     analytics: 'Analytics',
-    competitors: 'Competitors',
     settings: 'Settings',
   },
 
@@ -180,8 +179,6 @@ 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…',
@@ -459,34 +456,6 @@ 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',

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

@@ -5,7 +5,6 @@ export default {
     media: 'Medya',
     scheduler: 'Zamanlama',
     analytics: 'Analitik',
-    competitors: 'Rakipler',
     settings: 'Ayarlar',
   },
 
@@ -180,8 +179,6 @@ 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…',
@@ -459,34 +456,6 @@ 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',

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

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

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

@@ -173,12 +173,11 @@ 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, useCompetitorContext }),
+      body: JSON.stringify({ prompt, system, model }),
       signal,
     })
 

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

@@ -1,127 +0,0 @@
-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,
-  }
-})

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

@@ -1,239 +0,0 @@
-<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>

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

@@ -267,20 +267,6 @@
                   </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>
@@ -473,7 +459,6 @@ 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'
 
@@ -482,7 +467,6 @@ const composeStore = useComposeStore()
 const platformsStore = usePlatformsStore()
 const aiStore = useAiStore()
 const hashtagStore = useHashtagStore()
-const competitorStore = useCompetitorStore()
 const router = useRouter()
 const route = useRoute()
 
@@ -503,7 +487,6 @@ onMounted(async () => {
     platformsStore.fetchMetaConnections(),
     aiStore.fetchConfig(),
     hashtagStore.fetchGroups(),
-    competitorStore.fetchCompetitors(),
   ])
   composeStore.initDestinations()
 
@@ -704,14 +687,6 @@ 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)
 
@@ -784,7 +759,7 @@ async function generatePost() {
   composeStore.content = ''
 
   try {
-    const gen = aiStore.streamGenerate(prompt, system, undefined, abortController.value.signal, useCompetitorContext.value)
+    const gen = aiStore.streamGenerate(prompt, system, undefined, abortController.value.signal)
     for await (const token of gen) {
       composeStore.content += token
     }