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

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:
Maciej Pienczyn 2026-02-09 12:05:08 +01:00
parent 7802dcd5e1
commit e8b7f2214f
10 changed files with 943 additions and 1 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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

View 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;

View File

@ -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
View 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

View File

@ -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': {

View File

@ -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 []

View File

@ -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">

View File

@ -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 -->