1402 lines
51 KiB
Python
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 — 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"> </div>
|
|
</div>"""
|
|
if change_pct > 0:
|
|
arrow = "▲" # ▲
|
|
color = "#e74c3c" if inverse else "#27ae60"
|
|
sign = "+"
|
|
elif change_pct < 0:
|
|
arrow = "▼" # ▼
|
|
color = "#27ae60" if inverse else "#e74c3c"
|
|
sign = ""
|
|
else:
|
|
arrow = "●" # ●
|
|
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 — 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 "—"
|
|
title_new = p.get("title_new", "") or "—"
|
|
category = p.get("category", "") or "—"
|
|
unit_price = p.get("unit_price", "") or "—"
|
|
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 — 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 = "—"
|
|
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} — 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 — 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 — 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 — 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 — 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} — {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} — {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", "➤")}</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 — 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 — 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> — 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()
|