first commit
This commit is contained in:
161
scripts/apply_adspro_cl4_catch_all.py
Normal file
161
scripts/apply_adspro_cl4_catch_all.py
Normal 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()
|
||||
173
scripts/export_product_sales_history.py
Normal file
173
scripts/export_product_sales_history.py
Normal 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
0
scripts/lib/__init__.py
Normal file
172
scripts/lib/gads_client.py
Normal file
172
scripts/lib/gads_client.py
Normal 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
|
||||
274
scripts/product_cl1_sales_summary.py
Normal file
274
scripts/product_cl1_sales_summary.py
Normal 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()
|
||||
803
scripts/reports/fetch_monthly_report_data.py
Normal file
803
scripts/reports/fetch_monthly_report_data.py
Normal 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("zł", "")
|
||||
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()
|
||||
171
scripts/reports/fetch_semstorm_data.py
Normal file
171
scripts/reports/fetch_semstorm_data.py
Normal 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()
|
||||
114
scripts/reports/fetch_seo_links.py
Normal file
114
scripts/reports/fetch_seo_links.py
Normal 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()
|
||||
180
scripts/reports/fetch_shoper_data.py
Normal file
180
scripts/reports/fetch_shoper_data.py
Normal 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()
|
||||
204
scripts/reports/fetch_shopify_orders.py
Normal file
204
scripts/reports/fetch_shopify_orders.py
Normal 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()
|
||||
74
scripts/reports/generate_ga4_token.py
Normal file
74
scripts/reports/generate_ga4_token.py
Normal 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)
|
||||
1401
scripts/reports/generate_html_report.py
Normal file
1401
scripts/reports/generate_html_report.py
Normal file
File diff suppressed because it is too large
Load Diff
32
scripts/reports/list_ga4_properties.py
Normal file
32
scripts/reports/list_ga4_properties.py
Normal 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()
|
||||
716
scripts/reports/output/aruba.rzeszow.pl/2026-02/index.html
Normal file
716
scripts/reports/output/aruba.rzeszow.pl/2026-02/index.html
Normal 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 — 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 — 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">⚠</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">📈</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 — 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">
|
||||
▼ -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">
|
||||
▼ -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">
|
||||
▼ -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">
|
||||
▼ -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">
|
||||
▼ -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">
|
||||
▲ +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">
|
||||
▼ -30.7% vs Styczeń
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<!-- DAILY CHART -->
|
||||
<section class="report-section" id="ads-chart">
|
||||
<h2 class="section-title">Google Ads — Aktywność dzienna</h2>
|
||||
<div class="chart-container">
|
||||
<canvas id="dailyClicksChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CAMPAIGNS TABLE -->
|
||||
<section class="report-section" id="campaigns">
|
||||
<h2 class="section-title">Kampanie</h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kampania</th>
|
||||
<th>Typ</th>
|
||||
<th>Wyświetlenia</th>
|
||||
<th>Kliknięcia</th>
|
||||
<th>CTR</th>
|
||||
<th>Konwersje</th>
|
||||
<th>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> — 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>
|
||||
716
scripts/reports/output/aruba.rzeszow.pl/2026-04/index.html
Normal file
716
scripts/reports/output/aruba.rzeszow.pl/2026-04/index.html
Normal 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 — 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 — 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">⚠</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">📈</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">🔍</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 — 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">
|
||||
▼ -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">
|
||||
▲ +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">
|
||||
▲ +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">
|
||||
▼ -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">
|
||||
▲ +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">
|
||||
▲ +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">
|
||||
▼ -1.5% vs Marzec
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<!-- DAILY CHART -->
|
||||
<section class="report-section" id="ads-chart">
|
||||
<h2 class="section-title">Google Ads — Aktywność dzienna</h2>
|
||||
<div class="chart-container">
|
||||
<canvas id="dailyClicksChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CAMPAIGNS TABLE -->
|
||||
<section class="report-section" id="campaigns">
|
||||
<h2 class="section-title">Kampanie</h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kampania</th>
|
||||
<th>Typ</th>
|
||||
<th>Wyświetlenia</th>
|
||||
<th>Kliknięcia</th>
|
||||
<th>CTR</th>
|
||||
<th>Konwersje</th>
|
||||
<th>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> — 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>
|
||||
412
scripts/reports/output/aruba.rzeszow.pl_2026-02.json
Normal file
412
scripts/reports/output/aruba.rzeszow.pl_2026-02.json
Normal 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": "⚠",
|
||||
"title": "Spadek konwersji do obserwacji",
|
||||
"text": "Liczba konwersji spadla o 30.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu."
|
||||
},
|
||||
{
|
||||
"icon": "📈",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
429
scripts/reports/output/aruba.rzeszow.pl_2026-04.json
Normal file
429
scripts/reports/output/aruba.rzeszow.pl_2026-04.json
Normal 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": "⚠",
|
||||
"title": "Spadek konwersji do obserwacji",
|
||||
"text": "Liczba konwersji spadla o 8.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu."
|
||||
},
|
||||
{
|
||||
"icon": "📈",
|
||||
"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": "🔍",
|
||||
"title": "Kontrola wzrostu kosztu",
|
||||
"text": "Koszt reklam wzrosl o 12.2% miesiac do miesiaca. Warto porownac wzrost kosztu ze wzrostem konwersji i wartosci konwersji."
|
||||
}
|
||||
]
|
||||
}
|
||||
1182
scripts/reports/output/ibra-makeup.pl/2026-04/index.html
Normal file
1182
scripts/reports/output/ibra-makeup.pl/2026-04/index.html
Normal file
File diff suppressed because it is too large
Load Diff
1021
scripts/reports/output/ibra-makeup.pl_2026-04.json
Normal file
1021
scripts/reports/output/ibra-makeup.pl_2026-04.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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": "✅",
|
||||
"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": "📈",
|
||||
"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": "🔍",
|
||||
"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": "⚠",
|
||||
"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": "💰",
|
||||
"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": "➤",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
40
scripts/reports/test_ga4_access.py
Normal file
40
scripts/reports/test_ga4_access.py
Normal 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}")
|
||||
121
scripts/reports/upload_report_ftp.py
Normal file
121
scripts/reports/upload_report_ftp.py
Normal 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()
|
||||
Reference in New Issue
Block a user