Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -3,282 +3,248 @@ import numpy as np
|
|
| 3 |
import streamlit as st
|
| 4 |
import requests
|
| 5 |
|
| 6 |
-
#
|
| 7 |
-
# This URL points to a sample Google Search Console (GSC) data CSV file hosted on Hugging Face.
|
| 8 |
SAMPLE_FILE_URL = (
|
| 9 |
"https://huggingface.co/spaces/Em4e/seo-b2b-saas-forecasting-tool/"
|
| 10 |
"resolve/main/sample_gsc_data.csv"
|
| 11 |
)
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
|
| 15 |
-
# This function handles loading the GSC data, either from an uploaded file or the sample URL.
|
| 16 |
-
# It also standardizes column names and simulates CPC values if missing.
|
| 17 |
-
@st.cache_data # Caches the output of this function to improve performance.
|
| 18 |
-
def load_csv(uploaded_file_obj):
|
| 19 |
"""
|
| 20 |
-
|
| 21 |
-
normalizes column names, and ensures a 'cpc' column exists.
|
| 22 |
-
Args:
|
| 23 |
-
uploaded_file_obj (streamlit.uploaded_file_manager.UploadedFile): The file object
|
| 24 |
-
uploaded by the user, or None.
|
| 25 |
-
Returns:
|
| 26 |
-
pd.DataFrame: The loaded and processed DataFrame, or None if an error occurs.
|
| 27 |
"""
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
"""
|
| 58 |
-
Performs core calculations for SEO forecasting
|
| 59 |
-
Args:
|
| 60 |
-
df (pd.DataFrame): The input DataFrame containing GSC data.
|
| 61 |
-
target_position (float): The desired average search engine result page position.
|
| 62 |
-
conversion_rate (float): Percentage of clicks that convert to signups.
|
| 63 |
-
close_rate (float): Percentage of signups that become paying customers.
|
| 64 |
-
mrr_per_customer (int): Monthly Recurring Revenue per customer.
|
| 65 |
-
seo_cost (int): Total investment in SEO efforts.
|
| 66 |
-
add_spend (int): Hypothetical additional ad spend for comparison.
|
| 67 |
-
Returns:
|
| 68 |
-
tuple: A dictionary of calculated metrics and a DataFrame with detailed results.
|
| 69 |
-
Returns (None, pd.DataFrame()) if required columns are missing.
|
| 70 |
"""
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
return None, pd.DataFrame()
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
net_savings_vs_paid = total_avoided_paid_spend - seo_cost
|
| 109 |
-
total_incremental_conversions = df["incremental_clicks"].sum() * (
|
| 110 |
-
conversion_rate / 100
|
| 111 |
-
)
|
| 112 |
-
total_incremental_customers = total_incremental_conversions * (close_rate / 100)
|
| 113 |
-
incremental_mrr = total_incremental_customers * mrr_per_customer
|
| 114 |
-
# SEO ROI calculation, handling division by zero for seo_cost
|
| 115 |
-
if seo_cost > 0:
|
| 116 |
-
seo_roi = (incremental_mrr - seo_cost) / seo_cost
|
| 117 |
-
else:
|
| 118 |
-
seo_roi = np.inf # Undefined or very high if no SEO cost
|
| 119 |
-
# Categorize impact for each query based on its current position relative to the target
|
| 120 |
-
def categorize_impact(row):
|
| 121 |
-
if row["position"] > target_position:
|
| 122 |
-
return "π Improvement" # Position is worse than target, room for improvement
|
| 123 |
-
elif (
|
| 124 |
-
row["position"] <= target_position and row["incremental_clicks"] > 0
|
| 125 |
-
):
|
| 126 |
-
return "β
Maintain & Grow" # Position is at or better than target, still gaining clicks
|
| 127 |
else:
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
<p> β’ <b>Incremental MRR</b> = Customers Γ MRR_per_Customer</p>
|
| 160 |
-
<p> β’ <b>SEO ROI</b> = (Incremental MRR β SEO Investment) Γ· SEO Investment</p>
|
| 161 |
-
<p><b>Understanding "Additional Ad Spend"</b></p>
|
| 162 |
-
<p>The "Additional Ad Spend" input in the sidebar is a <b>hypothetical budget figure you provide for comparison</b>. It's <b>not</b> calculated from your GSC data or CPC. Instead, it allows you to:</p>
|
| 163 |
-
<ul>
|
| 164 |
-
<li><b>Compare SEO's revenue generation directly against a specific paid ad budget.</b> For instance, if you're considering spending an extra X dollars on Google Ads, you can see whether your SEO's projected incremental MRR is higher or lower than that same amount.</li>
|
| 165 |
-
<li><b>Visualize the efficiency of your SEO investment.</b> If your SEO investment generates significantly more incremental MRR than a comparable additional ad spend, it highlights SEO as a potentially more effective use of marketing funds.</li>
|
| 166 |
-
</ul>
|
| 167 |
-
<p>The "Ad Spend" metric will be <span style="color: green; font-weight: bold;">green</span> if your projected Incremental MRR from SEO is <b>greater than</b> this additional ad spend, and <span style="color: red; font-weight: bold;">red</span> if it is not.</p>
|
| 168 |
-
|
| 169 |
-
<p>5. <b>Interpreting Results & Assumptions</b></p>
|
| 170 |
-
<ul>
|
| 171 |
-
<li><b>Target SERP Position:</b> The 'Target SERP Position' is an <u>aspirational average</u> you aim for among your <u>most important and achievable keywords</u>, rather than a literal expectation for every single query. In reality, not all keywords will reach the same position due to varying competition and relevance.</li>
|
| 172 |
-
<li><b>High-Impact Queries:</b> While the model calculates for all queries, focus your analysis on the 'Detailed Keyword Performance' table. Look for queries with a 'π Improvement' impact category and high 'impressions' and 'incremental_clicks'. These are often your most promising opportunities for SEO effort.</li>
|
| 173 |
-
</ul>
|
| 174 |
-
</div>
|
| 175 |
-
""",
|
| 176 |
-
unsafe_allow_html=True,
|
| 177 |
-
)
|
| 178 |
-
|
| 179 |
-
# β Sidebar inputs
|
| 180 |
-
# This section defines the input controls in the Streamlit sidebar, allowing users to
|
| 181 |
-
# adjust various parameters for the SEO forecasting.
|
| 182 |
-
with st.sidebar:
|
| 183 |
-
st.header("π§ Assumptions & Inputs")
|
| 184 |
-
uploaded_file = st.file_uploader("Upload GSC CSV", type="csv")
|
| 185 |
-
target_position = st.slider(
|
| 186 |
-
"Target SERP Position",
|
| 187 |
-
1.0,
|
| 188 |
-
10.0,
|
| 189 |
-
4.0,
|
| 190 |
-
0.5,
|
| 191 |
-
help="This is the **average search engine ranking you assume all your queries will achieve.** A lower number (e.g., position 1) indicates a higher, more visible ranking. This target position is used to project the future click-through rate for every query."
|
| 192 |
-
) # Desired average search engine result page position
|
| 193 |
-
conversion_rate = st.slider(
|
| 194 |
-
"Conversion Rate (% β signup)", 0.1, 10.0, 2.0, 0.1
|
| 195 |
-
) # Percentage of clicks that convert to signups
|
| 196 |
-
close_rate = st.slider(
|
| 197 |
-
"Close Rate (% β customer)", 1.0, 100.0, 20.0, 1.0
|
| 198 |
-
) # Percentage of signups that become paying customers
|
| 199 |
-
mrr_per_customer = st.slider(
|
| 200 |
-
"MRR per Customer ($)", 10, 1000, 200, 10
|
| 201 |
-
) # Monthly Recurring Revenue per customer
|
| 202 |
-
seo_cost = st.slider(
|
| 203 |
-
"Total SEO Investment ($)", 1_000, 100_000, 10_000, 1_000
|
| 204 |
-
) # Total investment in SEO efforts
|
| 205 |
-
add_spend = st.slider(
|
| 206 |
-
"Additional Ad Spend ($)", 0, 50_000, 0, 1_000
|
| 207 |
-
) # Hypothetical additional ad spend for comparison
|
| 208 |
-
|
| 209 |
-
# β Download sample CSV button
|
| 210 |
-
# Provides a button for users to download the sample GSC data CSV.
|
| 211 |
-
sample_bytes = requests.get(SAMPLE_FILE_URL).content
|
| 212 |
-
st.download_button(
|
| 213 |
-
label="π₯ Download sample CSV",
|
| 214 |
-
data=sample_bytes,
|
| 215 |
-
file_name="sample_gsc_data.csv",
|
| 216 |
-
mime="text/csv",
|
| 217 |
-
)
|
| 218 |
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
st.write("---")
|
| 235 |
st.header("π SEO Performance Summary")
|
| 236 |
-
# Display key performance metrics in a 3-column layout
|
| 237 |
col1, col2, col3 = st.columns(3)
|
| 238 |
with col1:
|
| 239 |
-
st.metric(
|
| 240 |
-
label="Total Avoided Paid Spend π°",
|
| 241 |
-
value=f"${metrics['total_avoided_paid_spend']:,.2f}",
|
| 242 |
-
)
|
| 243 |
with col2:
|
| 244 |
-
st.metric(
|
| 245 |
-
label="Net Savings vs Paid π",
|
| 246 |
-
value=f"${metrics['net_savings_vs_paid']:,.2f}",
|
| 247 |
-
)
|
| 248 |
with col3:
|
| 249 |
-
st.metric(
|
| 250 |
-
|
| 251 |
-
value=f"${metrics['incremental_mrr']:,.2f}",
|
| 252 |
-
)
|
| 253 |
col4, col5, col6 = st.columns(3)
|
| 254 |
with col4:
|
| 255 |
-
st.metric(
|
| 256 |
-
label="Total Incremental Conversions π―",
|
| 257 |
-
value=f"{metrics['total_incremental_conversions']:,.0f}",
|
| 258 |
-
)
|
| 259 |
with col5:
|
| 260 |
-
st.metric(
|
| 261 |
-
label="Total Incremental Customers π€",
|
| 262 |
-
value=f"{metrics['total_incremental_customers']:,.0f}",
|
| 263 |
-
)
|
| 264 |
with col6:
|
| 265 |
-
st.metric(
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
)
|
| 269 |
st.write("---")
|
| 270 |
st.header("Hypothetical Comparison: SEO vs. Additional Ad Spend")
|
| 271 |
-
# Compare SEO's incremental MRR with a hypothetical additional ad spend
|
| 272 |
col_ad1, col_ad2, col_advice = st.columns([1, 1, 1])
|
| 273 |
with col_ad1:
|
| 274 |
-
st.metric(
|
| 275 |
-
label="Incremental MRR from SEO",
|
| 276 |
-
value=f"${metrics['incremental_mrr']:,.2f}",
|
| 277 |
-
)
|
| 278 |
with col_ad2:
|
| 279 |
-
st.metric(
|
| 280 |
-
label="Additional Ad Spend", value=f"${add_spend:,.2f}"
|
| 281 |
-
)
|
| 282 |
with col_advice:
|
| 283 |
if metrics["incremental_mrr"] > add_spend:
|
| 284 |
advice_message = "SEO is a better investment!"
|
|
@@ -295,10 +261,11 @@ if df is not None: # Proceed only if data loading was successful
|
|
| 295 |
""",
|
| 296 |
unsafe_allow_html=True,
|
| 297 |
)
|
|
|
|
|
|
|
| 298 |
st.write("---")
|
| 299 |
st.header("Detailed Keyword Performance")
|
| 300 |
st.info("π‘ **How to use this table:** Focus on queries with the 'π Improvement' impact category and high 'impressions'. These represent opportunities where improving your current position towards the 'Target SERP Position' can yield significant incremental clicks and avoided paid spend.")
|
| 301 |
-
# Display a detailed table of keyword performance, sorted by incremental clicks
|
| 302 |
st.dataframe(
|
| 303 |
df_results[
|
| 304 |
[
|
|
@@ -316,4 +283,33 @@ if df is not None: # Proceed only if data loading was successful
|
|
| 316 |
]
|
| 317 |
].sort_values(by="incremental_clicks", ascending=False),
|
| 318 |
use_container_width=True,
|
| 319 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import streamlit as st
|
| 4 |
import requests
|
| 5 |
|
| 6 |
+
# Constants
|
|
|
|
| 7 |
SAMPLE_FILE_URL = (
|
| 8 |
"https://huggingface.co/spaces/Em4e/seo-b2b-saas-forecasting-tool/"
|
| 9 |
"resolve/main/sample_gsc_data.csv"
|
| 10 |
)
|
| 11 |
|
| 12 |
+
# --- 1. Data Loading and Preprocessing (Single Responsibility Principle) ---
|
| 13 |
+
class DataLoader:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
+
Handles loading GSC data from various sources and performs initial standardization.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
+
def __init__(self, sample_file_url: str = SAMPLE_FILE_URL):
|
| 18 |
+
self.sample_file_url = sample_file_url
|
| 19 |
+
|
| 20 |
+
@st.cache_data
|
| 21 |
+
def load_csv(self, uploaded_file_obj: st.uploaded_file_manager.UploadedFile | None) -> pd.DataFrame | None:
|
| 22 |
+
"""
|
| 23 |
+
Loads the GSC data from an uploaded CSV or a sample URL,
|
| 24 |
+
normalizes column names, and ensures a 'cpc' column exists.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
uploaded_file_obj (streamlit.uploaded_file_manager.UploadedFile): The file object
|
| 28 |
+
uploaded by the user, or None.
|
| 29 |
+
Returns:
|
| 30 |
+
pd.DataFrame: The loaded and processed DataFrame, or None if an error occurs.
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
if uploaded_file_obj:
|
| 34 |
+
df = pd.read_csv(uploaded_file_obj)
|
| 35 |
+
else:
|
| 36 |
+
df = pd.read_csv(self.sample_file_url)
|
| 37 |
+
except Exception as e:
|
| 38 |
+
st.error(f"Error loading file: {e}")
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
df.columns = [col.lower() for col in df.columns]
|
| 42 |
+
|
| 43 |
+
if "cpc" not in df.columns:
|
| 44 |
+
st.warning("No `cpc` column foundβsimulating CPC values between 0.50β3.00 USD (for testing purposes only!)")
|
| 45 |
+
df["cpc"] = np.round(np.random.uniform(0.5, 3.0, size=len(df)), 2)
|
| 46 |
+
return df
|
| 47 |
+
|
| 48 |
+
# --- 2. Core Calculation Logic (Single Responsibility Principle) ---
|
| 49 |
+
class SeoCalculator:
|
| 50 |
"""
|
| 51 |
+
Performs core calculations for SEO forecasting.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
"""
|
| 53 |
+
def __init__(self):
|
| 54 |
+
# Define Click-Through Rate (CTR) benchmarks by position
|
| 55 |
+
self.ctr_benchmarks = {i: v for i, v in zip(range(1, 11), [0.25, 0.15, 0.10, 0.08, 0.06, 0.04, 0.03, 0.02, 0.015, 0.01])}
|
| 56 |
+
self.ctr_benchmarks.update({i: 0.005 for i in range(11, 21)})
|
| 57 |
+
|
| 58 |
+
self.required_columns_map = {
|
| 59 |
+
"query": ["query", "keyword", "queries"],
|
| 60 |
+
"impressions": ["impressions"],
|
| 61 |
+
"position": ["position", "avg. position", "average position"],
|
| 62 |
+
"cpc": ["cpc"],
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
def _get_ctr(self, position: float) -> float:
|
| 66 |
+
"""Helper to get CTR based on position, defaulting to 0.005 for positions > 20."""
|
| 67 |
+
return self.ctr_benchmarks.get(int(round(position)), 0.005)
|
| 68 |
+
|
| 69 |
+
def _validate_and_rename_columns(self, df: pd.DataFrame) -> pd.DataFrame | None:
|
| 70 |
+
"""Validates required columns and renames them to a standardized format."""
|
| 71 |
+
found_columns = {}
|
| 72 |
+
for key, options in self.required_columns_map.items():
|
| 73 |
+
for opt in options:
|
| 74 |
+
if opt in df.columns:
|
| 75 |
+
found_columns[key] = opt
|
| 76 |
+
break
|
| 77 |
+
if key not in found_columns:
|
| 78 |
+
st.error(f"Missing required column: {key}. Please ensure your CSV has one of {options}.")
|
| 79 |
+
return None
|
| 80 |
+
return df.rename(columns={found_columns[k]: k for k in found_columns})
|
| 81 |
+
|
| 82 |
+
@st.cache_data
|
| 83 |
+
def calculate_metrics(
|
| 84 |
+
self,
|
| 85 |
+
df: pd.DataFrame,
|
| 86 |
+
target_position: float,
|
| 87 |
+
conversion_rate: float,
|
| 88 |
+
close_rate: float,
|
| 89 |
+
mrr_per_customer: int,
|
| 90 |
+
seo_cost: int,
|
| 91 |
+
add_spend: int,
|
| 92 |
+
) -> tuple[dict, pd.DataFrame] | tuple[None, pd.DataFrame]:
|
| 93 |
+
"""
|
| 94 |
+
Performs core calculations for SEO forecasting based on GSC data and user inputs.
|
| 95 |
+
|
| 96 |
+
Returns:
|
| 97 |
+
tuple: A dictionary of calculated metrics and a DataFrame with detailed results.
|
| 98 |
+
Returns (None, pd.DataFrame()) if required columns are missing.
|
| 99 |
+
"""
|
| 100 |
+
df_processed = self._validate_and_rename_columns(df.copy())
|
| 101 |
+
if df_processed is None:
|
| 102 |
return None, pd.DataFrame()
|
| 103 |
+
|
| 104 |
+
df_processed["current_ctr"] = df_processed["position"].apply(self._get_ctr)
|
| 105 |
+
target_ctr_value = self._get_ctr(target_position)
|
| 106 |
+
df_processed["target_ctr"] = target_ctr_value
|
| 107 |
+
|
| 108 |
+
df_processed["current_clicks"] = df_processed["impressions"] * df_processed["current_ctr"]
|
| 109 |
+
df_processed["projected_clicks"] = df_processed["impressions"] * df_processed["target_ctr"]
|
| 110 |
+
df_processed["incremental_clicks"] = df_processed["projected_clicks"] - df_processed["current_clicks"]
|
| 111 |
+
df_processed["avoided_paid_spend"] = df_processed["incremental_clicks"] * df_processed["cpc"]
|
| 112 |
+
|
| 113 |
+
# --- Financial calculations ---
|
| 114 |
+
total_avoided_paid_spend = df_processed["avoided_paid_spend"].sum()
|
| 115 |
+
net_savings_vs_paid = total_avoided_paid_spend - seo_cost
|
| 116 |
+
total_incremental_conversions = df_processed["incremental_clicks"].sum() * (
|
| 117 |
+
conversion_rate / 100
|
| 118 |
+
)
|
| 119 |
+
total_incremental_customers = total_incremental_conversions * (close_rate / 100)
|
| 120 |
+
incremental_mrr = total_incremental_customers * mrr_per_customer
|
| 121 |
+
|
| 122 |
+
if seo_cost > 0:
|
| 123 |
+
seo_roi = (incremental_mrr - seo_cost) / seo_cost
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
else:
|
| 125 |
+
seo_roi = np.inf
|
| 126 |
+
|
| 127 |
+
# Categorize impact for each query
|
| 128 |
+
def categorize_impact(row):
|
| 129 |
+
if row["position"] > target_position:
|
| 130 |
+
return "π Improvement"
|
| 131 |
+
elif row["position"] <= target_position and row["incremental_clicks"] > 0:
|
| 132 |
+
return "β
Maintain & Grow"
|
| 133 |
+
else:
|
| 134 |
+
return "π― Reached Target"
|
| 135 |
+
df_processed["impact_category"] = df_processed.apply(categorize_impact, axis=1)
|
| 136 |
+
|
| 137 |
+
metrics = {
|
| 138 |
+
"total_avoided_paid_spend": total_avoided_paid_spend,
|
| 139 |
+
"net_savings_vs_paid": net_savings_vs_paid,
|
| 140 |
+
"total_incremental_conversions": total_incremental_conversions,
|
| 141 |
+
"total_incremental_customers": total_incremental_customers,
|
| 142 |
+
"incremental_mrr": incremental_mrr,
|
| 143 |
+
"seo_roi": seo_roi,
|
| 144 |
+
}
|
| 145 |
+
return metrics, df_processed
|
| 146 |
+
|
| 147 |
+
# --- 3. Streamlit User Interface (Single Responsibility Principle) ---
|
| 148 |
+
class SeoAppUI:
|
| 149 |
+
"""
|
| 150 |
+
Manages the Streamlit user interface and presentation.
|
| 151 |
+
"""
|
| 152 |
+
def __init__(self, data_loader: DataLoader, seo_calculator: SeoCalculator):
|
| 153 |
+
self.data_loader = data_loader
|
| 154 |
+
self.seo_calculator = seo_calculator
|
| 155 |
+
self._set_page_config()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
+
def _set_page_config(self):
|
| 158 |
+
st.set_page_config(page_title="SEO ROI & Savings Forecasting", layout="wide")
|
| 159 |
+
st.title("π B2B SaaS SEO ROI & Savings Simulator")
|
| 160 |
+
st.markdown("App created by [Emilija Gjorgjevska](https://www.linkedin.com/in/emilijagjorgjevska/)")
|
| 161 |
+
|
| 162 |
+
def _display_info_expander(self):
|
| 163 |
+
with st.expander("βΉοΈ How the app works", expanded=True):
|
| 164 |
+
st.markdown(
|
| 165 |
+
"""
|
| 166 |
+
<div style="background-color: #f0f2f6; padding: 20px; border-radius: 10px;">
|
| 167 |
+
<p>1. <b>Load your GSC data</b> (we lowercase all column names on load). If no file is uploaded, we use the default sample data. If no <code>cpc</code> column is present, we simulate values between 0.50 and 3.00 USD.</p>
|
| 168 |
+
<p>2. <b>CTR benchmarks</b> by position map an expected click-through rate for positions 1β20.</p>
|
| 169 |
+
<p>3. <b>Incremental Clicks</b> = Projected_Clicks β Current_Clicks</p>
|
| 170 |
+
<p> β’ Current_Clicks = Impressions Γ Current_CTR</p>
|
| 171 |
+
<p> β’ Projected_Clicks = Impressions Γ Target_CTR</p>
|
| 172 |
+
<p>4. <b>Financials</b></p>
|
| 173 |
+
<p> β’ <b>Avoided Paid Spend</b> = Incremental_Clicks Γ CPC. This represents the money you <b>don't</b> have to spend on paid ads because your organic SEO efforts are now bringing in those clicks and conversions.</p>
|
| 174 |
+
<p> β’ <b>Net Savings vs Paid</b> = Avoided Paid Spend β SEO Investment</p>
|
| 175 |
+
<p> β’ <b>Incremental MRR</b> = Customers Γ MRR_per_Customer</p>
|
| 176 |
+
<p> β’ <b>SEO ROI</b> = (Incremental MRR β SEO Investment) Γ· SEO Investment</p>
|
| 177 |
+
<p><b>Understanding "Additional Ad Spend"</b></p>
|
| 178 |
+
<p>The "Additional Ad Spend" input in the sidebar is a <b>hypothetical budget figure you provide for comparison</b>. It's <b>not</b> calculated from your GSC data or CPC. Instead, it allows you to:</p>
|
| 179 |
+
<ul>
|
| 180 |
+
<li><b>Compare SEO's revenue generation directly against a specific paid ad budget.</b> For instance, if you're considering spending an extra X dollars on Google Ads, you can see whether your SEO's projected incremental MRR is higher or lower than that same amount.</li>
|
| 181 |
+
<li><b>Visualize the efficiency of your SEO investment.</b> If your SEO investment generates significantly more incremental MRR than a comparable additional ad spend, it highlights SEO as a potentially more effective use of marketing funds.</li>
|
| 182 |
+
</ul>
|
| 183 |
+
<p>The "Ad Spend" metric will be <span style="color: green; font-weight: bold;">green</span> if your projected Incremental MRR from SEO is <b>greater than</b> this additional ad spend, and <span style="color: red; font-weight: bold;">red</span> if it is not.</p>
|
| 184 |
+
<p>5. <b>Interpreting Results & Assumptions</b></p>
|
| 185 |
+
<ul>
|
| 186 |
+
<li><b>Target SERP Position:</b> The 'Target SERP Position' is an <u>aspirational average</u> you aim for among your <u>most important and achievable keywords</u>, rather than a literal expectation for every single query. In reality, not all keywords will reach the same position due to varying competition and relevance.</li>
|
| 187 |
+
<li><b>High-Impact Queries:</b> While the model calculates for all queries, focus your analysis on the 'Detailed Keyword Performance' table. Look for queries with a 'π Improvement' impact category and high 'impressions' and 'incremental_clicks'. These are often your most promising opportunities for SEO effort.</li>
|
| 188 |
+
</ul>
|
| 189 |
+
</div>
|
| 190 |
+
""",
|
| 191 |
+
unsafe_allow_html=True,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
def _get_sidebar_inputs(self) -> tuple:
|
| 195 |
+
with st.sidebar:
|
| 196 |
+
st.header("π§ Assumptions & Inputs")
|
| 197 |
+
uploaded_file = st.file_uploader("Upload GSC CSV", type="csv")
|
| 198 |
+
target_position = st.slider(
|
| 199 |
+
"Target SERP Position",
|
| 200 |
+
1.0,
|
| 201 |
+
10.0,
|
| 202 |
+
4.0,
|
| 203 |
+
0.5,
|
| 204 |
+
help="This is the **average search engine ranking you assume all your queries will achieve.** A lower number (e.g., position 1) indicates a higher, more visible ranking. This target position is used to project the future click-through rate for every query."
|
| 205 |
+
)
|
| 206 |
+
conversion_rate = st.slider("Conversion Rate (% β signup)", 0.1, 10.0, 2.0, 0.1)
|
| 207 |
+
close_rate = st.slider("Close Rate (% β customer)", 1.0, 100.0, 20.0, 1.0)
|
| 208 |
+
mrr_per_customer = st.slider("MRR per Customer ($)", 10, 1000, 200, 10)
|
| 209 |
+
seo_cost = st.slider("Total SEO Investment ($)", 1_000, 100_000, 10_000, 1_000)
|
| 210 |
+
add_spend = st.slider("Additional Ad Spend ($)", 0, 50_000, 0, 1_000)
|
| 211 |
+
|
| 212 |
+
sample_bytes = requests.get(SAMPLE_FILE_URL).content
|
| 213 |
+
st.download_button(
|
| 214 |
+
label="π₯ Download sample CSV",
|
| 215 |
+
data=sample_bytes,
|
| 216 |
+
file_name="sample_gsc_data.csv",
|
| 217 |
+
mime="text/csv",
|
| 218 |
+
)
|
| 219 |
+
return uploaded_file, target_position, conversion_rate, close_rate, mrr_per_customer, seo_cost, add_spend
|
| 220 |
+
|
| 221 |
+
def _display_summary_metrics(self, metrics: dict):
|
| 222 |
st.write("---")
|
| 223 |
st.header("π SEO Performance Summary")
|
|
|
|
| 224 |
col1, col2, col3 = st.columns(3)
|
| 225 |
with col1:
|
| 226 |
+
st.metric("Total Avoided Paid Spend π°", f"${metrics['total_avoided_paid_spend']:,.2f}")
|
|
|
|
|
|
|
|
|
|
| 227 |
with col2:
|
| 228 |
+
st.metric("Net Savings vs Paid π", f"${metrics['net_savings_vs_paid']:,.2f}")
|
|
|
|
|
|
|
|
|
|
| 229 |
with col3:
|
| 230 |
+
st.metric("Incremental MRR (Monthly Recurring Revenue) π", f"${metrics['incremental_mrr']:,.2f}")
|
| 231 |
+
|
|
|
|
|
|
|
| 232 |
col4, col5, col6 = st.columns(3)
|
| 233 |
with col4:
|
| 234 |
+
st.metric("Total Incremental Conversions π―", f"{metrics['total_incremental_conversions']:,.0f}")
|
|
|
|
|
|
|
|
|
|
| 235 |
with col5:
|
| 236 |
+
st.metric("Total Incremental Customers π€", f"{metrics['total_incremental_customers']:,.0f}")
|
|
|
|
|
|
|
|
|
|
| 237 |
with col6:
|
| 238 |
+
st.metric("SEO ROI (Return on Investment) π°", f"{metrics['seo_roi']:.2%}")
|
| 239 |
+
|
| 240 |
+
def _display_ad_spend_comparison(self, metrics: dict, add_spend: int):
|
|
|
|
| 241 |
st.write("---")
|
| 242 |
st.header("Hypothetical Comparison: SEO vs. Additional Ad Spend")
|
|
|
|
| 243 |
col_ad1, col_ad2, col_advice = st.columns([1, 1, 1])
|
| 244 |
with col_ad1:
|
| 245 |
+
st.metric("Incremental MRR from SEO", f"${metrics['incremental_mrr']:,.2f}")
|
|
|
|
|
|
|
|
|
|
| 246 |
with col_ad2:
|
| 247 |
+
st.metric("Additional Ad Spend", value=f"${add_spend:,.2f}")
|
|
|
|
|
|
|
| 248 |
with col_advice:
|
| 249 |
if metrics["incremental_mrr"] > add_spend:
|
| 250 |
advice_message = "SEO is a better investment!"
|
|
|
|
| 261 |
""",
|
| 262 |
unsafe_allow_html=True,
|
| 263 |
)
|
| 264 |
+
|
| 265 |
+
def _display_detailed_performance_table(self, df_results: pd.DataFrame):
|
| 266 |
st.write("---")
|
| 267 |
st.header("Detailed Keyword Performance")
|
| 268 |
st.info("π‘ **How to use this table:** Focus on queries with the 'π Improvement' impact category and high 'impressions'. These represent opportunities where improving your current position towards the 'Target SERP Position' can yield significant incremental clicks and avoided paid spend.")
|
|
|
|
| 269 |
st.dataframe(
|
| 270 |
df_results[
|
| 271 |
[
|
|
|
|
| 283 |
]
|
| 284 |
].sort_values(by="incremental_clicks", ascending=False),
|
| 285 |
use_container_width=True,
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
def run(self):
|
| 289 |
+
self._display_info_expander()
|
| 290 |
+
uploaded_file, target_position, conversion_rate, close_rate, mrr_per_customer, seo_cost, add_spend = self._get_sidebar_inputs()
|
| 291 |
+
|
| 292 |
+
df = self.data_loader.load_csv(uploaded_file)
|
| 293 |
+
|
| 294 |
+
if df is not None:
|
| 295 |
+
metrics, df_results = self.seo_calculator.calculate_metrics(
|
| 296 |
+
df,
|
| 297 |
+
target_position,
|
| 298 |
+
conversion_rate,
|
| 299 |
+
close_rate,
|
| 300 |
+
mrr_per_customer,
|
| 301 |
+
seo_cost,
|
| 302 |
+
add_spend,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
if metrics is not None:
|
| 306 |
+
self._display_summary_metrics(metrics)
|
| 307 |
+
self._display_ad_spend_comparison(metrics, add_spend)
|
| 308 |
+
self._display_detailed_performance_table(df_results)
|
| 309 |
+
|
| 310 |
+
# --- Main Application Entry Point ---
|
| 311 |
+
if __name__ == "__main__":
|
| 312 |
+
data_loader = DataLoader()
|
| 313 |
+
seo_calculator = SeoCalculator()
|
| 314 |
+
app_ui = SeoAppUI(data_loader, seo_calculator)
|
| 315 |
+
app_ui.run()
|