Em4e commited on
Commit
79c343c
Β·
verified Β·
1 Parent(s): ba06a1a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +247 -251
app.py CHANGED
@@ -3,282 +3,248 @@ import numpy as np
3
  import streamlit as st
4
  import requests
5
 
6
- # ▢️ URL for your sample GSC CSV
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
- # === Helper Functions ===
14
- # === Load & normalize CSV ===
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
- Loads the GSC data from an uploaded CSV or a sample URL,
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
- try:
29
- if uploaded_file_obj:
30
- df = pd.read_csv(uploaded_file_obj)
31
- else:
32
- df = pd.read_csv(SAMPLE_FILE_URL)
33
- except Exception as e:
34
- st.error(f"Error loading file: {e}")
35
- return None
36
- # Convert all column names to lowercase for consistency
37
- df.columns = [col.lower() for col in df.columns]
38
- # Check for 'cpc' column; if missing, simulate values
39
- if "cpc" not in df.columns:
40
- st.warning("No `cpc` column foundβ€”simulating CPC values between 0.50–3.00 USD (for testing purposes only!)")
41
- df["cpc"] = np.round(np.random.uniform(0.5, 3.0, size=len(df)), 2)
42
- return df
43
-
44
- # === Core calculation ===
45
- # This function performs the main calculations for SEO performance, including
46
- # click-through rates, incremental clicks, avoided paid spend, and ROI.
47
- @st.cache_data # Caches the output of this function to improve performance.
48
- def calculate(
49
- df,
50
- target_position,
51
- conversion_rate,
52
- close_rate,
53
- mrr_per_customer,
54
- seo_cost,
55
- add_spend,
56
- ):
 
 
 
 
57
  """
58
- Performs core calculations for SEO forecasting based on GSC data and user inputs.
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
- # Define required column mappings for flexibility in input CSVs
72
- required_columns = {
73
- "query": ["query", "keyword", "queries"],
74
- "impressions": ["impressions"],
75
- "position": ["position", "avg. position", "average position"],
76
- "cpc": ["cpc"],
77
- }
78
- found_columns = {}
79
- for key, options in required_columns.items():
80
- for opt in options:
81
- if opt in df.columns:
82
- found_columns[key] = opt
83
- break
84
- if key not in found_columns:
85
- st.error(f"Missing required column: {key}. Please ensure your CSV has one of {options}.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  return None, pd.DataFrame()
87
- # Rename columns to a standardized format for easier processing
88
- df = df.rename(columns={found_columns[k]: k for k in found_columns})
89
- # Define Click-Through Rate (CTR) benchmarks by position
90
- # These are illustrative CTRs for positions 1-20
91
- 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])}
92
- ctr_benchmarks.update({i: 0.005 for i in range(11, 21)})
93
- # Helper function to get CTR based on position, defaulting to 0.005 for positions > 20
94
- get_ctr = lambda p: ctr_benchmarks.get(int(round(p)), 0.005)
95
-
96
- df["current_ctr"] = df["position"].apply(get_ctr)
97
-
98
- # Optimized: Calculate the target CTR value once and assign it to the whole column
99
- target_ctr_value = ctr_benchmarks.get(int(round(target_position)), 0.005)
100
- df["target_ctr"] = target_ctr_value # Optimized line
101
-
102
- df["current_clicks"] = df["impressions"] * df["current_ctr"]
103
- df["projected_clicks"] = df["impressions"] * df["target_ctr"]
104
- df["incremental_clicks"] = df["projected_clicks"] - df["current_clicks"]
105
- df["avoided_paid_spend"] = df["incremental_clicks"] * df["cpc"]
106
- # --- Financial calculations ---
107
- total_avoided_paid_spend = df["avoided_paid_spend"].sum()
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
- return "🎯 Reached Target" # Position is at or better than target, no further incremental clicks expected
129
- df["impact_category"] = df.apply(categorize_impact, axis=1)
130
- # Return calculated metrics and the detailed DataFrame
131
- return {
132
- "total_avoided_paid_spend": total_avoided_paid_spend,
133
- "net_savings_vs_paid": net_savings_vs_paid,
134
- "total_incremental_conversions": total_incremental_conversions,
135
- "total_incremental_customers": total_incremental_customers,
136
- "incremental_mrr": incremental_mrr,
137
- "seo_roi": seo_roi,
138
- }, df
139
-
140
- # Set Streamlit page configuration for a wider layout and a descriptive title.
141
- st.set_page_config(page_title="SEO ROI & Savings Forecasting", layout="wide")
142
- st.title("πŸ“ˆ B2B SaaS SEO ROI & Savings Simulator")
143
- st.markdown("App created by [Emilija Gjorgjevska](https://www.linkedin.com/in/emilijagjorgjevska/)")
144
-
145
- # ℹ️ How the app works
146
- # This section provides an expandable information box explaining the app's methodology.
147
- with st.expander("ℹ️ How the app works", expanded=True):
148
- st.markdown(
149
- """
150
- <div style="background-color: #f0f2f6; padding: 20px; border-radius: 10px;">
151
- <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>
152
- <p>2. <b>CTR benchmarks</b> by position map an expected click-through rate for positions 1–20.</p>
153
- <p>3. <b>Incremental Clicks</b> = Projected_Clicks – Current_Clicks</p>
154
- <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ Current_Clicks = Impressions Γ— Current_CTR</p>
155
- <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ Projected_Clicks = Impressions Γ— Target_CTR</p>
156
- <p>4. <b>Financials</b></p>
157
- <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ <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>
158
- <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ <b>Net Savings vs Paid</b> = Avoided Paid Spend – SEO Investment</p>
159
- <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ <b>Incremental MRR</b> = Customers Γ— MRR_per_Customer</p>
160
- <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ <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
- # --- Main app logic ---
220
- # This block orchestrates the flow of the Streamlit application.
221
- df = load_csv(uploaded_file) # Load the data first, passing the uploaded file object
222
- if df is not None: # Proceed only if data loading was successful
223
- # Perform the core calculations
224
- metrics, df_results = calculate(
225
- df.copy(),
226
- target_position,
227
- conversion_rate,
228
- close_rate,
229
- mrr_per_customer,
230
- seo_cost,
231
- add_spend,
232
- )
233
- if metrics is not None: # Proceed only if calculations were successful
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- label="Incremental MRR (Monthly Recurring Revenue) πŸš€",
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
- label="SEO ROI (Return on Investment) πŸ’°",
267
- value=f"{metrics['seo_roi']:.2%}",
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>&nbsp;&nbsp;&nbsp;&nbsp;β€’ Current_Clicks = Impressions Γ— Current_CTR</p>
171
+ <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ Projected_Clicks = Impressions Γ— Target_CTR</p>
172
+ <p>4. <b>Financials</b></p>
173
+ <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ <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>&nbsp;&nbsp;&nbsp;&nbsp;β€’ <b>Net Savings vs Paid</b> = Avoided Paid Spend – SEO Investment</p>
175
+ <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ <b>Incremental MRR</b> = Customers Γ— MRR_per_Customer</p>
176
+ <p>&nbsp;&nbsp;&nbsp;&nbsp;β€’ <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()