feat(seo): Display Google Search Console data on SEO audit dashboard
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Add GSC columns to DB, persist OAuth data during audits, and render
clicks/impressions/CTR/position with top queries table on the dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-08 18:38:17 +01:00
parent 14bd4f600d
commit bc2bc4f556
5 changed files with 105 additions and 0 deletions

View File

@ -193,6 +193,13 @@ def _collect_seo_data(db, company) -> dict:
analysis.crux_lcp_good_pct = crux_data.get('crux_lcp_ms_good_pct')
analysis.crux_inp_good_pct = crux_data.get('crux_inp_ms_good_pct')
analysis.crux_period_end = crux_data.get('crux_period_end')
if search_console_data:
analysis.gsc_clicks = search_console_data.get('clicks')
analysis.gsc_impressions = search_console_data.get('impressions')
analysis.gsc_ctr = search_console_data.get('ctr')
analysis.gsc_avg_position = search_console_data.get('position')
analysis.gsc_top_queries = search_console_data.get('top_queries', [])
analysis.gsc_period_days = search_console_data.get('period_days', 28)
db.commit()
except Exception as e:
logger.warning(f"Failed to persist live metrics for {company.name}: {e}")

View File

@ -166,6 +166,13 @@ def seo_audit_dashboard(slug):
'modern_image_count': analysis.modern_image_count,
'legacy_image_count': analysis.legacy_image_count,
'modern_image_ratio': float(analysis.modern_image_ratio) if analysis.modern_image_ratio is not None else None,
# Google Search Console (OAuth)
'gsc_clicks': analysis.gsc_clicks,
'gsc_impressions': analysis.gsc_impressions,
'gsc_ctr': float(analysis.gsc_ctr) if analysis.gsc_ctr is not None else None,
'gsc_avg_position': float(analysis.gsc_avg_position) if analysis.gsc_avg_position is not None else None,
'gsc_top_queries': analysis.gsc_top_queries,
'gsc_period_days': analysis.gsc_period_days,
# Citations list
'citations': [{'directory_name': c.directory_name, 'listing_url': c.listing_url, 'status': c.status, 'nap_accurate': c.nap_accurate} for c in citations],
}

View File

@ -1157,6 +1157,14 @@ class CompanyWebsiteAnalysis(Base):
google_maps_links = Column(JSONB) # Places API: directionsUri, writeAReviewUri, etc.
google_open_now = Column(Boolean) # Whether business is currently open (at audit time)
# === GOOGLE SEARCH CONSOLE (OAuth) ===
gsc_clicks = Column(Integer) # Total clicks from Google Search in period
gsc_impressions = Column(Integer) # Total impressions in Google Search in period
gsc_ctr = Column(Numeric(5, 2)) # Click-through rate as percentage
gsc_avg_position = Column(Numeric(5, 1)) # Average position in search results
gsc_top_queries = Column(JSONB) # Top search queries with clicks/impressions
gsc_period_days = Column(Integer, default=28) # Data collection period in days
# === SEO AUDIT METADATA ===
seo_audit_version = Column(String(20)) # Version of SEO audit script used
seo_audited_at = Column(DateTime) # Timestamp of last SEO audit

View File

@ -0,0 +1,9 @@
-- Migration 063: Add Google Search Console (OAuth) columns to company_website_analysis
-- These columns persist GSC data collected via OAuth during SEO audits
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_clicks INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_impressions INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_ctr NUMERIC(5,2);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_avg_position NUMERIC(5,1);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_top_queries JSONB;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_period_days INTEGER DEFAULT 28;

View File

@ -683,6 +683,80 @@
</div>
{% endif %}
<!-- Google Search Console Section -->
{% if seo_data.gsc_clicks is not none %}
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
Google Search Console
<span style="font-size: var(--font-size-xs); background: #4285f4; color: white; padding: 2px 8px; border-radius: 12px; font-weight: 500; margin-left: 8px;">OAuth</span>
</h2>
<div style="background: var(--surface); padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-xl);">
<p style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin: 0 0 var(--spacing-md) 0;">Dane z Google Search za ostatnie {{ seo_data.gsc_period_days or 28 }} dni</p>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-name">Klikniecia</div>
<div class="metric-value">{{ '{:,}'.format(seo_data.gsc_clicks)|replace(',', ' ') }}</div>
</div>
<div class="metric-card">
<div class="metric-name">Wyswietlenia</div>
<div class="metric-value">{{ '{:,}'.format(seo_data.gsc_impressions)|replace(',', ' ') }}</div>
</div>
{% if seo_data.gsc_ctr is not none %}
<div class="metric-card">
<div class="metric-name">CTR</div>
<div class="metric-value">{{ '%.1f'|format(seo_data.gsc_ctr) }}%</div>
</div>
{% endif %}
{% if seo_data.gsc_avg_position is not none %}
<div class="metric-card">
<div class="metric-name">Srednia pozycja</div>
<div class="metric-value">{{ '%.1f'|format(seo_data.gsc_avg_position) }}</div>
</div>
{% endif %}
</div>
{% if seo_data.gsc_top_queries %}
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin: var(--spacing-lg) 0 var(--spacing-sm) 0;">Top zapytania w Google</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: var(--font-size-xs);">
<thead>
<tr style="border-bottom: 2px solid var(--border-color);">
<th style="text-align: left; padding: 8px 12px; font-weight: 600;">Zapytanie</th>
<th style="text-align: right; padding: 8px 12px; font-weight: 600;">Klikniecia</th>
<th style="text-align: right; padding: 8px 12px; font-weight: 600;">Wyswietlenia</th>
<th style="text-align: right; padding: 8px 12px; font-weight: 600;">CTR</th>
<th style="text-align: right; padding: 8px 12px; font-weight: 600;">Pozycja</th>
</tr>
</thead>
<tbody>
{% for q in seo_data.gsc_top_queries[:5] %}
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 8px 12px; font-weight: 500;">{{ q.query }}</td>
<td style="text-align: right; padding: 8px 12px;">{{ q.clicks }}</td>
<td style="text-align: right; padding: 8px 12px;">{{ q.impressions }}</td>
<td style="text-align: right; padding: 8px 12px;">{{ '%.1f'|format(q.ctr) }}%</td>
<td style="text-align: right; padding: 8px 12px;">{{ '%.1f'|format(q.position) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% else %}
<!-- GSC CTA - no data available -->
<div style="background: var(--surface); padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-xl); text-align: center;">
<svg width="32" height="32" fill="none" stroke="#9ca3af" viewBox="0 0 24 24" style="margin-bottom: var(--spacing-sm);">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<p style="color: var(--text-secondary); margin: 0 0 var(--spacing-sm) 0; font-size: var(--font-size-sm);">Polacz Google Search Console aby zobaczyc dane o widocznosci w wyszukiwarce</p>
<a href="/konto/integracje" style="display: inline-block; padding: 8px 20px; background: #4285f4; color: white; border-radius: var(--radius-md); text-decoration: none; font-size: var(--font-size-xs); font-weight: 500;">Polacz Search Console</a>
</div>
{% endif %}
{% if seo_data.local_seo_score is not none %}
<!-- Local SEO Section -->
<h2 class="section-title">