first commit

This commit is contained in:
2026-05-15 09:28:11 +02:00
commit ae25aae9ce
101 changed files with 62448 additions and 0 deletions

View File

@@ -0,0 +1,803 @@
#!/usr/bin/env python3
"""
Pobiera dane z Google Ads API + GA4 za wskazany miesiąc i zapisuje jako JSON.
Użycie:
python scripts/reports/fetch_monthly_report_data.py --customer studio-zoe.pl --month 2026-02
python scripts/reports/fetch_monthly_report_data.py --customer 3871661050 --month 2026-02 --output output/report.json
"""
import argparse
import calendar
import csv
import json
import os
import re
import sys
import io
import tomllib
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import parse_qs, urlparse
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.path.insert(0, str(Path(__file__).parent.parent))
from lib.gads_client import get_client, get_customer_id, run_query
import requests
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
from src.gads_v2.config import load_env
load_env(ROOT / ".env")
def load_client_report_config(domain):
"""Load scalar report settings for a client from config/clients.toml."""
config_path = ROOT / "config" / "clients.toml"
if not config_path.exists():
return {}
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
return data.get("clients", {}).get(domain, {})
def parse_month(month_str):
"""Parse YYYY-MM to (year, month) and calculate date range."""
year, month = map(int, month_str.split("-"))
last_day = calendar.monthrange(year, month)[1]
start = f"{year}-{month:02d}-01"
end = f"{year}-{month:02d}-{last_day:02d}"
return year, month, start, end
def prev_month(year, month):
"""Calculate previous month's date range."""
if month == 1:
py, pm = year - 1, 12
else:
py, pm = year, month - 1
last_day = calendar.monthrange(py, pm)[1]
start = f"{py}-{pm:02d}-01"
end = f"{py}-{pm:02d}-{last_day:02d}"
return py, pm, start, end
def pct_change(current, previous):
"""Calculate percentage change, handling zero division."""
if previous == 0:
return 100.0 if current > 0 else 0.0
return round(((current - previous) / previous) * 100, 1)
def normalize_header(value):
value = (value or "").strip().lower()
replacements = {
"ą": "a",
"ć": "c",
"ę": "e",
"ł": "l",
"ń": "n",
"ó": "o",
"ś": "s",
"ź": "z",
"ż": "z",
}
for src, dst in replacements.items():
value = value.replace(src, dst)
return re.sub(r"[^a-z0-9]+", "", value)
def parse_money(value):
text = str(value or "").strip()
if not text:
return 0.0
text = text.replace("PLN", "").replace("zl", "").replace("", "")
text = text.replace("\u00a0", " ").replace(" ", "")
if "," in text and "." in text:
text = text.replace(".", "").replace(",", ".")
elif "," in text:
text = text.replace(",", ".")
text = re.sub(r"[^0-9.\-]", "", text)
return round(float(text), 2) if text else 0.0
def parse_int_value(value):
return int(round(parse_money(value)))
def parse_history_month(value):
text = str(value or "").strip()
if not text:
return ""
if re.fullmatch(r"\d{4}-\d{2}", text):
return text
if re.fullmatch(r"\d{2}[.-]\d{4}", text):
month, year = re.split(r"[.-]", text)
return f"{int(year):04d}-{int(month):02d}"
if re.fullmatch(r"\d{4}[./-]\d{1,2}[./-]\d{1,2}", text):
year, month, _day = re.split(r"[./-]", text)
return f"{int(year):04d}-{int(month):02d}"
if re.fullmatch(r"\d{1,2}[./-]\d{1,2}[./-]\d{4}", text):
_day, month, year = re.split(r"[./-]", text)
return f"{int(year):04d}-{int(month):02d}"
return text
def parse_sheet_config(sheet_config):
value = str(sheet_config or "").strip()
if not value:
return "", "0"
if value.startswith("http"):
parsed = urlparse(value)
match = re.search(r"/spreadsheets/d/([^/]+)", parsed.path)
spreadsheet_id = match.group(1) if match else value
query_gid = parse_qs(parsed.query).get("gid", [None])[0]
fragment_gid = parse_qs(parsed.fragment).get("gid", [None])[0]
return spreadsheet_id, query_gid or fragment_gid or ""
if ":" in value:
return value.split(":", 1)
return value, ""
def fetch_sales_history_from_sheet(domain, sheet_config):
"""Fetch monthly sales history from a public Google Sheet CSV export."""
spreadsheet_id, gid = parse_sheet_config(sheet_config)
export_url = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/gviz/tq?tqx=out:csv"
if gid:
export_url += f"&gid={gid}"
response = requests.get(export_url, timeout=30)
response.raise_for_status()
response.encoding = "utf-8"
reader = csv.DictReader(io.StringIO(response.text))
history = []
for row in reader:
normalized = {normalize_header(key): value for key, value in row.items()}
month = parse_history_month(
normalized.get("month")
or normalized.get("miesiac")
or normalized.get("data")
or normalized.get("date")
)
revenue = parse_money(
normalized.get("revenue")
or normalized.get("przychod")
or normalized.get("przychody")
or normalized.get("sprzedaz")
or normalized.get("wartosc")
)
transactions = parse_int_value(
normalized.get("transactions")
or normalized.get("transakcje")
or normalized.get("zamowienia")
or normalized.get("orders")
)
if not month or not revenue:
continue
aov = parse_money(
normalized.get("aov")
or normalized.get("sredniakoszyka")
or normalized.get("sredniawartosckoszyka")
or normalized.get("sredniawartosczamowienia")
)
if not aov and transactions:
aov = round(revenue / transactions, 2)
history.append({
"month": month,
"transactions": transactions,
"revenue": revenue,
"aov": aov,
"source": "google_sheet",
})
return sorted(history, key=lambda item: item["month"])
def apply_sheet_ecommerce(report, sales_history, month, previous_month):
"""Use Google Sheet sales data for e-commerce KPI cards."""
by_month = {row["month"]: row for row in sales_history}
current = by_month.get(month)
if not current:
return False
previous = by_month.get(previous_month, {"transactions": 0, "revenue": 0.0, "aov": 0.0})
has_previous = previous_month in by_month
ecommerce = (report.get("ga4") or {}).get("ecommerce") or {}
ecommerce["current"] = {
"transactions": current.get("transactions", 0),
"revenue": current.get("revenue", 0.0),
"aov": current.get("aov", 0.0),
}
ecommerce["previous"] = {
"transactions": previous.get("transactions", 0),
"revenue": previous.get("revenue", 0.0),
"aov": previous.get("aov", 0.0),
}
ecommerce["mom_change"] = {
"transactions_pct": pct_change(ecommerce["current"]["transactions"], ecommerce["previous"]["transactions"]) if has_previous else None,
"revenue_pct": pct_change(ecommerce["current"]["revenue"], ecommerce["previous"]["revenue"]) if has_previous else None,
"aov_pct": pct_change(ecommerce["current"]["aov"], ecommerce["previous"]["aov"]) if has_previous else None,
}
ecommerce["source"] = "google_sheet"
if report.get("ga4") is None:
report["ga4"] = {}
report["ga4"]["ecommerce"] = ecommerce
return True
def fetch_google_ads_data(client, customer_id, start_date, end_date):
"""Fetch campaign metrics for a date range."""
query = f"""
SELECT campaign.id, campaign.name, campaign.status,
campaign.advertising_channel_type,
metrics.impressions, metrics.clicks,
metrics.cost_micros, metrics.conversions,
metrics.conversions_value,
metrics.ctr, metrics.average_cpc
FROM campaign
WHERE segments.date BETWEEN '{start_date}' AND '{end_date}'
AND campaign.status != 'REMOVED'
"""
rows = run_query(client, customer_id, query)
campaigns = {}
for r in rows:
cid = str(r.campaign.id)
if cid not in campaigns:
campaigns[cid] = {
"id": cid,
"name": r.campaign.name,
"status": r.campaign.status.name,
"type": r.campaign.advertising_channel_type.name,
"impressions": 0, "clicks": 0, "cost": 0.0,
"conversions": 0.0, "conversion_value": 0.0,
"ctr": 0.0, "cpc": 0.0,
}
c = campaigns[cid]
c["impressions"] += r.metrics.impressions
c["clicks"] += r.metrics.clicks
c["cost"] += r.metrics.cost_micros / 1_000_000
c["conversions"] += r.metrics.conversions
c["conversion_value"] += r.metrics.conversions_value
# Calculate derived metrics
for c in campaigns.values():
c["cost"] = round(c["cost"], 2)
c["conversions"] = round(c["conversions"], 1)
c["conversion_value"] = round(c["conversion_value"], 2)
c["ctr"] = round((c["clicks"] / c["impressions"] * 100) if c["impressions"] else 0, 2)
c["cpc"] = round((c["cost"] / c["clicks"]) if c["clicks"] else 0, 2)
c["cpa"] = round((c["cost"] / c["conversions"]) if c["conversions"] else 0, 2)
c["roas"] = round((c["conversion_value"] / c["cost"]) if c["cost"] else 0, 2)
return list(campaigns.values())
def calc_totals(campaigns):
"""Sum up totals across campaigns."""
t = {"impressions": 0, "clicks": 0, "cost": 0.0, "conversions": 0.0, "conversion_value": 0.0}
for c in campaigns:
t["impressions"] += c["impressions"]
t["clicks"] += c["clicks"]
t["cost"] += c["cost"]
t["conversions"] += c["conversions"]
t["conversion_value"] += c.get("conversion_value", 0.0)
t["cost"] = round(t["cost"], 2)
t["conversions"] = round(t["conversions"], 1)
t["conversion_value"] = round(t["conversion_value"], 2)
t["ctr"] = round((t["clicks"] / t["impressions"] * 100) if t["impressions"] else 0, 2)
t["cpc"] = round((t["cost"] / t["clicks"]) if t["clicks"] else 0, 2)
t["cpa"] = round((t["cost"] / t["conversions"]) if t["conversions"] else 0, 2)
t["roas"] = round((t["conversion_value"] / t["cost"]) if t["cost"] else 0, 2)
return t
def fetch_daily_data(client, customer_id, start_date, end_date):
"""Fetch daily breakdown for charts."""
query = f"""
SELECT segments.date,
metrics.impressions, metrics.clicks, metrics.cost_micros
FROM campaign
WHERE segments.date BETWEEN '{start_date}' AND '{end_date}'
AND campaign.status != 'REMOVED'
"""
rows = run_query(client, customer_id, query)
daily = {}
for r in rows:
d = r.segments.date
if d not in daily:
daily[d] = {"date": d, "impressions": 0, "clicks": 0, "cost": 0.0}
daily[d]["impressions"] += r.metrics.impressions
daily[d]["clicks"] += r.metrics.clicks
daily[d]["cost"] += r.metrics.cost_micros / 1_000_000
result = sorted(daily.values(), key=lambda x: x["date"])
for d in result:
d["cost"] = round(d["cost"], 2)
return result
def fetch_search_terms(client, customer_id, start_date, end_date, limit=15):
"""Fetch top search terms by clicks."""
query = f"""
SELECT search_term_view.search_term,
metrics.impressions, metrics.clicks,
metrics.cost_micros, metrics.conversions
FROM search_term_view
WHERE segments.date BETWEEN '{start_date}' AND '{end_date}'
ORDER BY metrics.clicks DESC
LIMIT {limit}
"""
rows = run_query(client, customer_id, query)
terms = []
for r in rows:
clicks = r.metrics.clicks
impressions = r.metrics.impressions
terms.append({
"term": r.search_term_view.search_term,
"impressions": impressions,
"clicks": clicks,
"cost": round(r.metrics.cost_micros / 1_000_000, 2),
"conversions": round(r.metrics.conversions, 1),
"ctr": round((clicks / impressions * 100) if impressions else 0, 2),
})
return terms
def fetch_ga4_data(property_id, start_date, end_date, prev_start, prev_end):
"""Fetch GA4 data: sessions, users, traffic sources, devices."""
from google.oauth2.credentials import Credentials
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
RunReportRequest, DateRange, Metric, Dimension, OrderBy,
)
credentials = Credentials(
token=None,
refresh_token=os.environ["GA4_REFRESH_TOKEN"],
client_id=os.environ["GOOGLE_ADS_OAUTH2_CLIENT_ID"],
client_secret=os.environ["GOOGLE_ADS_OAUTH2_CLIENT_SECRET"],
token_uri="https://oauth2.googleapis.com/token",
)
client = BetaAnalyticsDataClient(credentials=credentials)
prop = f"properties/{property_id}"
# 1. Sessions & Users (current + previous month)
def get_totals(sd, ed):
resp = client.run_report(RunReportRequest(
property=prop,
date_ranges=[DateRange(start_date=sd, end_date=ed)],
metrics=[
Metric(name="sessions"),
Metric(name="totalUsers"),
Metric(name="newUsers"),
Metric(name="screenPageViews"),
Metric(name="averageSessionDuration"),
Metric(name="bounceRate"),
],
))
row = resp.rows[0] if resp.rows else None
if not row:
return {"sessions": 0, "users": 0, "new_users": 0, "pageviews": 0, "avg_duration": 0, "bounce_rate": 0}
return {
"sessions": int(row.metric_values[0].value),
"users": int(row.metric_values[1].value),
"new_users": int(row.metric_values[2].value),
"pageviews": int(row.metric_values[3].value),
"avg_duration": round(float(row.metric_values[4].value), 1),
"bounce_rate": round(float(row.metric_values[5].value) * 100, 1),
}
current = get_totals(start_date, end_date)
previous = get_totals(prev_start, prev_end)
# 2. Traffic sources
resp = client.run_report(RunReportRequest(
property=prop,
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimensions=[Dimension(name="sessionSourceMedium")],
metrics=[Metric(name="sessions")],
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="sessions"), desc=True)],
limit=10,
))
sources = []
for row in resp.rows:
sources.append({
"source_medium": row.dimension_values[0].value,
"sessions": int(row.metric_values[0].value),
})
# 3. Devices
resp = client.run_report(RunReportRequest(
property=prop,
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimensions=[Dimension(name="deviceCategory")],
metrics=[Metric(name="sessions")],
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="sessions"), desc=True)],
))
devices = []
for row in resp.rows:
devices.append({
"device": row.dimension_values[0].value,
"sessions": int(row.metric_values[0].value),
})
# 4. Daily sessions (for chart)
resp = client.run_report(RunReportRequest(
property=prop,
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimensions=[Dimension(name="date")],
metrics=[Metric(name="sessions"), Metric(name="totalUsers")],
order_bys=[OrderBy(dimension=OrderBy.DimensionOrderBy(dimension_name="date"))],
))
daily_sessions = []
for row in resp.rows:
raw = row.dimension_values[0].value
formatted = f"{raw[:4]}-{raw[4:6]}-{raw[6:]}"
daily_sessions.append({
"date": formatted,
"sessions": int(row.metric_values[0].value),
"users": int(row.metric_values[1].value),
})
return {
"current": current,
"previous": previous,
"mom_change": {
"sessions_pct": pct_change(current["sessions"], previous["sessions"]),
"users_pct": pct_change(current["users"], previous["users"]),
"new_users_pct": pct_change(current["new_users"], previous["new_users"]),
"pageviews_pct": pct_change(current["pageviews"], previous["pageviews"]),
"avg_duration_pct": pct_change(current["avg_duration"], previous["avg_duration"]),
"bounce_rate_pct": pct_change(current["bounce_rate"], previous["bounce_rate"]),
},
"sources": sources,
"devices": devices,
"daily": daily_sessions,
}
def fetch_ga4_ecommerce(property_id, start_date, end_date, prev_start, prev_end):
"""Fetch GA4 e-commerce data: transactions, revenue, AOV."""
from google.oauth2.credentials import Credentials
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
RunReportRequest, DateRange, Metric, Dimension, OrderBy,
)
credentials = Credentials(
token=None,
refresh_token=os.environ["GA4_REFRESH_TOKEN"],
client_id=os.environ["GOOGLE_ADS_OAUTH2_CLIENT_ID"],
client_secret=os.environ["GOOGLE_ADS_OAUTH2_CLIENT_SECRET"],
token_uri="https://oauth2.googleapis.com/token",
)
client = BetaAnalyticsDataClient(credentials=credentials)
prop = f"properties/{property_id}"
def get_ecom(sd, ed):
resp = client.run_report(RunReportRequest(
property=prop,
date_ranges=[DateRange(start_date=sd, end_date=ed)],
metrics=[
Metric(name="transactions"),
Metric(name="purchaseRevenue"),
Metric(name="averagePurchaseRevenue"),
],
))
row = resp.rows[0] if resp.rows else None
if not row:
return {"transactions": 0, "revenue": 0.0, "aov": 0.0}
return {
"transactions": int(row.metric_values[0].value),
"revenue": round(float(row.metric_values[1].value), 2),
"aov": round(float(row.metric_values[2].value), 2),
}
current = get_ecom(start_date, end_date)
previous = get_ecom(prev_start, prev_end)
# Daily revenue chart
resp = client.run_report(RunReportRequest(
property=prop,
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimensions=[Dimension(name="date")],
metrics=[Metric(name="purchaseRevenue"), Metric(name="transactions")],
order_bys=[OrderBy(dimension=OrderBy.DimensionOrderBy(dimension_name="date"))],
))
daily_revenue = []
for row in resp.rows:
raw = row.dimension_values[0].value
formatted = f"{raw[:4]}-{raw[4:6]}-{raw[6:]}"
daily_revenue.append({
"date": formatted,
"revenue": round(float(row.metric_values[0].value), 2),
"transactions": int(row.metric_values[1].value),
})
# Revenue by source
resp = client.run_report(RunReportRequest(
property=prop,
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimensions=[Dimension(name="sessionSourceMedium")],
metrics=[Metric(name="purchaseRevenue"), Metric(name="transactions")],
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="purchaseRevenue"), desc=True)],
limit=10,
))
revenue_by_source = []
for row in resp.rows:
revenue_by_source.append({
"source_medium": row.dimension_values[0].value,
"revenue": round(float(row.metric_values[0].value), 2),
"transactions": int(row.metric_values[1].value),
})
# Top products by revenue
resp = client.run_report(RunReportRequest(
property=prop,
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimensions=[Dimension(name="itemName")],
metrics=[
Metric(name="itemRevenue"),
Metric(name="itemsPurchased"),
],
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="itemRevenue"), desc=True)],
limit=10,
))
top_products = []
for row in resp.rows:
top_products.append({
"name": row.dimension_values[0].value,
"revenue": round(float(row.metric_values[0].value), 2),
"quantity": int(row.metric_values[1].value),
})
return {
"current": current,
"previous": previous,
"mom_change": {
"transactions_pct": pct_change(current["transactions"], previous["transactions"]),
"revenue_pct": pct_change(current["revenue"], previous["revenue"]),
"aov_pct": pct_change(current["aov"], previous["aov"]),
},
"daily": daily_revenue,
"revenue_by_source": revenue_by_source,
"top_products": top_products,
}
def main():
parser = argparse.ArgumentParser(description="Pobierz dane do raportu miesięcznego")
parser.add_argument("--customer", required=True, help="Domena lub Google Ads customer ID")
parser.add_argument("--month", required=True, help="Miesiąc raportu (YYYY-MM)")
parser.add_argument("--output", help="Ścieżka do pliku JSON")
parser.add_argument("--ga4-property", help="GA4 Property ID (domyślnie z .env)")
parser.add_argument("--skip-ga4", action="store_true", help="Pomiń dane GA4")
args = parser.parse_args()
customer_id = get_customer_id(args.customer)
client = get_client(use_proto_plus=True)
year, month, start_date, end_date = parse_month(args.month)
py, pm, prev_start, prev_end = prev_month(year, month)
month_names_pl = {
1: "Styczeń", 2: "Luty", 3: "Marzec", 4: "Kwiecień",
5: "Maj", 6: "Czerwiec", 7: "Lipiec", 8: "Sierpień",
9: "Wrzesień", 10: "Październik", 11: "Listopad", 12: "Grudzień",
}
# Resolve domain name for output
domain = args.customer if not args.customer.replace("-", "").isdigit() else args.customer
print(f"Pobieram dane Google Ads: {domain} za {args.month}...")
# Google Ads data
campaigns = fetch_google_ads_data(client, customer_id, start_date, end_date)
prev_campaigns = fetch_google_ads_data(client, customer_id, prev_start, prev_end)
totals = calc_totals(campaigns)
prev_totals = calc_totals(prev_campaigns)
daily = fetch_daily_data(client, customer_id, start_date, end_date)
search_terms = fetch_search_terms(client, customer_id, start_date, end_date)
mom_change = {
"impressions_pct": pct_change(totals["impressions"], prev_totals["impressions"]),
"clicks_pct": pct_change(totals["clicks"], prev_totals["clicks"]),
"cost_pct": pct_change(totals["cost"], prev_totals["cost"]),
"conversions_pct": pct_change(totals["conversions"], prev_totals["conversions"]),
"conversion_value_pct": pct_change(totals["conversion_value"], prev_totals["conversion_value"]),
"ctr_pct": pct_change(totals["ctr"], prev_totals["ctr"]),
"cpc_pct": pct_change(totals["cpc"], prev_totals["cpc"]),
"cpa_pct": pct_change(totals["cpa"], prev_totals["cpa"]),
"roas_pct": pct_change(totals["roas"], prev_totals["roas"]),
}
report = {
"client": domain,
"month": args.month,
"month_name": month_names_pl[month],
"year": year,
"prev_month": f"{py}-{pm:02d}",
"prev_month_name": month_names_pl[pm],
"generated_at": datetime.now().isoformat(),
"google_ads": {
"campaigns": campaigns,
"totals": totals,
"prev_totals": prev_totals,
"mom_change": mom_change,
"daily": daily,
"search_terms": search_terms,
},
}
# GA4 data
if not args.skip_ga4:
ga4_property = args.ga4_property
if not ga4_property:
# Try to find GA4 property in .env
env_key = f"GA4_PROPERTY_ID_{domain}"
ga4_property = os.environ.get(env_key)
if ga4_property:
print(f"Pobieram dane GA4 (property: {ga4_property})...")
try:
ga4 = fetch_ga4_data(ga4_property, start_date, end_date, prev_start, prev_end)
report["ga4"] = ga4
print(f" GA4: {ga4['current']['sessions']} sesji, {ga4['current']['users']} uzytkownikow")
except Exception as e:
print(f" UWAGA: Blad GA4: {e}")
report["ga4"] = None
else:
print(f" Brak GA4 Property ID w .env ({env_key}) - pomijam GA4")
report["ga4"] = None
else:
report["ga4"] = None
# Semstorm SEO data
semstorm_login = os.environ.get("SEMSTORM_LOGIN", "")
if semstorm_login:
print(f"Pobieram dane Semstorm...")
try:
sys.path.insert(0, str(Path(__file__).parent))
from fetch_semstorm_data import fetch_domain_stats
semstorm = fetch_domain_stats(domain, args.month)
report["semstorm"] = semstorm
if semstorm and semstorm.get("current"):
cur = semstorm["current"]
print(f" Semstorm: TOP3={cur['top3']}, TOP10={cur['top10']}, TOP50={cur['top50']}, traffic={cur['traffic']}")
except Exception as e:
print(f" UWAGA: Blad Semstorm: {e}")
report["semstorm"] = None
else:
report["semstorm"] = None
# E-commerce data: Shoper (primary) or GA4 (fallback)
shoper_key = f"SHOPER_API_URL_{domain}"
if os.environ.get(shoper_key):
print(f"Pobieram dane e-commerce ze Shoper...")
try:
from fetch_shoper_data import fetch_shoper_ecommerce
shoper_ecom = fetch_shoper_ecommerce(domain, args.month, f"{py}-{pm:02d}")
if shoper_ecom and shoper_ecom["current"]["transactions"] > 0:
# Get revenue_by_source and top_products from GA4
if report.get("ga4") and ga4_property:
try:
ga4_ecom = fetch_ga4_ecommerce(ga4_property, start_date, end_date, prev_start, prev_end)
if ga4_ecom:
shoper_ecom["revenue_by_source"] = ga4_ecom.get("revenue_by_source", [])
shoper_ecom["top_products"] = ga4_ecom.get("top_products", [])
except Exception as e:
print(f" UWAGA: GA4 revenue_by_source/top_products: {e}")
shoper_ecom["revenue_by_source"] = []
shoper_ecom["top_products"] = []
if report.get("ga4") is None:
report["ga4"] = {}
report["ga4"]["ecommerce"] = shoper_ecom
cur = shoper_ecom["current"]
print(f" Shoper: {cur['transactions']} zamówień, {cur['revenue']:.2f} PLN, AOV {cur['aov']:.2f} PLN")
else:
if report.get("ga4"):
report["ga4"]["ecommerce"] = None
except Exception as e:
print(f" UWAGA: Blad Shoper: {e}")
if report.get("ga4"):
report["ga4"]["ecommerce"] = None
elif report.get("ga4") and ga4_property:
print(f"Pobieram dane GA4 e-commerce...")
try:
ecom = fetch_ga4_ecommerce(ga4_property, start_date, end_date, prev_start, prev_end)
if ecom and ecom["current"]["transactions"] > 0:
report["ga4"]["ecommerce"] = ecom
cur = ecom["current"]
print(f" E-commerce (GA4): {cur['transactions']} transakcji, {cur['revenue']:.2f} PLN przychodu")
else:
report["ga4"]["ecommerce"] = None
except Exception as e:
print(f" UWAGA: Blad GA4 e-commerce: {e}")
if report.get("ga4"):
report["ga4"]["ecommerce"] = None
# Monthly sales history for chart. Prefer client Google Sheet when configured.
client_report_config = load_client_report_config(domain)
sales_history_sheet = client_report_config.get("sales_history_sheet") or os.environ.get(f"GSHEET_SALES_HISTORY_{domain}")
report_start = os.environ.get(f"REPORT_START_DATE_{domain}")
if sales_history_sheet:
try:
sales_history = fetch_sales_history_from_sheet(domain, sales_history_sheet)
if apply_sheet_ecommerce(report, sales_history, args.month, f"{py}-{pm:02d}"):
current_sheet = report["ga4"]["ecommerce"]["current"]
print(
f" E-commerce (Google Sheet): {current_sheet['transactions']} transakcji, "
f"{current_sheet['revenue']:.2f} PLN przychodu"
)
filtered = [e for e in sales_history if not report_start or e["month"] >= report_start]
report["sales_history"] = filtered
print(f" Historia sprzedaży z Google Sheet: {len(filtered)} miesięcy")
except Exception as e:
report["sales_history"] = []
print(f" UWAGA: Nie udalo sie pobrac historii sprzedazy z Google Sheet: {e}")
else:
ecom_data = report.get("ga4", {}).get("ecommerce") if report.get("ga4") else None
if ecom_data and ecom_data.get("current", {}).get("transactions", 0) > 0:
history_path = ROOT / "clients" / domain / "sales_history.json"
history_path.parent.mkdir(parents=True, exist_ok=True)
sales_history = []
if history_path.exists():
with open(history_path, "r", encoding="utf-8") as f:
sales_history = json.load(f)
cur_entry = {
"month": args.month,
"transactions": ecom_data["current"]["transactions"],
"revenue": ecom_data["current"]["revenue"],
"aov": ecom_data["current"]["aov"],
"source": ecom_data.get("source", "ga4"),
}
by_month = {e["month"]: e for e in sales_history}
by_month[args.month] = cur_entry
sales_history = sorted(by_month.values(), key=lambda x: x["month"])
with open(history_path, "w", encoding="utf-8") as f:
json.dump(sales_history, f, indent=2, ensure_ascii=False)
filtered = [e for e in sales_history if not report_start or e["month"] >= report_start]
report["sales_history"] = filtered
print(f" Historia sprzedaży: {len(filtered)} miesięcy zapisanych")
else:
report["sales_history"] = []
# SEO links from Google Sheets
seo_links_key = f"GSHEET_SEO_LINKS_{domain}"
if os.environ.get(seo_links_key):
print(f"Pobieram linki SEO...")
try:
from fetch_seo_links import fetch_seo_links, fetch_seo_activities
seo_links = fetch_seo_links(domain, args.month)
report["seo_links"] = seo_links or []
print(f" Linki SEO: {len(report['seo_links'])} w {args.month}")
# SEO activities (text box)
seo_act_key = f"GSHEET_SEO_ACTIVITIES_{domain}"
if os.environ.get(seo_act_key):
seo_activities = fetch_seo_activities(domain, args.month)
report["seo_activities"] = seo_activities
if seo_activities:
print(f" Działania SEO: {len(seo_activities)} znaków")
except Exception as e:
print(f" UWAGA: Blad SEO links: {e}")
report["seo_links"] = []
else:
report["seo_links"] = []
# Output
if args.output:
output_path = Path(args.output)
else:
output_path = ROOT / "scripts" / "reports" / "output" / f"{domain}_{args.month}.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"\nZapisano: {output_path}")
print(f"Google Ads: {totals['clicks']} klikniec, {totals['conversions']} konwersji, {totals['cost']:.2f} PLN")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
Pobiera dane SEO z Semstorm API (pozycje TOP 3/10/20/50, traffic).
Użycie:
python scripts/reports/fetch_semstorm_data.py --domain innsi.pl
python scripts/reports/fetch_semstorm_data.py --domain innsi.pl --month 2026-02
"""
import argparse
import json
import os
import sys
import io
from pathlib import Path
if __name__ == "__main__":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
# When imported as module, don't touch stdout
import requests
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
from src.gads_v2.config import load_env
load_env(ROOT / ".env")
def get_semstorm_token():
"""Authenticate and get bearer token."""
base = os.environ.get("SEMSTORM_API_BASE", "https://api.semstorm.com")
login = os.environ.get("SEMSTORM_LOGIN", "")
password = os.environ.get("SEMSTORM_PASSWORD", "")
if not login or not password:
raise ValueError("Brak SEMSTORM_LOGIN / SEMSTORM_PASSWORD w .env")
r = requests.post(f"{base}/consumer/login", data={
"username": login,
"password": password,
}, headers={"Accept": "application/json"}, timeout=30)
r.raise_for_status()
token = r.json().get("token", "")
if not token:
raise ValueError("Semstorm: brak tokenu w odpowiedzi logowania")
return base, token
def _history_path(domain):
"""Path to local cumulative Semstorm history file."""
return ROOT / "clients" / domain / "semstorm_history.json"
def _load_local_history(domain):
"""Load locally stored Semstorm history."""
path = _history_path(domain)
if path.exists():
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
return []
def _save_local_history(domain, entries):
"""Save Semstorm history locally (deduplicated, sorted)."""
path = _history_path(domain)
path.parent.mkdir(parents=True, exist_ok=True)
# Deduplicate by month, keep latest per month
by_month = {}
for e in entries:
by_month[e["month"]] = e
sorted_entries = sorted(by_month.values(), key=lambda x: x["date"])
with open(path, "w", encoding="utf-8") as f:
json.dump(sorted_entries, f, indent=2, ensure_ascii=False)
return sorted_entries
def _get_report_start(domain):
"""Get REPORT_START_DATE for domain from .env."""
key = f"REPORT_START_DATE_{domain}"
return os.environ.get(key)
def fetch_domain_stats(domain, month=None):
"""Fetch Semstorm domain stats. Merges API data with local history."""
base, token = get_semstorm_token()
r = requests.post(f"{base}/semstorm/v4/explorer/domain-stats",
json={"domains": [domain]},
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
timeout=30,
)
r.raise_for_status()
data = r.json()
api_entries = []
if data.get("success") and domain in data.get("results", {}):
domain_data = data["results"][domain]
for date_key, metrics in domain_data.items():
kw = metrics.get("keywords", {})
api_entries.append({
"date": f"{date_key[:4]}-{date_key[4:6]}-{date_key[6:]}",
"month": f"{date_key[:4]}-{date_key[4:6]}",
"top3": kw.get("top3", 0),
"top10": kw.get("top10", 0),
"top20": kw.get("top20", 0),
"top50": kw.get("top50", 0),
"top100": kw.get("top100", 0),
"traffic": metrics.get("traffic", 0),
})
# Merge with local history (local + API, deduplicated)
local_entries = _load_local_history(domain)
all_entries = local_entries + api_entries
entries = _save_local_history(domain, all_entries)
# Filter by REPORT_START_DATE if set
start = _get_report_start(domain)
if start:
entries = [e for e in entries if e["month"] >= start]
# If month specified, find that month + previous for MoM
if month:
current = next((e for e in entries if e["month"] == month), None)
prev_entries = [e for e in entries if e["month"] < month]
previous = prev_entries[-1] if prev_entries else None
result = {
"current": current,
"previous": previous,
"history": entries,
}
if current and previous:
result["mom_change"] = {
"top3_pct": _pct(current["top3"], previous["top3"]),
"top10_pct": _pct(current["top10"], previous["top10"]),
"top50_pct": _pct(current["top50"], previous["top50"]),
"traffic_pct": _pct(current["traffic"], previous["traffic"]),
}
return result
# Return latest + history
return {
"current": entries[-1] if entries else None,
"previous": entries[-2] if len(entries) > 1 else None,
"history": entries,
}
def _pct(current, previous):
if previous == 0:
return 100.0 if current > 0 else 0.0
return round(((current - previous) / previous) * 100, 1)
def main():
parser = argparse.ArgumentParser(description="Pobierz dane Semstorm")
parser.add_argument("--domain", required=True)
parser.add_argument("--month", help="YYYY-MM")
args = parser.parse_args()
data = fetch_domain_stats(args.domain, args.month)
print(json.dumps(data, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Pobiera linki SEO z Google Sheets (publiczny CSV export).
Użycie:
python scripts/reports/fetch_seo_links.py --domain innsi.pl --month 2026-02
"""
import argparse
import csv
import io
import json
import os
import sys
from pathlib import Path
if __name__ == "__main__":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
import requests
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
from src.gads_v2.config import load_env
load_env(ROOT / ".env")
def fetch_seo_links(domain, month):
"""Fetch SEO links for given domain and month from Google Sheets.
Returns list of dicts: [{"date": "2026-02-01", "url": "https://..."}]
"""
env_key = f"GSHEET_SEO_LINKS_{domain}"
sheet_config = os.environ.get(env_key, "")
if not sheet_config:
return None
if ":" in sheet_config:
spreadsheet_id, gid = sheet_config.split(":", 1)
else:
spreadsheet_id = sheet_config
gid = "0"
export_url = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/export?format=csv&gid={gid}"
r = requests.get(export_url, timeout=30)
r.raise_for_status()
r.encoding = "utf-8"
reader = csv.DictReader(io.StringIO(r.text))
links = []
for row in reader:
date = row.get("Data", "").strip()
url = row.get("URL", "").strip()
if not date or not url:
continue
# Match month (date format: YYYY-MM-DD)
if date[:7] == month:
links.append({"date": date, "url": url})
return links
def fetch_seo_activities(domain, month):
"""Fetch SEO activities description for given domain and month.
Returns string with activities text, or None.
"""
env_key = f"GSHEET_SEO_ACTIVITIES_{domain}"
sheet_config = os.environ.get(env_key, "")
if not sheet_config:
return None
if ":" in sheet_config:
spreadsheet_id, gid = sheet_config.split(":", 1)
else:
spreadsheet_id = sheet_config
gid = "0"
export_url = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/export?format=csv&gid={gid}"
r = requests.get(export_url, timeout=30)
r.raise_for_status()
r.encoding = "utf-8"
reader = csv.DictReader(io.StringIO(r.text))
for row in reader:
date = row.get("Data", "").strip()
text = row.get("URL", "").strip() # Column is named URL but contains text
if not date or not text:
continue
if date[:7] == month:
return text
return None
def main():
parser = argparse.ArgumentParser(description="Pobierz linki SEO z Google Sheets")
parser.add_argument("--domain", required=True)
parser.add_argument("--month", required=True, help="YYYY-MM")
args = parser.parse_args()
links = fetch_seo_links(args.domain, args.month)
if links is None:
print(f"Brak konfiguracji GSHEET_SEO_LINKS_{args.domain} w .env")
sys.exit(1)
print(json.dumps(links, indent=2, ensure_ascii=False))
print(f"\nLiczba linkow w {args.month}: {len(links)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
Pobiera dane e-commerce ze Shoper API (zamówienia, przychody, AOV).
Użycie:
python scripts/reports/fetch_shoper_data.py --domain innsi.pl --month 2026-02
"""
import argparse
import json
import os
import sys
import io
from collections import defaultdict
from pathlib import Path
if __name__ == "__main__":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
import requests
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
from src.gads_v2.config import load_env
load_env(ROOT / ".env")
def _shoper_auth(domain):
"""Authenticate to Shoper API, return (base_url, headers)."""
base = os.environ[f"SHOPER_API_URL_{domain}"].rstrip("/")
login = os.environ[f"SHOPER_API_LOGIN_{domain}"]
password = os.environ[f"SHOPER_API_PASSWORD_{domain}"]
r = requests.post(f"{base}/auth", auth=(login, password), timeout=15)
r.raise_for_status()
token = r.json()["access_token"]
return base, {"Authorization": f"Bearer {token}"}
def _collect_orders_for_month(base, headers, target_month):
"""Paginate orders (newest first) and collect all for target_month."""
orders = []
page = 1
found = False
while page < 100:
r = requests.get(
f"{base}/orders?limit=50&page={page}&order=date+desc",
headers=headers, timeout=20,
)
r.raise_for_status()
data = r.json()
page_orders = data.get("list", [])
if not page_orders:
break
for o in page_orders:
month = o["date"][:7]
if month == target_month:
found = True
orders.append(o)
elif found and month < target_month:
return orders
page += 1
return orders
def fetch_shoper_ecommerce(domain, month, prev_month):
"""Fetch Shoper e-commerce data for month and previous month.
Returns dict compatible with ga4.ecommerce structure.
"""
base, headers = _shoper_auth(domain)
# Collect orders for both months in one pass
current_orders = []
prev_orders = []
page = 1
found_current = False
found_prev = False
passed_prev = False
while page < 200 and not passed_prev:
r = requests.get(
f"{base}/orders?limit=50&page={page}&order=date+desc",
headers=headers, timeout=20,
)
r.raise_for_status()
data = r.json()
page_orders = data.get("list", [])
if not page_orders:
break
for o in page_orders:
m = o["date"][:7]
if m == month:
found_current = True
current_orders.append(o)
elif m == prev_month:
found_prev = True
prev_orders.append(o)
elif found_prev and m < prev_month:
passed_prev = True
break
page += 1
def _summarize(orders):
if not orders:
return {"transactions": 0, "revenue": 0.0, "aov": 0.0}
total = sum(float(o["sum"]) for o in orders)
count = len(orders)
return {
"transactions": count,
"revenue": round(total, 2),
"aov": round(total / count, 2),
}
current = _summarize(current_orders)
previous = _summarize(prev_orders)
# Daily breakdown
daily_map = defaultdict(lambda: {"revenue": 0.0, "transactions": 0})
for o in current_orders:
day = o["date"][:10]
daily_map[day]["revenue"] += float(o["sum"])
daily_map[day]["transactions"] += 1
daily = []
for day in sorted(daily_map.keys()):
daily.append({
"date": day,
"revenue": round(daily_map[day]["revenue"], 2),
"transactions": daily_map[day]["transactions"],
})
# MoM change
def _pct(cur, prev):
if prev == 0:
return 100.0 if cur > 0 else 0.0
return round(((cur - prev) / prev) * 100, 1)
mom = {
"transactions_pct": _pct(current["transactions"], previous["transactions"]),
"revenue_pct": _pct(current["revenue"], previous["revenue"]),
"aov_pct": _pct(current["aov"], previous["aov"]),
}
return {
"source": "shoper",
"current": current,
"previous": previous,
"mom_change": mom,
"daily": daily,
}
def main():
parser = argparse.ArgumentParser(description="Pobierz dane e-commerce ze Shoper")
parser.add_argument("--domain", required=True)
parser.add_argument("--month", required=True, help="YYYY-MM")
args = parser.parse_args()
# Calculate prev month
y, m = map(int, args.month.split("-"))
if m == 1:
prev = f"{y-1}-12"
else:
prev = f"{y}-{m-1:02d}"
data = fetch_shoper_ecommerce(args.domain, args.month, prev)
print(json.dumps(data, indent=2, ensure_ascii=False))
cur = data["current"]
print(f"\n{args.month}: {cur['transactions']} zamówień, {cur['revenue']:.2f} PLN, AOV {cur['aov']:.2f} PLN")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,204 @@
"""
Pobiera dane zamówień z Shopify Admin przez Playwright.
Loguje się do panelu, przechodzi do Analytics > Reports, pobiera dane sprzedaży.
Użycie:
python scripts/reports/fetch_shopify_orders.py --customer laitica.pl --month 2026-03
"""
import argparse
import json
import os
import re
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
from dotenv import load_dotenv
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
def get_month_range(month_str: str):
"""Zwraca (first_day, last_day) dla danego miesiąca YYYY-MM."""
year, month = map(int, month_str.split("-"))
first_day = datetime(year, month, 1)
if month == 12:
last_day = datetime(year + 1, 1, 1) - timedelta(days=1)
else:
last_day = datetime(year, month + 1, 1) - timedelta(days=1)
return first_day.strftime("%Y-%m-%d"), last_day.strftime("%Y-%m-%d")
def main():
parser = argparse.ArgumentParser(description="Pobierz zamówienia z Shopify Admin")
parser.add_argument("--customer", required=True, help="Domena klienta")
parser.add_argument("--month", required=True, help="Miesiąc YYYY-MM")
parser.add_argument("--headless", action="store_true", help="Tryb headless")
args = parser.parse_args()
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
domain = args.customer
admin_url = os.environ.get(f"SHOPIFY_ADMIN_URL_{domain}")
login_email = os.environ.get(f"SHOPIFY_LOGIN_{domain}")
login_password = os.environ.get(f"SHOPIFY_PASSWORD_{domain}")
if not all([admin_url, login_email, login_password]):
print(f"Brak danych logowania w .env dla {domain}")
sys.exit(1)
first_day, last_day = get_month_range(args.month)
print(f"Shopify Admin: {admin_url}")
print(f"Okres: {first_day}{last_day}")
with sync_playwright() as p:
browser = p.chromium.launch(headless=args.headless)
context = browser.new_context(
viewport={"width": 1280, "height": 900},
locale="pl-PL",
)
page = context.new_page()
# --- Logowanie ręczne ---
print("\n1. Otwieram stronę logowania Shopify...")
print(">>> ZALOGUJ SIĘ RĘCZNIE W OKNIE PRZEGLĄDARKI <<<")
print(">>> Czekam max 180 sekund na zalogowanie...\n")
page.goto(admin_url, wait_until="domcontentloaded", timeout=30000)
# Czekaj aż URL będzie wskazywał na zalogowany admin
for i in range(180):
time.sleep(1)
current_url = page.url
if "/store/" in current_url and "accounts.shopify.com" not in current_url and "lookup" not in current_url:
print(f" Zalogowano! ({current_url})")
break
if i % 15 == 0 and i > 0:
print(f" Czekam na logowanie... ({i}s)")
else:
print(" Timeout 180s — nie zalogowano.")
browser.close()
sys.exit(1)
# Przejdź do admina sklepu
print("2. Przechodzę do admina sklepu...")
page.goto(f"{admin_url}/orders?status=any", wait_until="networkidle", timeout=30000)
time.sleep(3)
# --- Pobieranie zamówień przez URL z filtrami dat ---
print(f"3. Pobieram zamówienia za {args.month}...")
# Shopify Admin API endpoint przez stronę
# Używamy filtrów w URL zamówień
orders_url = (
f"{admin_url}/orders.json"
f"?status=any"
f"&created_at_min={first_day}T00:00:00"
f"&created_at_max={last_day}T23:59:59"
f"&limit=250"
)
# Próba pobrania przez API endpoint (admin jest zalogowany)
response = page.goto(orders_url, wait_until="networkidle", timeout=30000)
orders_data = None
if response and response.status == 200:
try:
body = page.locator("body").inner_text()
orders_data = json.loads(body)
print(f" Pobrano dane JSON: {len(orders_data.get('orders', []))} zamówień")
except (json.JSONDecodeError, Exception):
print(" Nie udało się sparsować JSON z orders.json")
# Jeśli API nie zadziałało, pobierz z UI
if not orders_data:
print(" Próbuję pobrać z Analytics...")
# Przejdź do raportu sprzedaży
analytics_url = (
f"{admin_url}/analytics/reports/finances_summary"
f"?since={first_day}&until={last_day}"
)
page.goto(analytics_url, wait_until="networkidle", timeout=30000)
time.sleep(5)
# Screenshot dla debugowania
screenshot_path = Path(__file__).parent / "output" / f"shopify_debug_{domain}.png"
page.screenshot(path=str(screenshot_path), full_page=True)
print(f" Screenshot: {screenshot_path}")
# Próba wyciągnięcia danych z Analytics page
page_text = page.inner_text("body")
print(f" Tekst strony (pierwsze 2000 znaków):")
print(f" {page_text[:2000]}")
# Fallback — przejdź do orders z filtrem dat
print("\n Fallback: liczę zamówienia z listy orders...")
page.goto(
f"{admin_url}/orders?inContextTimelineDate%5Bgte%5D={first_day}"
f"&inContextTimelineDate%5Blte%5D={last_day}&status=any",
wait_until="networkidle",
timeout=30000,
)
time.sleep(5)
screenshot_path2 = Path(__file__).parent / "output" / f"shopify_orders_{domain}.png"
page.screenshot(path=str(screenshot_path2), full_page=True)
print(f" Screenshot orders: {screenshot_path2}")
page_text = page.inner_text("body")
print(f" Orders page text (2000 chars):")
print(f" {page_text[:2000]}")
# Przetwórz dane zamówień (jeśli mamy JSON)
if orders_data and "orders" in orders_data:
orders = orders_data["orders"]
# Filtruj tylko opłacone/zrealizowane
paid_orders = [
o for o in orders
if o.get("financial_status") in ("paid", "partially_refunded", "refunded")
or o.get("fulfillment_status") in ("fulfilled", "partial", None)
]
total_revenue = sum(float(o.get("total_price", 0)) for o in paid_orders)
total_orders = len(paid_orders)
aov = total_revenue / total_orders if total_orders > 0 else 0
result = {
"source": "shopify_admin",
"month": args.month,
"transactions": total_orders,
"revenue": round(total_revenue, 2),
"aov": round(aov, 2),
"orders_detail": [
{
"id": o.get("name", o.get("id")),
"date": o.get("created_at", "")[:10],
"total": float(o.get("total_price", 0)),
"status": o.get("financial_status", ""),
}
for o in paid_orders
],
}
# Zapisz
out_dir = Path(__file__).parent / "output"
out_dir.mkdir(exist_ok=True)
out_path = out_dir / f"shopify_{domain}_{args.month}.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
print(f"\n=== WYNIK ===")
print(f"Zamówienia: {total_orders}")
print(f"Przychód: {total_revenue:.2f} PLN")
print(f"AOV: {aov:.2f} PLN")
print(f"Zapisano: {out_path}")
else:
print("\nNie udało się automatycznie pobrać danych.")
print("Sprawdź screenshoty i spróbuj ręcznie podać dane.")
browser.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,74 @@
"""
Generate OAuth2 refresh token with GA4 Analytics scope.
Manual approach - no local server needed.
"""
import os
import urllib.parse
import requests
from dotenv import load_dotenv
load_dotenv()
CLIENT_ID = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_ID")
CLIENT_SECRET = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_SECRET")
SCOPES = "https://www.googleapis.com/auth/analytics.readonly"
REDIRECT_URI = "http://localhost"
# Step 1: Build auth URL
params = {
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": SCOPES,
"access_type": "offline",
"prompt": "consent",
}
auth_url = "https://accounts.google.com/o/oauth2/auth?" + urllib.parse.urlencode(params)
print("=" * 60)
print("KROK 1: Otworz ten URL w przegladarce:")
print("=" * 60)
print(auth_url)
print("=" * 60)
print()
print("KROK 2: Zaloguj sie i zezwol na dostep.")
print("Przegladarka przekieruje na http://localhost/?code=XXXXXX")
print("Strona NIE zaladuje sie (to normalne!).")
print("Skopiuj CALY URL z paska adresu przegladarki i wklej tutaj:")
print()
redirect_url = input("Wklej URL z paska adresu: ").strip()
# Step 2: Extract code from URL
parsed = urllib.parse.urlparse(redirect_url)
query_params = urllib.parse.parse_qs(parsed.query)
if "code" not in query_params:
print("Blad: nie znaleziono kodu w URL. Upewnij sie ze skopiowales caly URL.")
exit(1)
code = query_params["code"][0]
# Step 3: Exchange code for tokens
token_response = requests.post("https://oauth2.googleapis.com/token", data={
"code": code,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"redirect_uri": REDIRECT_URI,
"grant_type": "authorization_code",
})
if token_response.status_code != 200:
print(f"Blad: {token_response.text}")
exit(1)
tokens = token_response.json()
refresh_token = tokens.get("refresh_token")
print()
print("=" * 60)
print("GA4 REFRESH TOKEN:")
print("=" * 60)
print(refresh_token)
print("=" * 60)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
"""List all GA4 properties accessible by this token."""
import os
from dotenv import load_dotenv
from google.oauth2.credentials import Credentials
from google.analytics.admin_v1beta import AnalyticsAdminServiceClient
load_dotenv()
CLIENT_ID = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_ID")
CLIENT_SECRET = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_SECRET")
REFRESH_TOKEN = os.getenv("GA4_REFRESH_TOKEN")
credentials = Credentials(
token=None,
refresh_token=REFRESH_TOKEN,
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
token_uri="https://oauth2.googleapis.com/token",
)
client = AnalyticsAdminServiceClient(credentials=credentials)
print("Accounts:")
print("-" * 60)
for account in client.list_accounts():
print(f" Account: {account.name} | {account.display_name}")
print(" Properties:")
request = {"filter": f"parent:{account.name}"}
for prop in client.list_properties(request=request):
print(f" Property ID: {prop.name.replace('properties/', '')} | {prop.display_name}")
print()

View File

@@ -0,0 +1,716 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Raport Luty 2026 &mdash; Aruba Rzeszow</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">Aruba Rzeszow &mdash; Luty 2026</div>
</div>
</header>
<!-- RECOMMENDATIONS -->
<section class="report-section" id="recommendations">
<h2 class="section-title">Wnioski i rekomendacje</h2>
<div class="recommendations-list">
<div class="rec-item"><span class="rec-icon">&#9888;</span><div><strong>Spadek konwersji do obserwacji</strong><p>Liczba konwersji spadla o 30.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu.</p></div></div><div class="rec-item"><span class="rec-icon">&#128200;</span><div><strong>ROAS liczony z Google Ads</strong><p>ROAS z Google Ads wyniosl 9.50. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu.</p></div></div>
</div>
</section>
<!-- GA4 SECTION -->
<!-- E-COMMERCE -->
<!-- PRODUCT OPTIMIZATIONS -->
<!-- TOP ADS PRODUCTS -->
<!-- YEAR OVER YEAR -->
<!-- GOOGLE ADS KPIs -->
<section class="report-section" id="ads-kpi">
<h2 class="section-title">Google Ads &mdash; Podsumowanie</h2>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">Wyświetlenia</div>
<div class="kpi-value">181 763<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -12.6% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Kliknięcia</div>
<div class="kpi-value">4 628<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -27.0% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">CTR</div>
<div class="kpi-value">2.5<span class="kpi-unit">%</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -16.4% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Konwersje</div>
<div class="kpi-value">214<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -30.2% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Koszt</div>
<div class="kpi-value">3788.97<span class="kpi-unit"> PLN</span></div>
<div class="kpi-change" style="color: #27ae60">
&#9660; -0.3% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">CPA</div>
<div class="kpi-value">17.63<span class="kpi-unit"> PLN</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9650; +42.8% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">ROAS</div>
<div class="kpi-value">9.50<span class="kpi-unit">x</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -30.7% vs Styczeń
</div>
</div></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>Koszt</th>
<th>CPA</th>
</tr>
</thead>
<tbody>
<tr>
<td>[Search] brand</td>
<td><span class="badge badge-search">SEARCH</span></td>
<td class="num">1 572</td>
<td class="num">495</td>
<td class="num">31.5%</td>
<td class="num">24</td>
<td class="num">430.53 PLN</td>
<td class="num">17.94 PLN</td>
</tr>
<tr>
<td>[DSA] produkty</td>
<td><span class="badge badge-search">SEARCH</span></td>
<td class="num">16 608</td>
<td class="num">1 208</td>
<td class="num">7.3%</td>
<td class="num">12</td>
<td class="num">445.16 PLN</td>
<td class="num">37.41 PLN</td>
</tr>
<tr>
<td>[PMax] products (catch-all)</td>
<td><span class="badge badge-performance_max">PERFORMANCE_MAX</span></td>
<td class="num">158 661</td>
<td class="num">2 886</td>
<td class="num">1.8%</td>
<td class="num">178</td>
<td class="num">2828.52 PLN</td>
<td class="num">15.89 PLN</td>
</tr>
<tr>
<td>[PLA] produkty (bestsellers)</td>
<td><span class="badge badge-shopping">SHOPPING</span></td>
<td class="num">4 922</td>
<td class="num">39</td>
<td class="num">0.8%</td>
<td class="num">1</td>
<td class="num">84.76 PLN</td>
<td class="num">84.76 PLN</td>
</tr></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>
<tr>
<td class="num">1</td>
<td>aruba rzeszów</td>
<td class="num">770</td>
<td class="num">251</td>
<td class="num">32.6%</td>
<td class="num">11</td>
</tr>
<tr>
<td class="num">2</td>
<td>aruba hurtownia</td>
<td class="num">113</td>
<td class="num">45</td>
<td class="num">39.8%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">3</td>
<td>onygen krem</td>
<td class="num">1 114</td>
<td class="num">34</td>
<td class="num">3.0%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">4</td>
<td>aruba rzeszow</td>
<td class="num">117</td>
<td class="num">29</td>
<td class="num">24.8%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">5</td>
<td>aruba sklep</td>
<td class="num">54</td>
<td class="num">29</td>
<td class="num">53.7%</td>
<td class="num">3</td>
</tr>
<tr>
<td class="num">6</td>
<td>makijaż permanentny brwi</td>
<td class="num">217</td>
<td class="num">20</td>
<td class="num">9.2%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">7</td>
<td>autoklaw</td>
<td class="num">98</td>
<td class="num">18</td>
<td class="num">18.4%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">8</td>
<td>brwi permanentne</td>
<td class="num">231</td>
<td class="num">18</td>
<td class="num">7.8%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">9</td>
<td>aruba kosmetyki</td>
<td class="num">30</td>
<td class="num">15</td>
<td class="num">50.0%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">10</td>
<td>hurtownia aruba</td>
<td class="num">52</td>
<td class="num">14</td>
<td class="num">26.9%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">11</td>
<td>radiofrekwencja mikroigłowa</td>
<td class="num">342</td>
<td class="num">14</td>
<td class="num">4.1%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">12</td>
<td>hurtownia aruba rzeszów</td>
<td class="num">48</td>
<td class="num">13</td>
<td class="num">27.1%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">13</td>
<td>pielęgnacja brwi po makijażu permanentnym</td>
<td class="num">85</td>
<td class="num">13</td>
<td class="num">15.3%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">14</td>
<td>gen factor</td>
<td class="num">236</td>
<td class="num">11</td>
<td class="num">4.7%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">15</td>
<td>aruba hurtownia kosmetyczna</td>
<td class="num">22</td>
<td class="num">10</td>
<td class="num">45.5%</td>
<td class="num">2</td>
</tr></tbody>
</table>
</section>
<!-- NEGATIVE KEYWORDS ADDED -->
<!-- SEMSTORM SEO -->
<!-- SEO ACTIVITIES -->
<!-- SEO LINKS -->
<!-- SUMMARY -->
<section class="report-section" id="summary">
<h2 class="section-title">Podsumowanie miesiąca</h2>
<div class="summary-box">
Odnotowano 214 konwersji w tym miesiącu.
</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: ["02-01", "02-02", "02-03", "02-04", "02-05", "02-06", "02-07", "02-08", "02-09", "02-10", "02-11", "02-12", "02-13", "02-14", "02-15", "02-16", "02-17", "02-18", "02-19", "02-20", "02-21", "02-22", "02-23", "02-24", "02-25", "02-26", "02-27", "02-28"],
datasets: [{
label: 'Kliknięcia',
data: [210, 164, 188, 242, 204, 198, 163, 208, 202, 206, 193, 169, 153, 113, 139, 174, 148, 137, 112, 143, 139, 151, 160, 184, 157, 141, 141, 89],
borderColor: '#0d8b8b',
backgroundColor: 'rgba(13,139,139,0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#0d8b8b',
}, {
label: 'Wyświetlenia',
data: [7761, 8752, 6894, 6890, 7048, 8251, 6007, 8393, 6761, 8531, 6071, 5122, 6360, 4092, 5897, 6193, 6761, 6894, 5773, 6152, 6529, 5916, 7070, 7262, 6054, 4538, 5064, 4727],
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 } }
}
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,716 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Raport Kwiecień 2026 &mdash; Aruba Rzeszow</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">Aruba Rzeszow &mdash; Kwiecień 2026</div>
</div>
</header>
<!-- RECOMMENDATIONS -->
<section class="report-section" id="recommendations">
<h2 class="section-title">Wnioski i rekomendacje</h2>
<div class="recommendations-list">
<div class="rec-item"><span class="rec-icon">&#9888;</span><div><strong>Spadek konwersji do obserwacji</strong><p>Liczba konwersji spadla o 8.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu.</p></div></div><div class="rec-item"><span class="rec-icon">&#128200;</span><div><strong>ROAS liczony z Google Ads</strong><p>ROAS z Google Ads wyniosl 8.47. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu.</p></div></div><div class="rec-item"><span class="rec-icon">&#128269;</span><div><strong>Kontrola wzrostu kosztu</strong><p>Koszt reklam wzrosl o 12.2% miesiac do miesiaca. Warto porownac wzrost kosztu ze wzrostem konwersji i wartosci konwersji.</p></div></div>
</div>
</section>
<!-- GA4 SECTION -->
<!-- E-COMMERCE -->
<!-- PRODUCT OPTIMIZATIONS -->
<!-- TOP ADS PRODUCTS -->
<!-- YEAR OVER YEAR -->
<!-- GOOGLE ADS KPIs -->
<section class="report-section" id="ads-kpi">
<h2 class="section-title">Google Ads &mdash; Podsumowanie</h2>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">Wyświetlenia</div>
<div class="kpi-value">172 277<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -0.6% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Kliknięcia</div>
<div class="kpi-value">3 826<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #27ae60">
&#9650; +2.5% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">CTR</div>
<div class="kpi-value">2.2<span class="kpi-unit">%</span></div>
<div class="kpi-change" style="color: #27ae60">
&#9650; +3.3% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Konwersje</div>
<div class="kpi-value">199<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -8.2% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Koszt</div>
<div class="kpi-value">4880.74<span class="kpi-unit"> PLN</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9650; +12.2% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">CPA</div>
<div class="kpi-value">24.46<span class="kpi-unit"> PLN</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9650; +22.2% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">ROAS</div>
<div class="kpi-value">8.47<span class="kpi-unit">x</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -1.5% vs Marzec
</div>
</div></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>Koszt</th>
<th>CPA</th>
</tr>
</thead>
<tbody>
<tr>
<td>[Search] brand</td>
<td><span class="badge badge-search">SEARCH</span></td>
<td class="num">1 614</td>
<td class="num">483</td>
<td class="num">29.9%</td>
<td class="num">27</td>
<td class="num">337.96 PLN</td>
<td class="num">12.52 PLN</td>
</tr>
<tr>
<td>[DSA] produkty</td>
<td><span class="badge badge-search">SEARCH</span></td>
<td class="num">9 984</td>
<td class="num">694</td>
<td class="num">7.0%</td>
<td class="num">23</td>
<td class="num">1098.15 PLN</td>
<td class="num">47.75 PLN</td>
</tr>
<tr>
<td>[PMax] products (catch-all)</td>
<td><span class="badge badge-performance_max">PERFORMANCE_MAX</span></td>
<td class="num">138 921</td>
<td class="num">2 164</td>
<td class="num">1.6%</td>
<td class="num">106</td>
<td class="num">2762.99 PLN</td>
<td class="num">25.94 PLN</td>
</tr>
<tr>
<td>[PLA] produkty (bestsellers)</td>
<td><span class="badge badge-shopping">SHOPPING</span></td>
<td class="num">21 758</td>
<td class="num">485</td>
<td class="num">2.2%</td>
<td class="num">43</td>
<td class="num">681.64 PLN</td>
<td class="num">15.85 PLN</td>
</tr></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>
<tr>
<td class="num">1</td>
<td>aruba rzeszów</td>
<td class="num">836</td>
<td class="num">246</td>
<td class="num">29.4%</td>
<td class="num">16</td>
</tr>
<tr>
<td class="num">2</td>
<td>gen factor</td>
<td class="num">858</td>
<td class="num">59</td>
<td class="num">6.9%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">3</td>
<td>aruba hurtownia</td>
<td class="num">122</td>
<td class="num">48</td>
<td class="num">39.3%</td>
<td class="num">4</td>
</tr>
<tr>
<td class="num">4</td>
<td>aruba rzeszow</td>
<td class="num">127</td>
<td class="num">39</td>
<td class="num">30.7%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">5</td>
<td>gen factor green</td>
<td class="num">207</td>
<td class="num">21</td>
<td class="num">10.1%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">6</td>
<td>gen factor</td>
<td class="num">604</td>
<td class="num">21</td>
<td class="num">3.5%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">7</td>
<td>verru immuno</td>
<td class="num">495</td>
<td class="num">19</td>
<td class="num">3.8%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">8</td>
<td>aruba sklep</td>
<td class="num">48</td>
<td class="num">17</td>
<td class="num">35.4%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">9</td>
<td>aurumaris</td>
<td class="num">113</td>
<td class="num">13</td>
<td class="num">11.5%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">10</td>
<td>aruba hurtownia kosmetyczna</td>
<td class="num">25</td>
<td class="num">12</td>
<td class="num">48.0%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">11</td>
<td>aruba kosmetyki</td>
<td class="num">33</td>
<td class="num">12</td>
<td class="num">36.4%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">12</td>
<td>gen factor 09</td>
<td class="num">47</td>
<td class="num">11</td>
<td class="num">23.4%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">13</td>
<td>genfactor</td>
<td class="num">111</td>
<td class="num">11</td>
<td class="num">9.9%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">14</td>
<td>podopharm verru immuno</td>
<td class="num">230</td>
<td class="num">11</td>
<td class="num">4.8%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">15</td>
<td>hurtownia aruba</td>
<td class="num">32</td>
<td class="num">10</td>
<td class="num">31.2%</td>
<td class="num">0</td>
</tr></tbody>
</table>
</section>
<!-- NEGATIVE KEYWORDS ADDED -->
<!-- SEMSTORM SEO -->
<!-- SEO ACTIVITIES -->
<!-- SEO LINKS -->
<!-- SUMMARY -->
<section class="report-section" id="summary">
<h2 class="section-title">Podsumowanie miesiąca</h2>
<div class="summary-box">
Odnotowano 199 konwersji w tym miesiącu. Ruch z reklam wzrósł o 2.5% (3826 kliknięć).
</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: ["04-01", "04-02", "04-03", "04-04", "04-05", "04-06", "04-07", "04-08", "04-09", "04-10", "04-11", "04-12", "04-13", "04-14", "04-15", "04-16", "04-17", "04-18", "04-19", "04-20", "04-21", "04-22", "04-23", "04-24", "04-25", "04-26", "04-27", "04-28", "04-29", "04-30"],
datasets: [{
label: 'Kliknięcia',
data: [102, 108, 72, 54, 39, 96, 130, 166, 137, 112, 95, 114, 185, 176, 164, 149, 107, 101, 114, 196, 163, 210, 170, 116, 112, 131, 144, 132, 135, 96],
borderColor: '#0d8b8b',
backgroundColor: 'rgba(13,139,139,0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#0d8b8b',
}, {
label: 'Wyświetlenia',
data: [6909, 5632, 4210, 3045, 2088, 3976, 5853, 7519, 6605, 4340, 3177, 4104, 7332, 7941, 7296, 6191, 4557, 3621, 5409, 7762, 7615, 9246, 9234, 5931, 5078, 5786, 6014, 6078, 5629, 4099],
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 } }
}
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,412 @@
{
"client": "aruba.rzeszow.pl",
"month": "2026-02",
"month_name": "Luty",
"year": 2026,
"prev_month": "2026-01",
"prev_month_name": "Styczeń",
"generated_at": "2026-05-14T23:27:24.133206",
"google_ads": {
"campaigns": [
{
"id": "19591441631",
"name": "[Search] brand",
"status": "ENABLED",
"type": "SEARCH",
"impressions": 1572,
"clicks": 495,
"cost": 430.53,
"conversions": 24.0,
"conversion_value": 5500.26,
"ctr": 31.49,
"cpc": 0.87,
"cpa": 17.94,
"roas": 12.78
},
{
"id": "20561423980",
"name": "[DSA] produkty",
"status": "ENABLED",
"type": "SEARCH",
"impressions": 16608,
"clicks": 1208,
"cost": 445.16,
"conversions": 11.9,
"conversion_value": 3113.12,
"ctr": 7.27,
"cpc": 0.37,
"cpa": 37.41,
"roas": 6.99
},
{
"id": "21260050298",
"name": "[PMax] products (catch-all)",
"status": "ENABLED",
"type": "PERFORMANCE_MAX",
"impressions": 158661,
"clicks": 2886,
"cost": 2828.52,
"conversions": 178.0,
"conversion_value": 27308.33,
"ctr": 1.82,
"cpc": 0.98,
"cpa": 15.89,
"roas": 9.65
},
{
"id": "22926581178",
"name": "[PLA] produkty (bestsellers)",
"status": "ENABLED",
"type": "SHOPPING",
"impressions": 4922,
"clicks": 39,
"cost": 84.76,
"conversions": 1.0,
"conversion_value": 90.0,
"ctr": 0.79,
"cpc": 2.17,
"cpa": 84.76,
"roas": 1.06
}
],
"totals": {
"impressions": 181763,
"clicks": 4628,
"cost": 3788.97,
"conversions": 214.9,
"conversion_value": 36011.71,
"ctr": 2.55,
"cpc": 0.82,
"cpa": 17.63,
"roas": 9.5
},
"prev_totals": {
"impressions": 208079,
"clicks": 6338,
"cost": 3801.39,
"conversions": 307.7,
"conversion_value": 52085.85,
"ctr": 3.05,
"cpc": 0.6,
"cpa": 12.35,
"roas": 13.7
},
"mom_change": {
"impressions_pct": -12.6,
"clicks_pct": -27.0,
"cost_pct": -0.3,
"conversions_pct": -30.2,
"ctr_pct": -16.4,
"cpc_pct": 36.7,
"cpa_pct": 42.8
},
"daily": [
{
"date": "2026-02-01",
"impressions": 7761,
"clicks": 210,
"cost": 132.82
},
{
"date": "2026-02-02",
"impressions": 8752,
"clicks": 164,
"cost": 139.71
},
{
"date": "2026-02-03",
"impressions": 6894,
"clicks": 188,
"cost": 142.18
},
{
"date": "2026-02-04",
"impressions": 6890,
"clicks": 242,
"cost": 162.52
},
{
"date": "2026-02-05",
"impressions": 7048,
"clicks": 204,
"cost": 128.61
},
{
"date": "2026-02-06",
"impressions": 8251,
"clicks": 198,
"cost": 159.4
},
{
"date": "2026-02-07",
"impressions": 6007,
"clicks": 163,
"cost": 106.12
},
{
"date": "2026-02-08",
"impressions": 8393,
"clicks": 208,
"cost": 166.05
},
{
"date": "2026-02-09",
"impressions": 6761,
"clicks": 202,
"cost": 128.55
},
{
"date": "2026-02-10",
"impressions": 8531,
"clicks": 206,
"cost": 155.46
},
{
"date": "2026-02-11",
"impressions": 6071,
"clicks": 193,
"cost": 159.92
},
{
"date": "2026-02-12",
"impressions": 5122,
"clicks": 169,
"cost": 96.72
},
{
"date": "2026-02-13",
"impressions": 6360,
"clicks": 153,
"cost": 158.77
},
{
"date": "2026-02-14",
"impressions": 4092,
"clicks": 113,
"cost": 99.1
},
{
"date": "2026-02-15",
"impressions": 5897,
"clicks": 139,
"cost": 168.71
},
{
"date": "2026-02-16",
"impressions": 6193,
"clicks": 174,
"cost": 155.53
},
{
"date": "2026-02-17",
"impressions": 6761,
"clicks": 148,
"cost": 162.05
},
{
"date": "2026-02-18",
"impressions": 6894,
"clicks": 137,
"cost": 116.89
},
{
"date": "2026-02-19",
"impressions": 5773,
"clicks": 112,
"cost": 161.77
},
{
"date": "2026-02-20",
"impressions": 6152,
"clicks": 143,
"cost": 119.62
},
{
"date": "2026-02-21",
"impressions": 6529,
"clicks": 139,
"cost": 117.97
},
{
"date": "2026-02-22",
"impressions": 5916,
"clicks": 151,
"cost": 150.73
},
{
"date": "2026-02-23",
"impressions": 7070,
"clicks": 160,
"cost": 140.72
},
{
"date": "2026-02-24",
"impressions": 7262,
"clicks": 184,
"cost": 158.14
},
{
"date": "2026-02-25",
"impressions": 6054,
"clicks": 157,
"cost": 121.12
},
{
"date": "2026-02-26",
"impressions": 4538,
"clicks": 141,
"cost": 103.38
},
{
"date": "2026-02-27",
"impressions": 5064,
"clicks": 141,
"cost": 105.53
},
{
"date": "2026-02-28",
"impressions": 4727,
"clicks": 89,
"cost": 70.88
}
],
"search_terms": [
{
"term": "aruba rzeszów",
"impressions": 770,
"clicks": 251,
"cost": 178.38,
"conversions": 11.1,
"ctr": 32.6
},
{
"term": "aruba hurtownia",
"impressions": 113,
"clicks": 45,
"cost": 23.81,
"conversions": 2.0,
"ctr": 39.82
},
{
"term": "onygen krem",
"impressions": 1114,
"clicks": 34,
"cost": 25.49,
"conversions": 1.0,
"ctr": 3.05
},
{
"term": "aruba rzeszow",
"impressions": 117,
"clicks": 29,
"cost": 34.21,
"conversions": 2.0,
"ctr": 24.79
},
{
"term": "aruba sklep",
"impressions": 54,
"clicks": 29,
"cost": 15.73,
"conversions": 3.0,
"ctr": 53.7
},
{
"term": "makijaż permanentny brwi",
"impressions": 217,
"clicks": 20,
"cost": 4.64,
"conversions": 0.0,
"ctr": 9.22
},
{
"term": "autoklaw",
"impressions": 98,
"clicks": 18,
"cost": 4.79,
"conversions": 0.0,
"ctr": 18.37
},
{
"term": "brwi permanentne",
"impressions": 231,
"clicks": 18,
"cost": 4.37,
"conversions": 0.0,
"ctr": 7.79
},
{
"term": "aruba kosmetyki",
"impressions": 30,
"clicks": 15,
"cost": 14.3,
"conversions": 0.0,
"ctr": 50.0
},
{
"term": "hurtownia aruba",
"impressions": 52,
"clicks": 14,
"cost": 11.64,
"conversions": 0.0,
"ctr": 26.92
},
{
"term": "radiofrekwencja mikroigłowa",
"impressions": 342,
"clicks": 14,
"cost": 3.33,
"conversions": 0.0,
"ctr": 4.09
},
{
"term": "hurtownia aruba rzeszów",
"impressions": 48,
"clicks": 13,
"cost": 11.53,
"conversions": 1.0,
"ctr": 27.08
},
{
"term": "pielęgnacja brwi po makijażu permanentnym",
"impressions": 85,
"clicks": 13,
"cost": 1.95,
"conversions": 0.0,
"ctr": 15.29
},
{
"term": "gen factor",
"impressions": 236,
"clicks": 11,
"cost": 4.29,
"conversions": 0.0,
"ctr": 4.66
},
{
"term": "aruba hurtownia kosmetyczna",
"impressions": 22,
"clicks": 10,
"cost": 5.8,
"conversions": 2.0,
"ctr": 45.45
}
]
},
"ga4": null,
"semstorm": null,
"sales_history": [],
"seo_links": [],
"recommendations": [
{
"icon": "&#9888;",
"title": "Spadek konwersji do obserwacji",
"text": "Liczba konwersji spadla o 30.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu."
},
{
"icon": "&#128200;",
"title": "ROAS liczony z Google Ads",
"text": "ROAS z Google Ads wyniosl 9.50. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu."
}
]
}

View File

@@ -0,0 +1,429 @@
{
"client": "aruba.rzeszow.pl",
"month": "2026-04",
"month_name": "Kwiecień",
"year": 2026,
"prev_month": "2026-03",
"prev_month_name": "Marzec",
"generated_at": "2026-05-14T23:23:53.496703",
"google_ads": {
"campaigns": [
{
"id": "19591441631",
"name": "[Search] brand",
"status": "ENABLED",
"type": "SEARCH",
"impressions": 1614,
"clicks": 483,
"cost": 337.96,
"conversions": 27.0,
"conversion_value": 7967.63,
"ctr": 29.93,
"cpc": 0.7,
"cpa": 12.52,
"roas": 23.58
},
{
"id": "20561423980",
"name": "[DSA] produkty",
"status": "ENABLED",
"type": "SEARCH",
"impressions": 9984,
"clicks": 694,
"cost": 1098.15,
"conversions": 23.0,
"conversion_value": 6600.7,
"ctr": 6.95,
"cpc": 1.58,
"cpa": 47.75,
"roas": 6.01
},
{
"id": "21260050298",
"name": "[PMax] products (catch-all)",
"status": "ENABLED",
"type": "PERFORMANCE_MAX",
"impressions": 138921,
"clicks": 2164,
"cost": 2762.99,
"conversions": 106.5,
"conversion_value": 19390.88,
"ctr": 1.56,
"cpc": 1.28,
"cpa": 25.94,
"roas": 7.02
},
{
"id": "22926581178",
"name": "[PLA] produkty (bestsellers)",
"status": "ENABLED",
"type": "SHOPPING",
"impressions": 21758,
"clicks": 485,
"cost": 681.64,
"conversions": 43.0,
"conversion_value": 7367.07,
"ctr": 2.23,
"cpc": 1.41,
"cpa": 15.85,
"roas": 10.81
}
],
"totals": {
"impressions": 172277,
"clicks": 3826,
"cost": 4880.74,
"conversions": 199.5,
"conversion_value": 41326.28,
"ctr": 2.22,
"cpc": 1.28,
"cpa": 24.46,
"roas": 8.47
},
"prev_totals": {
"impressions": 173273,
"clicks": 3733,
"cost": 4351.39,
"conversions": 217.4,
"conversion_value": 37429.84,
"ctr": 2.15,
"cpc": 1.17,
"cpa": 20.02,
"roas": 8.6
},
"mom_change": {
"impressions_pct": -0.6,
"clicks_pct": 2.5,
"cost_pct": 12.2,
"conversions_pct": -8.2,
"ctr_pct": 3.3,
"cpc_pct": 9.4,
"cpa_pct": 22.2
},
"daily": [
{
"date": "2026-04-01",
"impressions": 6909,
"clicks": 102,
"cost": 120.77
},
{
"date": "2026-04-02",
"impressions": 5632,
"clicks": 108,
"cost": 167.66
},
{
"date": "2026-04-03",
"impressions": 4210,
"clicks": 72,
"cost": 95.19
},
{
"date": "2026-04-04",
"impressions": 3045,
"clicks": 54,
"cost": 101.11
},
{
"date": "2026-04-05",
"impressions": 2088,
"clicks": 39,
"cost": 49.2
},
{
"date": "2026-04-06",
"impressions": 3976,
"clicks": 96,
"cost": 112.82
},
{
"date": "2026-04-07",
"impressions": 5853,
"clicks": 130,
"cost": 138.02
},
{
"date": "2026-04-08",
"impressions": 7519,
"clicks": 166,
"cost": 225.05
},
{
"date": "2026-04-09",
"impressions": 6605,
"clicks": 137,
"cost": 165.58
},
{
"date": "2026-04-10",
"impressions": 4340,
"clicks": 112,
"cost": 130.39
},
{
"date": "2026-04-11",
"impressions": 3177,
"clicks": 95,
"cost": 92.74
},
{
"date": "2026-04-12",
"impressions": 4104,
"clicks": 114,
"cost": 116.26
},
{
"date": "2026-04-13",
"impressions": 7332,
"clicks": 185,
"cost": 201.76
},
{
"date": "2026-04-14",
"impressions": 7941,
"clicks": 176,
"cost": 232.59
},
{
"date": "2026-04-15",
"impressions": 7296,
"clicks": 164,
"cost": 186.57
},
{
"date": "2026-04-16",
"impressions": 6191,
"clicks": 149,
"cost": 165.26
},
{
"date": "2026-04-17",
"impressions": 4557,
"clicks": 107,
"cost": 95.56
},
{
"date": "2026-04-18",
"impressions": 3621,
"clicks": 101,
"cost": 118.02
},
{
"date": "2026-04-19",
"impressions": 5409,
"clicks": 114,
"cost": 175.25
},
{
"date": "2026-04-20",
"impressions": 7762,
"clicks": 196,
"cost": 239.2
},
{
"date": "2026-04-21",
"impressions": 7615,
"clicks": 163,
"cost": 262.91
},
{
"date": "2026-04-22",
"impressions": 9246,
"clicks": 210,
"cost": 265.25
},
{
"date": "2026-04-23",
"impressions": 9234,
"clicks": 170,
"cost": 222.45
},
{
"date": "2026-04-24",
"impressions": 5931,
"clicks": 116,
"cost": 202.37
},
{
"date": "2026-04-25",
"impressions": 5078,
"clicks": 112,
"cost": 174.69
},
{
"date": "2026-04-26",
"impressions": 5786,
"clicks": 131,
"cost": 162.94
},
{
"date": "2026-04-27",
"impressions": 6014,
"clicks": 144,
"cost": 191.42
},
{
"date": "2026-04-28",
"impressions": 6078,
"clicks": 132,
"cost": 181.99
},
{
"date": "2026-04-29",
"impressions": 5629,
"clicks": 135,
"cost": 166.02
},
{
"date": "2026-04-30",
"impressions": 4099,
"clicks": 96,
"cost": 121.72
}
],
"search_terms": [
{
"term": "aruba rzeszów",
"impressions": 836,
"clicks": 246,
"cost": 131.67,
"conversions": 16.0,
"ctr": 29.43
},
{
"term": "gen factor",
"impressions": 858,
"clicks": 59,
"cost": 134.33,
"conversions": 1.0,
"ctr": 6.88
},
{
"term": "aruba hurtownia",
"impressions": 122,
"clicks": 48,
"cost": 26.45,
"conversions": 4.0,
"ctr": 39.34
},
{
"term": "aruba rzeszow",
"impressions": 127,
"clicks": 39,
"cost": 24.46,
"conversions": 0.0,
"ctr": 30.71
},
{
"term": "gen factor green",
"impressions": 207,
"clicks": 21,
"cost": 46.65,
"conversions": 2.0,
"ctr": 10.14
},
{
"term": "gen factor",
"impressions": 604,
"clicks": 21,
"cost": 25.05,
"conversions": 1.0,
"ctr": 3.48
},
{
"term": "verru immuno",
"impressions": 495,
"clicks": 19,
"cost": 27.24,
"conversions": 2.0,
"ctr": 3.84
},
{
"term": "aruba sklep",
"impressions": 48,
"clicks": 17,
"cost": 3.4,
"conversions": 1.0,
"ctr": 35.42
},
{
"term": "aurumaris",
"impressions": 113,
"clicks": 13,
"cost": 14.52,
"conversions": 0.0,
"ctr": 11.5
},
{
"term": "aruba hurtownia kosmetyczna",
"impressions": 25,
"clicks": 12,
"cost": 7.85,
"conversions": 1.0,
"ctr": 48.0
},
{
"term": "aruba kosmetyki",
"impressions": 33,
"clicks": 12,
"cost": 3.27,
"conversions": 1.0,
"ctr": 36.36
},
{
"term": "gen factor 09",
"impressions": 47,
"clicks": 11,
"cost": 15.43,
"conversions": 0.0,
"ctr": 23.4
},
{
"term": "genfactor",
"impressions": 111,
"clicks": 11,
"cost": 27.84,
"conversions": 2.0,
"ctr": 9.91
},
{
"term": "podopharm verru immuno",
"impressions": 230,
"clicks": 11,
"cost": 15.75,
"conversions": 2.0,
"ctr": 4.78
},
{
"term": "hurtownia aruba",
"impressions": 32,
"clicks": 10,
"cost": 7.31,
"conversions": 0.0,
"ctr": 31.25
}
]
},
"ga4": null,
"semstorm": null,
"sales_history": [],
"seo_links": [],
"recommendations": [
{
"icon": "&#9888;",
"title": "Spadek konwersji do obserwacji",
"text": "Liczba konwersji spadla o 8.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu."
},
{
"icon": "&#128200;",
"title": "ROAS liczony z Google Ads",
"text": "ROAS z Google Ads wyniosl 8.47. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu."
},
{
"icon": "&#128269;",
"title": "Kontrola wzrostu kosztu",
"text": "Koszt reklam wzrosl o 12.2% miesiac do miesiaca. Warto porownac wzrost kosztu ze wzrostem konwersji i wartosci konwersji."
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
{
"source": "agent_ai",
"instruction": "Uzupelnia agent AI po analizie danych raportu. Skrypt nie powinien sam generowac wnioskow ani rekomendacji.",
"context": {
"google_ads_totals": {
"cost": 6705.35,
"clicks": 4339,
"conversions": 641.8,
"conversion_value": 72679.4,
"roas": 10.84,
"cpa": 10.45
},
"google_ads_mom_change": {
"cost_pct": 8.2,
"clicks_pct": 1.7,
"conversions_pct": 7.9,
"conversion_value_pct": 0,
"roas_pct": 0,
"cpa_pct": 0.4
},
"ga4_ecommerce": {
"transactions": 1711,
"revenue": 187795.28,
"transactions_pct": 0,
"revenue_pct": 0
},
"top_campaigns_by_cost": [
{
"name": "[Search] brand",
"cost": 2712.17,
"conversions": 298.2,
"conversion_value": 34878.62,
"roas": 12.86
},
{
"name": "[PMax] products (catch-all)",
"cost": 2263.02,
"conversions": 255.0,
"conversion_value": 29204.43,
"roas": 12.91
},
{
"name": "[PLA] produkty (bestsellers)",
"cost": 607.74,
"conversions": 70.5,
"conversion_value": 7193.01,
"roas": 11.84
},
{
"name": "[DG] YouTube Shorts",
"cost": 502.01,
"conversions": 7.3,
"conversion_value": 725.68,
"roas": 1.45
},
{
"name": "[GDN] porzucone koszyki",
"cost": 304.26,
"conversions": 5.8,
"conversion_value": 310.57,
"roas": 1.02
}
]
},
"recommendations": [
{
"icon": "&#9989;",
"title": "Google Ads utrzymuje bardzo dobrą rentowność",
"text": "Konto wygenerowało 641,8 konwersji przy koszcie 6705,35 PLN i ROAS 10,84. Utrzymujemy aktywne kampanie sprzedażowe, a dalsze zwiększanie budżetu prowadzimy sekwencyjnie, przede wszystkim w kampaniach z ROAS powyżej średniej konta."
},
{
"icon": "&#128200;",
"title": "Więcej konwersji przy prawie stabilnym koszcie pozyskania",
"text": "W porównaniu miesiąc do miesiąca konwersje wzrosły o 7,9%, koszt o 8,2%, a CPA tylko o 0,4%. Skala rosła bez widocznego pogorszenia kosztu pozyskania, dlatego nie tniemy budżetu całościowo. Pracujemy na miksie kampanii i przesuwamy uwagę na te segmenty, które utrzymują rentowność."
},
{
"icon": "&#128269;",
"title": "Brand i PMax niosą główny wynik",
"text": "Największą część kosztu i wartości konwersji generują [Search] brand oraz [PMax] products (catch-all). Obie kampanie mają ROAS około 12,9, dlatego zostają główne w strukturze. Zmiany celów ROAS lub budżetów wprowadzamy stopniowo i kontrolujemy wolumen po każdej zmianie."
},
{
"icon": "&#9888;",
"title": "Kampanie z niskim ROAS wymagają osobnej decyzji",
"text": "[DG] YouTube Shorts i wybrane kampanie PLA_CL1 mają wyraźnie niższy ROAS niż średnia konta. Nie wyłączamy ich automatycznie tylko na podstawie tego raportu. Rozdzielamy ich role na wsparcie lejka i realną sprzedaż, a przy celu czystej efektywności ograniczamy lub zawężamy je w pierwszej kolejności."
},
{
"icon": "&#128176;",
"title": "Sprzedaż sklepu jest mocniejsza w danych z arkusza",
"text": "Arkusz sprzedażowy pokazuje 1711 transakcji i 187795,28 PLN przychodu w kwietniu. Średnia wartość koszyka wynosi 109,76 PLN, dlatego w komunikacji i kampaniach wzmacniamy produkty oraz zestawy, które podnoszą wartość zamówienia, zamiast skupiać się wyłącznie na liczbie transakcji."
},
{
"icon": "&#10148;",
"title": "Rekomendowany następny krok",
"text": "Utrzymujemy główny kierunek konta, a optymalizacje prowadzimy punktowo: kontrolujemy kampanie o niskim ROAS, analizujemy udział brandu w wyniku i porównujemy PMax z PLA pod kątem produktów, które można efektywniej skalować. Zmiany budżetów i celów Smart Bidding wdrażamy pojedynczo, z oceną po kolejnej paczce danych."
}
]
}

View File

@@ -0,0 +1,40 @@
"""Quick test: check if OAuth credentials can access GA4 property."""
import os
from dotenv import load_dotenv
from google.oauth2.credentials import Credentials
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import RunReportRequest, DateRange, Metric
load_dotenv()
PROPERTY_ID = os.getenv("GA4_PROPERTY_ID_studio-zoe.pl")
CLIENT_ID = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_ID")
CLIENT_SECRET = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_SECRET")
REFRESH_TOKEN = os.getenv("GA4_REFRESH_TOKEN")
print(f"GA4 Property ID: {PROPERTY_ID}")
credentials = Credentials(
token=None,
refresh_token=REFRESH_TOKEN,
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
token_uri="https://oauth2.googleapis.com/token",
)
client = BetaAnalyticsDataClient(credentials=credentials)
try:
request = RunReportRequest(
property=f"properties/{PROPERTY_ID}",
date_ranges=[DateRange(start_date="2026-02-01", end_date="2026-02-28")],
metrics=[Metric(name="sessions"), Metric(name="totalUsers")],
)
response = client.run_report(request)
for row in response.rows:
print(f"Sessions: {row.metric_values[0].value}, Users: {row.metric_values[1].value}")
print("\nGA4 access works!")
except Exception as e:
print(f"\nGA4 access failed: {e}")

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Upload raportu HTML na serwer FTP (adspro.projectpro.pl).
Użycie:
python scripts/reports/upload_report_ftp.py --local-dir output/studio-zoe.pl/2026-02/ --remote-path /raporty/studio-zoe/2026-02/
"""
import argparse
import ftplib
import os
import sys
import io
from pathlib import Path
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
from src.gads_v2.config import load_env
load_env(ROOT / ".env")
def ftp_mkdirs(ftp, path):
"""Create nested directories on FTP server."""
dirs = path.strip("/").split("/")
current = ""
for d in dirs:
current += f"/{d}"
try:
ftp.cwd(current)
except ftplib.error_perm:
try:
ftp.mkd(current)
print(f" Utworzono katalog: {current}")
except ftplib.error_perm:
pass # already exists or no permission
def main():
parser = argparse.ArgumentParser(description="Upload raportu na FTP")
parser.add_argument("--local-dir", required=True, help="Lokalny folder z raportem")
parser.add_argument("--remote-path", required=True, help="Sciezka na serwerze (np. /raporty/studio-zoe/2026-02/)")
args = parser.parse_args()
host = os.environ["ADSPRO_HOST"]
user = os.environ["ADSPRO_USERNAME"]
password = os.environ["ADSPRO_PASSWORD"]
base_path = os.environ["ADSPRO_REMOTE_PATH"]
local_dir = Path(args.local_dir)
if not local_dir.is_absolute():
local_dir = ROOT / "scripts" / "reports" / local_dir
if not local_dir.exists():
print(f"Blad: folder {local_dir} nie istnieje")
sys.exit(1)
# Fix Git Bash path mangling on Windows (e.g. /raporty -> C:/Program Files/Git/raporty)
remote_path = args.remote_path
if "Program Files/Git" in remote_path or ":" in remote_path:
# Extract the intended path after the Git prefix
import re
match = re.search(r'/raporty/.+', remote_path)
if match:
remote_path = match.group(0)
else:
remote_path = "/" + remote_path.split("/")[-3] + "/" + remote_path.split("/")[-2] + "/" + remote_path.split("/")[-1]
remote_full = base_path.rstrip("/") + "/" + remote_path.strip("/")
print(f"Laczenie z {host}...")
# Try FTP_TLS first, fallback to plain FTP
try:
ftp = ftplib.FTP_TLS(host, timeout=30)
ftp.login(user, password)
ftp.prot_p()
print(" Polaczono (FTP TLS)")
except Exception:
ftp = ftplib.FTP(host, timeout=30)
ftp.login(user, password)
print(" Polaczono (FTP)")
ftp.encoding = "utf-8"
# Create remote directory structure
print(f"Tworzenie katalogow: {remote_full}")
ftp_mkdirs(ftp, remote_full)
ftp.cwd(remote_full)
# Upload all files
files_uploaded = 0
for file_path in local_dir.rglob("*"):
if file_path.is_file():
relative = file_path.relative_to(local_dir)
remote_file = str(relative).replace("\\", "/")
# Create subdirectories if needed
if "/" in remote_file:
subdir = "/".join(remote_file.split("/")[:-1])
ftp_mkdirs(ftp, remote_full + "/" + subdir)
ftp.cwd(remote_full)
print(f" Uploading: {remote_file} ({file_path.stat().st_size:,} bytes)")
with open(file_path, "rb") as f:
ftp.storbinary(f"STOR {remote_file}", f)
files_uploaded += 1
ftp.quit()
domain_part = args.remote_path.strip("/")
url = f"https://adspro.projectpro.pl/{domain_part}/"
print(f"\nUpload zakonczony! {files_uploaded} plikow.")
print(f"URL: {url}")
if __name__ == "__main__":
main()