From 2c3b269975a0ec19b3a90d7dcb53e462fe9e8622 Mon Sep 17 00:00:00 2001 From: Martin Bowling Date: Thu, 5 Sep 2024 12:07:47 -0400 Subject: [PATCH] Implement SERP data graphing and Share of Voice view This commit adds new features to visualize SERP data and Share of Voice for projects and tags: - Add TagProjectGraph component for visualizing SERP data over time - Add ShareOfVoice component for displaying Share of Voice metrics - Update RankTable component to include new graph components - Add new API endpoints in the backend for fetching graph and Share of Voice data - Implement new actions in the store for fetching graph and Share of Voice data - Update TODO list to reflect completed items These changes provide users with more detailed insights into their keyword performance and allow for better visualization of trends over time. --- backend/app.py | 135 ++++++++++++++++++ .../src/components/RankTable.vue | 27 ++++ .../src/components/ShareOfVoice.vue | 70 +++++++++ .../src/components/TagProjectGraph.vue | 61 ++++++++ .../rankenberry-frontent/src/stores/index.js | 30 +++- 5 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 frontend/rankenberry-frontent/src/components/ShareOfVoice.vue create mode 100644 frontend/rankenberry-frontent/src/components/TagProjectGraph.vue diff --git a/backend/app.py b/backend/app.py index c398d8c..f6052af 100644 --- a/backend/app.py +++ b/backend/app.py @@ -15,6 +15,8 @@ import logging import aiohttp import json +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger load_dotenv() @@ -569,5 +571,138 @@ async def update_search_volume(keyword_id, keyword): conn.close() +@app.get("/api/rolling-average/{keyword_id}") +async def get_rolling_average(keyword_id: int): + conn = get_db_connection() + cursor = conn.cursor() + + # Get the last 7 days of data + end_date = datetime.now() + start_date = end_date - timedelta(days=7) + + cursor.execute(''' + SELECT date, rank + FROM serp_data + WHERE keyword_id = ? AND date BETWEEN ? AND ? + ORDER BY date + ''', (keyword_id, start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d'))) + + results = cursor.fetchall() + conn.close() + + # Calculate 7-day rolling average + rolling_average = [] + for i in range(len(results)): + start_idx = max(0, i - 6) + window = results[start_idx:i+1] + avg_rank = sum(r[1] for r in window if r[1] != -1) / len([r for r in window if r[1] != -1]) + rolling_average.append({ + 'date': results[i][0], + 'rolling_avg': round(avg_rank, 2) + }) + + return rolling_average + +scheduler = BackgroundScheduler() + +async def fetch_all_serp_data(): + # Implement the logic to fetch SERP data for all active keywords + pass + +# Schedule the task to run daily at 1:00 AM +scheduler.add_job(fetch_all_serp_data, CronTrigger(hour=1, minute=0)) + +# Start the scheduler when the app starts +@app.on_event("startup") +async def start_scheduler(): + scheduler.start() + +# Shut down the scheduler when the app stops +@app.on_event("shutdown") +async def shutdown_scheduler(): + scheduler.shutdown() + +@app.get("/api/project-graph-data/{project_id}") +async def get_project_graph_data(project_id: int): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT k.keyword, s.date, s.rank + FROM keywords k + JOIN serp_data s ON k.id = s.keyword_id + WHERE k.project_id = ? + ORDER BY k.keyword, s.date + ''', (project_id,)) + results = cursor.fetchall() + conn.close() + + graph_data = {} + for row in results: + keyword, date, rank = row + if keyword not in graph_data: + graph_data[keyword] = {'dates': [], 'ranks': []} + graph_data[keyword]['dates'].append(date) + graph_data[keyword]['ranks'].append(rank) + + return [{'keyword': k, 'dates': v['dates'], 'ranks': v['ranks']} for k, v in graph_data.items()] + +@app.get("/api/tag-graph-data/{tag_id}") +async def get_tag_graph_data(tag_id: int): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT k.keyword, s.date, s.rank + FROM keywords k + JOIN keyword_tags kt ON k.id = kt.keyword_id + JOIN serp_data s ON k.id = s.keyword_id + WHERE kt.tag_id = ? + ORDER BY k.keyword, s.date + ''', (tag_id,)) + results = cursor.fetchall() + conn.close() + + graph_data = {} + for row in results: + keyword, date, rank = row + if keyword not in graph_data: + graph_data[keyword] = {'dates': [], 'ranks': []} + graph_data[keyword]['dates'].append(date) + graph_data[keyword]['ranks'].append(rank) + + return [{'keyword': k, 'dates': v['dates'], 'ranks': v['ranks']} for k, v in graph_data.items()] + +@app.get("/api/project-share-of-voice/{project_id}") +async def get_project_share_of_voice(project_id: int): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT k.keyword, s.rank, k.search_volume + FROM keywords k + LEFT JOIN serp_data s ON k.id = s.keyword_id + WHERE k.project_id = ? + AND s.date = (SELECT MAX(date) FROM serp_data WHERE keyword_id = k.id) + ''', (project_id,)) + results = cursor.fetchall() + conn.close() + + return [{'keyword': row[0], 'rank': row[1], 'search_volume': row[2]} for row in results] + +@app.get("/api/tag-share-of-voice/{tag_id}") +async def get_tag_share_of_voice(tag_id: int): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT k.keyword, s.rank, k.search_volume + FROM keywords k + JOIN keyword_tags kt ON k.id = kt.keyword_id + LEFT JOIN serp_data s ON k.id = s.keyword_id + WHERE kt.tag_id = ? + AND s.date = (SELECT MAX(date) FROM serp_data WHERE keyword_id = k.id) + ''', (tag_id,)) + results = cursor.fetchall() + conn.close() + + return [{'keyword': row[0], 'rank': row[1], 'search_volume': row[2]} for row in results] + if __name__ == "__main__": uvicorn.run("app:app", host="0.0.0.0", port=5001, reload=True) \ No newline at end of file diff --git a/frontend/rankenberry-frontent/src/components/RankTable.vue b/frontend/rankenberry-frontent/src/components/RankTable.vue index 7057eb3..49fc0a9 100644 --- a/frontend/rankenberry-frontent/src/components/RankTable.vue +++ b/frontend/rankenberry-frontent/src/components/RankTable.vue @@ -75,6 +75,23 @@ +
+
+ +
+
+ +
+
+ @@ -187,6 +204,8 @@ import SerpDetails from './SerpDetails.vue' import KeywordHistoryModal from './KeywordHistoryModal.vue' import { DatePicker } from 'v-calendar' import 'v-calendar/dist/style.css' +import TagProjectGraph from './TagProjectGraph.vue' +import ShareOfVoice from './ShareOfVoice.vue' const store = useMainStore() const { rankData, projects, tags } = storeToRefs(store) @@ -332,6 +351,14 @@ const totalSearchVolume = computed(() => { }, 0) }) +const graphData = computed(() => { + return latestRankData.value.map(item => ({ + keyword: item.keyword, + dates: item.history.map(h => h.date), + ranks: item.history.map(h => h.rank) + })) +}) + const previousPage = () => { if (currentPage.value > 1) { currentPage.value-- diff --git a/frontend/rankenberry-frontent/src/components/ShareOfVoice.vue b/frontend/rankenberry-frontent/src/components/ShareOfVoice.vue new file mode 100644 index 0000000..014839d --- /dev/null +++ b/frontend/rankenberry-frontent/src/components/ShareOfVoice.vue @@ -0,0 +1,70 @@ + + + + + \ No newline at end of file diff --git a/frontend/rankenberry-frontent/src/components/TagProjectGraph.vue b/frontend/rankenberry-frontent/src/components/TagProjectGraph.vue new file mode 100644 index 0000000..c3f78b7 --- /dev/null +++ b/frontend/rankenberry-frontent/src/components/TagProjectGraph.vue @@ -0,0 +1,61 @@ + + + + + \ No newline at end of file diff --git a/frontend/rankenberry-frontent/src/stores/index.js b/frontend/rankenberry-frontent/src/stores/index.js index 9850797..82a0e67 100644 --- a/frontend/rankenberry-frontent/src/stores/index.js +++ b/frontend/rankenberry-frontent/src/stores/index.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import axios from 'axios' +import { useToast } from 'vue-toastification' const API_URL = 'http://localhost:5001/api' @@ -17,6 +18,7 @@ export const useMainStore = defineStore('main', { this.projects = response.data } catch (error) { console.error('Error fetching projects:', error) + useToast().error('Failed to fetch projects. Please try again.') throw error } }, @@ -24,14 +26,12 @@ export const useMainStore = defineStore('main', { try { const response = await axios.post(`${API_URL}/projects`, { name, domain }) this.projects.push(response.data) + useToast().success('Project added successfully!') return response.data } catch (error) { console.error('Error adding project:', error) - if (error.response && error.response.data) { - throw new Error(error.response.data.detail || 'An error occurred') - } else { - throw error - } + useToast().error('Failed to add project. Please try again.') + throw error } }, async fetchKeywords(projectId) { @@ -282,5 +282,25 @@ export const useMainStore = defineStore('main', { throw error } }, + async fetchGraphData(type, id) { + try { + const response = await axios.get(`${API_URL}/${type}-graph-data/${id}`) + return response.data + } catch (error) { + console.error(`Error fetching graph data for ${type}:`, error) + useToast().error(`Failed to fetch graph data for ${type}. Please try again.`) + throw error + } + }, + async fetchShareOfVoiceData(type, id) { + try { + const response = await axios.get(`${API_URL}/${type}-share-of-voice/${id}`) + return response.data + } catch (error) { + console.error(`Error fetching share of voice data for ${type}:`, error) + useToast().error(`Failed to fetch share of voice data for ${type}. Please try again.`) + throw error + } + } } }) \ No newline at end of file