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

Route passes 16 new fields from DB (technical SEO, meta tags, structured
data, performance) plus CrUX/security/image metrics. Template shows new
sections: Meta Tags & Content, CrUX Field Data, Security Headers (score
X/4), Image Optimization (% modern formats), and 9 new Technical SEO
checklist items. Migration 059 adds 16 columns for persisting live data.
AI service now saves CrUX/security/image data to DB during analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-08 12:53:07 +01:00
parent 4c104744ea
commit ea3d26282f
5 changed files with 369 additions and 1 deletions

View File

@ -156,6 +156,29 @@ def _collect_seo_data(db, company) -> dict:
except Exception as e:
logger.warning(f"CrUX error for {company.website}: {e}")
# Persist live-collected data to DB for dashboard display
try:
if security_headers:
for key, val in security_headers.items():
setattr(analysis, key, val)
if image_formats:
analysis.modern_image_count = image_formats.get('modern_format_count')
analysis.legacy_image_count = image_formats.get('legacy_format_count')
analysis.modern_image_ratio = image_formats.get('modern_format_ratio')
if crux_data:
analysis.crux_lcp_ms = crux_data.get('crux_lcp_ms')
analysis.crux_inp_ms = crux_data.get('crux_inp_ms')
analysis.crux_cls = crux_data.get('crux_cls')
analysis.crux_fcp_ms = crux_data.get('crux_fcp_ms')
analysis.crux_ttfb_ms = crux_data.get('crux_ttfb_ms')
analysis.crux_lcp_good_pct = crux_data.get('crux_lcp_ms_good_pct')
analysis.crux_inp_good_pct = crux_data.get('crux_inp_ms_good_pct')
analysis.crux_period_end = crux_data.get('crux_period_end')
db.commit()
except Exception as e:
logger.warning(f"Failed to persist live metrics for {company.name}: {e}")
db.rollback()
return {
'company_name': company.name,
'company_category': company.category.name if company.category else None,

View File

@ -128,6 +128,44 @@ def seo_audit_dashboard(slug):
# Social sharing
'has_og_tags': analysis.has_og_tags,
'has_twitter_cards': analysis.has_twitter_cards,
# Technical SEO details
'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,
'html_lang': analysis.html_lang,
'has_hreflang': analysis.has_hreflang,
# Meta tags
'meta_title': analysis.meta_title,
'meta_description': analysis.meta_description,
# Structured data
'has_structured_data': analysis.has_structured_data,
'structured_data_types': analysis.structured_data_types,
# Performance
'load_time_ms': analysis.load_time_ms,
'word_count_homepage': analysis.word_count_homepage,
# CrUX field data (real user metrics)
'crux_lcp_ms': analysis.crux_lcp_ms,
'crux_inp_ms': analysis.crux_inp_ms,
'crux_cls': float(analysis.crux_cls) if analysis.crux_cls is not None else None,
'crux_fcp_ms': analysis.crux_fcp_ms,
'crux_ttfb_ms': analysis.crux_ttfb_ms,
'crux_lcp_good_pct': float(analysis.crux_lcp_good_pct) if analysis.crux_lcp_good_pct is not None else None,
'crux_inp_good_pct': float(analysis.crux_inp_good_pct) if analysis.crux_inp_good_pct is not None else None,
# Security headers
'has_hsts': analysis.has_hsts,
'has_csp': analysis.has_csp,
'has_x_frame_options': analysis.has_x_frame_options,
'has_x_content_type_options': analysis.has_x_content_type_options,
'security_headers_count': analysis.security_headers_count,
# Image formats
'modern_image_count': analysis.modern_image_count,
'legacy_image_count': analysis.legacy_image_count,
'modern_image_ratio': float(analysis.modern_image_ratio) if analysis.modern_image_ratio is not None else None,
# Citations list
'citations': [{'directory_name': c.directory_name, 'listing_url': c.listing_url, 'status': c.status, 'nap_accurate': c.nap_accurate} for c in citations],
}

View File

@ -28,7 +28,7 @@ import os
import json
from datetime import datetime
from enum import IntEnum
from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, Numeric, Date, Time, TypeDecorator, UniqueConstraint, Enum
from sqlalchemy import create_engine, Column, Integer, SmallInteger, String, Text, Boolean, DateTime, ForeignKey, Table, Numeric, Date, Time, TypeDecorator, UniqueConstraint, Enum
from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY, JSONB as PG_JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
@ -1125,6 +1125,28 @@ class CompanyWebsiteAnalysis(Base):
html_lang = Column(String(10)) # Language attribute from <html lang="...">
has_hreflang = Column(Boolean, default=False) # Whether page has hreflang tags
# === CrUX FIELD DATA (Chrome User Experience Report) ===
crux_lcp_ms = Column(Integer) # Real user LCP p75 in milliseconds
crux_inp_ms = Column(Integer) # Real user INP p75 in milliseconds
crux_cls = Column(Numeric(6, 4)) # Real user CLS p75
crux_fcp_ms = Column(Integer) # Real user FCP p75 in milliseconds
crux_ttfb_ms = Column(Integer) # Real user TTFB p75 in milliseconds
crux_lcp_good_pct = Column(Numeric(5, 2)) # % of users with good LCP
crux_inp_good_pct = Column(Numeric(5, 2)) # % of users with good INP
crux_period_end = Column(Date) # End date of CrUX data collection period
# === SECURITY HEADERS ===
has_hsts = Column(Boolean) # Strict-Transport-Security header present
has_csp = Column(Boolean) # Content-Security-Policy header present
has_x_frame_options = Column(Boolean) # X-Frame-Options header present
has_x_content_type_options = Column(Boolean) # X-Content-Type-Options header present
security_headers_count = Column(SmallInteger) # Count of security headers (0-4)
# === IMAGE FORMAT ANALYSIS ===
modern_image_count = Column(Integer) # WebP + AVIF + SVG images count
legacy_image_count = Column(Integer) # JPG + PNG + GIF images count
modern_image_ratio = Column(Numeric(5, 2)) # % of images in modern formats
# === SEO AUDIT METADATA ===
seo_audit_version = Column(String(20)) # Version of SEO audit script used
seo_audited_at = Column(DateTime) # Timestamp of last SEO audit

View File

@ -0,0 +1,28 @@
-- Migration 059: Add CrUX field data, security headers, and image format columns
-- These metrics are currently collected live during AI analysis but not persisted
-- Adding DB columns allows displaying them in the SEO audit dashboard
-- CrUX field data (Chrome User Experience Report - real user metrics)
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS crux_lcp_ms INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS crux_inp_ms INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS crux_cls NUMERIC(6,4);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS crux_fcp_ms INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS crux_ttfb_ms INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS crux_lcp_good_pct NUMERIC(5,2);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS crux_inp_good_pct NUMERIC(5,2);
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS crux_period_end DATE;
-- Security headers
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS has_hsts BOOLEAN;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS has_csp BOOLEAN;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS has_x_frame_options BOOLEAN;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS has_x_content_type_options BOOLEAN;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS security_headers_count SMALLINT;
-- Image format analysis
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS modern_image_count INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS legacy_image_count INTEGER;
ALTER TABLE company_website_analysis ADD COLUMN IF NOT EXISTS modern_image_ratio NUMERIC(5,2);
-- Grant permissions to application user
GRANT ALL ON TABLE company_website_analysis TO nordabiz_app;

View File

@ -619,6 +619,70 @@
</div>
{% endif %}
{% if seo_data.crux_lcp_ms is not none %}
<!-- CrUX Field Data Section -->
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Dane z Chrome UX Report (realni uzytkownicy)
</h2>
<div style="background: var(--surface); padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-xl);">
<p style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin: 0 0 var(--spacing-md) 0;">Metryki p75 z realnych sesji uzytkownikow Chrome (ostatnie 28 dni)</p>
<div class="metrics-grid">
{% set crux_lcp = seo_data.crux_lcp_ms %}
{% set crux_lcp_class = 'good' if crux_lcp <= 2500 else ('medium' if crux_lcp <= 4000 else 'poor') %}
<div class="metric-card {{ crux_lcp_class }}">
<div class="metric-name">LCP (Field)</div>
<div class="metric-value {{ crux_lcp_class }}">{{ '%.1f'|format(crux_lcp / 1000) }}s</div>
{% if seo_data.crux_lcp_good_pct is not none %}
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin-top: 4px;">{{ '%.0f'|format(seo_data.crux_lcp_good_pct) }}% dobrych</div>
{% endif %}
</div>
{% if seo_data.crux_inp_ms is not none %}
{% set crux_inp = seo_data.crux_inp_ms %}
{% set crux_inp_class = 'good' if crux_inp <= 200 else ('medium' if crux_inp <= 500 else 'poor') %}
<div class="metric-card {{ crux_inp_class }}">
<div class="metric-name">INP (Field)</div>
<div class="metric-value {{ crux_inp_class }}">{{ crux_inp }}ms</div>
{% if seo_data.crux_inp_good_pct is not none %}
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary); margin-top: 4px;">{{ '%.0f'|format(seo_data.crux_inp_good_pct) }}% dobrych</div>
{% endif %}
</div>
{% endif %}
{% if seo_data.crux_cls is not none %}
{% set crux_cls = seo_data.crux_cls %}
{% set crux_cls_class = 'good' if crux_cls < 0.1 else ('medium' if crux_cls < 0.25 else 'poor') %}
<div class="metric-card {{ crux_cls_class }}">
<div class="metric-name">CLS (Field)</div>
<div class="metric-value {{ crux_cls_class }}">{{ '%.3f'|format(crux_cls) }}</div>
</div>
{% endif %}
{% if seo_data.crux_fcp_ms is not none %}
{% set crux_fcp = seo_data.crux_fcp_ms %}
{% set crux_fcp_class = 'good' if crux_fcp <= 1800 else ('medium' if crux_fcp <= 3000 else 'poor') %}
<div class="metric-card {{ crux_fcp_class }}">
<div class="metric-name">FCP (Field)</div>
<div class="metric-value {{ crux_fcp_class }}">{{ '%.1f'|format(crux_fcp / 1000) }}s</div>
</div>
{% endif %}
{% if seo_data.crux_ttfb_ms is not none %}
{% set crux_ttfb = seo_data.crux_ttfb_ms %}
{% set crux_ttfb_class = 'good' if crux_ttfb <= 800 else ('medium' if crux_ttfb <= 1800 else 'poor') %}
<div class="metric-card {{ crux_ttfb_class }}">
<div class="metric-name">TTFB (Field)</div>
<div class="metric-value {{ crux_ttfb_class }}">{{ crux_ttfb }}ms</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if seo_data.local_seo_score is not none %}
<!-- Local SEO Section -->
<h2 class="section-title">
@ -745,6 +809,64 @@
</div>
{% endif %}
{% if seo_data.meta_title or seo_data.meta_description or seo_data.load_time_ms is not none or seo_data.word_count_homepage is not none %}
<!-- Meta Tags & Content -->
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
</svg>
Meta Tagi i Tresc
</h2>
<div style="background: var(--surface); padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-xl);">
{% if seo_data.meta_title %}
{% set title_len = seo_data.meta_title|length %}
{% set title_status = 'good' if title_len >= 50 and title_len <= 60 else ('medium' if title_len >= 30 and title_len <= 70 else 'poor') %}
<div style="margin-bottom: var(--spacing-md);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-xs);">
<span style="font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary);">Meta Title</span>
<span style="font-size: var(--font-size-xs); padding: 2px 8px; border-radius: var(--radius-sm); background: {{ '#dcfce7' if title_status == 'good' else ('#fef3c7' if title_status == 'medium' else '#fee2e2') }}; color: {{ '#10b981' if title_status == 'good' else ('#f59e0b' if title_status == 'medium' else '#ef4444') }};">{{ title_len }} znakow {% if title_status == 'good' %}(idealnie){% elif title_len < 50 %}(za krotki){% else %}(za dlugi){% endif %}</span>
</div>
<div style="padding: var(--spacing-sm); background: var(--bg-tertiary); border-radius: var(--radius); font-size: var(--font-size-sm); color: var(--text-secondary); word-break: break-word;">{{ seo_data.meta_title }}</div>
</div>
{% endif %}
{% if seo_data.meta_description %}
{% set desc_len = seo_data.meta_description|length %}
{% set desc_status = 'good' if desc_len >= 150 and desc_len <= 160 else ('medium' if desc_len >= 120 and desc_len <= 180 else 'poor') %}
<div style="margin-bottom: var(--spacing-md);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-xs);">
<span style="font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary);">Meta Description</span>
<span style="font-size: var(--font-size-xs); padding: 2px 8px; border-radius: var(--radius-sm); background: {{ '#dcfce7' if desc_status == 'good' else ('#fef3c7' if desc_status == 'medium' else '#fee2e2') }}; color: {{ '#10b981' if desc_status == 'good' else ('#f59e0b' if desc_status == 'medium' else '#ef4444') }};">{{ desc_len }} znakow {% if desc_status == 'good' %}(idealnie){% elif desc_len < 150 %}(za krotki){% else %}(za dlugi){% endif %}</span>
</div>
<div style="padding: var(--spacing-sm); background: var(--bg-tertiary); border-radius: var(--radius); font-size: var(--font-size-sm); color: var(--text-secondary); word-break: break-word;">{{ seo_data.meta_description }}</div>
</div>
{% endif %}
{% if seo_data.load_time_ms is not none or seo_data.word_count_homepage is not none %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--spacing-sm);">
{% if seo_data.load_time_ms is not none %}
{% set lt = seo_data.load_time_ms %}
{% set lt_status = 'good' if lt < 1000 else ('medium' if lt < 3000 else 'poor') %}
<div style="padding: var(--spacing-sm); background: var(--bg-tertiary); border-radius: var(--radius); text-align: center;">
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary);">Czas ladowania</div>
<div style="font-size: var(--font-size-lg); font-weight: 700; color: {{ '#10b981' if lt_status == 'good' else ('#f59e0b' if lt_status == 'medium' else '#ef4444') }};">{{ '%.1f'|format(lt / 1000) }}s</div>
</div>
{% endif %}
{% if seo_data.word_count_homepage is not none %}
{% set wc = seo_data.word_count_homepage %}
{% set wc_status = 'good' if wc >= 300 else ('medium' if wc >= 100 else 'poor') %}
<div style="padding: var(--spacing-sm); background: var(--bg-tertiary); border-radius: var(--radius); text-align: center;">
<div style="font-size: var(--font-size-xs); color: var(--text-tertiary);">Liczba slow</div>
<div style="font-size: var(--font-size-lg); font-weight: 700; color: {{ '#10b981' if wc_status == 'good' else ('#f59e0b' if wc_status == 'medium' else '#ef4444') }};">{{ wc }}</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% if seo_data.has_ssl is not none or seo_data.has_google_analytics is not none or seo_data.h1_count is not none or seo_data.total_images is not none %}
<!-- Technical SEO Checklist -->
<h2 class="section-title">
@ -814,6 +936,69 @@
<span style="font-size: var(--font-size-sm);">Linki: {{ seo_data.internal_links_count or 0 }} wew., {{ seo_data.external_links_count or 0 }} zew.{% if broken > 0 %}, {{ broken }} uszkodzonych{% endif %}</span>
</div>
{% endif %}
{% if seo_data.has_sitemap is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.has_sitemap else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.has_sitemap else '#ef4444' }};">{{ '✓' if seo_data.has_sitemap else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">Sitemap XML</span>
</div>
{% endif %}
{% if seo_data.has_robots_txt is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.has_robots_txt else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.has_robots_txt else '#ef4444' }};">{{ '✓' if seo_data.has_robots_txt else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">Robots.txt</span>
</div>
{% endif %}
{% if seo_data.has_canonical is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.has_canonical else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.has_canonical else '#ef4444' }};">{{ '✓' if seo_data.has_canonical else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">Canonical URL{% if seo_data.has_canonical and seo_data.canonical_url %}: <span style="color: var(--text-secondary); word-break: break-all;">{{ seo_data.canonical_url[:60] }}{% if seo_data.canonical_url|length > 60 %}...{% endif %}</span>{% endif %}</span>
</div>
{% endif %}
{% if seo_data.is_indexable is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.is_indexable else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.is_indexable else '#ef4444' }};">{{ '✓' if seo_data.is_indexable else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">Indeksowalnosc{% if not seo_data.is_indexable and seo_data.noindex_reason %} ({{ seo_data.noindex_reason }}){% endif %}</span>
</div>
{% endif %}
{% if seo_data.is_mobile_friendly is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.is_mobile_friendly else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.is_mobile_friendly else '#ef4444' }};">{{ '✓' if seo_data.is_mobile_friendly else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">Mobile Friendly</span>
</div>
{% endif %}
{% if seo_data.viewport_configured is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.viewport_configured else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.viewport_configured else '#ef4444' }};">{{ '✓' if seo_data.viewport_configured else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">Viewport Meta Tag</span>
</div>
{% endif %}
{% if seo_data.html_lang %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: #dcfce7;">
<span style="color: #10b981;"></span>
<span style="font-size: var(--font-size-sm);">HTML Lang: <strong>{{ seo_data.html_lang }}</strong></span>
</div>
{% endif %}
{% if seo_data.has_hreflang is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.has_hreflang else '#f3f4f6' }};">
<span style="color: {{ '#10b981' if seo_data.has_hreflang else '#9ca3af' }};">{{ '✓' if seo_data.has_hreflang else '—' }}</span>
<span style="font-size: var(--font-size-sm);">Hreflang{% if not seo_data.has_hreflang %} (opcjonalne){% endif %}</span>
</div>
{% endif %}
{% if seo_data.has_structured_data is not none %}
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.has_structured_data else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.has_structured_data else '#ef4444' }};">{{ '✓' if seo_data.has_structured_data else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">Dane strukturalne{% if seo_data.has_structured_data and seo_data.structured_data_types %}: {{ seo_data.structured_data_types|join(', ') }}{% endif %}</span>
</div>
{% endif %}
</div>
{% if seo_data.h1_text %}
@ -825,6 +1010,78 @@
</div>
{% endif %}
{% if seo_data.has_hsts is not none %}
<!-- Security Headers Section -->
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
Naglowki bezpieczenstwa
</h2>
<div style="background: var(--surface); padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-xl);">
{% set sec_count = seo_data.security_headers_count or 0 %}
<div style="display: flex; align-items: center; gap: var(--spacing-md); margin-bottom: var(--spacing-md);">
<div style="font-size: var(--font-size-xl); font-weight: 700; color: {{ '#10b981' if sec_count == 4 else ('#f59e0b' if sec_count >= 2 else '#ef4444') }};">{{ sec_count }}/4</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">
{% if sec_count == 4 %}Wszystkie naglowki bezpieczenstwa skonfigurowane{% elif sec_count >= 2 %}Czesciowa ochrona — brakuje {{ 4 - sec_count }} naglowkow{% else %}Slaba ochrona — wymagana konfiguracja{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--spacing-sm);">
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.has_hsts else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.has_hsts else '#ef4444' }};">{{ '✓' if seo_data.has_hsts else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">Strict-Transport-Security (HSTS)</span>
</div>
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.has_csp else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.has_csp else '#ef4444' }};">{{ '✓' if seo_data.has_csp else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">Content-Security-Policy (CSP)</span>
</div>
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.has_x_frame_options else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.has_x_frame_options else '#ef4444' }};">{{ '✓' if seo_data.has_x_frame_options else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">X-Frame-Options</span>
</div>
<div style="display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm); border-radius: var(--radius); background: {{ '#dcfce7' if seo_data.has_x_content_type_options else '#fee2e2' }};">
<span style="color: {{ '#10b981' if seo_data.has_x_content_type_options else '#ef4444' }};">{{ '✓' if seo_data.has_x_content_type_options else '✗' }}</span>
<span style="font-size: var(--font-size-sm);">X-Content-Type-Options</span>
</div>
</div>
</div>
{% endif %}
{% if seo_data.modern_image_ratio is not none %}
<!-- Image Optimization Section -->
<h2 class="section-title">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
Optymalizacja obrazow
</h2>
<div style="background: var(--surface); padding: var(--spacing-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-xl);">
{% set ratio = seo_data.modern_image_ratio %}
{% set ratio_class = 'good' if ratio >= 80 else ('medium' if ratio >= 40 else 'poor') %}
<div style="display: flex; align-items: center; gap: var(--spacing-lg); margin-bottom: var(--spacing-md);">
<div>
<div style="font-size: var(--font-size-xl); font-weight: 700; color: {{ '#10b981' if ratio_class == 'good' else ('#f59e0b' if ratio_class == 'medium' else '#ef4444') }};">{{ '%.0f'|format(ratio) }}%</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">nowoczesnych formatow</div>
</div>
<div style="flex: 1;">
<div style="height: 12px; background: #e5e7eb; border-radius: 6px; overflow: hidden;">
<div style="height: 100%; width: {{ ratio }}%; background: {{ '#10b981' if ratio_class == 'good' else ('#f59e0b' if ratio_class == 'medium' else '#ef4444') }}; border-radius: 6px; transition: width 0.3s;"></div>
</div>
</div>
</div>
<div style="display: flex; gap: var(--spacing-lg); font-size: var(--font-size-sm); color: var(--text-secondary);">
{% if seo_data.modern_image_count is not none %}
<span>WebP/AVIF/SVG: <strong style="color: #10b981;">{{ seo_data.modern_image_count }}</strong></span>
{% endif %}
{% if seo_data.legacy_image_count is not none %}
<span>JPG/PNG/GIF: <strong style="color: {{ '#ef4444' if seo_data.legacy_image_count > 0 else '#10b981' }};">{{ seo_data.legacy_image_count }}</strong></span>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<!-- No Audit State -->
<div class="no-audit-state">