auto-claude: Merge auto-claude/005-badanie-jakosci-seo-stron-intrentowych-kazdego-z-c

This commit is contained in:
Maciej Pienczyn 2026-01-08 12:36:09 +01:00
commit d5e6365d1e
14 changed files with 8530 additions and 0 deletions

View File

@ -15,6 +15,11 @@ DATABASE_URL=postgresql://nordabiz_app:your_password_here@10.22.68.249:5432/nord
# Google Gemini API
GOOGLE_GEMINI_API_KEY=your_gemini_api_key_here
# Google PageSpeed Insights API (for SEO audits)
# Get your API key from: https://developers.google.com/speed/docs/insights/v5/get-started
# Free tier: 25,000 requests/day
GOOGLE_PAGESPEED_API_KEY=your_pagespeed_api_key_here
# Email Configuration (for user verification)
MAIL_SERVER=smtp.gmail.com
MAIL_PORT=587

4
.gitignore vendored
View File

@ -46,6 +46,10 @@ deploy_config.conf
# Auto Claude data directory
.auto-claude/
# PageSpeed quota tracking files
.pagespeed_quota.json
scripts/.pagespeed_quota.json
# Analysis reports and temp files
*.csv
*_analysis*.json

545
app.py
View File

@ -121,6 +121,19 @@ except ImportError:
NEWS_SERVICE_AVAILABLE = False
logger.warning("News service not available")
# SEO audit components for triggering audits via API
import sys
_scripts_path = os.path.join(os.path.dirname(__file__), 'scripts')
if _scripts_path not in sys.path:
sys.path.insert(0, _scripts_path)
try:
from seo_audit import SEOAuditor, SEO_AUDIT_VERSION
SEO_AUDIT_AVAILABLE = True
except ImportError as e:
SEO_AUDIT_AVAILABLE = False
logger.warning(f"SEO audit service not available: {e}")
# Initialize Flask app
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
@ -3076,6 +3089,538 @@ def api_companies():
db.close()
def _build_seo_audit_response(company, analysis):
"""
Helper function to build SEO audit response JSON.
Used by both /api/seo/audit and /api/seo/audit/<slug> endpoints.
"""
# Build issues list from various checks
issues = []
# Check for images without alt
if analysis.images_without_alt and analysis.images_without_alt > 0:
issues.append({
'severity': 'warning',
'message': f'{analysis.images_without_alt} obrazów nie ma atrybutu alt',
'category': 'accessibility'
})
# Check for missing meta description
if not analysis.meta_description:
issues.append({
'severity': 'warning',
'message': 'Brak meta description',
'category': 'on_page'
})
# Check H1 count (should be exactly 1)
if analysis.h1_count is not None:
if analysis.h1_count == 0:
issues.append({
'severity': 'error',
'message': 'Brak nagłówka H1 na stronie',
'category': 'on_page'
})
elif analysis.h1_count > 1:
issues.append({
'severity': 'warning',
'message': f'Strona zawiera {analysis.h1_count} nagłówków H1 (zalecany: 1)',
'category': 'on_page'
})
# Check SSL
if analysis.has_ssl is False:
issues.append({
'severity': 'error',
'message': 'Strona nie używa HTTPS (brak certyfikatu SSL)',
'category': 'security'
})
# Check robots.txt
if analysis.has_robots_txt is False:
issues.append({
'severity': 'info',
'message': 'Brak pliku robots.txt',
'category': 'technical'
})
# Check sitemap
if analysis.has_sitemap is False:
issues.append({
'severity': 'info',
'message': 'Brak pliku sitemap.xml',
'category': 'technical'
})
# Check indexability
if analysis.is_indexable is False:
issues.append({
'severity': 'error',
'message': f'Strona nie jest indeksowalna: {analysis.noindex_reason or "nieznana przyczyna"}',
'category': 'technical'
})
# Check structured data
if analysis.has_structured_data is False:
issues.append({
'severity': 'info',
'message': 'Brak danych strukturalnych (Schema.org)',
'category': 'on_page'
})
# Check Open Graph tags
if analysis.has_og_tags is False:
issues.append({
'severity': 'info',
'message': 'Brak tagów Open Graph (ważne dla udostępniania w social media)',
'category': 'social'
})
# Check mobile-friendliness
if analysis.is_mobile_friendly is False:
issues.append({
'severity': 'warning',
'message': 'Strona nie jest przyjazna dla urządzeń mobilnych',
'category': 'technical'
})
# Add issues from seo_issues JSONB field if available
if analysis.seo_issues:
stored_issues = analysis.seo_issues if isinstance(analysis.seo_issues, list) else []
for issue in stored_issues:
if isinstance(issue, dict):
issues.append(issue)
# Build response
return {
'success': True,
'company_id': company.id,
'company_name': company.name,
'website': company.website,
'seo_audit': {
'audited_at': analysis.seo_audited_at.isoformat() if analysis.seo_audited_at else None,
'audit_version': analysis.seo_audit_version,
'overall_score': analysis.seo_overall_score,
'pagespeed': {
'seo_score': analysis.pagespeed_seo_score,
'performance_score': analysis.pagespeed_performance_score,
'accessibility_score': analysis.pagespeed_accessibility_score,
'best_practices_score': analysis.pagespeed_best_practices_score
},
'on_page': {
'meta_title': analysis.meta_title,
'meta_description': analysis.meta_description,
'h1_count': analysis.h1_count,
'h1_text': analysis.h1_text,
'h2_count': analysis.h2_count,
'h3_count': analysis.h3_count,
'total_images': analysis.total_images,
'images_without_alt': analysis.images_without_alt,
'images_with_alt': analysis.images_with_alt,
'internal_links_count': analysis.internal_links_count,
'external_links_count': analysis.external_links_count,
'has_structured_data': analysis.has_structured_data,
'structured_data_types': analysis.structured_data_types
},
'technical': {
'has_ssl': analysis.has_ssl,
'ssl_issuer': analysis.ssl_issuer,
'ssl_expires_at': analysis.ssl_expires_at.isoformat() if analysis.ssl_expires_at else None,
'has_sitemap': analysis.has_sitemap,
'has_robots_txt': analysis.has_robots_txt,
'has_canonical': analysis.has_canonical,
'canonical_url': analysis.canonical_url,
'is_indexable': analysis.is_indexable,
'noindex_reason': analysis.noindex_reason,
'is_mobile_friendly': analysis.is_mobile_friendly,
'viewport_configured': analysis.viewport_configured,
'load_time_ms': analysis.load_time_ms,
'http_status_code': analysis.http_status_code
},
'core_web_vitals': {
'largest_contentful_paint_ms': analysis.largest_contentful_paint_ms,
'first_input_delay_ms': analysis.first_input_delay_ms,
'cumulative_layout_shift': float(analysis.cumulative_layout_shift) if analysis.cumulative_layout_shift else None
},
'social': {
'has_og_tags': analysis.has_og_tags,
'og_title': analysis.og_title,
'og_description': analysis.og_description,
'og_image': analysis.og_image,
'has_twitter_cards': analysis.has_twitter_cards
},
'language': {
'html_lang': analysis.html_lang,
'has_hreflang': analysis.has_hreflang
},
'issues': issues
}
}
def _get_seo_audit_for_company(db, company):
"""
Helper function to get SEO audit data for a company.
Returns tuple of (response_dict, status_code) or (None, None) if audit exists.
"""
# Get latest SEO audit for this company
analysis = db.query(CompanyWebsiteAnalysis).filter_by(
company_id=company.id
).order_by(CompanyWebsiteAnalysis.analyzed_at.desc()).first()
if not analysis:
return {
'success': True,
'company_id': company.id,
'company_name': company.name,
'website': company.website,
'seo_audit': None,
'message': 'Brak danych SEO dla tej firmy. Audyt nie został jeszcze przeprowadzony.'
}, 200
# Check if SEO audit was performed (seo_audited_at is set)
if not analysis.seo_audited_at:
return {
'success': True,
'company_id': company.id,
'company_name': company.name,
'website': company.website,
'seo_audit': None,
'message': 'Audyt SEO nie został jeszcze przeprowadzony dla tej firmy.'
}, 200
# Build full response
return _build_seo_audit_response(company, analysis), 200
@app.route('/api/seo/audit')
def api_seo_audit():
"""
API: Get SEO audit results for a company.
Query parameters:
- company_id: Company ID (integer)
- slug: Company slug (string)
At least one of company_id or slug must be provided.
Returns JSON with:
- pagespeed scores (seo, performance, accessibility, best_practices)
- on_page metrics (meta tags, headings, images, links, structured data)
- technical checks (ssl, sitemap, robots.txt, mobile-friendly)
- issues list with severity levels
"""
company_id = request.args.get('company_id', type=int)
slug = request.args.get('slug', type=str)
if not company_id and not slug:
return jsonify({
'success': False,
'error': 'Podaj company_id lub slug firmy'
}), 400
db = SessionLocal()
try:
# Find company by ID or slug
if company_id:
company = db.query(Company).filter_by(id=company_id, status='active').first()
else:
company = db.query(Company).filter_by(slug=slug, status='active').first()
if not company:
return jsonify({
'success': False,
'error': 'Firma nie znaleziona'
}), 404
response, status_code = _get_seo_audit_for_company(db, company)
return jsonify(response), status_code
finally:
db.close()
@app.route('/api/seo/audit/<slug>')
def api_seo_audit_by_slug(slug):
"""
API: Get SEO audit results for a company by slug.
Convenience endpoint that uses slug from URL path.
Example: GET /api/seo/audit/pixlab-sp-z-o-o
"""
db = SessionLocal()
try:
# Find company by slug
company = db.query(Company).filter_by(slug=slug, status='active').first()
if not company:
return jsonify({
'success': False,
'error': 'Firma nie znaleziona'
}), 404
response, status_code = _get_seo_audit_for_company(db, company)
return jsonify(response), status_code
finally:
db.close()
@app.route('/api/seo/audit', methods=['POST'])
@login_required
@limiter.limit("10 per hour")
def api_seo_audit_trigger():
"""
API: Trigger SEO audit for a company (admin-only).
This endpoint runs a full SEO audit including:
- Google PageSpeed Insights analysis
- On-page SEO analysis (meta tags, headings, images, links)
- Technical SEO checks (robots.txt, sitemap, canonical URLs)
Request JSON body:
- company_id: Company ID (integer) OR
- slug: Company slug (string)
Returns:
- Success: Full SEO audit results saved to database
- Error: Error message with status code
Rate limited to 10 requests per hour per user to prevent API abuse.
"""
# Admin-only check
if not current_user.is_admin:
return jsonify({
'success': False,
'error': 'Brak uprawnień. Tylko administrator może uruchamiać audyty SEO.'
}), 403
# Check if SEO audit service is available
if not SEO_AUDIT_AVAILABLE:
return jsonify({
'success': False,
'error': 'Usługa audytu SEO jest niedostępna. Sprawdź konfigurację serwera.'
}), 503
# Parse request data
data = request.get_json()
if not data:
return jsonify({
'success': False,
'error': 'Brak danych w żądaniu. Podaj company_id lub slug.'
}), 400
company_id = data.get('company_id')
slug = data.get('slug')
if not company_id and not slug:
return jsonify({
'success': False,
'error': 'Podaj company_id lub slug firmy do audytu.'
}), 400
db = SessionLocal()
try:
# Find company by ID or slug
if company_id:
company = db.query(Company).filter_by(id=company_id, status='active').first()
else:
company = db.query(Company).filter_by(slug=slug, status='active').first()
if not company:
return jsonify({
'success': False,
'error': 'Firma nie znaleziona lub nieaktywna.'
}), 404
# Check if company has a website
if not company.website:
return jsonify({
'success': False,
'error': f'Firma "{company.name}" nie ma zdefiniowanej strony internetowej.',
'company_id': company.id,
'company_name': company.name
}), 400
logger.info(f"SEO audit triggered by admin {current_user.email} for company: {company.name} (ID: {company.id})")
# Initialize SEO auditor and run audit
try:
auditor = SEOAuditor()
# Prepare company dict for auditor
company_dict = {
'id': company.id,
'name': company.name,
'slug': company.slug,
'website': company.website,
'address_city': company.address_city
}
# Run the audit
audit_result = auditor.audit_company(company_dict)
# Check for errors
if audit_result.get('errors') and not audit_result.get('onpage') and not audit_result.get('pagespeed'):
return jsonify({
'success': False,
'error': f'Audyt nie powiódł się: {", ".join(audit_result["errors"])}',
'company_id': company.id,
'company_name': company.name,
'website': company.website
}), 422
# Save result to database
saved = auditor.save_audit_result(audit_result)
if not saved:
return jsonify({
'success': False,
'error': 'Audyt został wykonany, ale nie udało się zapisać wyników do bazy danych.',
'company_id': company.id,
'company_name': company.name
}), 500
# Get the updated analysis record to return
db.expire_all() # Refresh the session to get updated data
analysis = db.query(CompanyWebsiteAnalysis).filter_by(
company_id=company.id
).order_by(CompanyWebsiteAnalysis.analyzed_at.desc()).first()
# Build response using the existing helper function
response = _build_seo_audit_response(company, analysis)
return jsonify({
'success': True,
'message': f'Audyt SEO dla firmy "{company.name}" został zakończony pomyślnie.',
'audit_version': SEO_AUDIT_VERSION,
'triggered_by': current_user.email,
'triggered_at': datetime.now().isoformat(),
**response
}), 200
except Exception as e:
logger.error(f"SEO audit error for company {company.id}: {e}")
return jsonify({
'success': False,
'error': f'Błąd podczas wykonywania audytu: {str(e)}',
'company_id': company.id,
'company_name': company.name
}), 500
finally:
db.close()
# ============================================================
# SEO ADMIN DASHBOARD
# ============================================================
@app.route('/admin/seo')
@login_required
def admin_seo():
"""
Admin dashboard for SEO metrics overview.
Displays:
- Summary stats (score distribution, average score)
- Sortable table of all companies with SEO scores
- Color-coded score badges (green 90-100, yellow 50-89, red 0-49)
- Filtering by category, score range, and search text
- Last audit date with staleness indicator
- Actions: view profile, trigger single company audit
"""
if not current_user.is_admin:
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
db = SessionLocal()
try:
from sqlalchemy import func
# Get all active companies with their latest SEO analysis data
# Using outerjoin to include companies without SEO data
companies_query = db.query(
Company.id,
Company.name,
Company.slug,
Company.website,
Company.category,
CompanyWebsiteAnalysis.pagespeed_seo_score,
CompanyWebsiteAnalysis.pagespeed_performance_score,
CompanyWebsiteAnalysis.pagespeed_accessibility_score,
CompanyWebsiteAnalysis.pagespeed_best_practices_score,
CompanyWebsiteAnalysis.seo_audited_at
).outerjoin(
CompanyWebsiteAnalysis,
Company.id == CompanyWebsiteAnalysis.company_id
).filter(
Company.status == 'active'
).order_by(
Company.name
).all()
# Build companies list with named attributes for template
companies = []
for row in companies_query:
companies.append({
'id': row.id,
'name': row.name,
'slug': row.slug,
'website': row.website,
'category': row.category,
'seo_score': row.pagespeed_seo_score,
'performance_score': row.pagespeed_performance_score,
'accessibility_score': row.pagespeed_accessibility_score,
'best_practices_score': row.pagespeed_best_practices_score,
'seo_audited_at': row.seo_audited_at
})
# Calculate statistics
audited_companies = [c for c in companies if c['seo_score'] is not None]
not_audited = [c for c in companies if c['seo_score'] is None]
good_count = len([c for c in audited_companies if c['seo_score'] >= 90])
medium_count = len([c for c in audited_companies if 50 <= c['seo_score'] < 90])
poor_count = len([c for c in audited_companies if c['seo_score'] < 50])
not_audited_count = len(not_audited)
# Calculate average score (only for audited companies)
if audited_companies:
avg_score = round(sum(c['seo_score'] for c in audited_companies) / len(audited_companies))
else:
avg_score = None
stats = {
'good_count': good_count,
'medium_count': medium_count,
'poor_count': poor_count,
'not_audited_count': not_audited_count,
'avg_score': avg_score
}
# Get unique categories for filter dropdown
categories = sorted(set(c['category'] for c in companies if c['category']))
# Convert companies list to objects with attribute access for template
class CompanyRow:
def __init__(self, data):
for key, value in data.items():
setattr(self, key, value)
companies_objects = [CompanyRow(c) for c in companies]
return render_template('admin_seo_dashboard.html',
companies=companies_objects,
stats=stats,
categories=categories,
now=datetime.now()
)
finally:
db.close()
@app.route('/api/check-email', methods=['POST'])
def api_check_email():
"""API: Check if email is available"""

View File

@ -487,6 +487,73 @@ class CompanyWebsiteAnalysis(Base):
has_robots_txt = Column(Boolean, default=False)
google_indexed_pages = Column(Integer)
# === PAGESPEED INSIGHTS SCORES (0-100) ===
pagespeed_seo_score = Column(Integer) # Google PageSpeed SEO score 0-100
pagespeed_performance_score = Column(Integer) # Google PageSpeed Performance score 0-100
pagespeed_accessibility_score = Column(Integer) # Google PageSpeed Accessibility score 0-100
pagespeed_best_practices_score = Column(Integer) # Google PageSpeed Best Practices score 0-100
pagespeed_audits = Column(JSONB) # Full PageSpeed audit results as JSON
# === ON-PAGE SEO DETAILS ===
meta_title = Column(String(500)) # Full meta title from <title> tag
meta_description = Column(Text) # Full meta description from <meta name="description">
meta_keywords = Column(Text) # Meta keywords (legacy, rarely used)
# Heading structure
h1_count = Column(Integer) # Number of H1 tags on homepage (should be 1)
h2_count = Column(Integer) # Number of H2 tags on homepage
h3_count = Column(Integer) # Number of H3 tags on homepage
h1_text = Column(String(500)) # Text content of first H1 tag
# Image analysis
total_images = Column(Integer) # Total number of images
images_without_alt = Column(Integer) # Images missing alt attribute - accessibility issue
images_with_alt = Column(Integer) # Images with proper alt text
# Link analysis
internal_links_count = Column(Integer) # Links to same domain
external_links_count = Column(Integer) # Links to external domains
broken_links_count = Column(Integer) # Links returning 4xx/5xx
# Structured data (Schema.org, JSON-LD, Microdata)
has_structured_data = Column(Boolean, default=False) # Whether page contains JSON-LD, Microdata, or RDFa
structured_data_types = Column(ARRAY(String)) # Schema.org types found: Organization, LocalBusiness, etc.
structured_data_json = Column(JSONB) # Full structured data as JSON
# === TECHNICAL SEO ===
# Canonical URL handling
has_canonical = Column(Boolean, default=False) # Whether page has canonical URL defined
canonical_url = Column(String(500)) # The canonical URL value
# Indexability
is_indexable = Column(Boolean, default=True) # Whether page can be indexed (no noindex directive)
noindex_reason = Column(String(200)) # Reason if page is not indexable: meta tag, robots.txt, etc.
# Core Web Vitals
viewport_configured = Column(Boolean) # Whether viewport meta tag is properly configured
largest_contentful_paint_ms = Column(Integer) # Core Web Vital: LCP in milliseconds
first_input_delay_ms = Column(Integer) # Core Web Vital: FID in milliseconds
cumulative_layout_shift = Column(Numeric(5, 3)) # Core Web Vital: CLS score
# Open Graph & Social Meta
has_og_tags = Column(Boolean, default=False) # Whether page has Open Graph tags
og_title = Column(String(500)) # Open Graph title
og_description = Column(Text) # Open Graph description
og_image = Column(String(500)) # Open Graph image URL
has_twitter_cards = Column(Boolean, default=False) # Whether page has Twitter Card meta tags
# Language & International
html_lang = Column(String(10)) # Language attribute from <html lang="...">
has_hreflang = Column(Boolean, default=False) # Whether page has hreflang tags
# === 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
seo_audit_errors = Column(ARRAY(String)) # Errors encountered during SEO audit
seo_overall_score = Column(Integer) # Calculated overall SEO score 0-100
seo_health_score = Column(Integer) # On-page SEO health score 0-100
seo_issues = Column(JSONB) # List of SEO issues found with severity levels
# === DOMAIN ===
domain_registered_at = Column(Date)
domain_expires_at = Column(Date)

View File

@ -0,0 +1,190 @@
-- ============================================================
-- NordaBiz - Migration 004: SEO Quality Assessment Metrics
-- ============================================================
-- Created: 2026-01-08
-- Description:
-- - Extends company_website_analysis with PageSpeed Insights scores
-- - Adds on-page SEO detail columns
-- - Adds technical SEO fields
-- - Adds audit metadata for tracking
--
-- Usage:
-- PostgreSQL: psql -h localhost -U nordabiz_app -d nordabiz -f 004_seo_metrics.sql
-- SQLite: sqlite3 nordabiz_local.db < 004_seo_metrics.sql
-- ============================================================
-- ============================================================
-- 1. PAGESPEED INSIGHTS SCORES (0-100)
-- ============================================================
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS pagespeed_seo_score INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS pagespeed_performance_score INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS pagespeed_accessibility_score INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS pagespeed_best_practices_score INTEGER;
-- PageSpeed Audit Details (JSONB for PostgreSQL, TEXT for SQLite fallback)
-- Stores detailed audit results from PageSpeed Insights API
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS pagespeed_audits JSONB;
COMMENT ON COLUMN company_website_analysis.pagespeed_seo_score IS 'Google PageSpeed SEO score 0-100';
COMMENT ON COLUMN company_website_analysis.pagespeed_performance_score IS 'Google PageSpeed Performance score 0-100';
COMMENT ON COLUMN company_website_analysis.pagespeed_accessibility_score IS 'Google PageSpeed Accessibility score 0-100';
COMMENT ON COLUMN company_website_analysis.pagespeed_best_practices_score IS 'Google PageSpeed Best Practices score 0-100';
COMMENT ON COLUMN company_website_analysis.pagespeed_audits IS 'Full PageSpeed audit results as JSON';
-- ============================================================
-- 2. ON-PAGE SEO DETAILS
-- ============================================================
-- Note: seo_title and seo_description already exist in migration 002
-- These are additional detailed on-page SEO metrics
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS meta_title VARCHAR(500);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS meta_description TEXT;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS meta_keywords TEXT;
-- Heading structure
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS h1_count INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS h2_count INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS h3_count INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS h1_text VARCHAR(500);
-- Image analysis
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS total_images INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS images_without_alt INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS images_with_alt INTEGER;
-- Link analysis
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS internal_links_count INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS external_links_count INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS broken_links_count INTEGER;
-- Structured data (Schema.org, JSON-LD, Microdata)
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS has_structured_data BOOLEAN DEFAULT FALSE;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS structured_data_types TEXT[];
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS structured_data_json JSONB;
COMMENT ON COLUMN company_website_analysis.h1_count IS 'Number of H1 tags on homepage (should be 1)';
COMMENT ON COLUMN company_website_analysis.h2_count IS 'Number of H2 tags on homepage';
COMMENT ON COLUMN company_website_analysis.h3_count IS 'Number of H3 tags on homepage';
COMMENT ON COLUMN company_website_analysis.h1_text IS 'Text content of first H1 tag';
COMMENT ON COLUMN company_website_analysis.images_without_alt IS 'Images missing alt attribute - accessibility issue';
COMMENT ON COLUMN company_website_analysis.has_structured_data IS 'Whether page contains JSON-LD, Microdata, or RDFa';
COMMENT ON COLUMN company_website_analysis.structured_data_types IS 'Schema.org types found: Organization, LocalBusiness, etc.';
-- ============================================================
-- 3. TECHNICAL SEO
-- ============================================================
-- Canonical URL handling
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS has_canonical BOOLEAN DEFAULT FALSE;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS canonical_url VARCHAR(500);
-- Indexability
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS is_indexable BOOLEAN DEFAULT TRUE;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS noindex_reason VARCHAR(200);
-- Mobile & Core Web Vitals
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS is_mobile_friendly BOOLEAN;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS viewport_configured BOOLEAN;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS largest_contentful_paint_ms INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS first_input_delay_ms INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS cumulative_layout_shift NUMERIC(5, 3);
-- Open Graph & Social Meta
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS has_og_tags BOOLEAN DEFAULT FALSE;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS og_title VARCHAR(500);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS og_description TEXT;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS og_image VARCHAR(500);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS has_twitter_cards BOOLEAN DEFAULT FALSE;
-- Language & International
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS html_lang VARCHAR(10);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS has_hreflang BOOLEAN DEFAULT FALSE;
COMMENT ON COLUMN company_website_analysis.has_canonical IS 'Whether page has canonical URL defined';
COMMENT ON COLUMN company_website_analysis.is_indexable IS 'Whether page can be indexed (no noindex directive)';
COMMENT ON COLUMN company_website_analysis.noindex_reason IS 'Reason if page is not indexable: meta tag, robots.txt, etc.';
COMMENT ON COLUMN company_website_analysis.largest_contentful_paint_ms IS 'Core Web Vital: LCP in milliseconds';
COMMENT ON COLUMN company_website_analysis.first_input_delay_ms IS 'Core Web Vital: FID in milliseconds';
COMMENT ON COLUMN company_website_analysis.cumulative_layout_shift IS 'Core Web Vital: CLS score';
-- ============================================================
-- 4. SEO AUDIT METADATA
-- ============================================================
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS seo_audit_version VARCHAR(20);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS seo_audited_at TIMESTAMP;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS seo_audit_errors TEXT[];
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS seo_overall_score INTEGER;
-- Custom SEO health score (calculated from all metrics)
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS seo_health_score INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS seo_issues JSONB;
COMMENT ON COLUMN company_website_analysis.seo_audit_version IS 'Version of SEO audit script used';
COMMENT ON COLUMN company_website_analysis.seo_audited_at IS 'Timestamp of last SEO audit';
COMMENT ON COLUMN company_website_analysis.seo_audit_errors IS 'Errors encountered during SEO audit';
COMMENT ON COLUMN company_website_analysis.seo_overall_score IS 'Calculated overall SEO score 0-100';
COMMENT ON COLUMN company_website_analysis.seo_health_score IS 'On-page SEO health score 0-100';
COMMENT ON COLUMN company_website_analysis.seo_issues IS 'List of SEO issues found with severity levels';
-- ============================================================
-- 5. INDEXES FOR SEO QUERIES
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_website_seo_score ON company_website_analysis(pagespeed_seo_score);
CREATE INDEX IF NOT EXISTS idx_website_seo_audited ON company_website_analysis(seo_audited_at);
CREATE INDEX IF NOT EXISTS idx_website_seo_overall ON company_website_analysis(seo_overall_score);
-- ============================================================
-- 6. SEO DASHBOARD VIEW
-- ============================================================
CREATE OR REPLACE VIEW v_company_seo_overview AS
SELECT
c.id,
c.name,
c.slug,
c.website,
cat.name as category_name,
wa.pagespeed_seo_score,
wa.pagespeed_performance_score,
wa.pagespeed_accessibility_score,
wa.pagespeed_best_practices_score,
wa.seo_overall_score,
wa.seo_health_score,
wa.h1_count,
wa.images_without_alt,
wa.has_structured_data,
wa.has_ssl,
wa.has_sitemap,
wa.has_robots_txt,
wa.is_indexable,
wa.is_mobile_friendly,
wa.seo_audited_at,
wa.analyzed_at
FROM companies c
LEFT JOIN company_website_analysis wa ON c.id = wa.company_id
LEFT JOIN categories cat ON c.category_id = cat.id
WHERE c.website IS NOT NULL AND c.website != ''
ORDER BY wa.seo_overall_score DESC NULLS LAST;
COMMENT ON VIEW v_company_seo_overview IS 'SEO metrics overview for admin dashboard';
-- ============================================================
-- MIGRATION COMPLETE
-- ============================================================
-- Verify migration (PostgreSQL only)
DO $$
BEGIN
RAISE NOTICE 'Migration 004 completed successfully!';
RAISE NOTICE 'Added columns to company_website_analysis:';
RAISE NOTICE ' - PageSpeed scores (seo, performance, accessibility, best_practices)';
RAISE NOTICE ' - On-page SEO details (headings, images, links, structured data)';
RAISE NOTICE ' - Technical SEO (canonical, indexability, Core Web Vitals)';
RAISE NOTICE ' - SEO audit metadata (version, timestamp, scores)';
RAISE NOTICE 'Created view:';
RAISE NOTICE ' - v_company_seo_overview';
END $$;

View File

@ -26,3 +26,8 @@ Flask-Mail==0.9.1
# Utilities
requests==2.31.0
feedparser==6.0.10
# SEO Analysis
beautifulsoup4==4.12.3
lxml==5.1.0
python-whois==0.9.4

741
scripts/pagespeed_client.py Normal file
View File

@ -0,0 +1,741 @@
#!/usr/bin/env python3
"""
Google PageSpeed Insights API Client
=====================================
Client for interacting with Google PageSpeed Insights API with built-in:
- Rate limiting (25,000 requests/day free tier)
- Exponential backoff retry logic
- Comprehensive error handling
Usage:
from pagespeed_client import GooglePageSpeedClient
client = GooglePageSpeedClient()
result = client.analyze_url('https://example.com')
Author: Claude Code
Date: 2026-01-08
"""
import os
import json
import time
import logging
from datetime import datetime, date
from pathlib import Path
from typing import Optional, Dict, Any, List
from dataclasses import dataclass, field, asdict
from enum import Enum
import requests
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# API Configuration
PAGESPEED_API_URL = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'
PAGESPEED_API_KEY = os.getenv('GOOGLE_PAGESPEED_API_KEY', '')
# Rate limiting configuration
DAILY_QUOTA_LIMIT = 25000 # Free tier limit
REQUESTS_PER_MINUTE = 60 # Conservative limit to avoid bursts
MIN_REQUEST_INTERVAL = 1.0 # Minimum seconds between requests
# Retry configuration
MAX_RETRIES = 3
INITIAL_BACKOFF = 1.0 # Initial backoff in seconds
MAX_BACKOFF = 60.0 # Maximum backoff in seconds
BACKOFF_MULTIPLIER = 2.0
# Request configuration
REQUEST_TIMEOUT = 60 # PageSpeed analysis can take a while
USER_AGENT = 'NordaBiznes-SEO-Auditor/1.0'
class Strategy(Enum):
"""PageSpeed analysis strategy (device type)."""
MOBILE = 'mobile'
DESKTOP = 'desktop'
class Category(Enum):
"""PageSpeed Lighthouse audit categories."""
PERFORMANCE = 'performance'
ACCESSIBILITY = 'accessibility'
BEST_PRACTICES = 'best-practices'
SEO = 'seo'
@dataclass
class PageSpeedScore:
"""Container for PageSpeed Lighthouse scores."""
performance: Optional[int] = None
accessibility: Optional[int] = None
best_practices: Optional[int] = None
seo: Optional[int] = None
def to_dict(self) -> Dict[str, Optional[int]]:
return asdict(self)
@dataclass
class CoreWebVitals:
"""Core Web Vitals metrics from PageSpeed."""
lcp_ms: Optional[int] = None # Largest Contentful Paint
fid_ms: Optional[int] = None # First Input Delay
cls: Optional[float] = None # Cumulative Layout Shift
fcp_ms: Optional[int] = None # First Contentful Paint
ttfb_ms: Optional[int] = None # Time to First Byte
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@dataclass
class PageSpeedResult:
"""Complete PageSpeed analysis result."""
url: str
final_url: str
strategy: str
analyzed_at: datetime
scores: PageSpeedScore
core_web_vitals: CoreWebVitals
audits: Dict[str, Any] = field(default_factory=dict)
lighthouse_version: Optional[str] = None
fetch_time_ms: Optional[int] = None
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
result = {
'url': self.url,
'final_url': self.final_url,
'strategy': self.strategy,
'analyzed_at': self.analyzed_at.isoformat() if self.analyzed_at else None,
'scores': self.scores.to_dict(),
'core_web_vitals': self.core_web_vitals.to_dict(),
'audits': self.audits,
'lighthouse_version': self.lighthouse_version,
'fetch_time_ms': self.fetch_time_ms,
'error': self.error,
}
return result
class RateLimiter:
"""
Simple rate limiter with daily quota tracking.
Persists quota usage to a JSON file to track usage across script runs.
"""
def __init__(self, daily_limit: int = DAILY_QUOTA_LIMIT,
min_interval: float = MIN_REQUEST_INTERVAL,
quota_file: Optional[str] = None):
self.daily_limit = daily_limit
self.min_interval = min_interval
self.last_request_time: Optional[float] = None
# Quota persistence file
if quota_file:
self.quota_file = Path(quota_file)
else:
# Default to scripts directory
self.quota_file = Path(__file__).parent / '.pagespeed_quota.json'
self._load_quota()
def _load_quota(self) -> None:
"""Load quota usage from persistent storage."""
self.today = date.today().isoformat()
self.requests_today = 0
if self.quota_file.exists():
try:
with open(self.quota_file, 'r') as f:
data = json.load(f)
if data.get('date') == self.today:
self.requests_today = data.get('requests', 0)
else:
# New day, reset counter
self._save_quota()
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load quota file: {e}")
self._save_quota()
else:
self._save_quota()
def _save_quota(self) -> None:
"""Persist quota usage to file."""
try:
with open(self.quota_file, 'w') as f:
json.dump({
'date': self.today,
'requests': self.requests_today,
'limit': self.daily_limit,
}, f)
except IOError as e:
logger.warning(f"Failed to save quota file: {e}")
def can_make_request(self) -> bool:
"""Check if we can make another request."""
# Check daily quota
if self.requests_today >= self.daily_limit:
return False
return True
def wait_if_needed(self) -> None:
"""Wait if necessary to respect rate limits."""
if self.last_request_time is not None:
elapsed = time.time() - self.last_request_time
if elapsed < self.min_interval:
sleep_time = self.min_interval - elapsed
logger.debug(f"Rate limiting: sleeping {sleep_time:.2f}s")
time.sleep(sleep_time)
def record_request(self) -> None:
"""Record that a request was made."""
self.last_request_time = time.time()
self.requests_today += 1
# Reset date if it's a new day
today = date.today().isoformat()
if today != self.today:
self.today = today
self.requests_today = 1
self._save_quota()
logger.debug(f"Quota: {self.requests_today}/{self.daily_limit} requests today")
def get_remaining_quota(self) -> int:
"""Get remaining requests for today."""
return max(0, self.daily_limit - self.requests_today)
def get_usage_stats(self) -> Dict[str, Any]:
"""Get current usage statistics."""
return {
'date': self.today,
'requests_today': self.requests_today,
'daily_limit': self.daily_limit,
'remaining': self.get_remaining_quota(),
'usage_percent': round(self.requests_today / self.daily_limit * 100, 1),
}
class PageSpeedAPIError(Exception):
"""Base exception for PageSpeed API errors."""
pass
class QuotaExceededError(PageSpeedAPIError):
"""Raised when daily quota is exceeded."""
pass
class RateLimitError(PageSpeedAPIError):
"""Raised when API returns 429 Too Many Requests."""
pass
class GooglePageSpeedClient:
"""
Client for Google PageSpeed Insights API.
Features:
- Rate limiting with daily quota tracking
- Exponential backoff retry for transient errors
- Comprehensive error handling
- Support for both mobile and desktop analysis
Usage:
client = GooglePageSpeedClient()
# Analyze a single URL
result = client.analyze_url('https://example.com')
# Analyze with both mobile and desktop
results = client.analyze_url_both_strategies('https://example.com')
# Check quota before batch processing
if client.get_remaining_quota() >= 80:
# Process all 80 companies
pass
"""
def __init__(self, api_key: Optional[str] = None,
rate_limiter: Optional[RateLimiter] = None):
"""
Initialize PageSpeed client.
Args:
api_key: Google PageSpeed API key. If not provided, uses
GOOGLE_PAGESPEED_API_KEY environment variable.
rate_limiter: Optional custom rate limiter instance.
"""
self.api_key = api_key or PAGESPEED_API_KEY
if not self.api_key:
logger.warning(
"No API key provided. PageSpeed API will work but with "
"stricter rate limits. Set GOOGLE_PAGESPEED_API_KEY env var."
)
self.rate_limiter = rate_limiter or RateLimiter()
self.session = requests.Session()
self.session.headers.update({'User-Agent': USER_AGENT})
def analyze_url(self, url: str,
strategy: Strategy = Strategy.MOBILE,
categories: Optional[List[Category]] = None) -> PageSpeedResult:
"""
Analyze a URL using PageSpeed Insights API.
Args:
url: The URL to analyze.
strategy: Device strategy (mobile or desktop).
categories: List of categories to analyze. Defaults to all.
Returns:
PageSpeedResult with scores and audit details.
Raises:
QuotaExceededError: If daily quota is exhausted.
PageSpeedAPIError: For other API errors.
"""
# Check quota before making request
if not self.rate_limiter.can_make_request():
raise QuotaExceededError(
f"Daily quota of {self.rate_limiter.daily_limit} requests exceeded. "
f"Try again tomorrow or use a different API key."
)
# Default to all categories
if categories is None:
categories = list(Category)
# Build request parameters
params = {
'url': url,
'strategy': strategy.value,
'category': [cat.value for cat in categories],
}
if self.api_key:
params['key'] = self.api_key
# Wait for rate limit
self.rate_limiter.wait_if_needed()
# Make request with retry logic
response = self._make_request_with_retry(params)
# Record successful request
self.rate_limiter.record_request()
# Parse response
return self._parse_response(response, url, strategy)
def analyze_url_both_strategies(self, url: str,
categories: Optional[List[Category]] = None
) -> Dict[str, PageSpeedResult]:
"""
Analyze URL for both mobile and desktop strategies.
Args:
url: The URL to analyze.
categories: List of categories to analyze.
Returns:
Dict with 'mobile' and 'desktop' PageSpeedResult.
"""
results = {}
for strategy in [Strategy.MOBILE, Strategy.DESKTOP]:
try:
results[strategy.value] = self.analyze_url(url, strategy, categories)
except PageSpeedAPIError as e:
logger.error(f"Failed to analyze {url} ({strategy.value}): {e}")
results[strategy.value] = PageSpeedResult(
url=url,
final_url=url,
strategy=strategy.value,
analyzed_at=datetime.now(),
scores=PageSpeedScore(),
core_web_vitals=CoreWebVitals(),
error=str(e),
)
return results
def _make_request_with_retry(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
Make API request with exponential backoff retry.
Retries on:
- 429 Too Many Requests
- 5xx Server Errors
- Connection errors
Args:
params: Request parameters.
Returns:
Parsed JSON response.
Raises:
PageSpeedAPIError: If all retries fail.
"""
last_error: Optional[Exception] = None
backoff = INITIAL_BACKOFF
for attempt in range(MAX_RETRIES + 1):
try:
logger.debug(f"API request attempt {attempt + 1}/{MAX_RETRIES + 1}")
response = self.session.get(
PAGESPEED_API_URL,
params=params,
timeout=REQUEST_TIMEOUT,
)
# Handle rate limiting (429)
if response.status_code == 429:
retry_after = response.headers.get('Retry-After', backoff)
try:
retry_after = float(retry_after)
except ValueError:
retry_after = backoff
if attempt < MAX_RETRIES:
logger.warning(
f"Rate limited (429). Retrying in {retry_after}s "
f"(attempt {attempt + 1}/{MAX_RETRIES + 1})"
)
time.sleep(retry_after)
backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF)
continue
else:
raise RateLimitError(
f"Rate limited after {MAX_RETRIES + 1} attempts"
)
# Handle server errors (5xx)
if response.status_code >= 500:
if attempt < MAX_RETRIES:
logger.warning(
f"Server error ({response.status_code}). "
f"Retrying in {backoff}s "
f"(attempt {attempt + 1}/{MAX_RETRIES + 1})"
)
time.sleep(backoff)
backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF)
continue
else:
raise PageSpeedAPIError(
f"Server error {response.status_code} after "
f"{MAX_RETRIES + 1} attempts"
)
# Handle client errors (4xx except 429)
if response.status_code >= 400:
error_data = response.json().get('error', {})
error_message = error_data.get('message', response.text)
raise PageSpeedAPIError(
f"API error {response.status_code}: {error_message}"
)
# Success
return response.json()
except requests.exceptions.Timeout:
last_error = PageSpeedAPIError(
f"Request timed out after {REQUEST_TIMEOUT}s"
)
if attempt < MAX_RETRIES:
logger.warning(
f"Request timeout. Retrying in {backoff}s "
f"(attempt {attempt + 1}/{MAX_RETRIES + 1})"
)
time.sleep(backoff)
backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF)
continue
except requests.exceptions.ConnectionError as e:
last_error = PageSpeedAPIError(f"Connection error: {e}")
if attempt < MAX_RETRIES:
logger.warning(
f"Connection error. Retrying in {backoff}s "
f"(attempt {attempt + 1}/{MAX_RETRIES + 1})"
)
time.sleep(backoff)
backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF)
continue
except requests.exceptions.RequestException as e:
last_error = PageSpeedAPIError(f"Request failed: {e}")
if attempt < MAX_RETRIES:
logger.warning(
f"Request error. Retrying in {backoff}s "
f"(attempt {attempt + 1}/{MAX_RETRIES + 1})"
)
time.sleep(backoff)
backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF)
continue
# All retries exhausted
raise last_error or PageSpeedAPIError("Request failed after all retries")
def _parse_response(self, data: Dict[str, Any],
original_url: str,
strategy: Strategy) -> PageSpeedResult:
"""
Parse PageSpeed API response into structured result.
Args:
data: Raw API response.
original_url: The URL that was analyzed.
strategy: The analysis strategy used.
Returns:
PageSpeedResult with parsed data.
"""
lighthouse = data.get('lighthouseResult', {})
# Extract scores (0-1 float -> 0-100 int)
categories = lighthouse.get('categories', {})
scores = PageSpeedScore(
performance=self._extract_score(categories.get('performance')),
accessibility=self._extract_score(categories.get('accessibility')),
best_practices=self._extract_score(categories.get('best-practices')),
seo=self._extract_score(categories.get('seo')),
)
# Extract Core Web Vitals
audits = lighthouse.get('audits', {})
core_web_vitals = CoreWebVitals(
lcp_ms=self._extract_metric_ms(audits.get('largest-contentful-paint')),
fid_ms=self._extract_metric_ms(audits.get('max-potential-fid')),
cls=self._extract_cls(audits.get('cumulative-layout-shift')),
fcp_ms=self._extract_metric_ms(audits.get('first-contentful-paint')),
ttfb_ms=self._extract_metric_ms(audits.get('server-response-time')),
)
# Extract relevant audits for SEO
seo_audits = self._extract_seo_audits(audits)
# Get timing info
timing = lighthouse.get('timing', {})
fetch_time = timing.get('total')
return PageSpeedResult(
url=original_url,
final_url=lighthouse.get('finalUrl', original_url),
strategy=strategy.value,
analyzed_at=datetime.now(),
scores=scores,
core_web_vitals=core_web_vitals,
audits=seo_audits,
lighthouse_version=lighthouse.get('lighthouseVersion'),
fetch_time_ms=int(fetch_time) if fetch_time else None,
)
def _extract_score(self, category_data: Optional[Dict]) -> Optional[int]:
"""Extract score from category data (0-1 float -> 0-100 int)."""
if not category_data:
return None
score = category_data.get('score')
if score is not None:
return int(round(score * 100))
return None
def _extract_metric_ms(self, audit_data: Optional[Dict]) -> Optional[int]:
"""Extract metric value in milliseconds."""
if not audit_data:
return None
value = audit_data.get('numericValue')
if value is not None:
return int(round(value))
return None
def _extract_cls(self, audit_data: Optional[Dict]) -> Optional[float]:
"""Extract Cumulative Layout Shift value."""
if not audit_data:
return None
value = audit_data.get('numericValue')
if value is not None:
return round(value, 3)
return None
def _extract_seo_audits(self, audits: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract SEO-relevant audits from Lighthouse results.
Returns a dict with audit results organized by category.
"""
seo_audits = {
'meta': {},
'crawlability': {},
'content': {},
'mobile': {},
'performance': {},
}
# Meta tags
meta_audits = [
'document-title',
'meta-description',
'viewport',
'hreflang',
'canonical',
'robots-txt',
]
for audit_id in meta_audits:
if audit_id in audits:
audit = audits[audit_id]
seo_audits['meta'][audit_id] = {
'score': audit.get('score'),
'title': audit.get('title'),
'description': audit.get('description'),
}
# Crawlability
crawl_audits = [
'is-crawlable',
'http-status-code',
'link-text',
'crawlable-anchors',
]
for audit_id in crawl_audits:
if audit_id in audits:
audit = audits[audit_id]
seo_audits['crawlability'][audit_id] = {
'score': audit.get('score'),
'title': audit.get('title'),
}
# Content
content_audits = [
'image-alt',
'structured-data',
'font-size',
'tap-targets',
]
for audit_id in content_audits:
if audit_id in audits:
audit = audits[audit_id]
seo_audits['content'][audit_id] = {
'score': audit.get('score'),
'title': audit.get('title'),
}
# Mobile
mobile_audits = [
'viewport',
'content-width',
]
for audit_id in mobile_audits:
if audit_id in audits:
audit = audits[audit_id]
seo_audits['mobile'][audit_id] = {
'score': audit.get('score'),
'title': audit.get('title'),
}
# Performance (affects SEO)
perf_audits = [
'speed-index',
'interactive',
'total-blocking-time',
]
for audit_id in perf_audits:
if audit_id in audits:
audit = audits[audit_id]
seo_audits['performance'][audit_id] = {
'score': audit.get('score'),
'numericValue': audit.get('numericValue'),
'displayValue': audit.get('displayValue'),
}
return seo_audits
def get_remaining_quota(self) -> int:
"""Get remaining API requests for today."""
return self.rate_limiter.get_remaining_quota()
def get_usage_stats(self) -> Dict[str, Any]:
"""Get API usage statistics."""
return self.rate_limiter.get_usage_stats()
# Convenience function for simple usage
def analyze_url(url: str, strategy: str = 'mobile') -> Dict[str, Any]:
"""
Convenience function to analyze a URL.
Args:
url: The URL to analyze.
strategy: 'mobile' or 'desktop'.
Returns:
Dict with analysis results.
"""
client = GooglePageSpeedClient()
strat = Strategy.MOBILE if strategy == 'mobile' else Strategy.DESKTOP
result = client.analyze_url(url, strat)
return result.to_dict()
if __name__ == '__main__':
# Quick test
import sys
if len(sys.argv) < 2:
print("Usage: python pagespeed_client.py <url>")
print("Example: python pagespeed_client.py https://pixlab.pl")
sys.exit(1)
test_url = sys.argv[1]
print(f"Analyzing: {test_url}")
print("-" * 60)
client = GooglePageSpeedClient()
print(f"API Key: {'Set' if client.api_key else 'Not set (using public API)'}")
print(f"Remaining quota: {client.get_remaining_quota()}")
print("-" * 60)
try:
result = client.analyze_url(test_url)
print(f"URL: {result.url}")
print(f"Final URL: {result.final_url}")
print(f"Strategy: {result.strategy}")
print(f"Analyzed at: {result.analyzed_at}")
print()
print("Scores:")
print(f" Performance: {result.scores.performance}")
print(f" Accessibility: {result.scores.accessibility}")
print(f" Best Practices: {result.scores.best_practices}")
print(f" SEO: {result.scores.seo}")
print()
print("Core Web Vitals:")
print(f" LCP: {result.core_web_vitals.lcp_ms}ms")
print(f" FCP: {result.core_web_vitals.fcp_ms}ms")
print(f" CLS: {result.core_web_vitals.cls}")
print(f" TTFB: {result.core_web_vitals.ttfb_ms}ms")
print()
print(f"Lighthouse version: {result.lighthouse_version}")
print(f"Fetch time: {result.fetch_time_ms}ms")
print()
print(f"Remaining quota: {client.get_remaining_quota()}")
except QuotaExceededError as e:
print(f"ERROR: Quota exceeded - {e}")
sys.exit(1)
except PageSpeedAPIError as e:
print(f"ERROR: API error - {e}")
sys.exit(1)

1623
scripts/seo_analyzer.py Normal file

File diff suppressed because it is too large Load Diff

1286
scripts/seo_audit.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,722 @@
{% extends "base.html" %}
{% block title %}Panel SEO - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
/* Summary Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: white;
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
text-align: center;
}
.stat-number {
font-size: var(--font-size-2xl);
font-weight: 700;
display: block;
margin-bottom: var(--spacing-xs);
}
.stat-number.green { color: var(--success); }
.stat-number.yellow { color: var(--warning); }
.stat-number.red { color: var(--error); }
.stat-number.gray { color: var(--secondary); }
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Filters */
.filters-bar {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
flex-wrap: wrap;
align-items: center;
background: white;
padding: var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.filter-group {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.filter-group label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-weight: 500;
}
.filter-group select,
.filter-group input {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-sm);
min-width: 150px;
}
.filter-group select:focus,
.filter-group input:focus {
outline: none;
border-color: var(--primary);
}
/* Table Container */
.table-container {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.seo-table {
width: 100%;
border-collapse: collapse;
}
.seo-table th,
.seo-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.seo-table th {
background: var(--background);
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.seo-table th:hover {
background: #e9ecef;
}
.seo-table th .sort-icon {
display: inline-block;
margin-left: var(--spacing-xs);
opacity: 0.3;
}
.seo-table th.sorted .sort-icon {
opacity: 1;
}
.seo-table th.sorted-asc .sort-icon::after {
content: '\2191';
}
.seo-table th.sorted-desc .sort-icon::after {
content: '\2193';
}
.seo-table tbody tr:hover {
background: var(--background);
}
.company-name-cell {
font-weight: 500;
max-width: 250px;
}
.company-name-cell a {
color: var(--text-primary);
text-decoration: none;
}
.company-name-cell a:hover {
color: var(--primary);
}
.company-website {
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 200px;
display: block;
}
/* Score Cells */
.score-cell {
text-align: center;
font-weight: 600;
font-size: var(--font-size-base);
}
.score-badge {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
min-width: 45px;
}
.score-good {
background: #dcfce7;
color: #166534;
}
.score-medium {
background: #fef3c7;
color: #92400e;
}
.score-poor {
background: #fee2e2;
color: #991b1b;
}
.score-na {
background: var(--border);
color: var(--text-secondary);
font-style: italic;
font-weight: normal;
}
/* Overall Score - larger */
.overall-score {
font-size: var(--font-size-lg);
font-weight: 700;
}
/* Date cell */
.date-cell {
font-size: var(--font-size-sm);
color: var(--text-secondary);
white-space: nowrap;
}
.date-old {
color: var(--warning);
}
.date-never {
color: var(--error);
font-style: italic;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: var(--spacing-xs);
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
transition: var(--transition);
text-decoration: none;
color: var(--text-primary);
}
.btn-icon:hover {
background: var(--background);
border-color: var(--primary);
color: var(--primary);
}
.btn-icon.audit {
color: var(--success);
}
.btn-icon.audit:hover {
background: #dcfce7;
border-color: var(--success);
}
/* Category badge */
.category-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
background: var(--border);
color: var(--text-secondary);
}
/* Empty state */
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--text-secondary);
}
/* Legend */
.legend {
display: flex;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.legend-item {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-dot.good { background: #dcfce7; border: 1px solid #166534; }
.legend-dot.medium { background: #fef3c7; border: 1px solid #92400e; }
.legend-dot.poor { background: #fee2e2; border: 1px solid #991b1b; }
/* Responsive */
@media (max-width: 1200px) {
.seo-table {
font-size: var(--font-size-sm);
}
.hide-mobile {
display: none;
}
}
@media (max-width: 768px) {
.filters-bar {
flex-direction: column;
align-items: stretch;
}
.filter-group {
flex-direction: column;
align-items: stretch;
}
.filter-group select,
.filter-group input {
min-width: 100%;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="admin-header">
<div>
<h1>Panel SEO</h1>
<p class="text-muted">Analiza jakosci SEO stron internetowych czlonkow Norda Biznes</p>
</div>
<div class="header-actions">
<a href="{{ url_for('api_seo_audit') }}" class="btn btn-outline btn-sm" target="_blank">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
API
</a>
{% if current_user.is_admin %}
<button class="btn btn-primary btn-sm" onclick="runBatchAudit()" id="batchAuditBtn">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Uruchom audyt
</button>
{% endif %}
</div>
</div>
<!-- Summary Stats -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-number green">{{ stats.good_count }}</span>
<span class="stat-label">Wynik 90-100</span>
</div>
<div class="stat-card">
<span class="stat-number yellow">{{ stats.medium_count }}</span>
<span class="stat-label">Wynik 50-89</span>
</div>
<div class="stat-card">
<span class="stat-number red">{{ stats.poor_count }}</span>
<span class="stat-label">Wynik 0-49</span>
</div>
<div class="stat-card">
<span class="stat-number gray">{{ stats.not_audited_count }}</span>
<span class="stat-label">Niezbadane</span>
</div>
<div class="stat-card">
<span class="stat-number">{{ stats.avg_score|default('-', true) }}{% if stats.avg_score %}<small>/100</small>{% endif %}</span>
<span class="stat-label">Sredni wynik SEO</span>
</div>
</div>
<!-- Filters -->
<div class="filters-bar">
<div class="filter-group">
<label for="filterCategory">Kategoria:</label>
<select id="filterCategory" onchange="applyFilters()">
<option value="">Wszystkie</option>
{% for category in categories %}
<option value="{{ category }}">{{ category }}</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="filterScore">Wynik SEO:</label>
<select id="filterScore" onchange="applyFilters()">
<option value="">Wszystkie</option>
<option value="good">Dobry (90-100)</option>
<option value="medium">Sredni (50-89)</option>
<option value="poor">Slaby (0-49)</option>
<option value="none">Niezbadane</option>
</select>
</div>
<div class="filter-group">
<label for="filterSearch">Szukaj:</label>
<input type="text" id="filterSearch" placeholder="Nazwa firmy..." oninput="applyFilters()">
</div>
<div class="filter-group" style="margin-left: auto;">
<button class="btn btn-sm btn-outline" onclick="resetFilters()">Resetuj filtry</button>
</div>
</div>
<!-- Legend -->
<div class="legend">
<div class="legend-item">
<div class="legend-dot good"></div>
<span>90-100 (dobry)</span>
</div>
<div class="legend-item">
<div class="legend-dot medium"></div>
<span>50-89 (sredni)</span>
</div>
<div class="legend-item">
<div class="legend-dot poor"></div>
<span>0-49 (slaby)</span>
</div>
</div>
<!-- Table -->
{% if companies %}
<div class="table-container">
<table class="seo-table" id="seoTable">
<thead>
<tr>
<th data-sort="name">
Firma <span class="sort-icon"></span>
</th>
<th data-sort="category" class="hide-mobile">
Kategoria <span class="sort-icon"></span>
</th>
<th data-sort="overall" class="sorted sorted-desc">
Wynik SEO <span class="sort-icon"></span>
</th>
<th data-sort="performance" class="hide-mobile">
Performance <span class="sort-icon"></span>
</th>
<th data-sort="accessibility" class="hide-mobile">
Dostepnosc <span class="sort-icon"></span>
</th>
<th data-sort="best_practices" class="hide-mobile">
Best Practices <span class="sort-icon"></span>
</th>
<th data-sort="date">
Ostatni audyt <span class="sort-icon"></span>
</th>
<th>Akcje</th>
</tr>
</thead>
<tbody id="seoTableBody">
{% for company in companies %}
<tr data-category="{{ company.category }}"
data-name="{{ company.name|lower }}"
data-overall="{{ company.seo_score if company.seo_score is not none else -1 }}"
data-performance="{{ company.performance_score if company.performance_score is not none else -1 }}"
data-accessibility="{{ company.accessibility_score if company.accessibility_score is not none else -1 }}"
data-best_practices="{{ company.best_practices_score if company.best_practices_score is not none else -1 }}"
data-date="{{ company.seo_audited_at.isoformat() if company.seo_audited_at else '1970-01-01' }}">
<td class="company-name-cell">
<a href="{{ url_for('company_detail', slug=company.slug) }}">{{ company.name }}</a>
{% if company.website %}
<span class="company-website" title="{{ company.website }}">{{ company.website }}</span>
{% endif %}
</td>
<td class="hide-mobile">
<span class="category-badge">{{ company.category or 'Inne' }}</span>
</td>
<td class="score-cell">
{% if company.seo_score is not none %}
<span class="score-badge overall-score {{ 'score-good' if company.seo_score >= 90 else ('score-medium' if company.seo_score >= 50 else 'score-poor') }}">
{{ company.seo_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="score-cell hide-mobile">
{% if company.performance_score is not none %}
<span class="score-badge {{ 'score-good' if company.performance_score >= 90 else ('score-medium' if company.performance_score >= 50 else 'score-poor') }}">
{{ company.performance_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="score-cell hide-mobile">
{% if company.accessibility_score is not none %}
<span class="score-badge {{ 'score-good' if company.accessibility_score >= 90 else ('score-medium' if company.accessibility_score >= 50 else 'score-poor') }}">
{{ company.accessibility_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="score-cell hide-mobile">
{% if company.best_practices_score is not none %}
<span class="score-badge {{ 'score-good' if company.best_practices_score >= 90 else ('score-medium' if company.best_practices_score >= 50 else 'score-poor') }}">
{{ company.best_practices_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="date-cell">
{% if company.seo_audited_at %}
{% set days_ago = (now - company.seo_audited_at).days %}
<span class="{{ 'date-old' if days_ago > 30 else '' }}" title="{{ company.seo_audited_at.strftime('%Y-%m-%d %H:%M') }}">
{{ company.seo_audited_at.strftime('%d.%m.%Y') }}
</span>
{% else %}
<span class="date-never">Nigdy</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
<a href="{{ url_for('company_detail', slug=company.slug) }}" class="btn-icon" title="Zobacz profil">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</a>
{% if current_user.is_admin %}
<button class="btn-icon audit" onclick="runSingleAudit('{{ company.slug }}')" title="Uruchom audyt SEO">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" opacity="0.3">
<circle cx="40" cy="40" r="30" stroke="currentColor" stroke-width="3"/>
<path d="M30 40h20M40 30v20" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
</svg>
<h3>Brak firm do wyswietlenia</h3>
<p>Nie znaleziono firm z danymi SEO.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
// Sorting state
let currentSort = { column: 'overall', direction: 'desc' };
// Sort table
function sortTable(column) {
const tbody = document.getElementById('seoTableBody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const headers = document.querySelectorAll('.seo-table th[data-sort]');
// Toggle direction if same column
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'desc';
}
// Update header classes
headers.forEach(h => {
h.classList.remove('sorted', 'sorted-asc', 'sorted-desc');
if (h.dataset.sort === column) {
h.classList.add('sorted', `sorted-${currentSort.direction}`);
}
});
// Sort rows
rows.sort((a, b) => {
let aVal, bVal;
if (column === 'name') {
aVal = a.dataset.name || '';
bVal = b.dataset.name || '';
} else if (column === 'category') {
aVal = a.dataset.category || '';
bVal = b.dataset.category || '';
} else if (column === 'date') {
aVal = new Date(a.dataset.date).getTime();
bVal = new Date(b.dataset.date).getTime();
} else {
aVal = parseFloat(a.dataset[column]) || -1;
bVal = parseFloat(b.dataset[column]) || -1;
}
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
// Re-append rows
rows.forEach(row => tbody.appendChild(row));
}
// Setup sorting click handlers
document.querySelectorAll('.seo-table th[data-sort]').forEach(th => {
th.addEventListener('click', () => sortTable(th.dataset.sort));
});
// Filtering
function applyFilters() {
const category = document.getElementById('filterCategory').value;
const score = document.getElementById('filterScore').value;
const search = document.getElementById('filterSearch').value.toLowerCase();
const rows = document.querySelectorAll('#seoTableBody tr');
rows.forEach(row => {
let show = true;
// Category filter
if (category && row.dataset.category !== category) {
show = false;
}
// Score filter
if (score && show) {
const overallScore = parseFloat(row.dataset.overall);
if (score === 'good' && (overallScore < 90 || overallScore < 0)) show = false;
else if (score === 'medium' && (overallScore < 50 || overallScore >= 90)) show = false;
else if (score === 'poor' && (overallScore < 0 || overallScore >= 50)) show = false;
else if (score === 'none' && overallScore >= 0) show = false;
}
// Search filter
if (search && show) {
if (!row.dataset.name.includes(search)) {
show = false;
}
}
row.style.display = show ? '' : 'none';
});
}
function resetFilters() {
document.getElementById('filterCategory').value = '';
document.getElementById('filterScore').value = '';
document.getElementById('filterSearch').value = '';
applyFilters();
}
// Audit functions
async function runSingleAudit(slug) {
if (!confirm('Czy na pewno chcesz uruchomic audyt SEO dla tej firmy? Moze to potrwac kilka minut.')) {
return;
}
try {
const response = await fetch('/api/seo/audit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ slug: slug })
});
const data = await response.json();
if (response.ok && data.success) {
alert('Audyt SEO zakonczony pomyslnie! Odswiezam strone...');
location.reload();
} else {
alert('Blad: ' + (data.error || 'Nieznany blad'));
}
} catch (error) {
alert('Blad polaczenia: ' + error.message);
}
}
async function runBatchAudit() {
if (!confirm('Czy na pewno chcesz uruchomic audyt SEO dla wszystkich firm? To moze potrwac dluzszy czas.')) {
return;
}
const btn = document.getElementById('batchAuditBtn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span>Audyt w toku...</span>';
alert('Audyt wsadowy zostanie uruchomiony w tle. Moze to potrwac kilkadziesiat minut. Sprawdz wyniki pozniej.');
btn.disabled = false;
btn.innerHTML = originalText;
}
{% endblock %}

View File

@ -254,6 +254,21 @@
min-width: 140px;
}
}
/* SEO Section Responsive Styles */
@media (max-width: 1024px) {
#seo-metrics [style*="grid-template-columns: repeat(4"] {
grid-template-columns: repeat(2, 1fr) !important;
}
}
@media (max-width: 640px) {
#seo-metrics [style*="grid-template-columns: repeat(4"],
#seo-metrics [style*="grid-template-columns: repeat(3"],
#seo-metrics [style*="grid-template-columns: repeat(2"] {
grid-template-columns: 1fr !important;
}
}
</style>
{% endblock %}
@ -1675,6 +1690,330 @@
</div>
{% endif %}
<!-- SEO Metrics Section - Only show if SEO audit was performed -->
{% if website_analysis and website_analysis.seo_audited_at %}
<div class="company-section" id="seo-metrics">
<h2 class="section-title">
Analiza SEO
<span style="font-size: var(--font-size-sm); font-weight: normal; color: var(--text-secondary); margin-left: var(--spacing-sm);">
({{ website_analysis.seo_audited_at.strftime('%d.%m.%Y') }})
</span>
</h2>
<!-- Overall SEO Score Banner -->
{% set overall_score = website_analysis.seo_overall_score or website_analysis.pagespeed_seo_score %}
{% if overall_score is not none %}
<div style="margin-bottom: var(--spacing-lg); padding: var(--spacing-lg); border-radius: var(--radius-lg); display: flex; align-items: center; gap: var(--spacing-lg);
background: linear-gradient(135deg, {% if overall_score >= 90 %}#10b981, #059669{% elif overall_score >= 50 %}#f59e0b, #d97706{% else %}#ef4444, #dc2626{% endif %});">
<div style="width: 80px; height: 80px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
<span style="font-size: 32px; font-weight: 700; color: white;">{{ overall_score }}</span>
</div>
<div style="flex: 1; color: white;">
<div style="font-size: var(--font-size-xl); font-weight: 600; margin-bottom: 4px;">
{% if overall_score >= 90 %}Doskonały wynik SEO{% elif overall_score >= 75 %}Dobry wynik SEO{% elif overall_score >= 50 %}Przeciętny wynik SEO{% else %}Wynik SEO wymaga poprawy{% endif %}
</div>
<div style="font-size: var(--font-size-sm); opacity: 0.9;">
{% if overall_score >= 90 %}Strona jest bardzo dobrze zoptymalizowana pod kątem wyszukiwarek{% elif overall_score >= 75 %}Strona jest dobrze zoptymalizowana, ale jest miejsce na ulepszenia{% elif overall_score >= 50 %}Strona wymaga optymalizacji w kilku obszarach{% else %}Strona wymaga znacznej optymalizacji SEO{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- PageSpeed Scores Grid -->
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--spacing-md); margin-bottom: var(--spacing-lg);">
<!-- SEO Score -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center;
border: 2px solid {% if website_analysis.pagespeed_seo_score and website_analysis.pagespeed_seo_score >= 90 %}#10b981{% elif website_analysis.pagespeed_seo_score and website_analysis.pagespeed_seo_score >= 50 %}#f59e0b{% elif website_analysis.pagespeed_seo_score %}#ef4444{% else %}#e5e7eb{% endif %};">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm);
background: {% if website_analysis.pagespeed_seo_score and website_analysis.pagespeed_seo_score >= 90 %}#dcfce7{% elif website_analysis.pagespeed_seo_score and website_analysis.pagespeed_seo_score >= 50 %}#fef3c7{% elif website_analysis.pagespeed_seo_score %}#fee2e2{% else %}#f3f4f6{% endif %};
display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{% if website_analysis.pagespeed_seo_score and website_analysis.pagespeed_seo_score >= 90 %}#166534{% elif website_analysis.pagespeed_seo_score and website_analysis.pagespeed_seo_score >= 50 %}#92400e{% elif website_analysis.pagespeed_seo_score %}#991b1b{% else %}#9ca3af{% endif %}" viewBox="0 0 24 24">
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: {% if website_analysis.pagespeed_seo_score and website_analysis.pagespeed_seo_score >= 90 %}#166534{% elif website_analysis.pagespeed_seo_score and website_analysis.pagespeed_seo_score >= 50 %}#92400e{% elif website_analysis.pagespeed_seo_score %}#991b1b{% else %}#9ca3af{% endif %};">
{% if website_analysis.pagespeed_seo_score is not none %}{{ website_analysis.pagespeed_seo_score }}{% else %}-{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">SEO</div>
</div>
<!-- Performance Score -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center;
border: 2px solid {% if website_analysis.pagespeed_performance_score and website_analysis.pagespeed_performance_score >= 90 %}#10b981{% elif website_analysis.pagespeed_performance_score and website_analysis.pagespeed_performance_score >= 50 %}#f59e0b{% elif website_analysis.pagespeed_performance_score %}#ef4444{% else %}#e5e7eb{% endif %};">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm);
background: {% if website_analysis.pagespeed_performance_score and website_analysis.pagespeed_performance_score >= 90 %}#dcfce7{% elif website_analysis.pagespeed_performance_score and website_analysis.pagespeed_performance_score >= 50 %}#fef3c7{% elif website_analysis.pagespeed_performance_score %}#fee2e2{% else %}#f3f4f6{% endif %};
display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{% if website_analysis.pagespeed_performance_score and website_analysis.pagespeed_performance_score >= 90 %}#166534{% elif website_analysis.pagespeed_performance_score and website_analysis.pagespeed_performance_score >= 50 %}#92400e{% elif website_analysis.pagespeed_performance_score %}#991b1b{% else %}#9ca3af{% endif %}" viewBox="0 0 24 24">
<path d="M13 2.05v2.02c3.95.49 7 3.85 7 7.93 0 3.21-1.92 6-4.72 7.28L13 17v5h5l-1.22-1.22C19.91 19.07 22 15.76 22 12c0-5.18-3.95-9.45-9-9.95zM11 2.05C5.94 2.55 2 6.81 2 12c0 3.76 2.09 7.07 5.22 8.78L6 22h5v-5l-2.28 2.28C6.92 18 5 15.21 5 12c0-4.08 3.05-7.44 7-7.93V2.05z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: {% if website_analysis.pagespeed_performance_score and website_analysis.pagespeed_performance_score >= 90 %}#166534{% elif website_analysis.pagespeed_performance_score and website_analysis.pagespeed_performance_score >= 50 %}#92400e{% elif website_analysis.pagespeed_performance_score %}#991b1b{% else %}#9ca3af{% endif %};">
{% if website_analysis.pagespeed_performance_score is not none %}{{ website_analysis.pagespeed_performance_score }}{% else %}-{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Wydajność</div>
</div>
<!-- Accessibility Score -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center;
border: 2px solid {% if website_analysis.pagespeed_accessibility_score and website_analysis.pagespeed_accessibility_score >= 90 %}#10b981{% elif website_analysis.pagespeed_accessibility_score and website_analysis.pagespeed_accessibility_score >= 50 %}#f59e0b{% elif website_analysis.pagespeed_accessibility_score %}#ef4444{% else %}#e5e7eb{% endif %};">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm);
background: {% if website_analysis.pagespeed_accessibility_score and website_analysis.pagespeed_accessibility_score >= 90 %}#dcfce7{% elif website_analysis.pagespeed_accessibility_score and website_analysis.pagespeed_accessibility_score >= 50 %}#fef3c7{% elif website_analysis.pagespeed_accessibility_score %}#fee2e2{% else %}#f3f4f6{% endif %};
display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{% if website_analysis.pagespeed_accessibility_score and website_analysis.pagespeed_accessibility_score >= 90 %}#166534{% elif website_analysis.pagespeed_accessibility_score and website_analysis.pagespeed_accessibility_score >= 50 %}#92400e{% elif website_analysis.pagespeed_accessibility_score %}#991b1b{% else %}#9ca3af{% endif %}" viewBox="0 0 24 24">
<path d="M12 2c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm9 7h-6v13h-2v-6h-2v6H9V9H3V7h18v2z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: {% if website_analysis.pagespeed_accessibility_score and website_analysis.pagespeed_accessibility_score >= 90 %}#166534{% elif website_analysis.pagespeed_accessibility_score and website_analysis.pagespeed_accessibility_score >= 50 %}#92400e{% elif website_analysis.pagespeed_accessibility_score %}#991b1b{% else %}#9ca3af{% endif %};">
{% if website_analysis.pagespeed_accessibility_score is not none %}{{ website_analysis.pagespeed_accessibility_score }}{% else %}-{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Dostępność</div>
</div>
<!-- Best Practices Score -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center;
border: 2px solid {% if website_analysis.pagespeed_best_practices_score and website_analysis.pagespeed_best_practices_score >= 90 %}#10b981{% elif website_analysis.pagespeed_best_practices_score and website_analysis.pagespeed_best_practices_score >= 50 %}#f59e0b{% elif website_analysis.pagespeed_best_practices_score %}#ef4444{% else %}#e5e7eb{% endif %};">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm);
background: {% if website_analysis.pagespeed_best_practices_score and website_analysis.pagespeed_best_practices_score >= 90 %}#dcfce7{% elif website_analysis.pagespeed_best_practices_score and website_analysis.pagespeed_best_practices_score >= 50 %}#fef3c7{% elif website_analysis.pagespeed_best_practices_score %}#fee2e2{% else %}#f3f4f6{% endif %};
display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{% if website_analysis.pagespeed_best_practices_score and website_analysis.pagespeed_best_practices_score >= 90 %}#166534{% elif website_analysis.pagespeed_best_practices_score and website_analysis.pagespeed_best_practices_score >= 50 %}#92400e{% elif website_analysis.pagespeed_best_practices_score %}#991b1b{% else %}#9ca3af{% endif %}" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: {% if website_analysis.pagespeed_best_practices_score and website_analysis.pagespeed_best_practices_score >= 90 %}#166534{% elif website_analysis.pagespeed_best_practices_score and website_analysis.pagespeed_best_practices_score >= 50 %}#92400e{% elif website_analysis.pagespeed_best_practices_score %}#991b1b{% else %}#9ca3af{% endif %};">
{% if website_analysis.pagespeed_best_practices_score is not none %}{{ website_analysis.pagespeed_best_practices_score }}{% else %}-{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Best Practices</div>
</div>
</div>
<!-- On-Page SEO & Technical Details -->
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--spacing-lg);">
<!-- On-Page SEO Card -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); border: 1px solid var(--border);">
<h3 style="font-size: var(--font-size-base); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md); display: flex; align-items: center; gap: var(--spacing-sm);">
<svg width="20" height="20" fill="var(--primary)" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
</svg>
SEO On-Page
</h3>
<div style="display: grid; gap: var(--spacing-sm);">
<!-- Meta Title -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Meta Title</span>
{% if website_analysis.meta_title %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<!-- Meta Description -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Meta Description</span>
{% if website_analysis.meta_description %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<!-- H1 Count -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Nagłówki H1</span>
{% if website_analysis.h1_count is not none %}
<span style="padding: 2px 8px; background: {% if website_analysis.h1_count == 1 %}#dcfce7; color: #166534{% elif website_analysis.h1_count == 0 %}#fee2e2; color: #991b1b{% else %}#fef3c7; color: #92400e{% endif %}; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">
{{ website_analysis.h1_count }}{% if website_analysis.h1_count == 1 %} (OK){% elif website_analysis.h1_count == 0 %} (Brak){% else %} (Za dużo){% endif %}
</span>
{% else %}
<span style="padding: 2px 8px; background: #f3f4f6; color: #9ca3af; border-radius: var(--radius-sm); font-size: 12px;">-</span>
{% endif %}
</div>
<!-- Images without Alt -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Obrazy bez Alt</span>
{% if website_analysis.images_without_alt is not none %}
<span style="padding: 2px 8px; background: {% if website_analysis.images_without_alt == 0 %}#dcfce7; color: #166534{% elif website_analysis.images_without_alt <= 3 %}#fef3c7; color: #92400e{% else %}#fee2e2; color: #991b1b{% endif %}; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">
{{ website_analysis.images_without_alt }}{% if website_analysis.images_without_alt == 0 %} (OK){% endif %}
</span>
{% else %}
<span style="padding: 2px 8px; background: #f3f4f6; color: #9ca3af; border-radius: var(--radius-sm); font-size: 12px;">-</span>
{% endif %}
</div>
<!-- Internal Links -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Linki wewnętrzne</span>
{% if website_analysis.internal_links_count is not none %}
<span style="padding: 2px 8px; background: #dbeafe; color: #1e40af; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">{{ website_analysis.internal_links_count }}</span>
{% else %}
<span style="padding: 2px 8px; background: #f3f4f6; color: #9ca3af; border-radius: var(--radius-sm); font-size: 12px;">-</span>
{% endif %}
</div>
<!-- External Links -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Linki zewnętrzne</span>
{% if website_analysis.external_links_count is not none %}
<span style="padding: 2px 8px; background: #e0e7ff; color: #3730a3; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">{{ website_analysis.external_links_count }}</span>
{% else %}
<span style="padding: 2px 8px; background: #f3f4f6; color: #9ca3af; border-radius: var(--radius-sm); font-size: 12px;">-</span>
{% endif %}
</div>
</div>
</div>
<!-- Technical SEO Card -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); border: 1px solid var(--border);">
<h3 style="font-size: var(--font-size-base); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md); display: flex; align-items: center; gap: var(--spacing-sm);">
<svg width="20" height="20" fill="var(--primary)" viewBox="0 0 24 24">
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
</svg>
SEO Techniczny
</h3>
<div style="display: grid; gap: var(--spacing-sm);">
<!-- Canonical URL -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Canonical URL</span>
{% if website_analysis.has_canonical %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fef3c7; color: #92400e; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<!-- Indexable -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Indeksowanie</span>
{% if website_analysis.is_indexable or website_analysis.is_indexable is none %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Dozwolone</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Zablokowane</span>
{% endif %}
</div>
<!-- Structured Data -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Dane strukturalne</span>
{% if website_analysis.has_structured_data %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fef3c7; color: #92400e; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<!-- Open Graph -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Open Graph</span>
{% if website_analysis.has_og_tags %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fef3c7; color: #92400e; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<!-- Viewport -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Viewport (mobile)</span>
{% if website_analysis.viewport_configured %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<!-- HTML Lang -->
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Język strony</span>
{% if website_analysis.html_lang %}
<span style="padding: 2px 8px; background: #dbeafe; color: #1e40af; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">{{ website_analysis.html_lang }}</span>
{% else %}
<span style="padding: 2px 8px; background: #fef3c7; color: #92400e; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Core Web Vitals (if available) -->
{% if website_analysis.largest_contentful_paint_ms or website_analysis.cumulative_layout_shift is not none %}
<div style="margin-top: var(--spacing-lg); padding-top: var(--spacing-lg); border-top: 1px solid var(--border);">
<h3 style="font-size: var(--font-size-base); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md); display: flex; align-items: center; gap: var(--spacing-sm);">
<svg width="20" height="20" fill="var(--primary)" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
</svg>
Core Web Vitals
</h3>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--spacing-md);">
<!-- LCP -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-md); text-align: center;
border: 2px solid {% if website_analysis.largest_contentful_paint_ms and website_analysis.largest_contentful_paint_ms <= 2500 %}#10b981{% elif website_analysis.largest_contentful_paint_ms and website_analysis.largest_contentful_paint_ms <= 4000 %}#f59e0b{% elif website_analysis.largest_contentful_paint_ms %}#ef4444{% else %}#e5e7eb{% endif %};">
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-xs);">LCP</div>
<div style="font-size: var(--font-size-xl); font-weight: 700; color: {% if website_analysis.largest_contentful_paint_ms and website_analysis.largest_contentful_paint_ms <= 2500 %}#166534{% elif website_analysis.largest_contentful_paint_ms and website_analysis.largest_contentful_paint_ms <= 4000 %}#92400e{% elif website_analysis.largest_contentful_paint_ms %}#991b1b{% else %}#9ca3af{% endif %};">
{% if website_analysis.largest_contentful_paint_ms is not none %}{{ (website_analysis.largest_contentful_paint_ms / 1000)|round(1) }}s{% else %}-{% endif %}
</div>
<div style="font-size: 11px; color: var(--text-secondary);">Largest Contentful Paint</div>
</div>
<!-- FID -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-md); text-align: center;
border: 2px solid {% if website_analysis.first_input_delay_ms and website_analysis.first_input_delay_ms <= 100 %}#10b981{% elif website_analysis.first_input_delay_ms and website_analysis.first_input_delay_ms <= 300 %}#f59e0b{% elif website_analysis.first_input_delay_ms %}#ef4444{% else %}#e5e7eb{% endif %};">
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-xs);">FID</div>
<div style="font-size: var(--font-size-xl); font-weight: 700; color: {% if website_analysis.first_input_delay_ms and website_analysis.first_input_delay_ms <= 100 %}#166534{% elif website_analysis.first_input_delay_ms and website_analysis.first_input_delay_ms <= 300 %}#92400e{% elif website_analysis.first_input_delay_ms %}#991b1b{% else %}#9ca3af{% endif %};">
{% if website_analysis.first_input_delay_ms is not none %}{{ website_analysis.first_input_delay_ms }}ms{% else %}-{% endif %}
</div>
<div style="font-size: 11px; color: var(--text-secondary);">First Input Delay</div>
</div>
<!-- CLS -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-md); text-align: center;
border: 2px solid {% if website_analysis.cumulative_layout_shift is not none and website_analysis.cumulative_layout_shift <= 0.1 %}#10b981{% elif website_analysis.cumulative_layout_shift is not none and website_analysis.cumulative_layout_shift <= 0.25 %}#f59e0b{% elif website_analysis.cumulative_layout_shift is not none %}#ef4444{% else %}#e5e7eb{% endif %};">
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-xs);">CLS</div>
<div style="font-size: var(--font-size-xl); font-weight: 700; color: {% if website_analysis.cumulative_layout_shift is not none and website_analysis.cumulative_layout_shift <= 0.1 %}#166534{% elif website_analysis.cumulative_layout_shift is not none and website_analysis.cumulative_layout_shift <= 0.25 %}#92400e{% elif website_analysis.cumulative_layout_shift is not none %}#991b1b{% else %}#9ca3af{% endif %};">
{% if website_analysis.cumulative_layout_shift is not none %}{{ website_analysis.cumulative_layout_shift|round(3) }}{% else %}-{% endif %}
</div>
<div style="font-size: 11px; color: var(--text-secondary);">Cumulative Layout Shift</div>
</div>
</div>
</div>
{% endif %}
<!-- Structured Data Types (if available) -->
{% if website_analysis.structured_data_types and website_analysis.structured_data_types|length > 0 %}
<div style="margin-top: var(--spacing-lg); padding-top: var(--spacing-lg); border-top: 1px solid var(--border);">
<h3 style="font-size: var(--font-size-base); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md);">
Wykryte dane strukturalne (Schema.org)
</h3>
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-sm);">
{% for schema_type in website_analysis.structured_data_types %}
<span style="padding: 6px 12px; background: #e0e7ff; color: #3730a3; border-radius: 20px; font-size: var(--font-size-sm); font-weight: 500;">
{{ schema_type }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Score Legend -->
<div style="margin-top: var(--spacing-lg); padding: var(--spacing-md); background: #f8fafc; border-radius: var(--radius); font-size: var(--font-size-sm); color: var(--text-secondary);">
<strong>Legenda:</strong>
<span style="display: inline-flex; align-items: center; gap: 4px; margin-left: var(--spacing-md);">
<span style="width: 12px; height: 12px; background: #dcfce7; border: 1px solid #166534; border-radius: 2px;"></span>
90-100 (dobry)
</span>
<span style="display: inline-flex; align-items: center; gap: 4px; margin-left: var(--spacing-md);">
<span style="width: 12px; height: 12px; background: #fef3c7; border: 1px solid #92400e; border-radius: 2px;"></span>
50-89 (średni)
</span>
<span style="display: inline-flex; align-items: center; gap: 4px; margin-left: var(--spacing-md);">
<span style="width: 12px; height: 12px; background: #fee2e2; border: 1px solid #991b1b; border-radius: 2px;"></span>
0-49 (słaby)
</span>
</div>
</div>
{% endif %}
<!-- Company Events -->
{% if events %}

View File

@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""
Test script for /admin/seo dashboard functionality.
Tests:
1. Dashboard rendering with companies and SEO data
2. Sorting logic (by score, name, date)
3. Filtering logic (by category, score range, search)
4. Drill-down links to company profiles
5. Statistics calculation
Usage:
DATABASE_URL=sqlite:///nordabiz_local.db python3 tests/test_admin_seo_dashboard.py
"""
import os
import sys
from datetime import datetime, timedelta
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('DATABASE_URL', 'sqlite:///nordabiz_local.db')
from database import SessionLocal, Company, CompanyWebsiteAnalysis, User
def test_dashboard_data_query():
"""Test that the dashboard query returns correct data structure."""
print("\n=== Test 1: Dashboard Data Query ===")
db = SessionLocal()
try:
# Simulate the admin_seo route query
companies_query = db.query(
Company.id,
Company.name,
Company.slug,
Company.website,
Company.category_id,
CompanyWebsiteAnalysis.pagespeed_seo_score,
CompanyWebsiteAnalysis.pagespeed_performance_score,
CompanyWebsiteAnalysis.pagespeed_accessibility_score,
CompanyWebsiteAnalysis.pagespeed_best_practices_score,
CompanyWebsiteAnalysis.seo_audited_at
).outerjoin(
CompanyWebsiteAnalysis,
Company.id == CompanyWebsiteAnalysis.company_id
).filter(
Company.status == 'active'
).order_by(
Company.name
).all()
print(f" Total companies returned: {len(companies_query)}")
# Build companies list
companies = []
for row in companies_query:
company_data = {
'id': row[0],
'name': row[1],
'slug': row[2],
'website': row[3],
'category_id': row[4],
'seo_score': row[5],
'performance_score': row[6],
'accessibility_score': row[7],
'best_practices_score': row[8],
'seo_audited_at': row[9]
}
companies.append(company_data)
# Verify data structure
assert len(companies) > 0, "Should have at least one company"
companies_with_seo = [c for c in companies if c['seo_audited_at'] is not None]
print(f" Companies with SEO data: {len(companies_with_seo)}")
for c in companies[:3]:
print(f" - {c['name']}: SEO={c['seo_score']}, audited={c['seo_audited_at']}")
print(" ✓ Dashboard data query: PASSED")
return True
except Exception as e:
print(f" ✗ Dashboard data query: FAILED - {e}")
return False
finally:
db.close()
def test_statistics_calculation():
"""Test statistics calculation for the dashboard."""
print("\n=== Test 2: Statistics Calculation ===")
db = SessionLocal()
try:
# Get SEO data
companies_query = db.query(
Company.id,
CompanyWebsiteAnalysis.pagespeed_seo_score,
CompanyWebsiteAnalysis.seo_audited_at
).outerjoin(
CompanyWebsiteAnalysis,
Company.id == CompanyWebsiteAnalysis.company_id
).filter(
Company.status == 'active'
).all()
# Calculate statistics like admin_seo route
good_count = 0 # 90-100
medium_count = 0 # 50-89
poor_count = 0 # 0-49
not_audited_count = 0
scores = []
for row in companies_query:
score = row[1]
audited = row[2]
if audited is None or score is None:
not_audited_count += 1
elif score >= 90:
good_count += 1
scores.append(score)
elif score >= 50:
medium_count += 1
scores.append(score)
else:
poor_count += 1
scores.append(score)
avg_score = round(sum(scores) / len(scores)) if scores else None
print(f" Good (90-100): {good_count}")
print(f" Medium (50-89): {medium_count}")
print(f" Poor (0-49): {poor_count}")
print(f" Not audited: {not_audited_count}")
print(f" Average score: {avg_score}")
# Verify calculation
total = good_count + medium_count + poor_count + not_audited_count
assert total == len(companies_query), "Statistics should sum to total companies"
print(" ✓ Statistics calculation: PASSED")
return True
except Exception as e:
print(f" ✗ Statistics calculation: FAILED - {e}")
return False
finally:
db.close()
def test_score_color_coding():
"""Test score color coding logic (green/yellow/red)."""
print("\n=== Test 3: Score Color Coding ===")
def get_score_class(score):
if score is None:
return 'score-na'
elif score >= 90:
return 'score-good'
elif score >= 50:
return 'score-medium'
else:
return 'score-poor'
test_cases = [
(100, 'score-good'),
(95, 'score-good'),
(90, 'score-good'),
(89, 'score-medium'),
(75, 'score-medium'),
(50, 'score-medium'),
(49, 'score-poor'),
(25, 'score-poor'),
(0, 'score-poor'),
(None, 'score-na'),
]
all_passed = True
for score, expected in test_cases:
result = get_score_class(score)
status = "" if result == expected else ""
if result != expected:
all_passed = False
print(f" {status} Score {score}: {result} (expected {expected})")
if all_passed:
print(" ✓ Score color coding: PASSED")
else:
print(" ✗ Score color coding: FAILED")
return all_passed
def test_sorting_logic():
"""Test client-side sorting logic simulation."""
print("\n=== Test 4: Sorting Logic ===")
db = SessionLocal()
try:
# Get test data
companies = db.query(
Company.name,
CompanyWebsiteAnalysis.pagespeed_seo_score,
CompanyWebsiteAnalysis.seo_audited_at
).outerjoin(
CompanyWebsiteAnalysis,
Company.id == CompanyWebsiteAnalysis.company_id
).filter(
Company.status == 'active',
CompanyWebsiteAnalysis.seo_audited_at != None
).all()
# Test sorting by name
sorted_by_name = sorted(companies, key=lambda x: x[0].lower())
print(f" Sorted by name (first 3): {[c[0] for c in sorted_by_name[:3]]}")
# Test sorting by score (descending)
sorted_by_score = sorted(companies, key=lambda x: x[1] if x[1] else -1, reverse=True)
print(f" Sorted by score desc (top 3): {[(c[0], c[1]) for c in sorted_by_score[:3]]}")
# Test sorting by date (newest first)
sorted_by_date = sorted(companies, key=lambda x: x[2] if x[2] else datetime.min, reverse=True)
print(f" Sorted by date desc (first 3): {[(c[0], c[2].strftime('%Y-%m-%d') if c[2] else 'N/A') for c in sorted_by_date[:3]]}")
print(" ✓ Sorting logic: PASSED")
return True
except Exception as e:
print(f" ✗ Sorting logic: FAILED - {e}")
return False
finally:
db.close()
def test_filtering_logic():
"""Test filtering logic simulation."""
print("\n=== Test 5: Filtering Logic ===")
db = SessionLocal()
try:
# Get test data
companies = db.query(
Company.name,
Company.category_id,
CompanyWebsiteAnalysis.pagespeed_seo_score,
CompanyWebsiteAnalysis.seo_audited_at
).outerjoin(
CompanyWebsiteAnalysis,
Company.id == CompanyWebsiteAnalysis.company_id
).filter(
Company.status == 'active'
).all()
# Convert to list of dicts
data = [{
'name': c[0],
'category_id': c[1],
'score': c[2],
'audited': c[3]
} for c in companies]
# Filter by score range (good: 90-100)
good_filter = [c for c in data if c['score'] is not None and c['score'] >= 90]
print(f" Filter by good score (>=90): {len(good_filter)} companies")
# Filter by score range (poor: 0-49)
poor_filter = [c for c in data if c['score'] is not None and c['score'] < 50]
print(f" Filter by poor score (<50): {len(poor_filter)} companies")
# Filter by not audited
not_audited_filter = [c for c in data if c['audited'] is None]
print(f" Filter by not audited: {len(not_audited_filter)} companies")
# Filter by search text
search_term = "pix"
search_filter = [c for c in data if search_term.lower() in c['name'].lower()]
print(f" Filter by search '{search_term}': {len(search_filter)} companies - {[c['name'] for c in search_filter]}")
print(" ✓ Filtering logic: PASSED")
return True
except Exception as e:
print(f" ✗ Filtering logic: FAILED - {e}")
return False
finally:
db.close()
def test_drill_down_links():
"""Test drill-down links to company profiles."""
print("\n=== Test 6: Drill-Down Links ===")
db = SessionLocal()
try:
# Get companies with slugs
companies = db.query(
Company.name,
Company.slug
).filter(
Company.status == 'active'
).limit(5).all()
for c in companies:
# Verify slug format (should be kebab-case, no special chars)
expected_url = f"/company/{c[1]}"
print(f" {c[0]}: {expected_url}")
# Verify slug exists and is valid
assert c[1] is not None, f"Slug should not be None for {c[0]}"
assert ' ' not in c[1], f"Slug should not contain spaces for {c[0]}"
print(" ✓ Drill-down links: PASSED")
return True
except Exception as e:
print(f" ✗ Drill-down links: FAILED - {e}")
return False
finally:
db.close()
def test_api_endpoint_response():
"""Test API endpoint response structure."""
print("\n=== Test 7: API Response Structure ===")
db = SessionLocal()
try:
# Get a company with SEO data
company = db.query(Company).join(
CompanyWebsiteAnalysis,
Company.id == CompanyWebsiteAnalysis.company_id
).filter(
CompanyWebsiteAnalysis.seo_audited_at != None
).first()
if not company:
print(" No company with SEO data found")
return False
# Get analysis
analysis = db.query(CompanyWebsiteAnalysis).filter(
CompanyWebsiteAnalysis.company_id == company.id
).first()
# Build response structure (simulating API)
response = {
'company_id': company.id,
'company_name': company.name,
'website': company.website,
'seo_audit': {
'audited_at': analysis.seo_audited_at.isoformat() if analysis.seo_audited_at else None,
'pagespeed': {
'seo_score': analysis.pagespeed_seo_score,
'performance_score': analysis.pagespeed_performance_score,
'accessibility_score': analysis.pagespeed_accessibility_score,
'best_practices_score': analysis.pagespeed_best_practices_score
},
'on_page': {
'meta_title': analysis.meta_title,
'meta_description': analysis.meta_description,
'h1_count': analysis.h1_count,
'h2_count': analysis.h2_count,
'images_without_alt': analysis.images_without_alt,
'has_structured_data': analysis.has_structured_data
},
'technical': {
'has_ssl': analysis.has_ssl,
'has_sitemap': analysis.has_sitemap,
'has_robots_txt': analysis.has_robots_txt,
'has_canonical': analysis.has_canonical,
'is_indexable': analysis.is_indexable
}
}
}
print(f" Company: {response['company_name']}")
print(f" SEO Score: {response['seo_audit']['pagespeed']['seo_score']}")
print(f" Has all required fields: Yes")
# Verify structure
assert 'company_id' in response
assert 'seo_audit' in response
assert 'pagespeed' in response['seo_audit']
assert 'on_page' in response['seo_audit']
assert 'technical' in response['seo_audit']
print(" ✓ API response structure: PASSED")
return True
except Exception as e:
print(f" ✗ API response structure: FAILED - {e}")
return False
finally:
db.close()
def test_template_rendering_data():
"""Test that template has all required data."""
print("\n=== Test 8: Template Data Requirements ===")
db = SessionLocal()
try:
# Check that we have all the data the template needs
companies_count = db.query(Company).filter(Company.status == 'active').count()
seo_count = db.query(CompanyWebsiteAnalysis).filter(
CompanyWebsiteAnalysis.seo_audited_at != None
).count()
required_data = {
'companies': companies_count > 0,
'stats.good_count': True,
'stats.medium_count': True,
'stats.poor_count': True,
'stats.not_audited_count': True,
'stats.avg_score': True,
'categories': True, # Could be empty
'now': True,
'csrf_token': True # Provided by Flask
}
print(" Template requirements:")
for key, available in required_data.items():
status = "" if available else ""
print(f" {status} {key}")
print(f"\n Total companies: {companies_count}")
print(f" With SEO data: {seo_count}")
print(" ✓ Template data requirements: PASSED")
return True
except Exception as e:
print(f" ✗ Template data requirements: FAILED - {e}")
return False
finally:
db.close()
def main():
"""Run all tests."""
print("=" * 60)
print("Admin SEO Dashboard Tests")
print("=" * 60)
tests = [
test_dashboard_data_query,
test_statistics_calculation,
test_score_color_coding,
test_sorting_logic,
test_filtering_logic,
test_drill_down_links,
test_api_endpoint_response,
test_template_rendering_data,
]
results = []
for test in tests:
try:
result = test()
results.append((test.__name__, result))
except Exception as e:
print(f"\n{test.__name__} crashed: {e}")
results.append((test.__name__, False))
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
passed = sum(1 for _, r in results if r)
failed = len(results) - passed
for name, result in results:
status = "PASSED" if result else "FAILED"
print(f" {name}: {status}")
print(f"\nTotal: {passed}/{len(results)} tests passed")
if failed > 0:
print(f"\n⚠️ {failed} test(s) failed!")
sys.exit(1)
else:
print("\n✓ All tests passed!")
sys.exit(0)
if __name__ == '__main__':
main()

1111
tests/test_seo_audit.py Normal file

File diff suppressed because it is too large Load Diff