Przeglądaj źródła

Account Keyword Fix

Benjamin Harris 3 tygodni temu
rodzic
commit
274225f60f

+ 81 - 40
services/gateway/server.js

@@ -2014,63 +2014,62 @@ function gradeHashtag(count, avgEngagement) {
   return 'D';
   return 'D';
 }
 }
 
 
-app.post('/hashtags/scrape', async () => {
+// POST /hashtags/scrape — scan YOUR published posts per-account.
+// Body: { accountKey?: string }  — omit to scan all accounts at once.
+app.post('/hashtags/scrape', async (request) => {
+  const { accountKey: filterAccount } = request.body || {};
   const db = await getDb();
   const db = await getDb();
 
 
-  // Collect hashtag → { count, totalEngagement, platforms }
+  // tagMap key: `${accountKey}||${hashtag}`
   const tagMap = {};
   const tagMap = {};
 
 
-  function touch(tag, platform, engagement) {
-    if (!tagMap[tag]) tagMap[tag] = { count: 0, totalEngagement: 0, platforms: new Set() };
-    tagMap[tag].count++;
-    tagMap[tag].totalEngagement += engagement;
-    tagMap[tag].platforms.add(platform);
+  function touch(tag, accountKey, platform, engagement) {
+    const key = `${accountKey}||${tag}`;
+    if (!tagMap[key]) tagMap[key] = { tag, accountKey, count: 0, totalEngagement: 0, platforms: new Set() };
+    tagMap[key].count++;
+    tagMap[key].totalEngagement += engagement;
+    tagMap[key].platforms.add(platform);
   }
   }
 
 
-  // Scan published posts
-  const posts = await db.collection('posts').find({}, { projection: { content: 1, destinations: 1, platformResults: 1 } }).toArray();
+  // Engagement lookup keyed by content fingerprint
   const postMetrics = await db.collection('post_metrics').find({}).toArray();
   const postMetrics = await db.collection('post_metrics').find({}).toArray();
-
-  // Build engagement lookup keyed by content fingerprint (first 100 chars)
   const metricsByContent = {};
   const metricsByContent = {};
   for (const m of postMetrics) {
   for (const m of postMetrics) {
     if (m.content) {
     if (m.content) {
       const key = m.content.slice(0, 100).toLowerCase().trim();
       const key = m.content.slice(0, 100).toLowerCase().trim();
-      metricsByContent[key] = (metricsByContent[key] || 0) + m.metrics.engagementTotal;
+      metricsByContent[key] = (metricsByContent[key] || 0) + (m.metrics?.engagementTotal || 0);
     }
     }
   }
   }
 
 
+  // Scan YOUR published posts only — feeds are others' content, not your performance
+  const posts = await db.collection('posts').find({}, { projection: { content: 1, destinations: 1 } }).toArray();
+
   for (const post of posts) {
   for (const post of posts) {
     const tags = extractHashtags(post.content || '');
     const tags = extractHashtags(post.content || '');
+    if (!tags.length) continue;
     const engagement = post.content
     const engagement = post.content
       ? (metricsByContent[post.content.slice(0, 100).toLowerCase().trim()] || 0)
       ? (metricsByContent[post.content.slice(0, 100).toLowerCase().trim()] || 0)
       : 0;
       : 0;
-    const platforms = (post.destinations || []).map((d) => d.platform);
-    for (const tag of tags) {
-      for (const platform of platforms.length ? platforms : ['unknown']) {
-        touch(tag, platform, engagement / Math.max(tags.length, 1));
-      }
-    }
-  }
+    const destinations = post.destinations?.length ? post.destinations : [{ platform: 'unknown' }];
 
 
-  // Also scan feed items
-  const feeds = await db.collection('feeds').find({}, { projection: { content: 1, platform: 1, metrics: 1 } }).toArray();
-  for (const item of feeds) {
-    const tags = extractHashtags(item.content || '');
-    const engagement = (item.metrics?.likes || 0) + (item.metrics?.comments || 0) + (item.metrics?.shares || 0);
-    for (const tag of tags) {
-      touch(tag, item.platform || 'unknown', engagement / Math.max(tags.length, 1));
+    for (const dest of destinations) {
+      const acctKey = dest.accountId ? `${dest.platform}:${dest.accountId}` : dest.platform;
+      if (filterAccount && acctKey !== filterAccount) continue;
+      for (const tag of tags) {
+        touch(tag, acctKey, dest.platform, engagement / Math.max(tags.length, 1));
+      }
     }
     }
   }
   }
 
 
-  // Upsert hashtag_stats
   let scraped = 0;
   let scraped = 0;
-  for (const [tag, data] of Object.entries(tagMap)) {
+  for (const [compoundKey, data] of Object.entries(tagMap)) {
     const avgEngagement = data.count > 0 ? data.totalEngagement / data.count : 0;
     const avgEngagement = data.count > 0 ? data.totalEngagement / data.count : 0;
     await db.collection('hashtag_stats').updateOne(
     await db.collection('hashtag_stats').updateOne(
-      { _id: tag },
+      { _id: compoundKey },
       {
       {
         $set: {
         $set: {
+          hashtag: data.tag,
+          accountKey: data.accountKey,
           count: data.count,
           count: data.count,
           avgEngagement: Math.round(avgEngagement * 10) / 10,
           avgEngagement: Math.round(avgEngagement * 10) / 10,
           grade: gradeHashtag(data.count, avgEngagement),
           grade: gradeHashtag(data.count, avgEngagement),
@@ -2083,19 +2082,58 @@ app.post('/hashtags/scrape', async () => {
     scraped++;
     scraped++;
   }
   }
 
 
-  app.log.info({ action: 'hashtag_scrape', outcome: 'success', scraped });
+  log.info({ action: 'hashtag_scrape', accountKey: filterAccount || 'all', outcome: 'success', scraped });
   return { success: true, scraped };
   return { success: true, scraped };
 });
 });
 
 
 app.get('/hashtags/stats', async (request) => {
 app.get('/hashtags/stats', async (request) => {
-  const sortBy = request.query.sort || 'count';
+  const { sort, accountKey } = request.query;
   const db = await getDb();
   const db = await getDb();
-  const sortField = sortBy === 'engagement' ? 'avgEngagement' : 'count';
-  const stats = await db.collection('hashtag_stats')
-    .find({})
-    .sort({ [sortField]: -1 })
-    .limit(200)
-    .toArray();
+  const sortField = sort === 'engagement' ? 'avgEngagement' : 'count';
+
+  if (accountKey) {
+    // Per-account view
+    const stats = await db.collection('hashtag_stats')
+      .find({ accountKey })
+      .sort({ [sortField]: -1 })
+      .limit(200)
+      .toArray();
+    return { stats };
+  }
+
+  // Aggregate view: group by hashtag across all accounts
+  const allStats = await db.collection('hashtag_stats').find({ accountKey: { $exists: true } }).toArray();
+  const grouped = new Map();
+  for (const s of allStats) {
+    if (!s.hashtag) continue;
+    if (!grouped.has(s.hashtag)) {
+      grouped.set(s.hashtag, { count: 0, totalEngagement: 0, totalCount: 0, platforms: new Set(), lastScraped: null });
+    }
+    const g = grouped.get(s.hashtag);
+    g.count += s.count;
+    g.totalEngagement += s.avgEngagement * s.count;
+    g.totalCount += s.count;
+    for (const p of (s.platforms || [])) g.platforms.add(p);
+    if (!g.lastScraped || (s.lastScraped && new Date(s.lastScraped) > new Date(g.lastScraped))) g.lastScraped = s.lastScraped;
+  }
+
+  const stats = [...grouped.entries()]
+    .map(([tag, g]) => {
+      const avgEngagement = g.totalCount > 0 ? Math.round((g.totalEngagement / g.totalCount) * 10) / 10 : 0;
+      return {
+        _id: tag,
+        hashtag: tag,
+        accountKey: null,
+        count: g.count,
+        avgEngagement,
+        grade: gradeHashtag(g.count, avgEngagement),
+        platforms: [...g.platforms],
+        lastScraped: g.lastScraped,
+      };
+    })
+    .sort((a, b) => b[sortField] - a[sortField])
+    .slice(0, 200);
+
   return { stats };
   return { stats };
 });
 });
 
 
@@ -2512,11 +2550,14 @@ app.post('/competitors/:id/analyze-gaps', async (request, reply) => {
   const keywords = (competitor.keywords || []);
   const keywords = (competitor.keywords || []);
   if (!keywords.length) return reply.code(400).send({ error: 'Extract keywords first before analysing gaps' });
   if (!keywords.length) return reply.code(400).send({ error: 'Extract keywords first before analysing gaps' });
 
 
-  const hashtagDocs = await db.collection('hashtag_stats').find({}, { projection: { _id: 1 } }).toArray();
+  const hashtagDocs = await db.collection('hashtag_stats')
+    .find({ accountKey: { $exists: true } }, { projection: { _id: 0, hashtag: 1 } })
+    .toArray();
   const hashtagStatsEmpty = hashtagDocs.length === 0;
   const hashtagStatsEmpty = hashtagDocs.length === 0;
 
 
-  // Strip '#' prefix and lowercase each hashtag for substring matching
-  const hashtagTexts = hashtagDocs.map((h) => ({ id: h._id, text: h._id.replace(/^#/, '').toLowerCase() }));
+  // Deduplicate across accounts — same hashtag used by any account counts as covered
+  const uniqueTags = [...new Set(hashtagDocs.map((h) => h.hashtag).filter(Boolean))];
+  const hashtagTexts = uniqueTags.map((tag) => ({ id: tag, text: tag.replace(/^#/, '').toLowerCase() }));
 
 
   const INTENT_ORDER = { transactional: 0, commercial: 1, informational: 2, navigational: 3 };
   const INTENT_ORDER = { transactional: 0, commercial: 1, informational: 2, navigational: 3 };
 
 

+ 3 - 1
ui/src/locales/en.ts

@@ -284,7 +284,7 @@ export default {
 
 
     hashtags: {
     hashtags: {
       sectionTitle: 'Hashtag Groups',
       sectionTitle: 'Hashtag Groups',
-      sectionSubtitle: 'Save hashtag presets and analyse performance from your published posts.',
+      sectionSubtitle: 'Save hashtag presets and analyse per-account performance from your published posts.',
       addGroup: 'Add Group',
       addGroup: 'Add Group',
       createGroup: 'Create Group',
       createGroup: 'Create Group',
       noGroups: 'No hashtag groups yet.',
       noGroups: 'No hashtag groups yet.',
@@ -305,6 +305,8 @@ export default {
       loadingStats: 'Loading stats…',
       loadingStats: 'Loading stats…',
       noStats: 'No hashtag stats yet — scan your posts to analyse performance.',
       noStats: 'No hashtag stats yet — scan your posts to analyse performance.',
       allAccounts: 'All accounts',
       allAccounts: 'All accounts',
+      aiSuggestForAccount: 'AI suggestions for selected account:',
+      aiSuggestAllAccounts: 'AI suggestions across all accounts:',
       aiSuggest: 'AI Suggest',
       aiSuggest: 'AI Suggest',
       suggesting: 'Generating…',
       suggesting: 'Generating…',
       selectToGroup: 'Click tags to select, then save as a group:',
       selectToGroup: 'Click tags to select, then save as a group:',

+ 3 - 1
ui/src/locales/tr.ts

@@ -284,7 +284,7 @@ export default {
 
 
     hashtags: {
     hashtags: {
       sectionTitle: 'Hashtag Grupları',
       sectionTitle: 'Hashtag Grupları',
-      sectionSubtitle: 'Hashtag ön ayarlarını kaydet ve yayınlanan gönderilerden performansı analiz et.',
+      sectionSubtitle: 'Hashtag ön ayarlarını kaydet ve hesap bazlı performansı yayınlanan gönderilerden analiz et.',
       addGroup: 'Grup Ekle',
       addGroup: 'Grup Ekle',
       createGroup: 'Grup Oluştur',
       createGroup: 'Grup Oluştur',
       noGroups: 'Henüz hashtag grubu yok.',
       noGroups: 'Henüz hashtag grubu yok.',
@@ -305,6 +305,8 @@ export default {
       loadingStats: 'İstatistikler yükleniyor…',
       loadingStats: 'İstatistikler yükleniyor…',
       noStats: 'Henüz istatistik yok — performansı analiz etmek için gönderileri tarayın.',
       noStats: 'Henüz istatistik yok — performansı analiz etmek için gönderileri tarayın.',
       allAccounts: 'Tüm hesaplar',
       allAccounts: 'Tüm hesaplar',
+      aiSuggestForAccount: 'Seçili hesap için YZ önerileri:',
+      aiSuggestAllAccounts: 'Tüm hesaplar için YZ önerileri:',
       aiSuggest: 'YZ ile Öner',
       aiSuggest: 'YZ ile Öner',
       suggesting: 'Oluşturuluyor…',
       suggesting: 'Oluşturuluyor…',
       selectToGroup: 'Etiketlere tıklayarak seç, ardından grup olarak kaydet:',
       selectToGroup: 'Etiketlere tıklayarak seç, ardından grup olarak kaydet:',

+ 11 - 6
ui/src/stores/hashtags.ts

@@ -11,7 +11,9 @@ export interface HashtagGroup {
 }
 }
 
 
 export interface HashtagStat {
 export interface HashtagStat {
-  _id: string        // the hashtag e.g. '#photography'
+  _id: string             // compound key e.g. 'facebook:PAGE_ID||#photography', or hashtag in aggregate view
+  hashtag: string         // e.g. '#photography'
+  accountKey: string | null  // null in aggregate view
   count: number
   count: number
   avgEngagement: number
   avgEngagement: number
   grade: 'A' | 'B' | 'C' | 'D'
   grade: 'A' | 'B' | 'C' | 'D'
@@ -58,12 +60,12 @@ export const useHashtagStore = defineStore('hashtags', () => {
     groups.value = groups.value.filter((g) => g._id !== id)
     groups.value = groups.value.filter((g) => g._id !== id)
   }
   }
 
 
-  async function scrapeHashtags() {
+  async function scrapeHashtags(accountKey?: string) {
     scraping.value = true
     scraping.value = true
     error.value = ''
     error.value = ''
     try {
     try {
-      await axios.post('/api/hashtags/scrape')
-      await fetchStats()
+      await axios.post('/api/hashtags/scrape', accountKey ? { accountKey } : {})
+      await fetchStats('count', accountKey)
     } catch (err: any) {
     } catch (err: any) {
       error.value = err.response?.data?.error || err.message
       error.value = err.response?.data?.error || err.message
     } finally {
     } finally {
@@ -71,10 +73,12 @@ export const useHashtagStore = defineStore('hashtags', () => {
     }
     }
   }
   }
 
 
-  async function fetchStats(sort: 'count' | 'engagement' = 'count') {
+  async function fetchStats(sort: 'count' | 'engagement' = 'count', accountKey?: string) {
     statsLoading.value = true
     statsLoading.value = true
     try {
     try {
-      const res = await axios.get('/api/hashtags/stats', { params: { sort } })
+      const params: Record<string, string> = { sort }
+      if (accountKey) params.accountKey = accountKey
+      const res = await axios.get('/api/hashtags/stats', { params })
       stats.value = res.data.stats || []
       stats.value = res.data.stats || []
     } catch (err: any) {
     } catch (err: any) {
       error.value = err.response?.data?.error || err.message
       error.value = err.response?.data?.error || err.message
@@ -88,6 +92,7 @@ export const useHashtagStore = defineStore('hashtags', () => {
     aiSuggestions.value = []
     aiSuggestions.value = []
     error.value = ''
     error.value = ''
     try {
     try {
+      // Pass top tags from whichever account is currently in view
       const topTags = stats.value.slice(0, 15)
       const topTags = stats.value.slice(0, 15)
       const res = await axios.post('/api/hashtags/ai-suggest', { accountKey, topTags, count })
       const res = await axios.post('/api/hashtags/ai-suggest', { accountKey, topTags, count })
       aiSuggestions.value = res.data.hashtags || []
       aiSuggestions.value = res.data.hashtags || []

+ 26 - 16
ui/src/views/Settings.vue

@@ -757,7 +757,7 @@
             </div>
             </div>
           </div>
           </div>
           <button
           <button
-            @click="showHashtagStats = !showHashtagStats; showHashtagStats && hashtagStore.stats.length === 0 && hashtagStore.fetchStats()"
+            @click="showHashtagStats = !showHashtagStats; showHashtagStats && hashtagStore.stats.length === 0 && hashtagStore.fetchStats('count', statsAccount || undefined)"
             class="text-xs px-3 py-1.5 rounded-lg border border-gray-700 text-gray-400 hover:bg-gray-800 transition-colors"
             class="text-xs px-3 py-1.5 rounded-lg border border-gray-700 text-gray-400 hover:bg-gray-800 transition-colors"
           >
           >
             {{ showHashtagStats ? $t('settings.hashtags.hideStats') : $t('settings.hashtags.showStats') }}
             {{ showHashtagStats ? $t('settings.hashtags.hideStats') : $t('settings.hashtags.showStats') }}
@@ -868,16 +868,25 @@
 
 
             <!-- Controls row -->
             <!-- Controls row -->
             <div class="flex items-center flex-wrap gap-2">
             <div class="flex items-center flex-wrap gap-2">
-              <p class="text-sm font-semibold text-gray-300 mr-2">{{ $t('settings.hashtags.statsTitle') }}</p>
+              <p class="text-sm font-semibold text-gray-300 mr-1">{{ $t('settings.hashtags.statsTitle') }}</p>
+
+              <!-- Account filter — scopes view, scan, and AI suggest -->
+              <select
+                v-model="statsAccount"
+                class="bg-gray-800 border border-gray-700 rounded-lg px-2.5 py-1 text-xs text-gray-300 focus:outline-none focus:border-emerald-500"
+              >
+                <option value="">{{ $t('settings.hashtags.allAccounts') }}</option>
+                <option v-for="acc in allConnectedAccounts" :key="acc.key" :value="acc.key">{{ acc.label }}</option>
+              </select>
 
 
               <button
               <button
-                @click="statsSort = 'count'; hashtagStore.fetchStats('count')"
+                @click="statsSort = 'count'; hashtagStore.fetchStats('count', statsAccount || undefined)"
                 class="text-xs px-2.5 py-1 rounded-lg border transition-colors"
                 class="text-xs px-2.5 py-1 rounded-lg border transition-colors"
                 :class="statsSort === 'count' ? 'border-emerald-600 text-emerald-300 bg-emerald-900/20' : 'border-gray-700 text-gray-400 hover:bg-gray-800'"
                 :class="statsSort === 'count' ? 'border-emerald-600 text-emerald-300 bg-emerald-900/20' : 'border-gray-700 text-gray-400 hover:bg-gray-800'"
               >{{ $t('settings.hashtags.sortByUsage') }}</button>
               >{{ $t('settings.hashtags.sortByUsage') }}</button>
 
 
               <button
               <button
-                @click="statsSort = 'engagement'; hashtagStore.fetchStats('engagement')"
+                @click="statsSort = 'engagement'; hashtagStore.fetchStats('engagement', statsAccount || undefined)"
                 class="text-xs px-2.5 py-1 rounded-lg border transition-colors"
                 class="text-xs px-2.5 py-1 rounded-lg border transition-colors"
                 :class="statsSort === 'engagement' ? 'border-emerald-600 text-emerald-300 bg-emerald-900/20' : 'border-gray-700 text-gray-400 hover:bg-gray-800'"
                 :class="statsSort === 'engagement' ? 'border-emerald-600 text-emerald-300 bg-emerald-900/20' : 'border-gray-700 text-gray-400 hover:bg-gray-800'"
               >{{ $t('settings.hashtags.sortByEngagement') }}</button>
               >{{ $t('settings.hashtags.sortByEngagement') }}</button>
@@ -897,13 +906,9 @@
 
 
             <!-- AI Suggest row -->
             <!-- AI Suggest row -->
             <div class="flex items-center gap-2 flex-wrap">
             <div class="flex items-center gap-2 flex-wrap">
-              <select
-                v-model="aiSuggestAccount"
-                class="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-300 focus:outline-none focus:border-violet-500 flex-1 min-w-0"
-              >
-                <option value="">{{ $t('settings.hashtags.allAccounts') }}</option>
-                <option v-for="acc in allConnectedAccounts" :key="acc.key" :value="acc.key">{{ acc.label }}</option>
-              </select>
+              <p class="text-xs text-gray-500">
+                {{ statsAccount ? $t('settings.hashtags.aiSuggestForAccount') : $t('settings.hashtags.aiSuggestAllAccounts') }}
+              </p>
               <button
               <button
                 @click="handleAiSuggest"
                 @click="handleAiSuggest"
                 :disabled="hashtagStore.aiSuggesting"
                 :disabled="hashtagStore.aiSuggesting"
@@ -967,7 +972,7 @@
                     :key="stat._id"
                     :key="stat._id"
                     class="hover:bg-gray-800/30 transition-colors"
                     class="hover:bg-gray-800/30 transition-colors"
                   >
                   >
-                    <td class="py-1.5 pr-4 font-mono text-emerald-400">{{ stat._id }}</td>
+                    <td class="py-1.5 pr-4 font-mono text-emerald-400">{{ stat.hashtag || stat._id }}</td>
                     <td class="py-1.5 pr-4 text-right text-gray-300">{{ stat.count }}</td>
                     <td class="py-1.5 pr-4 text-right text-gray-300">{{ stat.count }}</td>
                     <td class="py-1.5 pr-4 text-right text-gray-300">{{ stat.avgEngagement }}</td>
                     <td class="py-1.5 pr-4 text-right text-gray-300">{{ stat.avgEngagement }}</td>
                     <td class="py-1.5 pr-4 text-center">
                     <td class="py-1.5 pr-4 text-center">
@@ -1220,7 +1225,7 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, watch, onMounted } from 'vue'
 import { useRoute } from 'vue-router'
 import { useRoute } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
 import axios from 'axios'
 import axios from 'axios'
@@ -1363,7 +1368,12 @@ const editGroupName = ref('')
 const editGroupHashtags = ref('')
 const editGroupHashtags = ref('')
 const showHashtagStats = ref(false)
 const showHashtagStats = ref(false)
 const statsSort = ref<'count' | 'engagement'>('count')
 const statsSort = ref<'count' | 'engagement'>('count')
-const aiSuggestAccount = ref('')
+const statsAccount = ref('')
+
+// Auto-reload stats when account filter changes
+watch(statsAccount, (acct) => {
+  if (showHashtagStats.value) hashtagStore.fetchStats(statsSort.value, acct || undefined)
+})
 const selectedAiTags = ref(new Set<string>())
 const selectedAiTags = ref(new Set<string>())
 const aiGroupName = ref('')
 const aiGroupName = ref('')
 
 
@@ -1408,12 +1418,12 @@ async function handleDeleteGroup(id: string) {
 }
 }
 
 
 async function scrapeHashtagsNow() {
 async function scrapeHashtagsNow() {
-  await hashtagStore.scrapeHashtags()
+  await hashtagStore.scrapeHashtags(statsAccount.value || undefined)
   statsSort.value = 'count'
   statsSort.value = 'count'
 }
 }
 
 
 async function handleAiSuggest() {
 async function handleAiSuggest() {
-  await hashtagStore.aiSuggest(aiSuggestAccount.value || undefined)
+  await hashtagStore.aiSuggest(statsAccount.value || undefined)
 }
 }
 
 
 async function createGroupFromAi() {
 async function createGroupFromAi() {