feat: add SSE progress bar for portal SEO audit
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

Audit now runs step-by-step with real-time progress via Server-Sent Events.
Each of 9 steps (fetch, on-page, technical, PageSpeed, local SEO, citations,
freshness, save) shows status with spinner, checkmark, or error icon.
Removed old POST form in favor of SSE-based streaming approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-21 15:33:44 +01:00
parent 0c878e38b9
commit 4d6150fbde
2 changed files with 571 additions and 80 deletions

View File

@ -8,10 +8,17 @@ Tracks results over time for before/after comparison.
import logging
import json
import sys
import os
import time as time_module
from datetime import datetime, date
from decimal import Decimal
from flask import abort, render_template, request, redirect, url_for, flash, jsonify
import requests
from flask import (
abort, render_template, request, redirect, url_for,
flash, Response, stream_with_context
)
from flask_login import login_required, current_user
from . import bp
@ -22,6 +29,11 @@ logger = logging.getLogger(__name__)
PORTAL_URL = 'https://nordabiznes.pl'
# Path to scripts/ for SEOAuditor components
_SCRIPTS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'scripts'
)
def _make_json_safe(obj):
"""Recursively convert datetime/Decimal objects to JSON-serializable types."""
@ -36,6 +48,19 @@ def _make_json_safe(obj):
return obj
def _sse_event(data):
"""Format SSE event."""
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
def _get_auditor():
"""Import and return SEOAuditor instance."""
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
from seo_audit import SEOAuditor
return SEOAuditor()
@bp.route('/portal-seo')
@login_required
def admin_portal_seo():
@ -57,91 +82,379 @@ def admin_portal_seo():
db.close()
@bp.route('/portal-seo/run', methods=['POST'])
@bp.route('/portal-seo/run/stream')
@login_required
def admin_portal_seo_run():
"""Run a new SEO audit on nordabiznes.pl using the existing SEOAuditor"""
def admin_portal_seo_run_stream():
"""SSE endpoint for streaming portal SEO audit progress step by step."""
if not is_audit_owner():
abort(404)
import sys
import os
db = SessionLocal()
try:
# Use the same SEOAuditor that audits company websites
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'scripts'))
from seo_audit import SEOAuditor
notes = request.args.get('notes', '')
user_email = current_user.email
auditor = SEOAuditor()
result = auditor.audit_company({
'id': 0,
'name': 'NordaBiznes.pl',
'slug': 'nordabiznes-pl',
'website': PORTAL_URL,
'address_city': 'Wejherowo'
})
STEPS = [
(1, 'Inicjalizacja audytora SEO...'),
(2, 'Pobieranie strony nordabiznes.pl...'),
(3, 'Analiza on-page SEO...'),
(4, 'Sprawdzanie techniczne (robots.txt, sitemap, SSL)...'),
(5, 'PageSpeed Insights API...'),
(6, 'Analiza Local SEO...'),
(7, 'Sprawdzanie cytowań (citations)...'),
(8, 'Sprawdzanie aktualności treści...'),
(9, 'Zapisywanie wyników...'),
]
TOTAL = len(STEPS)
# Extract scores from audit result
ps = result.get('pagespeed', {})
ps_scores = ps.get('scores', {})
cwv = ps.get('core_web_vitals', {})
tech = result.get('technical', {})
on_page = result.get('on_page', {})
def generate():
try:
# Step 1: Init auditor
yield _sse_event({
'step': 1, 'total': TOTAL,
'message': STEPS[0][1], 'status': 'running'
})
sec = result.get('security_headers', {})
auditor = _get_auditor()
audit = PortalSEOAudit(
audited_at=datetime.now(),
url=PORTAL_URL,
# PageSpeed scores
pagespeed_performance=ps_scores.get('performance'),
pagespeed_seo=ps_scores.get('seo'),
pagespeed_accessibility=ps_scores.get('accessibility'),
pagespeed_best_practices=ps_scores.get('best_practices'),
# Core Web Vitals
lcp_ms=cwv.get('lcp_ms'),
fcp_ms=cwv.get('fcp_ms'),
cls=cwv.get('cls'),
tbt_ms=cwv.get('tbt_ms'),
speed_index_ms=cwv.get('speed_index_ms'),
# On-page checks
has_meta_title=bool(on_page.get('meta_title')),
has_meta_description=bool(on_page.get('meta_description')),
has_canonical=tech.get('has_canonical'),
has_robots_txt=tech.get('has_robots_txt'),
has_sitemap=tech.get('has_sitemap'),
has_structured_data=on_page.get('has_structured_data'),
has_og_tags=on_page.get('has_og_tags'),
has_ssl=tech.get('has_ssl'),
is_mobile_friendly=tech.get('is_mobile_friendly'),
# Security headers
has_hsts=sec.get('has_hsts'),
has_csp=sec.get('has_csp'),
has_x_frame=sec.get('has_x_frame_options'),
has_x_content_type=sec.get('has_x_content_type'),
# Content metrics
page_size_bytes=on_page.get('page_size_bytes'),
image_count=on_page.get('total_images'),
images_without_alt=on_page.get('images_without_alt'),
# Full data (sanitize for JSONB - datetime/Decimal not serializable)
full_results=_make_json_safe(result),
notes=request.form.get('notes', ''),
created_by=current_user.email
)
yield _sse_event({
'step': 1, 'total': TOTAL,
'message': 'Audytor SEO zainicjalizowany', 'status': 'done'
})
db.add(audit)
db.commit()
company = {
'id': 0,
'name': 'NordaBiznes.pl',
'slug': 'nordabiznes-pl',
'website': PORTAL_URL,
'address_city': 'Wejherowo'
}
result = {
'company_id': 0,
'company_name': 'NordaBiznes.pl',
'audit_date': datetime.now(),
'website_url': PORTAL_URL,
'errors': [],
}
html_content = None
final_url = PORTAL_URL
flash(f'Audyt SEO portalu zakończony. Performance: {audit.pagespeed_performance or "N/A"}', 'success')
return redirect(url_for('admin.admin_portal_seo'))
# Step 2: Fetch page
yield _sse_event({
'step': 2, 'total': TOTAL,
'message': STEPS[1][1], 'status': 'running'
})
except Exception as e:
db.rollback()
logger.error(f'Portal SEO audit failed: {e}', exc_info=True)
flash(f'Błąd audytu: {str(e)}', 'error')
return redirect(url_for('admin.admin_portal_seo'))
finally:
db.close()
try:
start = time_module.time()
resp = auditor.session.get(
PORTAL_URL, timeout=15, allow_redirects=True
)
load_ms = int((time_module.time() - start) * 1000)
final_url = resp.url
if resp.status_code == 200:
if resp.encoding and resp.encoding.lower() == 'iso-8859-1':
resp.encoding = resp.apparent_encoding
html_content = resp.text
result['http_status'] = 200
result['load_time_ms'] = load_ms
result['final_url'] = final_url
yield _sse_event({
'step': 2, 'total': TOTAL,
'message': f'Strona pobrana ({load_ms}ms)',
'status': 'done'
})
else:
result['errors'].append(f'HTTP {resp.status_code}')
yield _sse_event({
'step': 2, 'total': TOTAL,
'message': f'HTTP {resp.status_code}',
'status': 'warning'
})
except Exception as e:
result['errors'].append(str(e)[:100])
yield _sse_event({
'step': 2, 'total': TOTAL,
'message': f'Błąd pobierania: {str(e)[:80]}',
'status': 'error'
})
# Step 3: On-page analysis
yield _sse_event({
'step': 3, 'total': TOTAL,
'message': STEPS[2][1], 'status': 'running'
})
if html_content:
try:
onpage = auditor.onpage_analyzer.analyze_html(
html_content, base_url=final_url
)
result['onpage'] = onpage.to_dict()
yield _sse_event({
'step': 3, 'total': TOTAL,
'message': f'On-page: title="{onpage.meta_title[:40]}..."' if onpage.meta_title else 'On-page: brak meta title',
'status': 'done'
})
except Exception as e:
result['errors'].append(f'On-page: {str(e)[:100]}')
yield _sse_event({
'step': 3, 'total': TOTAL,
'message': f'On-page błąd: {str(e)[:80]}',
'status': 'error'
})
else:
yield _sse_event({
'step': 3, 'total': TOTAL,
'message': 'Pominięto (brak HTML)',
'status': 'skipped'
})
# Step 4: Technical SEO
yield _sse_event({
'step': 4, 'total': TOTAL,
'message': STEPS[3][1], 'status': 'running'
})
try:
tech_result = auditor.technical_checker.check_url(final_url)
result['technical'] = tech_result.to_dict()
checks = []
td = tech_result.to_dict()
if td.get('has_robots_txt'):
checks.append('robots.txt')
if td.get('has_sitemap'):
checks.append('sitemap')
if td.get('has_ssl'):
checks.append('SSL')
yield _sse_event({
'step': 4, 'total': TOTAL,
'message': f'Technical: {", ".join(checks) if checks else "brak kluczowych elementów"}',
'status': 'done'
})
except Exception as e:
result['errors'].append(f'Technical: {str(e)[:100]}')
yield _sse_event({
'step': 4, 'total': TOTAL,
'message': f'Technical błąd: {str(e)[:80]}',
'status': 'error'
})
# Step 5: PageSpeed Insights
yield _sse_event({
'step': 5, 'total': TOTAL,
'message': STEPS[4][1], 'status': 'running'
})
try:
remaining = auditor.pagespeed_client.get_remaining_quota()
if remaining > 0:
from pagespeed_client import Strategy
ps_result = auditor.pagespeed_client.analyze_url(
final_url, strategy=Strategy.MOBILE
)
result['pagespeed'] = ps_result.to_dict()
result['scores'] = {
'pagespeed_seo': ps_result.scores.seo,
'pagespeed_performance': ps_result.scores.performance,
'pagespeed_accessibility': ps_result.scores.accessibility,
'pagespeed_best_practices': ps_result.scores.best_practices,
}
yield _sse_event({
'step': 5, 'total': TOTAL,
'message': f'PageSpeed: Perf={ps_result.scores.performance}, SEO={ps_result.scores.seo}',
'status': 'done'
})
else:
result['errors'].append('PageSpeed API quota exceeded')
yield _sse_event({
'step': 5, 'total': TOTAL,
'message': 'Limit API wyczerpany — pominięto',
'status': 'warning'
})
except Exception as e:
result['errors'].append(f'PageSpeed: {str(e)[:100]}')
yield _sse_event({
'step': 5, 'total': TOTAL,
'message': f'PageSpeed błąd: {str(e)[:80]}',
'status': 'error'
})
# Step 6: Local SEO
yield _sse_event({
'step': 6, 'total': TOTAL,
'message': STEPS[5][1], 'status': 'running'
})
if html_content:
try:
local_seo = auditor.local_seo_analyzer.analyze(
html_content, final_url, company
)
result['local_seo'] = local_seo
score = local_seo.get('local_seo_score', 0)
yield _sse_event({
'step': 6, 'total': TOTAL,
'message': f'Local SEO score: {score}',
'status': 'done'
})
except Exception as e:
result['errors'].append(f'Local SEO: {str(e)[:100]}')
yield _sse_event({
'step': 6, 'total': TOTAL,
'message': f'Local SEO błąd: {str(e)[:80]}',
'status': 'error'
})
else:
yield _sse_event({
'step': 6, 'total': TOTAL,
'message': 'Pominięto (brak HTML)',
'status': 'skipped'
})
# Step 7: Citations
yield _sse_event({
'step': 7, 'total': TOTAL,
'message': STEPS[6][1], 'status': 'running'
})
try:
citations = auditor.citation_checker.check_citations(
'NordaBiznes.pl', 'Wejherowo'
)
result['citations'] = citations
found = sum(1 for c in citations if c.get('status') == 'found')
yield _sse_event({
'step': 7, 'total': TOTAL,
'message': f'Cytowania: {found}/{len(citations)} znalezione',
'status': 'done'
})
except Exception as e:
result['errors'].append(f'Citations: {str(e)[:100]}')
yield _sse_event({
'step': 7, 'total': TOTAL,
'message': f'Citations błąd: {str(e)[:80]}',
'status': 'error'
})
# Step 8: Content freshness
yield _sse_event({
'step': 8, 'total': TOTAL,
'message': STEPS[7][1], 'status': 'running'
})
try:
freshness = auditor.freshness_checker.check_freshness(
final_url, html_content
)
result['freshness'] = freshness
fscore = freshness.get('content_freshness_score', 0)
yield _sse_event({
'step': 8, 'total': TOTAL,
'message': f'Aktualność treści: {fscore}',
'status': 'done'
})
except Exception as e:
result['errors'].append(f'Freshness: {str(e)[:100]}')
yield _sse_event({
'step': 8, 'total': TOTAL,
'message': f'Freshness błąd: {str(e)[:80]}',
'status': 'error'
})
# Step 9: Save to DB
yield _sse_event({
'step': 9, 'total': TOTAL,
'message': STEPS[8][1], 'status': 'running'
})
# Extract data for DB columns
ps = result.get('pagespeed', {})
ps_scores = result.get('scores', {})
cwv = ps.get('core_web_vitals', {})
tech = result.get('technical', {})
onpage = result.get('onpage', {})
sec = tech.get('security_headers', {})
db = SessionLocal()
try:
audit = PortalSEOAudit(
audited_at=datetime.now(),
url=PORTAL_URL,
pagespeed_performance=ps_scores.get('pagespeed_performance'),
pagespeed_seo=ps_scores.get('pagespeed_seo'),
pagespeed_accessibility=ps_scores.get('pagespeed_accessibility'),
pagespeed_best_practices=ps_scores.get('pagespeed_best_practices'),
lcp_ms=cwv.get('lcp_ms'),
fcp_ms=cwv.get('fcp_ms'),
cls=cwv.get('cls'),
tbt_ms=cwv.get('tbt_ms'),
speed_index_ms=cwv.get('speed_index_ms'),
has_meta_title=bool(onpage.get('meta_title')),
has_meta_description=bool(onpage.get('meta_description')),
has_canonical=tech.get('has_canonical'),
has_robots_txt=tech.get('has_robots_txt'),
has_sitemap=tech.get('has_sitemap'),
has_structured_data=onpage.get('has_structured_data'),
has_og_tags=onpage.get('has_og_tags'),
has_ssl=tech.get('has_ssl'),
is_mobile_friendly=tech.get('is_mobile_friendly'),
has_hsts=sec.get('has_hsts'),
has_csp=sec.get('has_csp'),
has_x_frame=sec.get('has_x_frame_options'),
has_x_content_type=sec.get('has_x_content_type'),
page_size_bytes=onpage.get('page_size_bytes'),
image_count=onpage.get('total_images'),
images_without_alt=onpage.get('images_without_alt'),
full_results=_make_json_safe(result),
notes=notes,
created_by=user_email
)
db.add(audit)
db.commit()
audit_id = audit.id
yield _sse_event({
'step': 9, 'total': TOTAL,
'message': f'Zapisano audyt #{audit_id}',
'status': 'done'
})
# Send complete event
yield _sse_event({
'status': 'complete',
'audit_id': audit_id,
'performance': ps_scores.get('pagespeed_performance'),
'seo': ps_scores.get('pagespeed_seo'),
'errors': result.get('errors', []),
})
except Exception as e:
db.rollback()
logger.error(f'Portal SEO save failed: {e}', exc_info=True)
yield _sse_event({
'step': 9, 'total': TOTAL,
'message': f'Błąd zapisu: {str(e)[:80]}',
'status': 'error'
})
yield _sse_event({'status': 'error', 'message': str(e)[:200]})
finally:
db.close()
except Exception as e:
logger.error(f'Portal SEO audit stream failed: {e}', exc_info=True)
yield _sse_event({
'status': 'error',
'message': f'Krytyczny błąd: {str(e)[:200]}'
})
return Response(
stream_with_context(generate()),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
}
)
@bp.route('/portal-seo/<int:audit_id>')

View File

@ -111,6 +111,68 @@
align-items: center;
gap: var(--spacing-sm);
}
/* Progress panel */
.audit-progress {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
display: none;
}
.audit-progress.active { display: block; }
.progress-bar-container {
background: #e2e8f0;
border-radius: 999px;
height: 8px;
margin-bottom: var(--spacing-md);
overflow: hidden;
}
.progress-bar-fill {
background: var(--primary);
height: 100%;
border-radius: 999px;
width: 0%;
transition: width 0.4s ease;
}
.progress-steps {
list-style: none;
padding: 0;
margin: 0;
}
.progress-steps li {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: 6px 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.progress-steps li.active {
color: var(--text-primary);
font-weight: 600;
}
.progress-steps li.done { color: var(--success); }
.progress-steps li.error { color: var(--error); }
.progress-steps li.warning { color: var(--warning); }
.step-icon {
width: 20px;
text-align: center;
flex-shrink: 0;
}
.step-icon.pending { color: var(--border); }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
</style>
{% endblock %}
@ -120,13 +182,31 @@
<h1 style="margin-bottom: var(--spacing-xs);">SEO Portalu</h1>
<p style="color: var(--text-secondary);">Audyt {{ portal_url }} - historia zmian w czasie</p>
</div>
<form method="POST" action="{{ url_for('admin.admin_portal_seo_run') }}" class="run-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text" name="notes" placeholder="Notatka (opcjonalnie)" style="padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-sm); width: 200px;">
<button type="submit" class="btn btn-primary" style="white-space: nowrap;">
<div class="run-form">
<input type="text" id="audit-notes" placeholder="Notatka (opcjonalnie)" style="padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-sm); width: 200px;">
<button type="button" id="btn-run-audit" class="btn btn-primary" style="white-space: nowrap;" onclick="startAudit()">
Uruchom audyt
</button>
</form>
</div>
</div>
<!-- Progress panel -->
<div class="audit-progress" id="audit-progress">
<h3 style="margin-bottom: var(--spacing-md);">Audyt w toku...</h3>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progress-bar"></div>
</div>
<ul class="progress-steps" id="progress-steps">
<li data-step="1"><span class="step-icon pending">&#9675;</span> Inicjalizacja audytora SEO</li>
<li data-step="2"><span class="step-icon pending">&#9675;</span> Pobieranie strony</li>
<li data-step="3"><span class="step-icon pending">&#9675;</span> Analiza on-page SEO</li>
<li data-step="4"><span class="step-icon pending">&#9675;</span> Sprawdzanie techniczne</li>
<li data-step="5"><span class="step-icon pending">&#9675;</span> PageSpeed Insights API</li>
<li data-step="6"><span class="step-icon pending">&#9675;</span> Analiza Local SEO</li>
<li data-step="7"><span class="step-icon pending">&#9675;</span> Sprawdzanie cytowań</li>
<li data-step="8"><span class="step-icon pending">&#9675;</span> Sprawdzanie aktualności treści</li>
<li data-step="9"><span class="step-icon pending">&#9675;</span> Zapisywanie wyników</li>
</ul>
</div>
{% if audits %}
@ -282,3 +362,101 @@
{% endif %}
{% endblock %}
{% block extra_js %}
function startAudit() {
var btn = document.getElementById('btn-run-audit');
var panel = document.getElementById('audit-progress');
var bar = document.getElementById('progress-bar');
var stepsList = document.getElementById('progress-steps');
var notes = document.getElementById('audit-notes').value;
btn.disabled = true;
btn.textContent = 'Trwa audyt...';
panel.classList.add('active');
// Reset all steps
var items = stepsList.querySelectorAll('li');
items.forEach(function(li) {
li.className = '';
li.querySelector('.step-icon').className = 'step-icon pending';
li.querySelector('.step-icon').innerHTML = '&#9675;';
});
bar.style.width = '0%';
var url = '{{ url_for("admin.admin_portal_seo_run_stream") }}' + '?notes=' + encodeURIComponent(notes);
var source = new EventSource(url);
source.onmessage = function(e) {
var data = JSON.parse(e.data);
// Final events
if (data.status === 'complete') {
source.close();
bar.style.width = '100%';
btn.textContent = 'Ukończono!';
panel.querySelector('h3').textContent = 'Audyt zakończony';
setTimeout(function() {
window.location.reload();
}, 1500);
return;
}
if (data.status === 'error' && !data.step) {
source.close();
btn.disabled = false;
btn.textContent = 'Uruchom audyt';
panel.querySelector('h3').textContent = 'Błąd audytu: ' + (data.message || 'Nieznany');
return;
}
// Step progress
if (data.step) {
var pct = Math.round((data.step / data.total) * 100);
bar.style.width = pct + '%';
var li = stepsList.querySelector('li[data-step="' + data.step + '"]');
if (!li) return;
var icon = li.querySelector('.step-icon');
// Update step text with detail message
var stepLabel = li.childNodes[li.childNodes.length - 1];
if (data.status === 'running') {
li.className = 'active';
icon.className = 'step-icon';
icon.innerHTML = '<span class="spinner"></span>';
} else if (data.status === 'done') {
li.className = 'done';
icon.className = 'step-icon';
icon.innerHTML = '&#10003;';
stepLabel.textContent = ' ' + data.message;
} else if (data.status === 'error') {
li.className = 'error';
icon.className = 'step-icon';
icon.innerHTML = '&#10007;';
stepLabel.textContent = ' ' + data.message;
} else if (data.status === 'warning') {
li.className = 'warning';
icon.className = 'step-icon';
icon.innerHTML = '&#9888;';
stepLabel.textContent = ' ' + data.message;
} else if (data.status === 'skipped') {
li.className = '';
icon.className = 'step-icon';
icon.innerHTML = '&#8212;';
stepLabel.textContent = ' ' + data.message;
}
}
};
source.onerror = function() {
source.close();
btn.disabled = false;
btn.textContent = 'Uruchom audyt';
if (bar.style.width === '100%') return;
panel.querySelector('h3').textContent = 'Połączenie przerwane';
};
}
{% endblock %}