Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import logging
import aiohttp
import json
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger

load_dotenv()

Expand Down Expand Up @@ -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)
27 changes: 27 additions & 0 deletions frontend/rankenberry-frontent/src/components/RankTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@
</div>
</div>

<div class="columns">
<div class="column">
<TagProjectGraph
:type="selectedProject ? 'project' : 'tag'"
:id="selectedProject || selectedTag"
:data="graphData"
/>
</div>
<div class="column">
<ShareOfVoice
:type="selectedProject ? 'project' : 'tag'"
:id="selectedProject || selectedTag"
:data="latestRankData"
/>
</div>
</div>

<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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--
Expand Down
70 changes: 70 additions & 0 deletions frontend/rankenberry-frontent/src/components/ShareOfVoice.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<template>
<div>
<h2 class="title is-4">Share of Voice for {{ type === 'tag' ? 'Tag' : 'Project' }}</h2>
<div ref="chart" class="chart-container"></div>
</div>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue'
import Plotly from 'plotly.js-dist-min'

const props = defineProps({
type: {
type: String,
required: true,
validator: (value) => ['tag', 'project'].includes(value)
},
id: {
type: Number,
required: true
},
data: {
type: Array,
required: true
}
})

const chart = ref(null)

const calculateShareOfVoice = (rank) => {
if (rank === null || rank === -1) return 0
return Math.max(0, (10 - Math.min(rank, 10)) / 10) * 100
}

const createChart = () => {
const shareOfVoice = props.data.map(keyword => ({
keyword: keyword.keyword,
sov: calculateShareOfVoice(keyword.rank)
}))

const trace = {
x: shareOfVoice.map(item => item.keyword),
y: shareOfVoice.map(item => item.sov),
type: 'bar',
name: 'Share of Voice'
}

const layout = {
title: `Share of Voice for ${props.type === 'tag' ? 'Tag' : 'Project'} #${props.id}`,
xaxis: { title: 'Keyword' },
yaxis: { title: 'Share of Voice (%)' }
}

Plotly.newPlot(chart.value, [trace], layout)
}

onMounted(() => {
createChart()
})

watch(() => props.data, () => {
createChart()
})
</script>

<style scoped>
.chart-container {
height: 500px;
}
</style>
61 changes: 61 additions & 0 deletions frontend/rankenberry-frontent/src/components/TagProjectGraph.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<template>
<div>
<h2 class="title is-4">SERP Data for {{ type === 'tag' ? 'Tag' : 'Project' }}</h2>
<div ref="chart" class="chart-container"></div>
</div>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue'
import Plotly from 'plotly.js-dist-min'

const props = defineProps({
type: {
type: String,
required: true,
validator: (value) => ['tag', 'project'].includes(value)
},
id: {
type: Number,
required: true
},
data: {
type: Array,
required: true
}
})

const chart = ref(null)

const createChart = () => {
const traces = props.data.map(keyword => ({
x: keyword.dates,
y: keyword.ranks,
type: 'scatter',
mode: 'lines+markers',
name: keyword.keyword
}))

const layout = {
title: `SERP Data for ${props.type === 'tag' ? 'Tag' : 'Project'} #${props.id}`,
xaxis: { title: 'Date' },
yaxis: { title: 'Rank', autorange: 'reversed' }
}

Plotly.newPlot(chart.value, traces, layout)
}

onMounted(() => {
createChart()
})

watch(() => props.data, () => {
createChart()
})
</script>

<style scoped>
.chart-container {
height: 500px;
}
</style>
30 changes: 25 additions & 5 deletions frontend/rankenberry-frontent/src/stores/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import axios from 'axios'
import { useToast } from 'vue-toastification'

const API_URL = 'http://localhost:5001/api'

Expand All @@ -17,21 +18,20 @@ 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
}
},
async addProject(name, domain) {
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) {
Expand Down Expand Up @@ -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
}
}
}
})