Files
google-ads-ver-2/scripts/reports/generate_html_report.py
2026-05-15 09:28:11 +02:00

1402 lines
51 KiB
Python

#!/usr/bin/env python3
"""
Generuje raport HTML z danych JSON (Google Ads + GA4 + e-commerce + Semstorm).
Użycie:
python scripts/reports/generate_html_report.py --data output/innsi.pl_2026-02.json --client "INNSI"
"""
import argparse
import json
import re
import sys
import io
from pathlib import Path
from urllib.parse import urlparse
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
ROOT = Path(__file__).parent.parent.parent
TEMPLATES = Path(__file__).parent / "templates"
def generate_summary(data):
"""Generate positive-tone summary text based on data."""
ads = data["google_ads"]
mom = ads["mom_change"]
totals = ads["totals"]
ga4 = data.get("ga4")
ecom = ga4.get("ecommerce") if ga4 else None
lines = []
# E-commerce: revenue & transactions
if ecom and ecom.get("current"):
ec = ecom["current"]
ec_mom = ecom.get("mom_change", {})
revenue = ec["revenue"]
transactions = ec["transactions"]
aov = ec["aov"]
if (ec_mom.get("revenue_pct") or 0) > 5:
lines.append(
f"Przychód sklepu wyniósł <strong>{revenue:,.2f} PLN</strong> "
f"(+{ec_mom['revenue_pct']}% vs poprzedni miesiąc)."
)
elif (ec_mom.get("revenue_pct") or 0) > -5:
lines.append(f"Przychód sklepu utrzymał się na poziomie <strong>{revenue:,.2f} PLN</strong>.")
else:
lines.append(f"Przychód sklepu wyniósł <strong>{revenue:,.2f} PLN</strong>.")
lines.append(f"Zrealizowano {transactions} transakcji przy średniej wartości zamówienia {aov:.2f} PLN.")
# ROAS from Google Ads conversion_value
gads_roas = totals.get("roas", 0)
if gads_roas > 0:
lines.append(f"ROAS z Google Ads: <strong>{gads_roas:.2f}</strong> (każda wydana złotówka przyniosła {gads_roas:.2f} PLN przychodu).")
else:
# Non-ecommerce: conversions focus
if mom["conversions_pct"] > 10:
lines.append(
f"Liczba konwersji (telefonów) wzrosła o "
f"<strong>{mom['conversions_pct']}%</strong> w porównaniu "
f"do poprzedniego miesiąca ({int(totals['conversions'])} vs "
f"{int(ads['prev_totals']['conversions'])})."
)
elif mom["conversions_pct"] > 0:
lines.append(f"Konwersje utrzymały tendencję wzrostową ({int(totals['conversions'])} telefonów).")
elif mom["conversions_pct"] == 0:
lines.append(f"Liczba konwersji utrzymała się na stabilnym poziomie ({int(totals['conversions'])}).")
else:
lines.append(f"Odnotowano {int(totals['conversions'])} konwersji w tym miesiącu.")
# CPA
if mom["cpa_pct"] < -10:
lines.append(f"Koszt pozyskania klienta (CPA) spadł o <strong>{abs(mom['cpa_pct'])}%</strong> do {totals['cpa']:.2f} PLN &mdash; to bardzo dobry wynik.")
elif mom["cpa_pct"] < 0:
lines.append(f"CPA spadło do {totals['cpa']:.2f} PLN, co oznacza bardziej efektywne wydawanie budżetu.")
# CPC
if mom["cpc_pct"] < 0:
lines.append(f"Średni koszt kliknięcia (CPC) spadł o {abs(mom['cpc_pct'])}% do {totals['cpc']:.2f} PLN.")
# Clicks
if mom["clicks_pct"] > 0:
lines.append(f"Ruch z reklam wzrósł o {mom['clicks_pct']}% ({totals['clicks']} kliknięć).")
elif mom["clicks_pct"] > -5:
lines.append(f"Ruch z reklam utrzymał się na stabilnym poziomie ({totals['clicks']} kliknięć).")
# GA4
if ga4 and ga4.get("current"):
ga4_cur = ga4["current"]
ga4_mom = ga4.get("mom_change", {})
sessions = ga4_cur["sessions"]
users = ga4_cur["users"]
if ga4_mom.get("sessions_pct", 0) > 0:
lines.append(f"Ruch na stronie wzrósł: {sessions:,} sesji (+{ga4_mom['sessions_pct']}%), {users:,} użytkowników.")
else:
lines.append(f"Strona odnotowała {sessions:,} sesji i {users:,} użytkowników.")
# Semstorm
semstorm = data.get("semstorm")
if semstorm and semstorm.get("current"):
sc = semstorm["current"]
sm = semstorm.get("mom_change", {})
if sm.get("top10_pct", 0) > 0:
lines.append(f"Widoczność SEO w TOP10 wzrosła o {sm['top10_pct']}% ({sc['top10']} fraz).")
else:
lines.append(f"Widoczność SEO: {sc['top3']} fraz w TOP3, {sc['top10']} w TOP10.")
return " ".join(lines)
def format_number(n, decimals=0):
"""Format number with space as thousands separator."""
if decimals == 0:
return f"{int(n):,}".replace(",", " ")
return f"{n:,.{decimals}f}".replace(",", " ")
def build_html(data):
"""Build complete HTML report from data."""
ads = data["google_ads"]
totals = ads["totals"]
prev_totals = ads["prev_totals"]
mom = ads["mom_change"]
ga4 = data.get("ga4")
ecom = ga4.get("ecommerce") if ga4 else None
semstorm = data.get("semstorm")
seo_links = data.get("seo_links", [])
has_ecommerce = ecom and ecom.get("current")
has_semstorm = semstorm and semstorm.get("current")
has_seo_links = len(seo_links) > 0
client_name = data.get("client_display_name", data["client"])
month_name = data["month_name"]
year = data["year"]
prev_month_name = data["prev_month_name"]
summary_text = generate_summary(data)
recommendations = data.get("recommendations", [])
client_questions = data.get("client_questions", [])
# Filter out campaigns with 0 clicks
active_campaigns = [c for c in ads["campaigns"] if c["clicks"] > 0]
# Build KPI cards
def kpi_card(label, value, unit, change_pct, inverse=False):
"""inverse=True means decrease is good (cost, CPA)."""
if change_pct is None:
return f"""
<div class="kpi-card">
<div class="kpi-label">{label}</div>
<div class="kpi-value">{value}<span class="kpi-unit">{unit}</span></div>
<div class="kpi-change" style="color: #95a5a6">&nbsp;</div>
</div>"""
if change_pct > 0:
arrow = "&#9650;" # ▲
color = "#e74c3c" if inverse else "#27ae60"
sign = "+"
elif change_pct < 0:
arrow = "&#9660;" # ▼
color = "#27ae60" if inverse else "#e74c3c"
sign = ""
else:
arrow = "&#9679;" # ●
color = "#95a5a6"
sign = ""
return f"""
<div class="kpi-card">
<div class="kpi-label">{label}</div>
<div class="kpi-value">{value}<span class="kpi-unit">{unit}</span></div>
<div class="kpi-change" style="color: {color}">
{arrow} {sign}{change_pct}% vs {prev_month_name}
</div>
</div>"""
kpi_html = ""
kpi_html += kpi_card("Wyświetlenia", format_number(totals["impressions"]), "", mom["impressions_pct"])
kpi_html += kpi_card("Kliknięcia", format_number(totals["clicks"]), "", mom["clicks_pct"])
kpi_html += kpi_card("CTR", f'{totals["ctr"]:.1f}', "%", mom["ctr_pct"])
kpi_html += kpi_card("Konwersje", format_number(totals["conversions"]), "", mom["conversions_pct"])
kpi_html += kpi_card(
"Wartość konwersji",
format_number(totals.get("conversion_value", 0), 2),
" PLN",
mom.get("conversion_value_pct", 0),
)
kpi_html += kpi_card("Koszt", f'{totals["cost"]:.2f}', " PLN", mom["cost_pct"], inverse=True)
# ROAS from Google Ads conversion_value (not Shoper revenue)
gads_roas = totals.get("roas", 0)
prev_gads_roas = prev_totals.get("roas", 0)
if gads_roas > 0:
roas_change = mom.get("roas_pct")
if roas_change is None:
roas_change = round((gads_roas - prev_gads_roas) / prev_gads_roas * 100, 1) if prev_gads_roas > 0 else 0
kpi_html += kpi_card("ROAS", f'{gads_roas:.2f}', "x", roas_change)
# Campaign table rows
campaign_rows = ""
for c in active_campaigns:
campaign_rows += f"""
<tr>
<td>{c["name"]}</td>
<td><span class="badge badge-{c['type'].lower()}">{c["type"]}</span></td>
<td class="num">{format_number(c["impressions"])}</td>
<td class="num">{format_number(c["clicks"])}</td>
<td class="num">{c["ctr"]:.1f}%</td>
<td class="num">{c["conversions"]:.0f}</td>
<td class="num">{format_number(c.get("conversion_value", 0), 2)} PLN</td>
<td class="num">{c["cost"]:.2f} PLN</td>
<td class="num">{c["cpa"]:.2f} PLN</td>
</tr>"""
# Search terms table
search_rows = ""
for i, t in enumerate(ads["search_terms"][:15], 1):
search_rows += f"""
<tr>
<td class="num">{i}</td>
<td>{t["term"]}</td>
<td class="num">{format_number(t["impressions"])}</td>
<td class="num">{t["clicks"]}</td>
<td class="num">{t["ctr"]:.1f}%</td>
<td class="num">{t["conversions"]:.0f}</td>
</tr>"""
# Daily chart data
daily_labels = json.dumps([d["date"][5:] for d in ads["daily"]]) # MM-DD
daily_clicks = json.dumps([d["clicks"] for d in ads["daily"]])
daily_impressions = json.dumps([d["impressions"] for d in ads["daily"]])
# --- E-COMMERCE SECTION ---
ecom_section = ""
ecom_chart_js = ""
sales_history_chart_js = ""
if has_ecommerce:
ec = ecom["current"]
ec_mom = ecom.get("mom_change", {})
ecom_kpis = ""
ecom_kpis += kpi_card("Transakcje", format_number(ec["transactions"]), "", ec_mom.get("transactions_pct", 0))
ecom_kpis += kpi_card("Przychód", format_number(ec["revenue"], 2), " PLN", ec_mom.get("revenue_pct", 0))
ecom_kpis += kpi_card("Śr. wartość zamówienia", f'{ec["aov"]:.2f}', " PLN", ec_mom.get("aov_pct", 0))
# Revenue by source table
rbs = ecom.get("revenue_by_source", [])
rev_source_rows = ""
total_rev = ec["revenue"]
for rs in rbs:
pct = round(rs["revenue"] / total_rev * 100, 1) if total_rev else 0
bar_width = min(pct * 2, 100)
rev_source_rows += f"""
<tr>
<td>{rs["source_medium"]}</td>
<td class="num">{format_number(rs["revenue"], 2)} PLN</td>
<td class="num">{rs["transactions"]}</td>
<td class="num">{pct}%</td>
<td><div class="bar" style="width: {bar_width}%"></div></td>
</tr>"""
# Top products table
top_products = ecom.get("top_products", [])
top_products_rows = ""
for i, p in enumerate(top_products[:10], 1):
top_products_rows += f"""
<tr>
<td class="num">{i}</td>
<td>{p["name"]}</td>
<td class="num">{p["quantity"]}</td>
<td class="num">{format_number(p["revenue"], 2)} PLN</td>
</tr>"""
top_products_html = ""
if top_products_rows:
top_products_html = f"""
<h3 style="margin-top: 24px; color: var(--primary-dark); font-size: 16px;">Top 10 produktów</h3>
<table class="data-table" style="margin-top: 12px;">
<thead>
<tr>
<th>#</th>
<th>Produkt</th>
<th>Ilość</th>
<th>Przychód</th>
</tr>
</thead>
<tbody>{top_products_rows}</tbody>
</table>"""
# Daily revenue chart data
ecom_daily = ecom.get("daily", [])
ecom_daily_labels = json.dumps([d["date"][5:] for d in ecom_daily])
ecom_daily_revenue = json.dumps([d["revenue"] for d in ecom_daily])
ecom_daily_transactions = json.dumps([d["transactions"] for d in ecom_daily])
ecom_chart_js = f"""
var ctxEcom = document.getElementById('ecomDailyChart').getContext('2d');
new Chart(ctxEcom, {{
type: 'bar',
data: {{
labels: {ecom_daily_labels},
datasets: [{{
label: 'Przychód (PLN)',
data: {ecom_daily_revenue},
backgroundColor: 'rgba(39, 174, 96, 0.6)',
borderColor: '#27ae60',
borderWidth: 1,
yAxisID: 'y',
order: 2,
}}, {{
label: 'Transakcje',
data: {ecom_daily_transactions},
type: 'line',
borderColor: '#8e44ad',
backgroundColor: 'transparent',
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#8e44ad',
yAxisID: 'y1',
order: 1,
}}]
}},
options: {{
responsive: true,
interaction: {{ mode: 'index', intersect: false }},
scales: {{
y: {{ beginAtZero: true, position: 'left', grid: {{ color: '#f0f0f0' }},
ticks: {{ callback: function(v) {{ return v.toLocaleString('pl-PL') + ' PLN'; }} }} }},
y1: {{ beginAtZero: true, position: 'right', grid: {{ display: false }} }},
x: {{ grid: {{ display: false }}, ticks: {{ maxTicksLimit: 10 }} }}
}}
}}
}});"""
# Sales history chart
sales_history = data.get("sales_history", [])
sales_history_html = ""
if len(sales_history) >= 1:
sh_labels = json.dumps([h["month"] for h in sales_history])
sh_revenue = json.dumps([h["revenue"] for h in sales_history])
sh_transactions = json.dumps([h["transactions"] for h in sales_history])
sales_history_chart_js = f"""
var ctxSalesHist = document.getElementById('salesHistoryChart').getContext('2d');
new Chart(ctxSalesHist, {{
type: 'bar',
data: {{
labels: {sh_labels},
datasets: [{{
label: 'Przychód (PLN)',
data: {sh_revenue},
backgroundColor: 'rgba(39, 174, 96, 0.5)',
borderColor: '#27ae60',
borderWidth: 1,
yAxisID: 'y',
order: 2,
}}, {{
label: 'Zamówienia',
data: {sh_transactions},
type: 'line',
borderColor: '#8e44ad',
backgroundColor: 'transparent',
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: '#8e44ad',
yAxisID: 'y1',
order: 1,
}}]
}},
options: {{
responsive: true,
interaction: {{ mode: 'index', intersect: false }},
scales: {{
y: {{ beginAtZero: true, position: 'left', grid: {{ color: '#f0f0f0' }},
ticks: {{ callback: function(v) {{ return v.toLocaleString('pl-PL') + ' PLN'; }} }} }},
y1: {{ beginAtZero: true, position: 'right', grid: {{ display: false }} }},
x: {{ grid: {{ display: false }} }}
}}
}}
}});"""
sales_history_html = """
<div class="chart-container">
<h3>Historia sprzedaży (miesięcznie)</h3>
<canvas id="salesHistoryChart"></canvas>
</div>"""
daily_chart_html = ""
if ecom_daily:
daily_chart_html = """
<div class="chart-container">
<h3>Przychody i transakcje dziennie</h3>
<canvas id="ecomDailyChart"></canvas>
</div>"""
else:
ecom_chart_js = ""
rev_source_html = ""
if rev_source_rows:
rev_source_html = f"""
<p style="color:#5a6c7d;margin:8px 0 12px;font-size:13px;">
Ta tabela pochodzi z GA4 i pokazuje przychód według source / medium.
W sekcji Google Ads pokazujemy wartość konwersji z Google Ads, liczoną według atrybucji i okna konwersji Google Ads.
</p>
<h3 style="margin-top: 24px; color: var(--primary-dark); font-size: 16px;">Przychody wg źródeł ruchu</h3>
<table class="data-table" style="margin-top: 12px;">
<thead>
<tr>
<th>Źródło / Medium</th>
<th>Przychód</th>
<th>Transakcje</th>
<th>Udział</th>
<th></th>
</tr>
</thead>
<tbody>{rev_source_rows}</tbody>
</table>"""
ecom_section = f"""
<section class="report-section" id="ecommerce">
<h2 class="section-title">E-commerce &mdash; Sprzedaż</h2>
<div class="kpi-grid">{ecom_kpis}</div>
{daily_chart_html}
{sales_history_html}
{rev_source_html}
{top_products_html}
</section>"""
# --- PRODUCT OPTIMIZATIONS SECTION (e-commerce, globalna) ---
product_opts_section = ""
product_opts = data.get("product_optimizations", [])
if product_opts:
prod_rows = ""
for i, p in enumerate(product_opts, 1):
title_old = p.get("title_old", "") or "&mdash;"
title_new = p.get("title_new", "") or "&mdash;"
category = p.get("category", "") or "&mdash;"
unit_price = p.get("unit_price", "") or "&mdash;"
prod_rows += f"""
<tr>
<td class="num">{i}</td>
<td><div style="color:#95a5a6;font-size:12px;text-decoration:line-through;">{title_old}</div><div style="margin-top:4px;font-weight:500;">{title_new}</div></td>
<td>{category}</td>
<td>{unit_price}</td>
</tr>"""
product_opts_section = f"""
<section class="report-section" id="product-optimizations">
<h2 class="section-title">Optymalizacja produktów</h2>
<p style="color:#5a6c7d;margin-bottom:16px;">W tym miesiącu zoptymalizowaliśmy <strong>{len(product_opts)}</strong> produktów: tytuły reklamowe, kategorie Google oraz jednostki sprzedaży (unit price). Lepiej dopasowany feed = wyższa trafność, lepszy CTR i niższy CPC w kampaniach zakupowych.</p>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Zmiana tytułu</th>
<th>Kategoria Google</th>
<th>Unit price</th>
</tr>
</thead>
<tbody>{prod_rows}</tbody>
</table>
</section>"""
# --- TOP PRODUCTS (Google Ads) ---
top_ads_products_section = ""
top_ads_products = data.get("top_ads_products", [])
if top_ads_products:
rows = ""
for i, p in enumerate(top_ads_products[:10], 1):
roas = (p["value"] / p["cost"]) if p.get("cost") else 0
rows += f"""
<tr>
<td class="num">{i}</td>
<td>{p["title"]}</td>
<td class="num">{format_number(p["clicks"])}</td>
<td class="num">{p["cost"]:.2f} PLN</td>
<td class="num">{p["conv"]:.0f}</td>
<td class="num">{format_number(p["value"], 2)} PLN</td>
<td class="num">{roas:.2f}x</td>
</tr>"""
top_ads_products_section = f"""
<section class="report-section" id="top-ads-products">
<h2 class="section-title">Top produkty &mdash; Google Ads</h2>
<p style="color:#5a6c7d;margin-bottom:16px;">Produkty, które wygenerowały najwyższy przychód z kampanii zakupowych w {month_name} {year}. Ranking po wartości konwersji.</p>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Produkt</th>
<th>Kliknięcia</th>
<th>Koszt</th>
<th>Konwersje</th>
<th>Wartość konwersji</th>
<th>ROAS</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
</section>"""
# --- YEAR OVER YEAR COMPARISON ---
yoy_section = ""
yoy = data.get("yoy", {})
if yoy and yoy.get("prev_year"):
py = yoy["prev_year"]
prev_year_label = yoy.get("prev_year_label", "rok temu")
def _yoy_row(label, cur, prev, fmt="num", inverse=False):
if prev == 0:
pct_html = "&mdash;"
else:
pct = round((cur - prev) / prev * 100, 1)
if pct > 0:
color = "#e74c3c" if inverse else "#27ae60"
pct_html = f'<span style="color:{color};font-weight:600;">▲ +{pct}%</span>'
elif pct < 0:
color = "#27ae60" if inverse else "#e74c3c"
pct_html = f'<span style="color:{color};font-weight:600;">▼ {pct}%</span>'
else:
pct_html = '<span style="color:#95a5a6;">● 0%</span>'
if fmt == "money":
cur_s = f"{cur:,.2f} PLN".replace(",", " ")
prev_s = f"{prev:,.2f} PLN".replace(",", " ")
elif fmt == "ratio":
cur_s = f"{cur:.2f}x"
prev_s = f"{prev:.2f}x"
else:
cur_s = format_number(cur)
prev_s = format_number(prev)
return f"""<tr><td>{label}</td><td class="num">{prev_s}</td><td class="num">{cur_s}</td><td class="num">{pct_html}</td></tr>"""
cur_cost = totals["cost"]; prev_cost = py["cost"]
cur_conv = totals["conversions"]; prev_conv = py["conversions"]
cur_val = totals["conversion_value"]; prev_val = py["conversion_value"]
cur_roas = totals.get("roas", 0); prev_roas = (py["conversion_value"] / py["cost"]) if py.get("cost") else 0
cur_cpa = totals.get("cpa", 0); prev_cpa = (py["cost"] / py["conversions"]) if py.get("conversions") else 0
yoy_rows = ""
yoy_rows += _yoy_row("Wyświetlenia", totals["impressions"], py["impressions"])
yoy_rows += _yoy_row("Kliknięcia", totals["clicks"], py["clicks"])
yoy_rows += _yoy_row("Koszt", cur_cost, prev_cost, "money", inverse=True)
yoy_rows += _yoy_row("Konwersje", cur_conv, prev_conv)
yoy_rows += _yoy_row("Wartość konwersji", cur_val, prev_val, "money")
yoy_rows += _yoy_row("CPA", cur_cpa, prev_cpa, "money", inverse=True)
yoy_rows += _yoy_row("ROAS", cur_roas, prev_roas, "ratio")
yoy_section = f"""
<section class="report-section" id="yoy">
<h2 class="section-title">Porównanie rok do roku</h2>
<p style="color:#5a6c7d;margin-bottom:16px;">{prev_year_label} vs {month_name} {year} &mdash; jak rośnie skala działań i obrót generowany przez Google Ads.</p>
<table class="data-table">
<thead>
<tr>
<th>Wskaźnik</th>
<th>{prev_year_label}</th>
<th>{month_name} {year}</th>
<th>Zmiana YoY</th>
</tr>
</thead>
<tbody>{yoy_rows}</tbody>
</table>
</section>"""
# --- NEGATIVE KEYWORDS ADDED ---
negatives_section = ""
negatives = data.get("negative_keywords_added", [])
if negatives:
neg_rows = ""
for i, n in enumerate(negatives, 1):
neg_rows += f"""
<tr>
<td class="num">{i}</td>
<td>{n.get("campaign","")}</td>
<td><strong>{n.get("keyword","")}</strong></td>
<td>{n.get("match_type","")}</td>
<td>{n.get("reason","")}</td>
</tr>"""
negatives_section = f"""
<section class="report-section" id="negatives">
<h2 class="section-title">Wykluczenia fraz w {month_name} {year}</h2>
<p style="color:#5a6c7d;margin-bottom:16px;">Frazy wykluczone w tym miesiącu &mdash; każda z nich oznacza, że budżet nie jest już marnowany na ruch, który nie konwertuje. Łącznie dodano <strong>{len(negatives)}</strong> wykluczeń.</p>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Kampania</th>
<th>Fraza</th>
<th>Typ dopasowania</th>
<th>Powód</th>
</tr>
</thead>
<tbody>{neg_rows}</tbody>
</table>
</section>"""
# --- GA4 SECTION ---
ga4_section = ""
ga4_chart_js = ""
if ga4 and ga4.get("current"):
ga4_cur = ga4["current"]
ga4_mom = ga4.get("mom_change", {})
ga4_kpis = ""
ga4_kpis += kpi_card("Sesje", format_number(ga4_cur["sessions"]), "", ga4_mom.get("sessions_pct", 0))
ga4_kpis += kpi_card("Użytkownicy", format_number(ga4_cur["users"]), "", ga4_mom.get("users_pct", 0))
ga4_kpis += kpi_card("Nowi użytkownicy", format_number(ga4_cur["new_users"]), "", ga4_mom.get("new_users_pct", 0))
ga4_kpis += kpi_card("Odsłon", format_number(ga4_cur["pageviews"]), "", ga4_mom.get("pageviews_pct", 0))
# Sources table
sources_rows = ""
total_sessions = sum(s["sessions"] for s in ga4.get("sources", []))
for s in ga4.get("sources", []):
pct = round(s["sessions"] / total_sessions * 100, 1) if total_sessions else 0
bar_width = min(pct * 2, 100)
sources_rows += f"""
<tr>
<td>{s["source_medium"]}</td>
<td class="num">{format_number(s["sessions"])}</td>
<td class="num">{pct}%</td>
<td><div class="bar" style="width: {bar_width}%"></div></td>
</tr>"""
# Devices table
devices_rows = ""
device_labels_pl = {"desktop": "Komputer", "mobile": "Telefon", "tablet": "Tablet"}
for d in ga4.get("devices", []):
pct = round(d["sessions"] / total_sessions * 100, 1) if total_sessions else 0
devices_rows += f"""
<tr>
<td>{device_labels_pl.get(d["device"], d["device"])}</td>
<td class="num">{format_number(d["sessions"])}</td>
<td class="num">{pct}%</td>
</tr>"""
# GA4 daily chart data
ga4_daily = ga4.get("daily", [])
ga4_daily_labels = json.dumps([d["date"][5:] for d in ga4_daily])
ga4_daily_sessions = json.dumps([d["sessions"] for d in ga4_daily])
ga4_chart_js = f"""
var ctx3 = document.getElementById('ga4DailyChart').getContext('2d');
new Chart(ctx3, {{
type: 'line',
data: {{
labels: {ga4_daily_labels},
datasets: [{{
label: 'Sesje',
data: {ga4_daily_sessions},
borderColor: '#3498db',
backgroundColor: 'rgba(52,152,219,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
}}]
}},
options: {{
responsive: true,
plugins: {{ legend: {{ display: false }} }},
scales: {{
y: {{ beginAtZero: true, grid: {{ color: '#f0f0f0' }} }},
x: {{ grid: {{ display: false }}, ticks: {{ maxTicksLimit: 10 }} }}
}}
}}
}});"""
ga4_section = f"""
<section class="report-section" id="ga4">
<h2 class="section-title">Dane z Google Analytics</h2>
<div class="kpi-grid">{ga4_kpis}</div>
<div class="chart-container">
<h3>Sesje dziennie</h3>
<canvas id="ga4DailyChart"></canvas>
</div>
<div class="two-col">
<div>
<h3>Źródła ruchu</h3>
<table class="data-table">
<thead><tr><th>Źródło / Medium</th><th>Sesje</th><th>Udział</th><th></th></tr></thead>
<tbody>{sources_rows}</tbody>
</table>
</div>
<div>
<h3>Urządzenia</h3>
<table class="data-table">
<thead><tr><th>Urządzenie</th><th>Sesje</th><th>Udział</th></tr></thead>
<tbody>{devices_rows}</tbody>
</table>
</div>
</div>
</section>"""
# --- SEMSTORM SEO SECTION ---
semstorm_section = ""
semstorm_chart_js = ""
if has_semstorm:
sc = semstorm["current"]
sm = semstorm.get("mom_change", {})
sem_kpis = ""
sem_kpis += kpi_card("TOP 3", format_number(sc["top3"]), " fraz", sm.get("top3_pct", 0))
sem_kpis += kpi_card("TOP 10", format_number(sc["top10"]), " fraz", sm.get("top10_pct", 0))
sem_kpis += kpi_card("TOP 50", format_number(sc["top50"]), " fraz", sm.get("top50_pct", 0))
sem_kpis += kpi_card("Szac. ruch SEO", format_number(sc["traffic"]), "", sm.get("traffic_pct", 0))
# History chart
history = semstorm.get("history", [])
if history:
hist_labels = json.dumps([h["month"] for h in history])
hist_top3 = json.dumps([h["top3"] for h in history])
hist_top10 = json.dumps([h["top10"] for h in history])
hist_top50 = json.dumps([h["top50"] for h in history])
semstorm_chart_js = f"""
var ctxSem = document.getElementById('semstormChart').getContext('2d');
new Chart(ctxSem, {{
type: 'line',
data: {{
labels: {hist_labels},
datasets: [{{
label: 'TOP 3',
data: {hist_top3},
borderColor: '#27ae60',
backgroundColor: 'rgba(39,174,96,0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: '#27ae60',
}}, {{
label: 'TOP 10',
data: {hist_top10},
borderColor: '#2980b9',
backgroundColor: 'transparent',
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: '#2980b9',
}}, {{
label: 'TOP 50',
data: {hist_top50},
borderColor: '#95a5a6',
backgroundColor: 'transparent',
borderDash: [5, 5],
tension: 0.3,
pointRadius: 3,
yAxisID: 'y1',
}}]
}},
options: {{
responsive: true,
interaction: {{ mode: 'index', intersect: false }},
scales: {{
y: {{ beginAtZero: false, position: 'left', grid: {{ color: '#f0f0f0' }},
title: {{ display: true, text: 'TOP 3 / TOP 10' }} }},
y1: {{ beginAtZero: false, position: 'right', grid: {{ display: false }},
title: {{ display: true, text: 'TOP 50' }} }},
x: {{ grid: {{ display: false }} }}
}}
}}
}});"""
semstorm_section = f"""
<section class="report-section" id="semstorm">
<h2 class="section-title">SEO &mdash; Widoczność (Semstorm)</h2>
<div class="kpi-grid">{sem_kpis}</div>
{"" if not history else '''
<div class="chart-container">
<h3>Historia widoczności</h3>
<canvas id="semstormChart"></canvas>
</div>
'''}
</section>"""
# --- SEO ACTIVITIES BOX ---
seo_activities_section = ""
seo_activities_text = data.get("seo_activities", "")
if seo_activities_text:
# Convert URLs in text to clickable links
def _linkify(text):
return re.sub(
r'(https?://\S+)',
r'<a href="\1" target="_blank" rel="noopener" style="color: var(--primary);">\1</a>',
text
)
# Convert newlines to <br>
formatted = _linkify(seo_activities_text.replace("\n", "<br>\n"))
seo_activities_section = f"""
<section class="report-section" id="seo-activities">
<h2 class="section-title">SEO &mdash; Pozostałe działania</h2>
<div class="summary-box">
{formatted}
</div>
</section>"""
# --- SEO LINKS SECTION ---
seo_links_section = ""
if has_seo_links:
# Extract all URLs — either from individual entries or from text descriptions
all_urls = []
for lnk in seo_links:
url_val = lnk["url"].strip()
parsed_check = urlparse(url_val)
if parsed_check.scheme in ("http", "https") and "\n" not in url_val and len(url_val) < 300:
# Clean single URL
all_urls.append(url_val)
else:
# Text with embedded URLs — extract them
found = re.findall(r'https?://[^\s,\r\n]+', url_val)
all_urls.extend(found)
if all_urls:
link_rows = ""
for i, url in enumerate(all_urls, 1):
parsed = urlparse(url)
display_domain = parsed.netloc
link_rows += f"""
<tr>
<td class="num">{i}</td>
<td><a href="{url}" target="_blank" rel="noopener" style="color: var(--primary); text-decoration: none;">{display_domain}</a></td>
<td style="font-size: 12px; color: var(--gray); word-break: break-all;"><a href="{url}" target="_blank" rel="noopener" style="color: var(--gray); text-decoration: none;">{url}</a></td>
</tr>"""
seo_links_section = f"""
<section class="report-section" id="seo-links">
<h2 class="section-title">SEO &mdash; Pozyskane linki ({len(all_urls)})</h2>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Domena</th>
<th>URL</th>
</tr>
</thead>
<tbody>{link_rows}</tbody>
</table>
</section>"""
# Full HTML
html = f"""<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Raport {month_name} {year} &mdash; {client_name}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {{
--primary: #0d8b8b;
--primary-dark: #065a5a;
--primary-light: #e8f5f5;
--green: #27ae60;
--red: #e74c3c;
--gray: #6c757d;
--light-gray: #f8f9fa;
--border: #e9ecef;
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: #333;
background: #f5f5f5;
line-height: 1.6;
}}
.hero {{
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 60%, #10a5a5 100%);
color: white;
padding: 60px 40px;
position: relative;
overflow: hidden;
}}
.hero::before {{
content: '';
position: absolute;
top: 0; right: 0;
width: 50%;
height: 100%;
background: url("data:image/svg+xml,%3Csvg width='400' height='400' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cpattern id='grid' width='40' height='40' patternUnits='userSpaceOnUse'%3E%3Ccircle cx='20' cy='20' r='1.5' fill='rgba(255,255,255,0.15)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='400' height='400' fill='url(%23grid)'/%3E%3Cline x1='20' y1='60' x2='100' y2='20' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='100' y1='20' x2='180' y2='80' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='180' y1='80' x2='260' y2='40' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='260' y1='40' x2='340' y2='100' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='60' y1='140' x2='140' y2='120' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='140' y1='120' x2='220' y2='180' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='220' y1='180' x2='300' y2='140' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='300' y1='140' x2='380' y2='200' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3C/svg%3E") repeat;
opacity: 0.7;
}}
.hero-content {{
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
}}
.logo {{
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 50px;
}}
.logo svg {{
height: 40px;
}}
.logo-text {{
font-size: 22px;
font-weight: 700;
letter-spacing: 3px;
text-transform: uppercase;
}}
.logo-subtitle {{
font-size: 11px;
letter-spacing: 2px;
opacity: 0.85;
text-transform: lowercase;
}}
.hero h1 {{
font-size: 42px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 16px;
}}
.hero .meta {{
font-size: 18px;
opacity: 0.9;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
}}
.report-section {{
background: white;
border-radius: 12px;
padding: 32px;
margin: 24px auto;
max-width: 1400px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}}
.section-title {{
color: var(--primary-dark);
font-size: 24px;
font-weight: 700;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 3px solid var(--primary);
}}
.kpi-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 16px;
margin-bottom: 32px;
}}
.kpi-card {{
background: var(--light-gray);
border-radius: 10px;
padding: 20px;
text-align: center;
border: 1px solid var(--border);
}}
.kpi-label {{
font-size: 12px;
color: var(--primary);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
margin-bottom: 8px;
}}
.kpi-value {{
font-size: 28px;
font-weight: 700;
color: #2c3e50;
white-space: nowrap;
}}
.kpi-unit {{
font-size: 14px;
font-weight: 400;
color: var(--gray);
}}
.kpi-change {{
font-size: 13px;
margin-top: 6px;
font-weight: 500;
}}
.chart-container {{
margin: 24px 0;
}}
.chart-container h3 {{
color: var(--primary-dark);
margin-bottom: 12px;
font-size: 16px;
}}
.chart-container canvas {{
max-height: 300px;
}}
.data-table {{
width: 100%;
border-collapse: collapse;
font-size: 14px;
}}
.data-table thead {{
background: var(--primary);
color: white;
}}
.data-table th {{
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
.data-table td {{
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}}
.data-table tbody tr:hover {{
background: var(--primary-light);
}}
.data-table .num {{
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}}
.badge {{
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}}
.badge-search {{ background: #dbeafe; color: #1e40af; }}
.badge-performance_max {{ background: #fef3c7; color: #92400e; }}
.badge-shopping {{ background: #d1fae5; color: #065f46; }}
.badge-display {{ background: #ede9fe; color: #5b21b6; }}
.badge-demand_gen {{ background: #fce7f3; color: #9d174d; }}
.summary-box {{
background: var(--primary-light);
border-left: 4px solid var(--primary);
padding: 24px;
border-radius: 0 10px 10px 0;
font-size: 15px;
line-height: 1.8;
}}
.two-col {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 24px;
}}
.bar {{
height: 8px;
background: var(--primary);
border-radius: 4px;
min-width: 4px;
}}
.recommendations-list {{
display: flex;
flex-direction: column;
gap: 16px;
}}
.rec-item {{
display: flex;
gap: 16px;
padding: 16px;
background: var(--light-gray);
border-radius: 8px;
border-left: 4px solid var(--primary);
}}
.rec-icon {{
font-size: 20px;
flex-shrink: 0;
margin-top: 2px;
}}
.rec-item p {{
margin-top: 4px;
color: var(--gray);
font-size: 14px;
}}
.questions-box {{
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border-left: 4px solid #f59e0b;
border-radius: 10px;
padding: 24px 28px;
margin-top: 16px;
}}
.questions-box h3 {{
margin: 0 0 8px 0;
color: #92400e;
font-size: 18px;
}}
.questions-box .intro {{
color: #78350f;
font-size: 14px;
margin-bottom: 16px;
}}
.questions-list {{
display: flex;
flex-direction: column;
gap: 14px;
}}
.q-item {{
display: flex;
gap: 14px;
padding: 14px 16px;
background: rgba(255,255,255,0.7);
border-radius: 8px;
}}
.q-num {{
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: #f59e0b;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
}}
.q-item strong {{ color: #92400e; display: block; margin-bottom: 4px; }}
.q-item p {{ margin: 0; color: #57534e; font-size: 14px; line-height: 1.6; }}
.footer {{
text-align: center;
padding: 32px;
color: var(--gray);
font-size: 13px;
}}
.footer a {{
color: var(--primary);
text-decoration: none;
}}
@media (max-width: 768px) {{
.hero {{ padding: 40px 20px; }}
.hero h1 {{ font-size: 28px; }}
.kpi-grid {{ grid-template-columns: repeat(2, 1fr); }}
.two-col {{ grid-template-columns: 1fr; }}
.report-section {{ padding: 20px; margin: 12px; }}
}}
@media print {{
body {{ background: white; }}
.report-section {{ box-shadow: none; page-break-inside: avoid; }}
.hero {{ padding: 30px; }}
}}
</style>
</head>
<body>
<!-- HERO / TITLE -->
<header class="hero">
<div class="hero-content">
<div class="logo">
<img src="https://www.project-pro.pl/upload/filemanager/Project-Design/logos/project-pro/logo-white.svg" alt="Project-Pro" style="height: 40px;">
</div>
<h1>Raport z działań marketingowych</h1>
<div class="meta">{client_name} &mdash; {month_name} {year}</div>
</div>
</header>
<!-- RECOMMENDATIONS -->
{"" if not recommendations else '''
<section class="report-section" id="recommendations">
<h2 class="section-title">Wnioski i rekomendacje</h2>
<div class="recommendations-list">
''' + "".join(f'<div class="rec-item"><span class="rec-icon">{r.get("icon", "&#10148;")}</span><div><strong>{r["title"]}</strong><p>{r["text"]}</p></div></div>' for r in recommendations) + '''
</div>
</section>
'''}
{"" if not client_questions else '''
<section class="report-section" id="client-questions">
<div class="questions-box">
<h3>Pytania do Pana</h3>
<p class="intro">Aby lepiej zaplanować dalsze działania, chcielibyśmy poznać Pana zdanie w kilku kwestiach:</p>
<div class="questions-list">
''' + "".join(f'<div class="q-item"><div class="q-num">{i+1}</div><div><strong>{q["title"]}</strong><p>{q["text"]}</p></div></div>' for i, q in enumerate(client_questions)) + '''
</div>
</div>
</section>
'''}
<!-- GA4 SECTION -->
{ga4_section}
<!-- E-COMMERCE -->
{ecom_section}
<!-- PRODUCT OPTIMIZATIONS -->
{product_opts_section}
<!-- TOP ADS PRODUCTS -->
{top_ads_products_section}
<!-- YEAR OVER YEAR -->
{yoy_section}
<!-- GOOGLE ADS KPIs -->
<section class="report-section" id="ads-kpi">
<h2 class="section-title">Google Ads &mdash; Podsumowanie</h2>
<div class="kpi-grid">{kpi_html}</div>
</section>
<!-- DAILY CHART -->
<section class="report-section" id="ads-chart">
<h2 class="section-title">Google Ads &mdash; Aktywność dzienna</h2>
<div class="chart-container">
<canvas id="dailyClicksChart"></canvas>
</div>
</section>
<!-- CAMPAIGNS TABLE -->
<section class="report-section" id="campaigns">
<h2 class="section-title">Kampanie</h2>
<table class="data-table">
<thead>
<tr>
<th>Kampania</th>
<th>Typ</th>
<th>Wyświetlenia</th>
<th>Kliknięcia</th>
<th>CTR</th>
<th>Konwersje</th>
<th>Wartość konwersji</th>
<th>Koszt</th>
<th>CPA</th>
</tr>
</thead>
<tbody>{campaign_rows}</tbody>
</table>
</section>
<!-- SEARCH TERMS -->
<section class="report-section" id="search-terms">
<h2 class="section-title">Najpopularniejsze frazy wyszukiwania</h2>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Fraza</th>
<th>Wyświetlenia</th>
<th>Kliknięcia</th>
<th>CTR</th>
<th>Konwersje</th>
</tr>
</thead>
<tbody>{search_rows}</tbody>
</table>
</section>
<!-- NEGATIVE KEYWORDS ADDED -->
{negatives_section}
<!-- SEMSTORM SEO -->
{semstorm_section}
<!-- SEO ACTIVITIES -->
{seo_activities_section}
<!-- SEO LINKS -->
{seo_links_section}
<!-- SUMMARY -->
<section class="report-section" id="summary">
<h2 class="section-title">Podsumowanie miesiąca</h2>
<div class="summary-box">
{summary_text}
</div>
</section>
<!-- RECOMMENDATIONS moved to top -->
<!-- FOOTER -->
<div class="footer">
Raport wygenerowany przez <a href="https://www.project-pro.pl">Project-Pro</a> &mdash; marketing w wersji PRO
</div>
<script>
// Daily clicks chart
var ctx1 = document.getElementById('dailyClicksChart').getContext('2d');
new Chart(ctx1, {{
type: 'line',
data: {{
labels: {daily_labels},
datasets: [{{
label: 'Kliknięcia',
data: {daily_clicks},
borderColor: '#0d8b8b',
backgroundColor: 'rgba(13,139,139,0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#0d8b8b',
}}, {{
label: 'Wyświetlenia',
data: {daily_impressions},
borderColor: '#95a5a6',
backgroundColor: 'transparent',
borderDash: [5, 5],
tension: 0.3,
pointRadius: 0,
yAxisID: 'y1',
}}]
}},
options: {{
responsive: true,
interaction: {{ mode: 'index', intersect: false }},
scales: {{
y: {{ beginAtZero: true, position: 'left', grid: {{ color: '#f0f0f0' }} }},
y1: {{ beginAtZero: true, position: 'right', grid: {{ display: false }} }},
x: {{ grid: {{ display: false }}, ticks: {{ maxTicksLimit: 10 }} }}
}}
}}
}});
{ga4_chart_js}
{ecom_chart_js}
{sales_history_chart_js}
{semstorm_chart_js}
</script>
</body>
</html>"""
return html
def main():
parser = argparse.ArgumentParser(description="Generuj raport HTML")
parser.add_argument("--data", required=True, help="Sciezka do pliku JSON z danymi")
parser.add_argument("--client", default=None, help="Nazwa klienta do wyswietlenia (np. 'INNSI')")
parser.add_argument("--output", help="Sciezka do pliku wyjsciowego")
args = parser.parse_args()
data_path = Path(args.data)
if not data_path.is_absolute():
data_path = ROOT / "scripts" / "reports" / data_path
with open(data_path, "r", encoding="utf-8") as f:
data = json.load(f)
if args.client:
data["client_display_name"] = args.client
html = build_html(data)
if args.output:
out_path = Path(args.output)
else:
domain = data["client"].replace(".", "-")
out_dir = ROOT / "scripts" / "reports" / "output" / data["client"] / data["month"]
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / "index.html"
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
f.write(html)
print(f"Raport wygenerowany: {out_path}")
if __name__ == "__main__":
main()