feat(api): Expand Google API coverage to ~100% (GBP Performance + GSC extensions)
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
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 GBP Performance API integration for visibility metrics (Maps/Search impressions, call/website clicks, direction requests, search keywords). Extend Search Console with URL Inspection, Sitemaps, device/country/type breakdowns, and period-over-period trend comparison. Change OAuth scope from webmasters.readonly to webmasters for URL Inspection support. Migration 064 adds 24 new columns to company_website_analysis. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7802dcd5e1
commit
e8b7f2214f
@ -451,12 +451,61 @@ def api_seo_audit_trigger():
|
||||
company_id=company.id
|
||||
).first()
|
||||
if analysis_record:
|
||||
# Basic metrics
|
||||
analysis_record.gsc_clicks = gsc_data.get('clicks')
|
||||
analysis_record.gsc_impressions = gsc_data.get('impressions')
|
||||
analysis_record.gsc_ctr = gsc_data.get('ctr')
|
||||
analysis_record.gsc_avg_position = gsc_data.get('position')
|
||||
analysis_record.gsc_top_queries = gsc_data.get('top_queries', [])
|
||||
analysis_record.gsc_top_pages = gsc_data.get('top_pages', [])
|
||||
analysis_record.gsc_period_days = gsc_data.get('period_days', 28)
|
||||
|
||||
# Extended GSC data collection
|
||||
try:
|
||||
# Device breakdown
|
||||
device_data = gsc.get_device_breakdown(company.website, days=28)
|
||||
if device_data:
|
||||
analysis_record.gsc_device_breakdown = device_data
|
||||
|
||||
# Country breakdown
|
||||
country_data = gsc.get_country_breakdown(company.website, days=28)
|
||||
if country_data:
|
||||
analysis_record.gsc_country_breakdown = country_data
|
||||
|
||||
# Search type breakdown
|
||||
type_data = gsc.get_search_type_breakdown(company.website, days=28)
|
||||
if type_data:
|
||||
analysis_record.gsc_search_type_breakdown = type_data
|
||||
|
||||
# Trend data (period-over-period)
|
||||
trend_data = gsc.get_trend_data(company.website, days=28)
|
||||
if trend_data:
|
||||
analysis_record.gsc_trend_data = trend_data
|
||||
|
||||
# URL Inspection (for homepage)
|
||||
homepage = company.website
|
||||
if homepage and not homepage.endswith('/'):
|
||||
homepage += '/'
|
||||
inspection = gsc.inspect_url(company.website, homepage)
|
||||
if inspection:
|
||||
analysis_record.gsc_index_status = inspection.get('index_status')
|
||||
last_crawl = inspection.get('last_crawl')
|
||||
if last_crawl:
|
||||
try:
|
||||
from datetime import datetime as dt
|
||||
analysis_record.gsc_last_crawl = dt.fromisoformat(last_crawl.replace('Z', '+00:00'))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
analysis_record.gsc_crawled_as = inspection.get('crawled_as')
|
||||
|
||||
# Sitemaps
|
||||
sitemaps = gsc.get_sitemaps(company.website)
|
||||
if sitemaps:
|
||||
analysis_record.gsc_sitemaps = sitemaps
|
||||
|
||||
except Exception as ext_err:
|
||||
logger.warning(f"Extended GSC data collection failed for company {company.id}: {ext_err}")
|
||||
|
||||
db.commit()
|
||||
logger.info(f"GSC data saved for company {company.id}: {gsc_data.get('clicks', 0)} clicks")
|
||||
except ImportError:
|
||||
|
||||
@ -173,6 +173,16 @@ def seo_audit_dashboard(slug):
|
||||
'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,
|
||||
# GSC Extended data
|
||||
'gsc_top_pages': getattr(analysis, 'gsc_top_pages', None),
|
||||
'gsc_device_breakdown': getattr(analysis, 'gsc_device_breakdown', None),
|
||||
'gsc_index_status': getattr(analysis, 'gsc_index_status', None),
|
||||
'gsc_last_crawl': getattr(analysis, 'gsc_last_crawl', None),
|
||||
'gsc_crawled_as': getattr(analysis, 'gsc_crawled_as', None),
|
||||
'gsc_sitemaps': getattr(analysis, 'gsc_sitemaps', None),
|
||||
'gsc_country_breakdown': getattr(analysis, 'gsc_country_breakdown', None),
|
||||
'gsc_search_type_breakdown': getattr(analysis, 'gsc_search_type_breakdown', None),
|
||||
'gsc_trend_data': getattr(analysis, 'gsc_trend_data', None),
|
||||
# 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],
|
||||
}
|
||||
@ -369,6 +379,20 @@ def gbp_audit_dashboard(slug):
|
||||
'google_name': analysis.google_name,
|
||||
'google_address': analysis.google_address,
|
||||
'google_phone': analysis.google_phone,
|
||||
# GBP Performance API data
|
||||
'gbp_impressions_maps': getattr(analysis, 'gbp_impressions_maps', None),
|
||||
'gbp_impressions_search': getattr(analysis, 'gbp_impressions_search', None),
|
||||
'gbp_call_clicks': getattr(analysis, 'gbp_call_clicks', None),
|
||||
'gbp_website_clicks': getattr(analysis, 'gbp_website_clicks', None),
|
||||
'gbp_direction_requests': getattr(analysis, 'gbp_direction_requests', None),
|
||||
'gbp_conversations': getattr(analysis, 'gbp_conversations', None),
|
||||
'gbp_search_keywords': getattr(analysis, 'gbp_search_keywords', None),
|
||||
'gbp_performance_period_days': getattr(analysis, 'gbp_performance_period_days', None),
|
||||
# Owner data
|
||||
'google_owner_responses_count': getattr(analysis, 'google_owner_responses_count', None),
|
||||
'google_review_response_rate': float(analysis.google_review_response_rate) if getattr(analysis, 'google_review_response_rate', None) is not None else None,
|
||||
'google_posts_data': getattr(analysis, 'google_posts_data', None),
|
||||
'google_posts_count': getattr(analysis, 'google_posts_count', None),
|
||||
}
|
||||
|
||||
# If no audit exists, we still render the page (template handles this)
|
||||
|
||||
27
database.py
27
database.py
@ -1165,6 +1165,33 @@ class CompanyWebsiteAnalysis(Base):
|
||||
gsc_top_queries = Column(JSONB) # Top search queries with clicks/impressions
|
||||
gsc_period_days = Column(Integer, default=28) # Data collection period in days
|
||||
|
||||
# === GBP PERFORMANCE API ===
|
||||
gbp_impressions_maps = Column(Integer) # Maps views (desktop + mobile) in period
|
||||
gbp_impressions_search = Column(Integer) # Search views (desktop + mobile) in period
|
||||
gbp_call_clicks = Column(Integer) # Phone call clicks in period
|
||||
gbp_website_clicks = Column(Integer) # Website clicks in period
|
||||
gbp_direction_requests = Column(Integer) # Direction requests in period
|
||||
gbp_conversations = Column(Integer) # Conversations started in period
|
||||
gbp_search_keywords = Column(JSONB) # Top search keywords with impression counts
|
||||
gbp_performance_period_days = Column(Integer, default=30) # Performance data period
|
||||
|
||||
# === GOOGLE SEARCH CONSOLE EXTENSIONS ===
|
||||
gsc_top_pages = Column(JSONB) # Top pages with clicks/impressions
|
||||
gsc_device_breakdown = Column(JSONB) # {desktop: {clicks, impressions}, mobile: {...}, tablet: {...}}
|
||||
gsc_index_status = Column(String(50)) # URL Inspection: PASS, NEUTRAL, FAIL, etc.
|
||||
gsc_last_crawl = Column(DateTime) # URL Inspection: last crawl timestamp
|
||||
gsc_crawled_as = Column(String(50)) # URL Inspection: Googlebot type
|
||||
gsc_sitemaps = Column(JSONB) # Sitemaps list with status/errors
|
||||
gsc_country_breakdown = Column(JSONB) # Top countries with clicks/impressions
|
||||
gsc_search_type_breakdown = Column(JSONB) # Web/image/video breakdown
|
||||
gsc_trend_data = Column(JSONB) # Period-over-period comparison
|
||||
|
||||
# === GBP OWNER DATA (Management API) ===
|
||||
google_owner_responses_count = Column(Integer) # Number of owner responses to reviews
|
||||
google_review_response_rate = Column(Numeric(5, 1)) # % of reviews with owner response
|
||||
google_posts_data = Column(JSONB) # Recent Google Posts data
|
||||
google_posts_count = Column(Integer) # Total number of Google Posts
|
||||
|
||||
# === 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
|
||||
|
||||
32
database/migrations/064_google_api_expansion.sql
Normal file
32
database/migrations/064_google_api_expansion.sql
Normal file
@ -0,0 +1,32 @@
|
||||
-- Migration 064: Expand Google API data columns (GBP Performance + GSC extensions + Owner Data)
|
||||
-- Part of Google OAuth 100% coverage initiative
|
||||
|
||||
-- === GBP PERFORMANCE API ===
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gbp_impressions_maps INTEGER;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gbp_impressions_search INTEGER;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gbp_call_clicks INTEGER;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gbp_website_clicks INTEGER;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gbp_direction_requests INTEGER;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gbp_conversations INTEGER;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gbp_search_keywords JSONB;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gbp_performance_period_days INTEGER DEFAULT 30;
|
||||
|
||||
-- === GOOGLE SEARCH CONSOLE EXTENSIONS ===
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_top_pages JSONB;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_device_breakdown JSONB;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_index_status VARCHAR(50);
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_last_crawl TIMESTAMP;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_crawled_as VARCHAR(50);
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_sitemaps JSONB;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_country_breakdown JSONB;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_search_type_breakdown JSONB;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS gsc_trend_data JSONB;
|
||||
|
||||
-- === GBP OWNER DATA (Management API) ===
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS google_owner_responses_count INTEGER;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS google_review_response_rate NUMERIC(5,1);
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS google_posts_data JSONB;
|
||||
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS google_posts_count INTEGER;
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL ON TABLE company_website_analysis TO nordabiz_app;
|
||||
@ -1933,6 +1933,36 @@ def fetch_google_business_data(
|
||||
except Exception as e:
|
||||
logger.warning(f"OAuth GBP enrichment failed for company {company_id}: {e}")
|
||||
|
||||
# OAuth: Try GBP Performance API for visibility metrics
|
||||
try:
|
||||
from gbp_performance_service import GBPPerformanceService
|
||||
|
||||
if gbp_token and location_name:
|
||||
perf_service = GBPPerformanceService(gbp_token)
|
||||
# Extract location ID from location_name (format: accounts/X/locations/Y)
|
||||
# Performance API uses locations/Y format
|
||||
parts = location_name.split('/')
|
||||
if len(parts) >= 4:
|
||||
perf_location = f"locations/{parts[3]}"
|
||||
else:
|
||||
perf_location = location_name
|
||||
|
||||
perf_data = perf_service.get_all_performance_data(perf_location, days=30)
|
||||
if perf_data:
|
||||
result['data']['gbp_impressions_maps'] = perf_data.get('maps_impressions', 0)
|
||||
result['data']['gbp_impressions_search'] = perf_data.get('search_impressions', 0)
|
||||
result['data']['gbp_call_clicks'] = perf_data.get('call_clicks', 0)
|
||||
result['data']['gbp_website_clicks'] = perf_data.get('website_clicks', 0)
|
||||
result['data']['gbp_direction_requests'] = perf_data.get('direction_requests', 0)
|
||||
result['data']['gbp_conversations'] = perf_data.get('conversations', 0)
|
||||
result['data']['gbp_search_keywords'] = perf_data.get('search_keywords', [])
|
||||
result['data']['gbp_performance_period_days'] = perf_data.get('period_days', 30)
|
||||
logger.info(f"GBP Performance data collected for company {company_id}")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"GBP Performance API failed for company {company_id}: {e}")
|
||||
|
||||
# Step 3: Save to database
|
||||
result['steps'].append({
|
||||
'step': 'save_data',
|
||||
@ -1979,6 +2009,18 @@ def fetch_google_business_data(
|
||||
('google_photos_metadata', photos_meta if photos_meta else None),
|
||||
('google_maps_links', maps_links if maps_links else None),
|
||||
('google_open_now', open_now),
|
||||
('gbp_impressions_maps', result['data'].get('gbp_impressions_maps')),
|
||||
('gbp_impressions_search', result['data'].get('gbp_impressions_search')),
|
||||
('gbp_call_clicks', result['data'].get('gbp_call_clicks')),
|
||||
('gbp_website_clicks', result['data'].get('gbp_website_clicks')),
|
||||
('gbp_direction_requests', result['data'].get('gbp_direction_requests')),
|
||||
('gbp_conversations', result['data'].get('gbp_conversations')),
|
||||
('gbp_search_keywords', result['data'].get('gbp_search_keywords')),
|
||||
('gbp_performance_period_days', result['data'].get('gbp_performance_period_days')),
|
||||
('google_owner_responses_count', result['data'].get('google_owner_responses_count')),
|
||||
('google_review_response_rate', result['data'].get('google_review_response_rate')),
|
||||
('google_posts_data', result['data'].get('google_posts_data')),
|
||||
('google_posts_count', result['data'].get('google_posts_count')),
|
||||
]:
|
||||
try:
|
||||
setattr(analysis, attr, val)
|
||||
|
||||
194
gbp_performance_service.py
Normal file
194
gbp_performance_service.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""
|
||||
Google Business Profile Performance API Client
|
||||
===============================================
|
||||
|
||||
Uses OAuth 2.0 to fetch visibility and interaction metrics from GBP.
|
||||
Provides daily metrics time series and monthly search keyword impressions.
|
||||
|
||||
API docs: https://developers.google.com/my-business/reference/performance/rest
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Metrics to request from getDailyMetricsTimeSeries
|
||||
DAILY_METRICS = [
|
||||
'BUSINESS_IMPRESSIONS_DESKTOP_MAPS',
|
||||
'BUSINESS_IMPRESSIONS_MOBILE_MAPS',
|
||||
'BUSINESS_IMPRESSIONS_DESKTOP_SEARCH',
|
||||
'BUSINESS_IMPRESSIONS_MOBILE_SEARCH',
|
||||
'CALL_CLICKS',
|
||||
'WEBSITE_CLICKS',
|
||||
'BUSINESS_DIRECTION_REQUESTS',
|
||||
'BUSINESS_CONVERSATIONS',
|
||||
'BUSINESS_BOOKINGS',
|
||||
]
|
||||
|
||||
|
||||
class GBPPerformanceService:
|
||||
"""Google Business Profile Performance API client."""
|
||||
|
||||
BASE_URL = "https://businessprofileperformance.googleapis.com/v1"
|
||||
|
||||
def __init__(self, access_token: str):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
self.session.timeout = 20
|
||||
|
||||
def get_daily_metrics(self, location_name: str, days: int = 30) -> Dict:
|
||||
"""Get aggregated daily metrics for a location.
|
||||
|
||||
Args:
|
||||
location_name: Full location resource name (e.g., 'locations/123456')
|
||||
days: Number of days to aggregate (default 30)
|
||||
|
||||
Returns:
|
||||
Dict with aggregated metrics:
|
||||
{
|
||||
'maps_impressions': int,
|
||||
'search_impressions': int,
|
||||
'call_clicks': int,
|
||||
'website_clicks': int,
|
||||
'direction_requests': int,
|
||||
'conversations': int,
|
||||
'period_days': int,
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
'maps_impressions': 0,
|
||||
'search_impressions': 0,
|
||||
'call_clicks': 0,
|
||||
'website_clicks': 0,
|
||||
'direction_requests': 0,
|
||||
'conversations': 0,
|
||||
'period_days': days,
|
||||
}
|
||||
|
||||
end_date = datetime.now() - timedelta(days=1) # Yesterday (today may be incomplete)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Normalize location_name format
|
||||
if not location_name.startswith('locations/'):
|
||||
location_name = f'locations/{location_name}'
|
||||
|
||||
for metric in DAILY_METRICS:
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.BASE_URL}/{location_name}:getDailyMetricsTimeSeries",
|
||||
params={
|
||||
'dailyMetric': metric,
|
||||
'dailyRange.startDate.year': start_date.year,
|
||||
'dailyRange.startDate.month': start_date.month,
|
||||
'dailyRange.startDate.day': start_date.day,
|
||||
'dailyRange.endDate.year': end_date.year,
|
||||
'dailyRange.endDate.month': end_date.month,
|
||||
'dailyRange.endDate.day': end_date.day,
|
||||
}
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.debug(f"Performance API metric {metric} returned {resp.status_code}")
|
||||
continue
|
||||
|
||||
data = resp.json()
|
||||
time_series = data.get('timeSeries', {})
|
||||
daily_values = time_series.get('datedValues', [])
|
||||
|
||||
total = sum(
|
||||
dv.get('value', 0) for dv in daily_values
|
||||
if dv.get('value') is not None
|
||||
)
|
||||
|
||||
# Map metric to result key
|
||||
if 'MAPS' in metric:
|
||||
result['maps_impressions'] += total
|
||||
elif 'SEARCH' in metric:
|
||||
result['search_impressions'] += total
|
||||
elif metric == 'CALL_CLICKS':
|
||||
result['call_clicks'] = total
|
||||
elif metric == 'WEBSITE_CLICKS':
|
||||
result['website_clicks'] = total
|
||||
elif metric == 'BUSINESS_DIRECTION_REQUESTS':
|
||||
result['direction_requests'] = total
|
||||
elif metric == 'BUSINESS_CONVERSATIONS':
|
||||
result['conversations'] = total
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Performance API metric {metric} failed: {e}")
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"GBP Performance for {location_name}: "
|
||||
f"maps={result['maps_impressions']}, search={result['search_impressions']}, "
|
||||
f"calls={result['call_clicks']}, web={result['website_clicks']}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def get_search_keywords(self, location_name: str) -> List[Dict]:
|
||||
"""Get monthly search keyword impressions.
|
||||
|
||||
Args:
|
||||
location_name: Full location resource name
|
||||
|
||||
Returns:
|
||||
List of dicts: [{'keyword': str, 'impressions': int}, ...]
|
||||
"""
|
||||
# Normalize location_name format
|
||||
if not location_name.startswith('locations/'):
|
||||
location_name = f'locations/{location_name}'
|
||||
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.BASE_URL}/{location_name}/searchkeywords/impressions/monthly",
|
||||
params={'pageSize': 20}
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.debug(f"Search keywords API returned {resp.status_code}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
keywords = []
|
||||
|
||||
for item in data.get('searchKeywordsCounts', []):
|
||||
keyword = item.get('searchKeyword', '')
|
||||
# Get the most recent month's data
|
||||
monthly = item.get('insightsValue', {})
|
||||
impressions = monthly.get('value', 0)
|
||||
|
||||
if keyword:
|
||||
keywords.append({
|
||||
'keyword': keyword,
|
||||
'impressions': impressions,
|
||||
})
|
||||
|
||||
# Sort by impressions descending
|
||||
keywords.sort(key=lambda x: x['impressions'], reverse=True)
|
||||
|
||||
logger.info(f"GBP search keywords for {location_name}: {len(keywords)} keywords")
|
||||
return keywords[:20]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"GBP search keywords failed for {location_name}: {e}")
|
||||
return []
|
||||
|
||||
def get_all_performance_data(self, location_name: str, days: int = 30) -> Dict:
|
||||
"""Get all performance data (metrics + keywords) in one call.
|
||||
|
||||
Returns:
|
||||
Dict with all performance data combined.
|
||||
"""
|
||||
metrics = self.get_daily_metrics(location_name, days)
|
||||
keywords = self.get_search_keywords(location_name)
|
||||
metrics['search_keywords'] = keywords
|
||||
return metrics
|
||||
@ -29,7 +29,7 @@ OAUTH_PROVIDERS = {
|
||||
'token_url': 'https://oauth2.googleapis.com/token',
|
||||
'scopes': {
|
||||
'gbp': 'https://www.googleapis.com/auth/business.manage',
|
||||
'search_console': 'https://www.googleapis.com/auth/webmasters.readonly',
|
||||
'search_console': 'https://www.googleapis.com/auth/webmasters',
|
||||
},
|
||||
},
|
||||
'meta': {
|
||||
|
||||
@ -163,3 +163,298 @@ class SearchConsoleService:
|
||||
except Exception as e:
|
||||
logger.error(f"Search Console analytics failed for {site_url}: {e}")
|
||||
return {}
|
||||
|
||||
def get_device_breakdown(self, site_url: str, days: int = 28) -> Dict:
|
||||
"""Get clicks/impressions breakdown by device type.
|
||||
|
||||
Returns:
|
||||
Dict like {'desktop': {'clicks': N, 'impressions': N}, 'mobile': {...}, 'tablet': {...}}
|
||||
"""
|
||||
normalized = self._normalize_site_url(site_url)
|
||||
if not normalized:
|
||||
return {}
|
||||
|
||||
end_date = datetime.now() - timedelta(days=3)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
try:
|
||||
resp = self.session.post(
|
||||
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
|
||||
json={
|
||||
'startDate': start_date.strftime('%Y-%m-%d'),
|
||||
'endDate': end_date.strftime('%Y-%m-%d'),
|
||||
'dimensions': ['device'],
|
||||
}
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
for row in resp.json().get('rows', []):
|
||||
device = row['keys'][0].lower()
|
||||
result[device] = {
|
||||
'clicks': row.get('clicks', 0),
|
||||
'impressions': row.get('impressions', 0),
|
||||
'ctr': round(row.get('ctr', 0) * 100, 2),
|
||||
'position': round(row.get('position', 0), 1),
|
||||
}
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Device breakdown failed for {site_url}: {e}")
|
||||
return {}
|
||||
|
||||
def get_country_breakdown(self, site_url: str, days: int = 28) -> List[Dict]:
|
||||
"""Get top countries by clicks/impressions.
|
||||
|
||||
Returns:
|
||||
List of dicts: [{'country': 'POL', 'clicks': N, 'impressions': N}, ...]
|
||||
"""
|
||||
normalized = self._normalize_site_url(site_url)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
end_date = datetime.now() - timedelta(days=3)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
try:
|
||||
resp = self.session.post(
|
||||
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
|
||||
json={
|
||||
'startDate': start_date.strftime('%Y-%m-%d'),
|
||||
'endDate': end_date.strftime('%Y-%m-%d'),
|
||||
'dimensions': ['country'],
|
||||
'rowLimit': 10,
|
||||
}
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
'country': row['keys'][0],
|
||||
'clicks': row.get('clicks', 0),
|
||||
'impressions': row.get('impressions', 0),
|
||||
}
|
||||
for row in resp.json().get('rows', [])
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Country breakdown failed for {site_url}: {e}")
|
||||
return []
|
||||
|
||||
def get_search_type_breakdown(self, site_url: str, days: int = 28) -> Dict:
|
||||
"""Get breakdown by search type (web, image, video, news).
|
||||
|
||||
Returns:
|
||||
Dict like {'web': {'clicks': N, 'impressions': N}, 'image': {...}, ...}
|
||||
"""
|
||||
normalized = self._normalize_site_url(site_url)
|
||||
if not normalized:
|
||||
return {}
|
||||
|
||||
end_date = datetime.now() - timedelta(days=3)
|
||||
start_date = end_date - timedelta(days=days)
|
||||
result = {}
|
||||
|
||||
for search_type in ['web', 'image', 'video', 'news']:
|
||||
try:
|
||||
resp = self.session.post(
|
||||
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
|
||||
json={
|
||||
'startDate': start_date.strftime('%Y-%m-%d'),
|
||||
'endDate': end_date.strftime('%Y-%m-%d'),
|
||||
'searchType': search_type,
|
||||
'dimensions': [],
|
||||
}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
rows = resp.json().get('rows', [])
|
||||
if rows:
|
||||
row = rows[0]
|
||||
result[search_type] = {
|
||||
'clicks': row.get('clicks', 0),
|
||||
'impressions': row.get('impressions', 0),
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
def get_trend_data(self, site_url: str, days: int = 28) -> Dict:
|
||||
"""Compare current period vs previous period.
|
||||
|
||||
Returns:
|
||||
Dict with current, previous values and % change:
|
||||
{
|
||||
'clicks': {'current': N, 'previous': N, 'change_pct': float},
|
||||
'impressions': {'current': N, 'previous': N, 'change_pct': float},
|
||||
'ctr': {'current': float, 'previous': float, 'change_pct': float},
|
||||
'position': {'current': float, 'previous': float, 'change_pct': float},
|
||||
}
|
||||
"""
|
||||
normalized = self._normalize_site_url(site_url)
|
||||
if not normalized:
|
||||
return {}
|
||||
|
||||
end_date = datetime.now() - timedelta(days=3)
|
||||
current_start = end_date - timedelta(days=days)
|
||||
prev_end = current_start - timedelta(days=1)
|
||||
prev_start = prev_end - timedelta(days=days)
|
||||
|
||||
def _get_totals(start, end):
|
||||
try:
|
||||
resp = self.session.post(
|
||||
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
|
||||
json={
|
||||
'startDate': start.strftime('%Y-%m-%d'),
|
||||
'endDate': end.strftime('%Y-%m-%d'),
|
||||
'dimensions': [],
|
||||
}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
rows = resp.json().get('rows', [])
|
||||
if rows:
|
||||
return rows[0]
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
current = _get_totals(current_start, end_date)
|
||||
previous = _get_totals(prev_start, prev_end)
|
||||
|
||||
if not current:
|
||||
return {}
|
||||
|
||||
def _calc_change(curr_val, prev_val):
|
||||
if prev_val and prev_val != 0:
|
||||
return round((curr_val - prev_val) / abs(prev_val) * 100, 1)
|
||||
return None
|
||||
|
||||
result = {}
|
||||
for key in ['clicks', 'impressions']:
|
||||
c = current.get(key, 0)
|
||||
p = previous.get(key, 0)
|
||||
result[key] = {
|
||||
'current': c,
|
||||
'previous': p,
|
||||
'change_pct': _calc_change(c, p),
|
||||
}
|
||||
|
||||
for key in ['ctr']:
|
||||
c = round(current.get(key, 0) * 100, 2)
|
||||
p = round(previous.get(key, 0) * 100, 2)
|
||||
result[key] = {
|
||||
'current': c,
|
||||
'previous': p,
|
||||
'change_pct': _calc_change(c, p),
|
||||
}
|
||||
|
||||
for key in ['position']:
|
||||
c = round(current.get(key, 0), 1)
|
||||
p = round(previous.get(key, 0), 1)
|
||||
# For position, lower is better, so invert the change
|
||||
change = _calc_change(c, p)
|
||||
result[key] = {
|
||||
'current': c,
|
||||
'previous': p,
|
||||
'change_pct': -change if change is not None else None,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def inspect_url(self, site_url: str, page_url: str) -> Dict:
|
||||
"""Inspect a URL's indexing status using URL Inspection API.
|
||||
|
||||
Requires 'webmasters' scope (not readonly).
|
||||
|
||||
Args:
|
||||
site_url: The site property URL (as registered in Search Console)
|
||||
page_url: The specific page URL to inspect
|
||||
|
||||
Returns:
|
||||
Dict with: index_status, last_crawl, crawled_as, canonical_url, is_indexed
|
||||
"""
|
||||
INSPECTION_URL = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect"
|
||||
|
||||
normalized = self._normalize_site_url(site_url)
|
||||
if not normalized:
|
||||
return {}
|
||||
|
||||
try:
|
||||
resp = self.session.post(
|
||||
INSPECTION_URL,
|
||||
json={
|
||||
'inspectionUrl': page_url,
|
||||
'siteUrl': normalized,
|
||||
}
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.debug(f"URL Inspection returned {resp.status_code} for {page_url}")
|
||||
return {}
|
||||
|
||||
data = resp.json()
|
||||
result_data = data.get('inspectionResult', {})
|
||||
index_status = result_data.get('indexStatusResult', {})
|
||||
crawl_result = index_status
|
||||
|
||||
return {
|
||||
'index_status': index_status.get('verdict', 'UNKNOWN'),
|
||||
'coverage_state': index_status.get('coverageState', ''),
|
||||
'robots_txt_state': index_status.get('robotsTxtState', ''),
|
||||
'indexing_state': index_status.get('indexingState', ''),
|
||||
'last_crawl': index_status.get('lastCrawlTime', ''),
|
||||
'crawled_as': index_status.get('crawledAs', ''),
|
||||
'canonical_url': index_status.get('googleCanonical', ''),
|
||||
'user_canonical': index_status.get('userCanonical', ''),
|
||||
'is_indexed': index_status.get('verdict') == 'PASS',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"URL Inspection failed for {page_url}: {e}")
|
||||
return {}
|
||||
|
||||
def get_sitemaps(self, site_url: str) -> List[Dict]:
|
||||
"""Get sitemaps status for a site.
|
||||
|
||||
Returns:
|
||||
List of dicts: [{'path': str, 'lastSubmitted': str, 'isPending': bool,
|
||||
'lastDownloaded': str, 'warnings': int, 'errors': int, ...}]
|
||||
"""
|
||||
normalized = self._normalize_site_url(site_url)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/sitemaps"
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.debug(f"Sitemaps API returned {resp.status_code}")
|
||||
return []
|
||||
|
||||
sitemaps = []
|
||||
for sm in resp.json().get('sitemap', []):
|
||||
sitemaps.append({
|
||||
'path': sm.get('path', ''),
|
||||
'last_submitted': sm.get('lastSubmitted', ''),
|
||||
'last_downloaded': sm.get('lastDownloaded', ''),
|
||||
'is_pending': sm.get('isPending', False),
|
||||
'warnings': sm.get('warnings', 0),
|
||||
'errors': sm.get('errors', 0),
|
||||
'contents': [
|
||||
{
|
||||
'type': c.get('type', ''),
|
||||
'submitted': c.get('submitted', 0),
|
||||
'indexed': c.get('indexed', 0),
|
||||
}
|
||||
for c in sm.get('contents', [])
|
||||
],
|
||||
})
|
||||
return sitemaps
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Sitemaps fetch failed for {site_url}: {e}")
|
||||
return []
|
||||
|
||||
@ -1561,6 +1561,142 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- GBP Performance Section (from Performance API) -->
|
||||
{% if places_data and places_data.gbp_impressions_maps is not none %}
|
||||
<div class="card" style="margin-bottom: var(--spacing-xl); padding: var(--spacing-xl);">
|
||||
<h2 class="section-title" style="margin-bottom: var(--spacing-lg);">
|
||||
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
Statystyki widocznosci ({{ places_data.gbp_performance_period_days or 30 }} dni)
|
||||
</h2>
|
||||
|
||||
<!-- Impressions -->
|
||||
<h3 style="font-size: var(--font-size-base); font-weight: 600; margin-bottom: var(--spacing-sm); color: var(--text-secondary);">Wyswietlenia profilu</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-lg);">
|
||||
<div style="padding: var(--spacing-md); background: #eff6ff; border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-2xl); font-weight: 700; color: #1d4ed8;">{{ '{:,}'.format(places_data.gbp_impressions_maps or 0) }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: #3b82f6; margin-top: 4px;">Google Maps</div>
|
||||
</div>
|
||||
<div style="padding: var(--spacing-md); background: #fef3c7; border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-2xl); font-weight: 700; color: #92400e;">{{ '{:,}'.format(places_data.gbp_impressions_search or 0) }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: #d97706; margin-top: 4px;">Wyszukiwarka Google</div>
|
||||
</div>
|
||||
<div style="padding: var(--spacing-md); background: #f0fdf4; border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-2xl); font-weight: 700; color: #166534;">{{ '{:,}'.format((places_data.gbp_impressions_maps or 0) + (places_data.gbp_impressions_search or 0)) }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: #16a34a; margin-top: 4px;">Lacznie</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<h3 style="font-size: var(--font-size-base); font-weight: 600; margin-bottom: var(--spacing-sm); color: var(--text-secondary);">Akcje klientow</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-lg);">
|
||||
<div style="padding: var(--spacing-md); background: var(--bg-tertiary); border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-xl); font-weight: 700; color: var(--text-primary);">{{ places_data.gbp_call_clicks or 0 }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 4px;">Klikniecia telefon</div>
|
||||
</div>
|
||||
<div style="padding: var(--spacing-md); background: var(--bg-tertiary); border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-xl); font-weight: 700; color: var(--text-primary);">{{ places_data.gbp_website_clicks or 0 }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 4px;">Klikniecia strona</div>
|
||||
</div>
|
||||
<div style="padding: var(--spacing-md); background: var(--bg-tertiary); border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-xl); font-weight: 700; color: var(--text-primary);">{{ places_data.gbp_direction_requests or 0 }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 4px;">Prosby o trase</div>
|
||||
</div>
|
||||
{% if places_data.gbp_conversations %}
|
||||
<div style="padding: var(--spacing-md); background: var(--bg-tertiary); border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-xl); font-weight: 700; color: var(--text-primary);">{{ places_data.gbp_conversations }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 4px;">Rozmowy</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Search Keywords -->
|
||||
{% if places_data.gbp_search_keywords %}
|
||||
<h3 style="font-size: var(--font-size-base); font-weight: 600; margin-bottom: var(--spacing-sm); color: var(--text-secondary);">Frazy wyszukiwania</h3>
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: var(--font-size-sm);">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid var(--border-color);">
|
||||
<th style="text-align: left; padding: 8px; color: var(--text-secondary); font-weight: 500;">#</th>
|
||||
<th style="text-align: left; padding: 8px; color: var(--text-secondary); font-weight: 500;">Fraza</th>
|
||||
<th style="text-align: right; padding: 8px; color: var(--text-secondary); font-weight: 500;">Wyswietlenia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for kw in places_data.gbp_search_keywords[:10] %}
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 8px; color: var(--text-secondary);">{{ loop.index }}</td>
|
||||
<td style="padding: 8px; font-weight: 500;">{{ kw.keyword }}</td>
|
||||
<td style="padding: 8px; text-align: right; color: var(--text-secondary);">{{ '{:,}'.format(kw.impressions) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Google Posts Section -->
|
||||
{% if places_data and places_data.google_posts_data %}
|
||||
<div class="card" style="margin-bottom: var(--spacing-xl); padding: var(--spacing-xl);">
|
||||
<h2 class="section-title" style="margin-bottom: var(--spacing-lg);">
|
||||
<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="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/>
|
||||
</svg>
|
||||
Google Posts ({{ places_data.google_posts_count or places_data.google_posts_data|length }})
|
||||
</h2>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: var(--spacing-md);">
|
||||
{% for post in places_data.google_posts_data[:5] %}
|
||||
<div style="padding: var(--spacing-md); border: 1px solid var(--border-color); border-radius: var(--radius-md);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-xs);">
|
||||
<span style="font-size: var(--font-size-xs); padding: 2px 8px; background: #eff6ff; color: #1d4ed8; border-radius: var(--radius-sm); font-weight: 500;">
|
||||
{{ post.topicType|default(post.get('searchUrl', 'POST')|default('Post'))|replace('_', ' ')|title }}
|
||||
</span>
|
||||
{% if post.createTime or post.get('createTime') %}
|
||||
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">
|
||||
{{ (post.createTime or post.get('createTime', ''))[:10] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% set summary = post.get('summary', post.get('text', '')) %}
|
||||
{% if summary %}
|
||||
<p style="margin: 0; font-size: var(--font-size-sm); color: var(--text-secondary); line-height: 1.5;">
|
||||
{{ summary[:200] }}{% if summary|length > 200 %}...{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Owner Response Rate (enhanced) -->
|
||||
{% if places_data and places_data.google_owner_responses_count is not none %}
|
||||
<div class="card" style="margin-bottom: var(--spacing-xl); padding: var(--spacing-xl);">
|
||||
<h2 class="section-title" style="margin-bottom: var(--spacing-lg);">
|
||||
<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="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/>
|
||||
</svg>
|
||||
Odpowiedzi na opinie
|
||||
</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--spacing-md);">
|
||||
<div style="padding: var(--spacing-md); background: var(--bg-tertiary); border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-xl); font-weight: 700; color: var(--text-primary);">{{ places_data.google_owner_responses_count }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 4px;">Odpowiedzi wlasciciela</div>
|
||||
</div>
|
||||
{% if places_data.google_review_response_rate is not none %}
|
||||
<div style="padding: var(--spacing-md); background: {{ '#dcfce7' if places_data.google_review_response_rate >= 80 else '#fef3c7' if places_data.google_review_response_rate >= 50 else '#fee2e2' }}; border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-xl); font-weight: 700; color: {{ '#166534' if places_data.google_review_response_rate >= 80 else '#92400e' if places_data.google_review_response_rate >= 50 else '#991b1b' }};">{{ places_data.google_review_response_rate }}%</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 4px;">Wskaznik odpowiedzi</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recommendations Section -->
|
||||
{% if audit.recommendations %}
|
||||
<div class="recommendations-section">
|
||||
|
||||
@ -1106,6 +1106,149 @@
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Top Pages -->
|
||||
{% if seo_data.gsc_top_pages %}
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin: var(--spacing-lg) 0 var(--spacing-sm) 0;">Top strony 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;">Strona</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in seo_data.gsc_top_pages[:10] %}
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 8px 12px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ p.page }}">{{ p.page|replace('https://', '')|replace('http://', '') }}</td>
|
||||
<td style="text-align: right; padding: 8px 12px;">{{ p.clicks }}</td>
|
||||
<td style="text-align: right; padding: 8px 12px;">{{ p.impressions }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Device Breakdown -->
|
||||
{% if seo_data.gsc_device_breakdown %}
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin: var(--spacing-lg) 0 var(--spacing-sm) 0;">Urzadzenia</h3>
|
||||
{% set devices = seo_data.gsc_device_breakdown %}
|
||||
{% set total_clicks = (devices.get('desktop', {}).get('clicks', 0) or 0) + (devices.get('mobile', {}).get('clicks', 0) or 0) + (devices.get('tablet', {}).get('clicks', 0) or 0) %}
|
||||
<div style="display: flex; gap: var(--spacing-sm); margin-bottom: var(--spacing-sm);">
|
||||
{% for device_name, device_data in devices.items() %}
|
||||
{% set pct = ((device_data.clicks / total_clicks * 100)|round(1)) if total_clicks > 0 else 0 %}
|
||||
<div style="flex: {{ pct or 1 }}; min-width: 60px; padding: var(--spacing-sm); background: {{ '#eff6ff' if device_name == 'desktop' else '#f0fdf4' if device_name == 'mobile' else '#fef3c7' }}; border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-lg); font-weight: 700;">{{ pct }}%</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ device_name|capitalize }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary);">{{ device_data.clicks }} klik.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Trend Data -->
|
||||
{% if seo_data.gsc_trend_data %}
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin: var(--spacing-lg) 0 var(--spacing-sm) 0;">Trend (vs poprzedni okres)</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: var(--spacing-sm);">
|
||||
{% set trend = seo_data.gsc_trend_data %}
|
||||
{% for metric_name, metric_label in [('clicks', 'Klikniecia'), ('impressions', 'Wyswietlenia'), ('ctr', 'CTR'), ('position', 'Pozycja')] %}
|
||||
{% if trend.get(metric_name) %}
|
||||
{% set m = trend[metric_name] %}
|
||||
{% set change = m.get('change_pct') %}
|
||||
<div style="padding: var(--spacing-sm); background: var(--bg-tertiary); border-radius: var(--radius-md); text-align: center;">
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-bottom: 4px;">{{ metric_label }}</div>
|
||||
<div style="font-size: var(--font-size-base); font-weight: 600;">
|
||||
{% if metric_name == 'ctr' %}{{ '%.1f'|format(m.current) }}%
|
||||
{% elif metric_name == 'position' %}{{ '%.1f'|format(m.current) }}
|
||||
{% else %}{{ '{:,}'.format(m.current)|replace(',', ' ') }}{% endif %}
|
||||
</div>
|
||||
{% if change is not none %}
|
||||
<div style="font-size: var(--font-size-xs); font-weight: 600; color: {{ '#10b981' if change > 0 else '#ef4444' if change < 0 else '#6b7280' }};">
|
||||
{{ '+' if change > 0 else '' }}{{ '%.1f'|format(change) }}%
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Country Breakdown -->
|
||||
{% if seo_data.gsc_country_breakdown %}
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin: var(--spacing-lg) 0 var(--spacing-sm) 0;">Kraje</h3>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-xs);">
|
||||
{% for c in seo_data.gsc_country_breakdown[:5] %}
|
||||
<span style="padding: 4px 10px; background: var(--bg-tertiary); border-radius: var(--radius-sm); font-size: var(--font-size-xs);">
|
||||
{{ c.country }}: {{ c.clicks }} klik.
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Search Type Breakdown -->
|
||||
{% if seo_data.gsc_search_type_breakdown %}
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin: var(--spacing-lg) 0 var(--spacing-sm) 0;">Typ wyszukiwania</h3>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
|
||||
{% for type_name, type_data in seo_data.gsc_search_type_breakdown.items() %}
|
||||
<div style="padding: var(--spacing-sm); background: var(--bg-tertiary); border-radius: var(--radius-md); text-align: center; min-width: 80px;">
|
||||
<div style="font-size: var(--font-size-sm); font-weight: 600;">{{ type_data.clicks }}</div>
|
||||
<div style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ type_name|capitalize }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- URL Inspection -->
|
||||
{% if seo_data.gsc_index_status %}
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin: var(--spacing-lg) 0 var(--spacing-sm) 0;">Status indeksowania (URL Inspection)</h3>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-md);">
|
||||
<div style="display: flex; align-items: center; gap: var(--spacing-xs); padding: var(--spacing-xs) var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.gsc_index_status == 'PASS' else '#fef3c7' if seo_data.gsc_index_status == 'NEUTRAL' else '#fee2e2' }};">
|
||||
<span style="font-weight: 600; font-size: var(--font-size-sm); color: {{ '#166534' if seo_data.gsc_index_status == 'PASS' else '#92400e' if seo_data.gsc_index_status == 'NEUTRAL' else '#991b1b' }};">
|
||||
{{ 'Zaindeksowana' if seo_data.gsc_index_status == 'PASS' else 'Oczekuje' if seo_data.gsc_index_status == 'NEUTRAL' else seo_data.gsc_index_status }}
|
||||
</span>
|
||||
</div>
|
||||
{% if seo_data.gsc_last_crawl %}
|
||||
<span style="font-size: var(--font-size-xs); color: var(--text-secondary); padding: var(--spacing-xs) var(--spacing-sm); background: var(--bg-tertiary); border-radius: var(--radius);">
|
||||
Ostatni crawl: {{ seo_data.gsc_last_crawl.strftime('%Y-%m-%d') if seo_data.gsc_last_crawl.strftime is defined else seo_data.gsc_last_crawl[:10] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if seo_data.gsc_crawled_as %}
|
||||
<span style="font-size: var(--font-size-xs); color: var(--text-secondary); padding: var(--spacing-xs) var(--spacing-sm); background: var(--bg-tertiary); border-radius: var(--radius);">
|
||||
Bot: {{ seo_data.gsc_crawled_as }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sitemaps -->
|
||||
{% if seo_data.gsc_sitemaps %}
|
||||
<h3 style="font-size: var(--font-size-sm); font-weight: 600; margin: var(--spacing-lg) 0 var(--spacing-sm) 0;">Sitemaps w Search Console</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;">Sciezka</th>
|
||||
<th style="text-align: center; padding: 8px 12px; font-weight: 600;">Bledow</th>
|
||||
<th style="text-align: center; padding: 8px 12px; font-weight: 600;">Ostrzezen</th>
|
||||
<th style="text-align: left; padding: 8px 12px; font-weight: 600;">Ostatnio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sm in seo_data.gsc_sitemaps %}
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 8px 12px; font-weight: 500;">{{ sm.path|replace('https://', '')|replace('http://', '') }}</td>
|
||||
<td style="text-align: center; padding: 8px 12px; color: {{ '#ef4444' if sm.errors else '#10b981' }};">{{ sm.errors or 0 }}</td>
|
||||
<td style="text-align: center; padding: 8px 12px; color: {{ '#f59e0b' if sm.warnings else '#10b981' }};">{{ sm.warnings or 0 }}</td>
|
||||
<td style="padding: 8px 12px; color: var(--text-secondary);">{{ sm.last_submitted[:10] if sm.last_submitted else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif has_gsc_token %}
|
||||
<!-- GSC connected but no data yet -->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user