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,161 @@
from __future__ import annotations
import argparse
import csv
import os
import sys
from datetime import datetime
from pathlib import Path
import requests
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from scripts.product_cl1_sales_summary import (
fetch_cl1_segments,
find_sales_csv,
read_sales,
split_products_by_top,
)
from src.gads_v2.config import load_config, load_env
from src.gads_v2.tasks.pla_cl1_sync import fetch_adspro_products
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Ustawia custom_label_4=catch_all w adsPRO dla produktow spoza top N w kazdym CL1."
)
parser.add_argument("client", help="Domena klienta, np. laitica.pl")
parser.add_argument("--top-per-cl1", type=int, default=20)
parser.add_argument("--value", default="catch_all")
parser.add_argument("--sales-csv")
parser.add_argument("--apply", action="store_true", help="Bez tej flagi zapisuje tylko plan CSV.")
parser.add_argument(
"--action",
default="product_custom_label_4_set",
help="Nazwa akcji adsPRO ustawiajacej CL4.",
)
return parser.parse_args()
def csv_cell(value: object) -> str:
text = str(value or "")
if any(char in text for char in ['"', ";", "\r", "\n"]):
return '"' + text.replace('"', '""') + '"'
return text
def write_plan(path: Path, rows: list[dict]) -> None:
headers = [
"offer_id",
"title",
"custom_label_1",
"current_custom_label_4",
"target_custom_label_4",
"conversions",
"conversion_value",
"cost",
"roas",
"status",
"message",
]
lines = [";".join(headers)]
for row in rows:
lines.append(";".join(csv_cell(row.get(header, "")) for header in headers))
path.write_text("\n".join(lines) + "\n", encoding="utf-8-sig")
def set_cl4(api_url: str, api_key: str, client_id: str, action: str, offer_id: str, value: str) -> dict:
payload = {
"action": action,
"api_key": api_key,
"client_id": client_id,
"offer_id": offer_id,
"custom_label_4": value,
}
response = requests.post(api_url, data=payload, timeout=30)
response.raise_for_status()
response.encoding = "utf-8"
try:
return response.json()
except ValueError:
return {"result": "error", "message": response.text[:500]}
def main() -> None:
args = parse_args()
load_env(ROOT / ".env")
config = load_config()
if args.client not in config.clients:
known = ", ".join(sorted(config.clients))
raise SystemExit(f"Nie znaleziono klienta {args.client}. Dostepni: {known}")
client_config = config.clients[args.client]
if not client_config.adspro_client_id:
raise SystemExit(f"Brak adspro_client_id dla {args.client}.")
sales_path = Path(args.sales_csv) if args.sales_csv else find_sales_csv(args.client)
sales = read_sales(sales_path)
segments = fetch_cl1_segments(client_config)
products = fetch_adspro_products(client_config, segments)
_, catch_all_products = split_products_by_top(products, sales, args.top_per_cl1)
rows = []
for product in catch_all_products:
offer_id = str(product.get("offer_id") or "")
stats = sales.get(offer_id, {})
rows.append(
{
"offer_id": offer_id,
"title": product.get("title", ""),
"custom_label_1": product.get("custom_label_1", ""),
"current_custom_label_4": product.get("custom_label_4", ""),
"target_custom_label_4": args.value,
"conversions": str(product.get("conversions", stats.get("conversions", 0))),
"conversion_value": str(product.get("conversion_value", stats.get("conversion_value", 0))),
"cost": str(product.get("cost", stats.get("cost", 0))),
"roas": str(product.get("roas", stats.get("roas", 0))),
"status": "planned",
"message": "",
}
)
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
plan_path = ROOT / "clients" / args.client / "data" / f"adspro_cl4_catch_all_plan_{timestamp}.csv"
plan_path.parent.mkdir(parents=True, exist_ok=True)
if args.apply:
api_url = os.environ.get("ADSPRO_API_URL")
api_key = os.environ.get("ADSPRO_API_KEY")
if not api_url or not api_key:
raise SystemExit("Brak ADSPRO_API_URL lub ADSPRO_API_KEY w .env.")
for index, row in enumerate(rows, 1):
result = set_cl4(
api_url,
api_key,
client_config.adspro_client_id,
args.action,
row["offer_id"],
args.value,
)
if result.get("result") == "error":
row["status"] = "error"
row["message"] = result.get("message", "")
print(f"{index}/{len(rows)} ERROR {row['offer_id']}: {row['message']}", flush=True)
else:
row["status"] = "updated"
row["message"] = result.get("message", "")
if index % 50 == 0 or index == len(rows):
print(f"{index}/{len(rows)} zaktualizowano", flush=True)
write_plan(plan_path, rows)
updated = sum(1 for row in rows if row["status"] == "updated")
errors = sum(1 for row in rows if row["status"] == "error")
print(f"Produkty do CL4={args.value}: {len(rows)}")
print(f"Zaktualizowano: {updated}")
print(f"Bledy: {errors}")
print(f"Raport: {plan_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import argparse
import sys
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.gads_v2.config import load_config, load_env
from src.gads_v2.google_ads import get_google_ads_client, run_query
CSV_HEADERS = [
"product_id",
"product_name",
"conversions",
"conversion_value",
"cost",
"roas",
"impressions",
"clicks",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Eksport historycznych wynikow produktow z Google Ads API do CSV."
)
parser.add_argument("client", help="Domena klienta z config/clients.toml, np. laitica.pl")
parser.add_argument("--start", default="2000-01-01", help="Data poczatkowa YYYY-MM-DD")
parser.add_argument("--end", default=date.today().isoformat(), help="Data koncowa YYYY-MM-DD")
parser.add_argument("--output", help="Sciezka wynikowego pliku CSV")
return parser.parse_args()
def year_ranges(start: date, end: date) -> list[tuple[date, date]]:
ranges = []
current = start
while current <= end:
year_end = min(date(current.year, 12, 31), end)
ranges.append((current, year_end))
current = date(current.year + 1, 1, 1)
return ranges
def as_float(value: object) -> float:
return float(value or 0)
def as_int(value: object) -> int:
return int(value or 0)
def excel_number(value: int | float, decimals: int = 0) -> str:
if decimals <= 0:
return str(int(value or 0))
text = f"{float(value or 0):.{decimals}f}".rstrip("0").rstrip(".")
return text.replace(".", ",")
def csv_cell(value: object) -> str:
text = str(value or "")
if any(char in text for char in ['"', ";", "\r", "\n"]):
return '"' + text.replace('"', '""') + '"'
return text
def write_excel_csv(path: Path, rows: list[dict]) -> None:
lines = [";".join(CSV_HEADERS)]
for row in rows:
lines.append(
";".join(
[
csv_cell(row["product_id"]),
csv_cell(row["product_name"]),
excel_number(row["conversions"], 4),
excel_number(row["conversion_value"], 2),
excel_number(row["cost"], 2),
excel_number(row["roas"], 4),
excel_number(row["impressions"]),
excel_number(row["clicks"]),
]
)
)
path.write_text("\n".join(lines) + "\n", encoding="utf-8-sig")
def main() -> None:
args = parse_args()
load_env(ROOT / ".env")
config = load_config()
if args.client not in config.clients:
known = ", ".join(sorted(config.clients))
raise SystemExit(f"Nie znaleziono klienta {args.client}. Dostepni klienci: {known}")
start = date.fromisoformat(args.start)
end = date.fromisoformat(args.end)
if start > end:
raise SystemExit("--start nie moze byc pozniej niz --end")
client_config = config.clients[args.client]
output = Path(args.output) if args.output else ROOT / "clients" / args.client / "data" / f"google_ads_product_sales_history_{start}_{end}.csv"
output.parent.mkdir(parents=True, exist_ok=True)
google_client = get_google_ads_client(use_proto_plus=True)
records: dict[str, dict] = {}
title_votes: dict[str, Counter[str]] = defaultdict(Counter)
for chunk_start, chunk_end in year_ranges(start, end):
print(f"Pobieram {chunk_start} - {chunk_end}...", flush=True)
query = f"""
SELECT
segments.product_item_id,
segments.product_title,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value
FROM shopping_performance_view
WHERE segments.date BETWEEN '{chunk_start.isoformat()}' AND '{chunk_end.isoformat()}'
"""
rows = run_query(google_client, client_config.safe_customer_id, query, timeout=300.0)
for row in rows:
product_id = str(row.segments.product_item_id or "").strip()
if not product_id:
continue
title = str(row.segments.product_title or "").strip()
record = records.setdefault(
product_id,
{
"product_id": product_id,
"product_name": title,
"impressions": 0,
"clicks": 0,
"cost": 0.0,
"conversions": 0.0,
"conversion_value": 0.0,
"roas": 0.0,
},
)
if title:
title_votes[product_id][title] += 1
record["impressions"] += as_int(row.metrics.impressions)
record["clicks"] += as_int(row.metrics.clicks)
record["cost"] += as_float(row.metrics.cost_micros) / 1_000_000
record["conversions"] += as_float(row.metrics.conversions)
record["conversion_value"] += as_float(row.metrics.conversions_value)
for product_id, record in records.items():
if title_votes[product_id]:
record["product_name"] = title_votes[product_id].most_common(1)[0][0]
cost = record["cost"]
record["cost"] = round(cost, 2)
record["conversions"] = round(record["conversions"], 4)
record["conversion_value"] = round(record["conversion_value"], 2)
record["roas"] = round(record["conversion_value"] / cost, 4) if cost else 0.0
sorted_rows = sorted(
records.values(),
key=lambda item: (item["conversion_value"], item["conversions"], item["cost"]),
reverse=True,
)
write_excel_csv(output, sorted_rows)
print(f"Zapisano {len(sorted_rows)} produktow: {output}")
if __name__ == "__main__":
main()

0
scripts/lib/__init__.py Normal file
View File

172
scripts/lib/gads_client.py Normal file
View File

@@ -0,0 +1,172 @@
"""
Wspólna biblioteka Google Ads API.
Użycie:
from lib.gads_client import get_client, get_customer_id, run_query, write_csv
get_customer_id("laitica.pl") -> "2625677205"
get_customer_id("262-567-7205") -> "2625677205"
get_customer_id("2625677205") -> "2625677205"
"""
import csv
import io
import os
import re
import sys
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from google.ads.googleads.client import GoogleAdsClient
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
from src.gads_v2.config import load_config, load_env
load_env(ROOT / ".env")
# Wymuszamy UTF-8 na stdout — raz, przy pierwszym imporcie
if not isinstance(sys.stdout, io.TextIOWrapper) or sys.stdout.encoding.lower().replace("-", "") != "utf8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
def get_customer_id(customer: str) -> str:
"""
Zwraca customer_id (string cyfr bez myślników).
Przyjmuje:
- domenę: "laitica.pl" -> szuka GOOGLE_ACCOUNT_ID_laiticapl
- customer_id: "262-567-7205" lub "2625677205"
"""
# Już jest numeryczny (z myślnikami lub bez)
if re.fullmatch(r"[\d-]+", customer):
return customer.replace("-", "")
try:
cfg = load_config()
if customer in cfg.clients:
return cfg.clients[customer].safe_customer_id
except Exception:
pass
# Domena -> klucz środowiska (próbuj bez znaków specjalnych, potem oryginał)
env_key = "GOOGLE_ACCOUNT_ID_" + re.sub(r"[.\-]", "", customer)
value = os.environ.get(env_key)
if not value:
env_key = "GOOGLE_ACCOUNT_ID_" + customer
value = os.environ.get(env_key)
if not value:
raise ValueError(
f"Nie znaleziono {env_key} w .env. "
f"Dostępne klucze: {[k for k in os.environ if k.startswith('GOOGLE_ACCOUNT_ID_')]}"
)
return value.replace("-", "")
def get_client(use_proto_plus: bool = True) -> GoogleAdsClient:
"""Tworzy klienta Google Ads API."""
return GoogleAdsClient.load_from_dict(
{
"developer_token": os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN") or os.environ["GOOGLE_ADS_DEVELOPER_TOKNE"],
"client_id": os.environ["GOOGLE_ADS_OAUTH2_CLIENT_ID"],
"client_secret": os.environ["GOOGLE_ADS_OAUTH2_CLIENT_SECRET"],
"refresh_token": os.environ["GOOGLE_ADS_OAUTH2_REFRESH_TOKEN"],
"login_customer_id": os.environ["GOOGLE_ADS_MANAGER_ACCOUNT_ID"],
"use_proto_plus": use_proto_plus,
}
)
def run_query(
client: GoogleAdsClient,
customer_id: str,
query: str,
timeout: float | None = 300.0,
) -> list:
"""Wykonuje zapytanie GAQL i zwraca listę wyników.
`timeout` (sekundy) jest przekazany do gRPC. Default 300s (5 min) — chroni
przed cichym wiszącym RPC. Po przekroczeniu rzuca jasny wyjątek z hintem.
Pass `timeout=None` aby wyłączyć (rzadko potrzebne — patrz `feedback_script_timeout_handling.md`).
Note: SDK Google Ads Python ma wbudowany retry policy dla unary RPC (~5 attempts × exponential backoff).
`search_stream` jako server-streaming nie korzysta z retry per-batch — timeout jest tu twardym capem.
Override SDK retry policy wymaga edycji `grpc_service_config.json` wewnątrz pakietu — niepraktyczne.
Dla agresywniejszego anti-throttling: zmniejsz `timeout` (np. 60s) i obsłuż `DeadlineExceeded` w skrypcie.
"""
service = client.get_service("GoogleAdsService")
kwargs = {"customer_id": customer_id, "query": query}
if timeout is not None:
kwargs["timeout"] = timeout
rows = []
try:
for batch in service.search_stream(**kwargs):
for row in batch.results:
rows.append(row)
except Exception as e:
# Translate gRPC DeadlineExceeded / Aborted to actionable message
msg = str(e)
if "DEADLINE_EXCEEDED" in msg or "Deadline" in msg or "deadline" in msg:
raise RuntimeError(
f"GAQL query przekroczyło timeout {timeout}s. Sugestie: "
f"(1) dodaj filtr `--campaign-id` lub `LIMIT N` w GAQL, "
f"(2) skróć zakres dat (`segments.date BETWEEN ...`), "
f"(3) podziel query na mniejsze segmenty. "
f"Original: {msg[:200]}"
) from e
raise
return rows
def write_csv(path: Path, rows: list[dict]) -> None:
"""Zapisuje listę słowników do CSV (UTF-8 BOM, Excel-friendly)."""
if not rows:
print(f" Brak danych — pomijam {path.name}")
return
with open(path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
print(f" Zapisano {len(rows)} wierszy -> {path.name}")
@contextmanager
def heartbeat(label: str = "still working", interval: float = 10.0, file=sys.stderr):
"""
Context manager — pisze co `interval` sekund komunikat `[Ns] {label}...` do stderr.
Eliminuje wrażenie zawieszenia w długich skryptach (Google Ads API throttling, paginacja, retry).
Użycie:
with heartbeat("fetching ad_schedule"):
rows = run_query(client, customer_id, query)
Przerywa się automatycznie po wyjściu z bloku. Jeśli skrypt zakończy <interval s, nic nie wypisze.
"""
stop = threading.Event()
start = time.time()
def _tick():
while not stop.wait(interval):
elapsed = int(time.time() - start)
print(f" [{elapsed}s] {label}...", file=file, flush=True)
t = threading.Thread(target=_tick, daemon=True)
t.start()
try:
yield
finally:
stop.set()
t.join(timeout=0.5)
def output_dir(customer: str) -> Path:
"""Zwraca ścieżkę do folderu danych klienta, tworzy jeśli nie istnieje."""
# Próbuj znaleźć katalog po domenie
if not re.fullmatch(r"[\d-]+", customer):
d = ROOT / "clients" / customer
else:
# Dla ID szukaj folderu po wartości z .env
d = ROOT / "clients" / customer
d.mkdir(parents=True, exist_ok=True)
return d

View File

@@ -0,0 +1,274 @@
from __future__ import annotations
import argparse
import csv
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.gads_v2.config import load_config, load_env
from src.gads_v2.google_ads import get_google_ads_client, run_query
from src.gads_v2.tasks.pla_cl1_sync import fetch_adspro_products, parse_allowed_labels
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Podsumowanie produktow adsPRO wg CL1 i wynikow Google Ads."
)
parser.add_argument("client", help="Domena klienta, np. laitica.pl")
parser.add_argument(
"--sales-csv",
help="CSV z eksportem historii sprzedazy produktow Google Ads.",
)
parser.add_argument(
"--output",
help="Sciezka wynikowego CSV z podsumowaniem CL1.",
)
parser.add_argument(
"--top-per-cl1",
type=int,
default=20,
help="Ile najlepszych produktow w kazdym CL1 ma trafic do kolumny spelnia.",
)
return parser.parse_args()
def parse_number(value: str) -> float:
text = str(value or "").strip().replace("\u00a0", "").replace(" ", "")
if "," in text and "." in text:
text = text.replace(".", "").replace(",", ".")
else:
text = text.replace(",", ".")
return float(text or 0)
def csv_cell(value: object) -> str:
text = str(value or "")
if any(char in text for char in ['"', ";", "\r", "\n"]):
return '"' + text.replace('"', '""') + '"'
return text
def excel_number(value: int | float, decimals: int = 0) -> str:
if decimals <= 0:
return str(int(value or 0))
text = f"{float(value or 0):.{decimals}f}".rstrip("0").rstrip(".")
return text.replace(".", ",")
def find_sales_csv(domain: str) -> Path:
data_dir = ROOT / "clients" / domain / "data"
candidates = sorted(data_dir.glob("google_ads_product_sales_history_*.csv"), key=lambda path: path.stat().st_mtime)
if not candidates:
raise FileNotFoundError(f"Nie znaleziono CSV Google Ads w {data_dir}.")
return candidates[-1]
def read_sales(path: Path) -> dict[str, dict]:
sample = path.read_text(encoding="utf-8-sig").splitlines()[0]
delimiter = ";" if ";" in sample else ","
sales: dict[str, dict] = {}
with path.open(newline="", encoding="utf-8-sig") as handle:
reader = csv.DictReader(handle, delimiter=delimiter)
for row in reader:
product_id = (row.get("product_id") or "").strip()
if not product_id:
continue
sales[product_id] = {
"conversions": parse_number(row.get("conversions", "")),
"conversion_value": parse_number(row.get("conversion_value", "")),
"cost": parse_number(row.get("cost", "")),
"roas": parse_number(row.get("roas", "")),
}
return sales
def fetch_cl1_segments(client_config) -> list[str]:
google_client = get_google_ads_client(use_proto_plus=True)
rows = run_query(
google_client,
client_config.safe_customer_id,
"""
SELECT campaign.id, campaign.name, campaign.status
FROM campaign
WHERE campaign.name LIKE '%PLA_CL1%'
AND campaign.status = 'ENABLED'
""",
)
return sorted({label for row in rows for label in parse_allowed_labels(row.campaign.name)})
def write_summary(path: Path, rows: list[dict]) -> None:
headers = [
"cl1",
"produkty_adspro",
"produkty_z_danymi_google_ads",
"spelnia_top_produkty",
"nie_spelnia_warunku",
"konwersje_lacznie",
"wartosc_konwersji_lacznie",
"koszt_lacznie",
"roas_lacznie",
]
lines = [";".join(headers)]
for row in rows:
lines.append(
";".join(
[
csv_cell(row["cl1"]),
excel_number(row["produkty_adspro"]),
excel_number(row["produkty_z_danymi_google_ads"]),
excel_number(row["spelnia_warunek"]),
excel_number(row["nie_spelnia_warunku"]),
excel_number(row["konwersje_lacznie"], 4),
excel_number(row["wartosc_konwersji_lacznie"], 2),
excel_number(row["koszt_lacznie"], 2),
excel_number(row["roas_lacznie"], 4),
]
)
)
path.write_text("\n".join(lines) + "\n", encoding="utf-8-sig")
def split_products_by_top(products: list[dict], sales: dict[str, dict], top_per_cl1: int) -> tuple[list[dict], list[dict]]:
products_by_cl1: dict[str, list[dict]] = {}
for product in products:
cl1 = str(product.get("custom_label_1") or "(brak CL1)").strip() or "(brak CL1)"
offer_id = str(product.get("offer_id") or "").strip()
stats = sales.get(offer_id, {})
products_by_cl1.setdefault(cl1, []).append(
{
**product,
"conversions": float(stats.get("conversions", 0.0)),
"conversion_value": float(stats.get("conversion_value", 0.0)),
"cost": float(stats.get("cost", 0.0)),
"roas": float(stats.get("roas", 0.0)),
}
)
top_products = []
catch_all_products = []
for product_rows in products_by_cl1.values():
ranked = sorted(
product_rows,
key=lambda item: (
item["conversions"],
item["conversion_value"],
item["roas"],
-item["cost"],
),
reverse=True,
)
top_products.extend(ranked[:top_per_cl1])
catch_all_products.extend(ranked[top_per_cl1:])
return top_products, catch_all_products
def print_table(rows: list[dict]) -> None:
headers = ["CL1", "Produkty", "Z danymi", "Spełnia", "Nie spełnia", "ROAS łącznie"]
table_rows = [
[
row["cl1"],
str(row["produkty_adspro"]),
str(row["produkty_z_danymi_google_ads"]),
str(row["spelnia_warunek"]),
str(row["nie_spelnia_warunku"]),
excel_number(row["roas_lacznie"], 2),
]
for row in rows
]
widths = [
max(len(str(item)) for item in [header] + [row[index] for row in table_rows])
for index, header in enumerate(headers)
]
border = "+" + "+".join("-" * (width + 2) for width in widths) + "+"
sep = "+" + "+".join("-" * (width + 2) for width in widths) + "+"
bottom = "+" + "+".join("-" * (width + 2) for width in widths) + "+"
print(border)
print("| " + " | ".join(header.ljust(widths[index]) for index, header in enumerate(headers)) + " |")
print(sep)
for row in table_rows:
print("| " + " | ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)) + " |")
print(bottom)
def main() -> None:
args = parse_args()
load_env(ROOT / ".env")
config = load_config()
if args.client not in config.clients:
known = ", ".join(sorted(config.clients))
raise SystemExit(f"Nie znaleziono klienta {args.client}. Dostepni: {known}")
client_config = config.clients[args.client]
sales_path = Path(args.sales_csv) if args.sales_csv else find_sales_csv(args.client)
output_path = (
Path(args.output)
if args.output
else ROOT / "clients" / args.client / "data" / "google_ads_product_sales_by_cl1_summary.csv"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
sales = read_sales(sales_path)
segments = fetch_cl1_segments(client_config)
if not segments:
raise SystemExit("Nie znaleziono aktywnych kampanii [PLA_CL1], z ktorych mozna odczytac CL1.")
print("CL1 z aktywnych kampanii PLA_CL1: " + ", ".join(segments))
products = fetch_adspro_products(client_config, segments)
summary: dict[str, dict] = {}
top_products, catch_all_products = split_products_by_top(products, sales, args.top_per_cl1)
catch_all_ids = {product["offer_id"] for product in catch_all_products}
for product in products:
cl1 = str(product.get("custom_label_1") or "(brak CL1)").strip() or "(brak CL1)"
offer_id = str(product.get("offer_id") or "").strip()
stats = sales.get(offer_id, {})
row = summary.setdefault(
cl1,
{
"cl1": cl1,
"produkty_adspro": 0,
"produkty_z_danymi_google_ads": 0,
"spelnia_warunek": 0,
"nie_spelnia_warunku": 0,
"konwersje_lacznie": 0.0,
"wartosc_konwersji_lacznie": 0.0,
"koszt_lacznie": 0.0,
"roas_lacznie": 0.0,
},
)
conversions = float(stats.get("conversions", 0.0))
conversion_value = float(stats.get("conversion_value", 0.0))
cost = float(stats.get("cost", 0.0))
roas = float(stats.get("roas", 0.0))
row["produkty_adspro"] += 1
if offer_id in sales:
row["produkty_z_danymi_google_ads"] += 1
if offer_id in catch_all_ids:
row["nie_spelnia_warunku"] += 1
else:
row["spelnia_warunek"] += 1
row["konwersje_lacznie"] += conversions
row["wartosc_konwersji_lacznie"] += conversion_value
row["koszt_lacznie"] += cost
rows = sorted(summary.values(), key=lambda item: item["spelnia_warunek"], reverse=True)
for row in rows:
row["roas_lacznie"] = (
row["wartosc_konwersji_lacznie"] / row["koszt_lacznie"]
if row["koszt_lacznie"]
else 0.0
)
write_summary(output_path, rows)
print_table(rows)
print(f"\nCSV: {output_path}")
print(f"Produkty adsPRO: {sum(row['produkty_adspro'] for row in rows)}")
print(f"Plik sprzedazy Google Ads: {sales_path}")
if __name__ == "__main__":
main()

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()