This commit is contained in:
2026-05-15 23:19:26 +02:00
parent def1fae0fc
commit 75b9434de5
113 changed files with 50906 additions and 1305 deletions

View File

@@ -1,3 +0,0 @@
# Memory Index
- [Format listy klientów](feedback_client_list_format.md) — listy klientów prezentować jako numerowaną tabelę markdown

View File

@@ -6,4 +6,19 @@ GOOGLE_ADS_MANAGER_ACCOUNT_ID=8976093870
ADSPRO_API_URL=https://adspro.projectpro.pl/api.php
ADSPRO_API_KEY=42bda4bafa556546990c817b7ec1feab8ca5ed3e134ac797
ADSPRO_HOST=host700513.hostido.net.pl
ADSPRO_USERNAME=www@adspro.projectpro.pl
ADSPRO_PASSWORD=S75kmnqmD5heMZzJQf5n
ADSPRO_REMOTE_PATH=/public_html
# Import wiedzy przez OpenAI API
# Uzywane tylko przez: python gads.py wiedza dodaj ...
OPENAI_API_KEY=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
# Opcjonalnie: model do ekstrakcji regul wiedzy.
# Mozesz tez podac model jednorazowo przez --model.
KNOWLEDGE_OPENAI_MODEL=gpt-4.1-mini
# Google Analytics 4 API
GA4_REFRESH_TOKEN=1//09_hLDPStXz3QCgYIARAAGAkSNwF-L9IrKlofxXOfEYoI9jyiHebuffRwcLtBnUQB48jBU3De4MvTOLyZ5FYLRapCt5bcLjsvyxc
GA4_PROPERTY_ID_ibra-makeup.pl=401893399

6
.sync/Archive/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.env
config/clients.toml
clients/
__pycache__/
*.pyc

View File

@@ -166,6 +166,80 @@ Popros uzytkownika tylko o numer. Po wyborze:
- `2` pokaz liste klientow,
- `3` zakoncz.
## Raport klienta
Gdy uzytkownik poprosi o raport klienta albo poda komende w stylu:
```text
analiza-klienta aruba.rzeszow.pl 02-2026
```
uruchom:
```powershell
python gads.py analiza-klienta aruba.rzeszow.pl 02-2026
```
To jest alias dla generowania miesiecznego raportu HTML klienta. Obslugiwane sa formaty miesiaca `MM-YYYY`, `MM.YYYY` i `YYYY-MM`.
Jesli uzytkownik napisze tylko:
```text
raport-klienta
```
uruchom:
```powershell
python gads.py raport-klienta
```
Pokaz uzytkownikowi liste klientow i popros o numer. Po wyborze klienta oraz miesiaca uruchom:
```powershell
python gads.py raport-klienta --client-number <numer-klienta> --month <YYYY-MM>
```
Komenda najpierw pobiera dane i zatrzymuje sie przed generowaniem HTML. Tworzy plik roboczy:
```text
scripts/reports/output/<domena>_<YYYY-MM>_recommendations.json
```
Wnioski i rekomendacje przygotowuje agent AI, nie skrypt. Agent ma przeczytac dane raportu i kontekst w pliku rekomendacji, uzupelnic `recommendations` konkretnymi wnioskami, pokazac je uzytkownikowi i zapytac o akceptacje.
Wnioski pisz z perspektywy osoby, ktora obsluguje konto Google Ads klienta. Nie pisz do klienta, ze `warto cos sprawdzic`, `trzeba zweryfikowac` albo `nalezy przeanalizowac`, jakby decyzja byla po jego stronie. Pisz decyzyjnie: co robimy, co zostawiamy, co ograniczamy, co kontrolujemy i jaki jest nastepny krok po naszej stronie. Unikaj bezosobowych, nijakich rekomendacji.
W tekstach raportu dla klienta uzywaj poprawnych polskich znakow. Dotyczy to szczegolnie tytulow, wnioskow i rekomendacji w pliku `recommendations`. Nie zapisuj tam wersji bez ogonkow typu `zwiekszamy`, `wartosc`, `srednia`, jezeli tekst trafi do HTML widocznego dla klienta.
Po akceptacji wnioskow uruchom:
```powershell
python gads.py raport-klienta --client <domena> --month <YYYY-MM> --confirm-recommendations TAK
```
Dopiero wtedy komenda generuje lokalny raport HTML w:
```text
scripts/reports/output/<domena>/<YYYY-MM>/index.html
```
Jeżeli klient ma w `config/clients.toml` ustawione `sales_history_sheet`, historia sprzedaży miesięcznej oraz trzy kafelki w sekcji `E-commerce — Sprzedaż` mają pochodzić z tego arkusza Google Sheet. Arkusz powinien zawierać kolumny: `Miesiąc`, `Transakcje`, `Przychody`, `Średnia wartość koszyka`. Nie zastępuj tych danych GA4, jeżeli arkusz jest skonfigurowany.
Po wygenerowaniu raportu pokaz uzytkownikowi sciezke do pliku i popros o akceptacje przed wysylka na serwer. Nie wysylaj raportu bez jasnej zgody uzytkownika.
Po akceptacji uruchom upload:
```powershell
python gads.py raport-klienta --client <domena> --month <YYYY-MM> --confirm-upload TAK
```
Po wysylce podaj URL:
```text
https://adspro.projectpro.pl/raporty/<slug>/<YYYY-MM>/
```
## Zasady komunikacji
- Pisz po polsku.
@@ -245,6 +319,58 @@ Przed wdrozeniem tytulow agent musi uzupelnic docelowe wartosci tytulow w zapisa
Przed wdrozeniem kategorii agent musi uzupelnic docelowe wartosci kategorii w zapisanym planie JSON i dopiero wtedy zapytac uzytkownika o zgode.
Unit pricing moze byc proponowany przez skrypt, jezeli da sie go jednoznacznie odczytac z nazwy produktu.
## Reczne przypisywanie regul
Gdy uzytkownik napisze:
```text
Przypisz regule:
- tresc reguly do oceny
```
agent ma potraktowac to jako prosbe o kuracje pojedynczej reguly wiedzy.
Kolejnosc pracy:
1. Sprawdz aktualne grupy i zadania w `config/tasks.toml`.
2. Nie przywracaj usunietych zadan ani grup. Jesli lista zadan jest ograniczona, uznaj to za swiadoma decyzje uzytkownika.
3. Ocen, czy regule warto dodac do narzedzia.
4. Zaproponuj docelowe brzmienie reguly: `condition`, `recommendation`, `risk`, `rule_type`, `topic`, `confidence` i docelowe `task_ids`.
5. Zaproponuj policzalny `machine_condition` i `machine_effect`, jezeli regule da sie bezpiecznie zastosowac w skrypcie na danych pobieranych przez dane zadanie.
6. `machine_condition` ma uzywac tylko pol, ktore naprawde istnieja w planie danego zadania, np. `channel_type`, `conversions_30d`, `bidding_strategy_type`, `budget_context`, `search_budget_lost_impression_share`.
7. Jesli nie da sie zbudowac bezpiecznego warunku maszynowego, napisz to wprost i zaproponuj zapis reguly bez automatycznego wplywu, jako kontekst dla agenta AI/czlowieka.
8. Uzywaj tylko istniejacych identyfikatorow zadan z `config/tasks.toml`.
9. Jesli nie ma dobrego zadania, powiedz, ze regule lepiej odlozyc albo dodac dopiero po utworzeniu nowego zadania.
10. Nie zapisuj reguly do `knowledge/rules.jsonl`, dopoki uzytkownik jasno nie odpowie `Dodaj`.
11. Po odpowiedzi `Dodaj` dopisz jedna kompletna linie JSONL do `knowledge/rules.jsonl`.
Przy zapisie do `knowledge/rules.jsonl` uzupelnij pola: `id`, `status`, `topic`, `task_ids`, `suggested_task_ids`, `rule_type`, `condition`, `recommendation`, `risk`, `source`, `source_file`, `confidence`, `duplicate_of`, `supersedes`, `text`, `created_at`, `updated_at`. Jezeli regula ma dzialac automatycznie, dodaj tez `machine_condition` i `machine_effect`.
Przyklad policzalnej czesci reguly:
```json
{
"machine_condition": {
"all": [
{"field": "channel_type", "op": "eq", "value": "SEARCH"},
{"field": "conversions_30d", "op": "lt", "value": 15},
{"field": "bidding_strategy_type", "op": "in", "value": ["MAXIMIZE_CONVERSIONS", "TARGET_CPA", "MAXIMIZE_CONVERSION_VALUE", "TARGET_ROAS"]}
]
},
"machine_effect": {
"level": "ostroznie",
"action": "nie przechodz na automatyzacje konwersyjna",
"reason_prefix": "Regula wiedzy"
}
}
```
Ten tryb jest reczna alternatywa dla komendy:
```powershell
python gads.py wiedza przypisz
```
## Reguly i wyjatki klientow
Ustawienia globalne i wyjatki per klient sa w:

View File

@@ -1,218 +0,0 @@
# Rozbudowa narzedzia
Ten plik opisuje, jak dodawac nowe grupy zadan, zadania i skrypty, zeby kolejny agent nie musial projektowac procesu od zera.
## Zasada architektury
Kazde zadanie powinno dzialac w tym samym modelu:
1. Pobierz aktualne dane.
2. Zbuduj plan.
3. Zapisz plan do `clients/<domena>/plans/` jako `.json` i `.md`.
4. W trybie `--plan-only` nie wdrazaj zmian.
5. Po akceptacji uzytkownika wdrazaj tylko plan zapisany w JSON.
6. Zapisz historie do:
- `clients/<domena>/history/YYYY-MM-DD.jsonl`
- `clients/<domena>/changes/YYYY-MM-DD.md`
Agent AI prowadzi uzytkownika, ale logika pobierania danych, analizy i wdrozenia zmian ma byc w Pythonie.
## Dodanie nowej grupy zadan
Przed dodaniem wiekszego zakresu sprawdz:
```text
OLD_COMMANDS_CHECKLIST.md
```
To jest lista rzeczy sprawdzanych przez stary system z `D:\google ads\`.
Edytuj:
```text
config/tasks.toml
```
Dodaj nowa grupe:
```toml
[[groups]]
id = "search"
name = "Kampanie Search"
```
Zadania beda numerowane automatycznie jako `2.1`, `2.2`, itd. w zaleznosci od kolejnosci grup.
## Dodanie nowego zadania do grupy
W `config/tasks.toml` dodaj zadanie pod odpowiednia grupa:
```toml
[[groups.tasks]]
id = "check_search_settings"
name = "Sprawdzenie ustawien"
description = "Sprawdza ustawienia kampanii Search wedlug regul globalnych i wyjatkow klienta."
```
`id` jest techniczne i musi byc stabilne. `name` i `description` sa dla uzytkownika.
## Plik zadania w Pythonie
Dodaj modul w:
```text
src/gads_v2/tasks/
```
Przyklad nazwy:
```text
src/gads_v2/tasks/search_settings_check.py
```
Minimalny wzorzec funkcji:
```python
def run_check_search_settings(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
...
```
Wymagania:
- `plan_only=True` zawsze tylko zapisuje plan.
- `apply_plan_path` wdraza tylko wskazany plan JSON.
- `confirm_apply` musi wymagac wartosci `TAK`.
- `show_navigation=False` musi ukrywac pytanie `Co dalej`, bo uzywa tego tryb sekwencji.
## Struktura planu
Plan powinien miec klase lub slownik z metodami:
```python
to_dict()
from_dict()
```
Plan JSON musi zawierac:
```json
{
"created_at": "...",
"client": "example.pl",
"task": "task_id",
"changes": []
}
```
Plan Markdown powinien zawierac:
- krotkie podsumowanie,
- tabele po kampaniach, jesli zadanie dotyczy kampanii,
- tabele planowanych dzialan,
- ostrzezenia lub pominiete reguly.
## Podpiecie zadania do CLI
Edytuj:
```text
src/gads_v2/cli.py
```
1. Zaimportuj funkcje zadania:
```python
from .tasks.search_settings_check import run_check_search_settings
```
2. Dodaj `id` do argumentu `--task`:
```python
parser.add_argument("--task", choices=["sync_pla_cl1", "check_pla_settings", "check_search_settings"], ...)
```
3. Dodaj obsluge w `run_task()`:
```python
if task_id == "check_search_settings":
run_check_search_settings(
client,
global_rules,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
return
```
## Reguly globalne i wyjatki klientow
Reguly trzymaj w:
```text
config/clients.toml
```
Przyklad globalny:
```toml
[global_rules.search_settings]
require_presence_only = true
require_search_partners_off = true
```
Wyjatek per klient:
```toml
[clients."example.pl".search_settings]
require_search_partners_off = false
```
W kodzie uzywaj:
```python
rules = client_config.effective_rules(global_rules, "search_settings")
```
## Numeracja i wybory
Lista zadan uzywa formatu:
```text
1.1 - pierwsze zadanie w pierwszej grupie
1.2 - drugie zadanie w pierwszej grupie
1.0 - wszystkie zadania z pierwszej grupy
ALL - wszystkie zadania ze wszystkich grup
```
Nie dodawaj recznej numeracji do `tasks.toml`. Numeracja wynika z kolejnosci grup i zadan.
## Test po dodaniu zadania
Uruchom:
```powershell
python -m compileall -q gads.py src
python gads.py analiza-klienta --client-number 1
python gads.py analiza-klienta --client-number 1 --select <nr> --plan-only
```
Jesli zadanie wdraza zmiany, przetestuj najpierw tylko `--plan-only`.
## Format komunikacji agentow
Instrukcja dla agentow jest w:
```text
AGENTS.md
```
Po dodaniu nowego typu zadania dopisz tam tylko specjalne zasady, jesli agent ma wiedziec cos ponad standardowy przeplyw.

View File

@@ -1,67 +0,0 @@
# Google Ads ver 2
Terminalowe narzedzie do pracy na kontach Google Ads klientow.
Instrukcja pracy dla Claude Code, Codex, Gemini CLI i innych agentow AI jest w `AGENTS.md`.
Instrukcja rozbudowy o nowe grupy i zadania jest w `DEVELOPMENT.md`.
Backlog rzeczy sprawdzanych w starej wersji jest w `OLD_COMMANDS_CHECKLIST.md`.
## Start
1. Uzupelnij `.env` na podstawie `.env.example`.
2. Uzupelnij `config/clients.toml` na podstawie `config/clients.example.toml`.
3. Zainstaluj zaleznosci:
```powershell
python -m pip install -r requirements.txt
```
4. Uruchom menu:
```powershell
python gads.py
```
Albo uruchom konkretne zadanie bez menu:
```powershell
python gads.py --client laitica.pl --task sync_pla_cl1
```
Tryb dla Claude Code, Codex albo Gemini CLI:
```powershell
python gads.py analiza-klienta
python gads.py analiza-klienta --client-number 5
python gads.py analiza-klienta --client-number 5 --task-number 1 --plan-only
```
Po tej komendzie narzedzie zapisze plan w `clients/<domena>/plans/`.
Agent czyta plik `.md` albo `.json`, analizuje go i pyta Cie o zgode.
Po Twojej akceptacji agent uruchamia wdrozenie konkretnego planu:
```powershell
python gads.py --client laitica.pl --task sync_pla_cl1 --apply-plan clients/laitica.pl/plans/PLAN.json --confirm-apply TAK
```
## MVP
Pierwsze zadanie:
- pobiera kampanie `[PLA_CL1]` z Google Ads,
- wyciaga segmenty CL1 z nazw kampanii,
- pobiera produkty z adsPRO,
- przygotowuje plan synchronizacji grup reklam,
- czeka na akceptacje przed wdrozeniem zmian,
- zapisuje historie w katalogu klienta.
## Dane i historia
- `config/clients.toml` - lista klientow i identyfikatory kont.
- `config/clients.toml` - takze reguly globalne i wyjatki per klient, np. ustawienia kampanii PLA.
- `.env` - dane dostepowe do Google Ads i adsPRO.
- `clients/<domena>/data/` - pobrane dane robocze.
- `clients/<domena>/history/YYYY-MM-DD.jsonl` - historia do filtrowania po kliencie, dacie i kampanii.
- `clients/<domena>/changes/YYYY-MM-DD.md` - czytelny dziennik zmian.
Narzedzie nie uzywa API modeli AI. Claude Code, Codex albo Gemini CLI moga uruchamiac te same komendy terminalowe.

View File

@@ -0,0 +1,52 @@
[clients."pomysloweprezenty.pl"]
google_ads_customer_id = "941-605-1782"
adspro_client_id = "2"
[clients."innsi.pl"]
google_ads_customer_id = "133-343-6346"
adspro_client_id = "5"
[clients."van-dam.pl"]
google_ads_customer_id = "570-658-4790"
[clients."investagd.pl"]
google_ads_customer_id = "229-855-5588"
adspro_client_id = "8"
[clients."wyprzedaze.pl"]
google_ads_customer_id = "775-249-3197"
adspro_client_id = "10"
[clients."laitica.pl"]
google_ads_customer_id = "262-567-7205"
adspro_client_id = "9"
[clients."studio-zoe.pl"]
google_ads_customer_id = "387-166-1050"
[clients."aruba.rzeszow.pl"]
google_ads_customer_id = "374-470-8609"
adspro_client_id = "3"
sales_history_sheet = "https://docs.google.com/spreadsheets/d/1tkT1WgOi41uj7D0Q8ZTD0qINsd11VVsd7G9Q7M8kAN8/"
seo_works_history_sheet = "https://docs.google.com/spreadsheets/d/1lle7nkl0ykkJMlo2eA8AuV7Sd705ia6bmh7RM38nsRg/"
seo_links_history_sheet = "https://docs.google.com/spreadsheets/d/1b4uCBzNSFFxIv2X1fTsqZcdDr7NZZYOIsmc9kLJqkq0/"
[clients."ibra-makeup.pl"]
google_ads_customer_id = "818-919-2566"
adspro_client_id = "4"
sales_history_sheet = "https://docs.google.com/spreadsheets/d/1OGqRluQgd2vCVxbmVLhPd00q8RWucSc0L4GdcGAl8vU"
[global_rules]
max_create_groups_without_extra_confirm = 100
max_pause_groups_without_extra_confirm = 100
[global_rules.pla_settings]
require_presence_only = true
require_high_priority = true
[global_rules.product_feed_optimization]
limit = 10
min_days_between_title_changes = 30
[global_rules.budget_usage]
min_days_between_budget_changes = 7

View File

@@ -1,20 +0,0 @@
[clients."example.pl"]
google_ads_customer_id = "123-456-7890"
adspro_client_id = "1"
[global_rules]
max_create_groups_without_extra_confirm = 100
max_pause_groups_without_extra_confirm = 100
[global_rules.pla_settings]
require_presence_only = true
require_high_priority = true
[global_rules.product_feed_optimization]
limit = 10
min_days_between_title_changes = 30
# Wyjatek per klient:
# [clients."example.pl".pla_settings]
# require_high_priority = false
# require_presence_only = true

View File

@@ -9,10 +9,6 @@ adspro_client_id = "5"
[clients."van-dam.pl"]
google_ads_customer_id = "570-658-4790"
[clients."sklep.ele-comp.pl"]
google_ads_customer_id = "489-092-9476"
adspro_client_id = "7"
[clients."investagd.pl"]
google_ads_customer_id = "229-855-5588"
adspro_client_id = "8"
@@ -35,6 +31,7 @@ adspro_client_id = "3"
[clients."ibra-makeup.pl"]
google_ads_customer_id = "818-919-2566"
adspro_client_id = "4"
sales_history_sheet = "https://docs.google.com/spreadsheets/d/1OGqRluQgd2vCVxbmVLhPd00q8RWucSc0L4GdcGAl8vU"
[global_rules]
max_create_groups_without_extra_confirm = 100
@@ -47,3 +44,6 @@ require_high_priority = true
[global_rules.product_feed_optimization]
limit = 10
min_days_between_title_changes = 30
[global_rules.budget_usage]
min_days_between_budget_changes = 7

View File

@@ -0,0 +1,78 @@
[[groups]]
id = "campaigns_structure"
name = "Ustawienia kampanii"
[[groups.tasks]]
id = "check_pla_settings"
name = "Sprawdzenie ustawien PLA"
description = "Sprawdza ustawienia lokalizacji i priorytetu kampanii PLA wedlug regul globalnych i wyjatkow klienta."
[[groups]]
id = "search_campaigns"
name = "Kampanie Search"
[[groups.tasks]]
id = "check_search_basic_settings"
name = "Sprawdzenie podstawowych ustawien Search"
description = "Sprawdza podstawowe ustawienia kampanii Search i przygotowuje korekty: lokalizacja Obecnosc, wylaczona siec reklamowa oraz wylaczeni partnerzy wyszukiwania."
[[groups]]
id = "measurement_conversions"
name = "Pomiar i konwersje"
[[groups.tasks]]
id = "check_conversion_tracking"
name = "Sprawdzenie pomiaru konwersji"
description = "Pobiera akcje konwersji z Google Ads, sprawdza dane i wartosci oraz proponuje wlaczenie roznych wartosci konwersji dla e-commerce."
[[groups]]
id = "product_campaigns"
name = "Kampanie produktowe"
[[groups.tasks]]
id = "sync_pla_cl1"
name = "Synchronizacja kampanii PLA_CL1"
description = "Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam."
[[groups.tasks]]
id = "optimize_shopping_troas_ag"
name = "Automatyzacja tROAS per grupa reklam PLA"
description = "Analizuje Standard Shopping PLA z ostatnich 30 dni i przygotowuje plan stopniowej zmiany tROAS na poziomie grup reklam."
[[groups]]
id = "products_feed_shopping"
name = "Produkty, feed i Shopping"
[[groups.tasks]]
id = "optimize_product_titles"
name = "Optymalizacja tytulow produktow"
description = "Pobiera produkty z adsPRO i przygotowuje plan optymalizacji tytulow produktow."
[[groups.tasks]]
id = "optimize_product_categories"
name = "Optymalizacja kategorii Google"
description = "Pobiera produkty z adsPRO bez kategorii Google i przygotowuje plan decyzji agenta AI."
[[groups.tasks]]
id = "fill_product_unit_pricing"
name = "Uzupelnienie unit pricing"
description = "Pobiera produkty z adsPRO bez unit pricing i przygotowuje plan uzupelnienia miary oraz miary bazowej."
[[groups]]
id = "budget_bidding_alerts"
name = "Budzety, stawki i alerty"
[[groups.tasks]]
id = "check_budget_usage"
name = "Sprawdzenie wykorzystania budzetu"
description = "Sprawdza tygodniowe wykorzystanie budzetow, kampanie ograniczone budzetem i ryzyko zbyt szybkiego albo zbyt wolnego wydawania srodkow."
[[groups.tasks]]
id = "check_bidding_strategies"
name = "Sprawdzenie strategii stawek"
description = "Sprawdza strategie ustalania stawek, cele Docelowy ROAS/Docelowy CPA, wolumen konwersji i ryzyko zbyt szybkich zmian."
[[groups.tasks]]
id = "check_account_anomalies"
name = "Sprawdzenie anomalii konta"
description = "Sprawdza nagle spadki i wzrosty kosztu, klikniec, konwersji, wartosci konwersji, CTR, CPC i ROAS na poziomie kampanii."

View File

@@ -1,20 +1,25 @@
[[groups]]
id = "pla"
name = "Kampanie PLA"
id = "campaigns_structure"
name = "Kampanie i struktura"
[[groups.tasks]]
id = "sync_pla_cl1"
name = "Synchronizacja kampanii PLA_CL1"
description = "Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam."
[[groups.tasks]]
id = "optimize_shopping_troas_ag"
name = "Automatyzacja tROAS per grupa reklam PLA"
description = "Analizuje Standard Shopping PLA z ostatnich 30 dni i przygotowuje plan stopniowej zmiany tROAS na poziomie grup reklam."
[[groups.tasks]]
id = "check_pla_settings"
name = "Sprawdzenie ustawien"
name = "Sprawdzenie ustawien PLA"
description = "Sprawdza ustawienia lokalizacji i priorytetu kampanii PLA wedlug regul globalnych i wyjatkow klienta."
[[groups]]
id = "products"
name = "Produkty"
id = "products_feed_shopping"
name = "Produkty, feed i Shopping"
[[groups.tasks]]
id = "optimize_product_titles"
@@ -30,3 +35,21 @@ description = "Pobiera produkty z adsPRO bez kategorii Google i przygotowuje pla
id = "fill_product_unit_pricing"
name = "Uzupelnienie unit pricing"
description = "Pobiera produkty z adsPRO bez unit pricing i przygotowuje plan uzupelnienia miary oraz miary bazowej."
[[groups]]
id = "budget_bidding_alerts"
name = "Budzety, stawki i alerty"
[[groups.tasks]]
id = "check_budget_usage"
name = "Sprawdzenie wykorzystania budzetu"
description = "Sprawdza tygodniowe wykorzystanie budzetow, kampanie ograniczone budzetem i ryzyko zbyt szybkiego albo zbyt wolnego wydawania srodkow."
[[groups.tasks]]
id = "check_bidding_strategies"
name = "Sprawdzenie strategii stawek"
description = "Sprawdza strategie ustalania stawek, cele Docelowy ROAS/Docelowy CPA, wolumen konwersji i ryzyko zbyt szybkich zmian."
[[groups.tasks]]
id = "check_account_anomalies"
name = "Sprawdzenie anomalii konta"
description = "Sprawdza nagle spadki i wzrosty kosztu, klikniec, konwersji, wartosci konwersji, CTR, CPC i ROAS na poziomie kampanii."

View File

@@ -0,0 +1,165 @@
[[groups]]
id = "campaigns_structure"
name = "Kampanie i struktura"
[[groups.tasks]]
id = "sync_pla_cl1"
name = "Synchronizacja kampanii PLA_CL1"
description = "Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam."
[[groups.tasks]]
id = "check_pla_settings"
name = "Sprawdzenie ustawien PLA"
description = "Sprawdza ustawienia lokalizacji i priorytetu kampanii PLA wedlug regul globalnych i wyjatkow klienta."
[[groups.tasks]]
id = "check_search_basic_settings"
name = "Sprawdzenie podstawowych ustawien Search"
description = "Sprawdza podstawowe ustawienia kampanii Search: lokalizacje, sieci, jezyki i inne proste ustawienia konfiguracyjne."
[[groups.tasks]]
id = "check_pmax_structure"
name = "Sprawdzenie struktury PMax"
description = "Sprawdza kampanie Performance Max, asset groups, feed, brand/non-brand i ryzyka kanibalizacji."
[[groups.tasks]]
id = "check_feed_merchant_quality"
name = "Sprawdzenie feedu i Merchant Center"
description = "Sprawdza jakosc feedu produktowego, potencjalne problemy Merchant Center, atrybuty produktow i ryzyka odrzucen."
[[groups.tasks]]
id = "check_shopping_product_statuses"
name = "Sprawdzenie statusow produktow Shopping"
description = "Sprawdza produkty niedostepne, odrzucone, ograniczone albo bez emisji w Shopping i PMax."
[[groups.tasks]]
id = "check_shopping_product_performance"
name = "Sprawdzenie wynikow produktow Shopping"
description = "Sprawdza wyniki produktow Shopping z ostatnich 30 dni: koszt, klikniecia, konwersje, wartosc, ROAS i CPA."
[[groups]]
id = "measurement_audiences"
name = "Pomiar, konwersje i odbiorcy"
[[groups.tasks]]
id = "check_conversion_tracking"
name = "Sprawdzenie pomiaru konwersji"
description = "Sprawdza, czy konto ma poprawnie ustawione konwersje, tagowanie, GA4/Google Ads i czy dane nadaja sie do optymalizacji kampanii."
[[groups.tasks]]
id = "check_conversion_action_performance"
name = "Sprawdzenie akcji konwersji"
description = "Sprawdza, ktore akcje konwersji generuja wynik w kampaniach i czy rozklad konwersji wymaga recznej oceny pomiaru."
[[groups.tasks]]
id = "check_remarketing_setup"
name = "Sprawdzenie remarketingu"
description = "Sprawdza podstawy remarketingu: listy odbiorcow, dynamiczny remarketing, tagowanie produktowe i ryzyka konfliktu z PMax."
[[groups.tasks]]
id = "check_gender_performance"
name = "Sprawdzenie plci odbiorcow"
description = "Sprawdza wyniki kampanii wedlug plci odbiorcow, bez dodawania wykluczen demograficznych i bez korekt stawek."
[[groups.tasks]]
id = "check_age_performance"
name = "Sprawdzenie wieku odbiorcow"
description = "Sprawdza wyniki kampanii wedlug przedzialow wieku odbiorcow, bez dodawania wykluczen demograficznych i bez korekt stawek."
[[groups]]
id = "traffic_keywords_landing"
name = "Ruch, slowa i strony docelowe"
[[groups.tasks]]
id = "check_search_terms"
name = "Analiza zapytan i wykluczen"
description = "Sprawdza zapytania uzytkownikow, niepasujacy ruch, broad match, frazy brand/non-brand i kandydatow do wykluczen."
[[groups.tasks]]
id = "check_ad_group_performance"
name = "Sprawdzenie grup reklam"
description = "Sprawdza koszt, klikniecia, konwersje, wartosc konwersji, ROAS, CPA i CTR na poziomie grup reklam."
[[groups.tasks]]
id = "check_keyword_statuses"
name = "Sprawdzenie statusow slow kluczowych"
description = "Sprawdza odrzucone, ograniczone, wstrzymane albo technicznie problematyczne slowa kluczowe."
[[groups.tasks]]
id = "check_keyword_quality_score"
name = "Sprawdzenie Wyniku Jakosci slow kluczowych"
description = "Sprawdza Wynik Jakosci slow kluczowych, jakosc reklamy, strone docelowa, przewidywany CTR i wyniki."
[[groups.tasks]]
id = "check_landing_page_performance"
name = "Sprawdzenie stron docelowych"
description = "Sprawdza wyniki stron docelowych: koszt, klikniecia, konwersje, wartosc konwersji, ROAS, CPA, CTR i wspolczynnik konwersji."
[[groups]]
id = "ads_assets_statuses"
name = "Reklamy, zasoby i statusy"
[[groups.tasks]]
id = "check_rsa_assets"
name = "Sprawdzenie reklam RSA i zasobow"
description = "Sprawdza reklamy RSA, naglowki, teksty, assety, DKI i podstawowe ryzyka kreacji."
[[groups.tasks]]
id = "check_ad_asset_statuses"
name = "Sprawdzenie statusow reklam i zasobow"
description = "Sprawdza odrzucone, ograniczone, wstrzymane albo oczekujace reklamy i zasoby."
[[groups]]
id = "settings_segments_auctions"
name = "Ustawienia, segmenty i aukcje"
[[groups.tasks]]
id = "check_campaign_locations"
name = "Sprawdzenie lokalizacji kampanii"
description = "Sprawdza lokalizacje, wykluczone lokalizacje i tryb kierowania lokalizacja w kampaniach."
[[groups.tasks]]
id = "check_campaign_networks"
name = "Sprawdzenie sieci kampanii"
description = "Sprawdza ustawienia sieci kampanii: Google Search, partnerow wyszukiwania i siec reklamowa."
[[groups.tasks]]
id = "check_campaign_languages"
name = "Sprawdzenie jezykow kampanii"
description = "Sprawdza ustawienia jezykow kampanii i oznacza kampanie wymagajace recznej oceny dopasowania jezyka do rynku klienta."
[[groups.tasks]]
id = "check_ad_schedules"
name = "Sprawdzenie harmonogramu reklam"
description = "Sprawdza harmonogram emisji reklam, kampanie dzialajace 24/7 i nietypowe godziny emisji."
[[groups.tasks]]
id = "check_device_performance"
name = "Sprawdzenie urzadzen"
description = "Sprawdza wyniki wedlug urzadzen: komputer, telefon i tablet, pokazujac koszt, konwersje, wartosc konwersji, ROAS, CPA i udzial kosztu."
[[groups.tasks]]
id = "check_day_of_week_performance"
name = "Sprawdzenie dni tygodnia"
description = "Sprawdza wyniki wedlug dni tygodnia z ostatnich 30 dni: koszt, klikniecia, konwersje, wartosc konwersji, ROAS i CPA."
[[groups.tasks]]
id = "check_hour_of_day_performance"
name = "Sprawdzenie godzin dnia"
description = "Sprawdza wyniki wedlug godzin dnia z ostatnich 30 dni, aby wskazac godziny wymagajace oceny."
[[groups.tasks]]
id = "check_network_performance"
name = "Sprawdzenie efektywnosci sieci"
description = "Sprawdza realne wyniki wedlug sieci emisji, np. Search, partnerzy, Display i Shopping."
[[groups.tasks]]
id = "check_impression_share"
name = "Sprawdzenie udzialu w wyswietleniach"
description = "Sprawdza udzial w wyswietleniach, utrate przez budzet i utrate przez ranking na poziomie kampanii."
[[groups.tasks]]
id = "check_auction_insights"
name = "Sprawdzenie Auction Insights"
description = "Sprawdza konkurentow w aukcji, overlap rate, outranking share, position above rate oraz top i absolute top rate."

View File

@@ -1,3 +0,0 @@
google-ads>=25.0.0
requests>=2.31.0

View File

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

View File

@@ -0,0 +1,716 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Raport Kwiecień 2026 &mdash; Aruba Rzeszow</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--primary: #0d8b8b;
--primary-dark: #065a5a;
--primary-light: #e8f5f5;
--green: #27ae60;
--red: #e74c3c;
--gray: #6c757d;
--light-gray: #f8f9fa;
--border: #e9ecef;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: #333;
background: #f5f5f5;
line-height: 1.6;
}
.hero {
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 60%, #10a5a5 100%);
color: white;
padding: 60px 40px;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: 0; right: 0;
width: 50%;
height: 100%;
background: url("data:image/svg+xml,%3Csvg width='400' height='400' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cpattern id='grid' width='40' height='40' patternUnits='userSpaceOnUse'%3E%3Ccircle cx='20' cy='20' r='1.5' fill='rgba(255,255,255,0.15)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='400' height='400' fill='url(%23grid)'/%3E%3Cline x1='20' y1='60' x2='100' y2='20' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='100' y1='20' x2='180' y2='80' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='180' y1='80' x2='260' y2='40' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='260' y1='40' x2='340' y2='100' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='60' y1='140' x2='140' y2='120' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='140' y1='120' x2='220' y2='180' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='220' y1='180' x2='300' y2='140' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='300' y1='140' x2='380' y2='200' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3C/svg%3E") repeat;
opacity: 0.7;
}
.hero-content {
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 50px;
}
.logo svg {
height: 40px;
}
.logo-text {
font-size: 22px;
font-weight: 700;
letter-spacing: 3px;
text-transform: uppercase;
}
.logo-subtitle {
font-size: 11px;
letter-spacing: 2px;
opacity: 0.85;
text-transform: lowercase;
}
.hero h1 {
font-size: 42px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 16px;
}
.hero .meta {
font-size: 18px;
opacity: 0.9;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
}
.report-section {
background: white;
border-radius: 12px;
padding: 32px;
margin: 24px auto;
max-width: 1400px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.section-title {
color: var(--primary-dark);
font-size: 24px;
font-weight: 700;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 3px solid var(--primary);
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.kpi-card {
background: var(--light-gray);
border-radius: 10px;
padding: 20px;
text-align: center;
border: 1px solid var(--border);
}
.kpi-label {
font-size: 12px;
color: var(--primary);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.kpi-value {
font-size: 28px;
font-weight: 700;
color: #2c3e50;
white-space: nowrap;
}
.kpi-unit {
font-size: 14px;
font-weight: 400;
color: var(--gray);
}
.kpi-change {
font-size: 13px;
margin-top: 6px;
font-weight: 500;
}
.chart-container {
margin: 24px 0;
}
.chart-container h3 {
color: var(--primary-dark);
margin-bottom: 12px;
font-size: 16px;
}
.chart-container canvas {
max-height: 300px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table thead {
background: var(--primary);
color: white;
}
.data-table th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.data-table tbody tr:hover {
background: var(--primary-light);
}
.data-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.badge-search { background: #dbeafe; color: #1e40af; }
.badge-performance_max { background: #fef3c7; color: #92400e; }
.badge-shopping { background: #d1fae5; color: #065f46; }
.badge-display { background: #ede9fe; color: #5b21b6; }
.badge-demand_gen { background: #fce7f3; color: #9d174d; }
.summary-box {
background: var(--primary-light);
border-left: 4px solid var(--primary);
padding: 24px;
border-radius: 0 10px 10px 0;
font-size: 15px;
line-height: 1.8;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 24px;
}
.bar {
height: 8px;
background: var(--primary);
border-radius: 4px;
min-width: 4px;
}
.recommendations-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.rec-item {
display: flex;
gap: 16px;
padding: 16px;
background: var(--light-gray);
border-radius: 8px;
border-left: 4px solid var(--primary);
}
.rec-icon {
font-size: 20px;
flex-shrink: 0;
margin-top: 2px;
}
.rec-item p {
margin-top: 4px;
color: var(--gray);
font-size: 14px;
}
.questions-box {
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border-left: 4px solid #f59e0b;
border-radius: 10px;
padding: 24px 28px;
margin-top: 16px;
}
.questions-box h3 {
margin: 0 0 8px 0;
color: #92400e;
font-size: 18px;
}
.questions-box .intro {
color: #78350f;
font-size: 14px;
margin-bottom: 16px;
}
.questions-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.q-item {
display: flex;
gap: 14px;
padding: 14px 16px;
background: rgba(255,255,255,0.7);
border-radius: 8px;
}
.q-num {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: #f59e0b;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
}
.q-item strong { color: #92400e; display: block; margin-bottom: 4px; }
.q-item p { margin: 0; color: #57534e; font-size: 14px; line-height: 1.6; }
.footer {
text-align: center;
padding: 32px;
color: var(--gray);
font-size: 13px;
}
.footer a {
color: var(--primary);
text-decoration: none;
}
@media (max-width: 768px) {
.hero { padding: 40px 20px; }
.hero h1 { font-size: 28px; }
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
.report-section { padding: 20px; margin: 12px; }
}
@media print {
body { background: white; }
.report-section { box-shadow: none; page-break-inside: avoid; }
.hero { padding: 30px; }
}
</style>
</head>
<body>
<!-- HERO / TITLE -->
<header class="hero">
<div class="hero-content">
<div class="logo">
<img src="https://www.project-pro.pl/upload/filemanager/Project-Design/logos/project-pro/logo-white.svg" alt="Project-Pro" style="height: 40px;">
</div>
<h1>Raport z działań marketingowych</h1>
<div class="meta">Aruba Rzeszow &mdash; Kwiecień 2026</div>
</div>
</header>
<!-- RECOMMENDATIONS -->
<section class="report-section" id="recommendations">
<h2 class="section-title">Wnioski i rekomendacje</h2>
<div class="recommendations-list">
<div class="rec-item"><span class="rec-icon">&#9888;</span><div><strong>Spadek konwersji do obserwacji</strong><p>Liczba konwersji spadla o 8.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu.</p></div></div><div class="rec-item"><span class="rec-icon">&#128200;</span><div><strong>ROAS liczony z Google Ads</strong><p>ROAS z Google Ads wyniosl 8.47. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu.</p></div></div><div class="rec-item"><span class="rec-icon">&#128269;</span><div><strong>Kontrola wzrostu kosztu</strong><p>Koszt reklam wzrosl o 12.2% miesiac do miesiaca. Warto porownac wzrost kosztu ze wzrostem konwersji i wartosci konwersji.</p></div></div>
</div>
</section>
<!-- GA4 SECTION -->
<!-- E-COMMERCE -->
<!-- PRODUCT OPTIMIZATIONS -->
<!-- TOP ADS PRODUCTS -->
<!-- YEAR OVER YEAR -->
<!-- GOOGLE ADS KPIs -->
<section class="report-section" id="ads-kpi">
<h2 class="section-title">Google Ads &mdash; Podsumowanie</h2>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">Wyświetlenia</div>
<div class="kpi-value">172 277<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -0.6% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Kliknięcia</div>
<div class="kpi-value">3 826<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #27ae60">
&#9650; +2.5% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">CTR</div>
<div class="kpi-value">2.2<span class="kpi-unit">%</span></div>
<div class="kpi-change" style="color: #27ae60">
&#9650; +3.3% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Konwersje</div>
<div class="kpi-value">199<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -8.2% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Koszt</div>
<div class="kpi-value">4880.74<span class="kpi-unit"> PLN</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9650; +12.2% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">CPA</div>
<div class="kpi-value">24.46<span class="kpi-unit"> PLN</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9650; +22.2% vs Marzec
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">ROAS</div>
<div class="kpi-value">8.47<span class="kpi-unit">x</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -1.5% vs Marzec
</div>
</div></div>
</section>
<!-- DAILY CHART -->
<section class="report-section" id="ads-chart">
<h2 class="section-title">Google Ads &mdash; Aktywność dzienna</h2>
<div class="chart-container">
<canvas id="dailyClicksChart"></canvas>
</div>
</section>
<!-- CAMPAIGNS TABLE -->
<section class="report-section" id="campaigns">
<h2 class="section-title">Kampanie</h2>
<table class="data-table">
<thead>
<tr>
<th>Kampania</th>
<th>Typ</th>
<th>Wyświetlenia</th>
<th>Kliknięcia</th>
<th>CTR</th>
<th>Konwersje</th>
<th>Koszt</th>
<th>CPA</th>
</tr>
</thead>
<tbody>
<tr>
<td>[Search] brand</td>
<td><span class="badge badge-search">SEARCH</span></td>
<td class="num">1 614</td>
<td class="num">483</td>
<td class="num">29.9%</td>
<td class="num">27</td>
<td class="num">337.96 PLN</td>
<td class="num">12.52 PLN</td>
</tr>
<tr>
<td>[DSA] produkty</td>
<td><span class="badge badge-search">SEARCH</span></td>
<td class="num">9 984</td>
<td class="num">694</td>
<td class="num">7.0%</td>
<td class="num">23</td>
<td class="num">1098.15 PLN</td>
<td class="num">47.75 PLN</td>
</tr>
<tr>
<td>[PMax] products (catch-all)</td>
<td><span class="badge badge-performance_max">PERFORMANCE_MAX</span></td>
<td class="num">138 921</td>
<td class="num">2 164</td>
<td class="num">1.6%</td>
<td class="num">106</td>
<td class="num">2762.99 PLN</td>
<td class="num">25.94 PLN</td>
</tr>
<tr>
<td>[PLA] produkty (bestsellers)</td>
<td><span class="badge badge-shopping">SHOPPING</span></td>
<td class="num">21 758</td>
<td class="num">485</td>
<td class="num">2.2%</td>
<td class="num">43</td>
<td class="num">681.64 PLN</td>
<td class="num">15.85 PLN</td>
</tr></tbody>
</table>
</section>
<!-- SEARCH TERMS -->
<section class="report-section" id="search-terms">
<h2 class="section-title">Najpopularniejsze frazy wyszukiwania</h2>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Fraza</th>
<th>Wyświetlenia</th>
<th>Kliknięcia</th>
<th>CTR</th>
<th>Konwersje</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">1</td>
<td>aruba rzeszów</td>
<td class="num">836</td>
<td class="num">246</td>
<td class="num">29.4%</td>
<td class="num">16</td>
</tr>
<tr>
<td class="num">2</td>
<td>gen factor</td>
<td class="num">858</td>
<td class="num">59</td>
<td class="num">6.9%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">3</td>
<td>aruba hurtownia</td>
<td class="num">122</td>
<td class="num">48</td>
<td class="num">39.3%</td>
<td class="num">4</td>
</tr>
<tr>
<td class="num">4</td>
<td>aruba rzeszow</td>
<td class="num">127</td>
<td class="num">39</td>
<td class="num">30.7%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">5</td>
<td>gen factor green</td>
<td class="num">207</td>
<td class="num">21</td>
<td class="num">10.1%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">6</td>
<td>gen factor</td>
<td class="num">604</td>
<td class="num">21</td>
<td class="num">3.5%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">7</td>
<td>verru immuno</td>
<td class="num">495</td>
<td class="num">19</td>
<td class="num">3.8%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">8</td>
<td>aruba sklep</td>
<td class="num">48</td>
<td class="num">17</td>
<td class="num">35.4%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">9</td>
<td>aurumaris</td>
<td class="num">113</td>
<td class="num">13</td>
<td class="num">11.5%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">10</td>
<td>aruba hurtownia kosmetyczna</td>
<td class="num">25</td>
<td class="num">12</td>
<td class="num">48.0%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">11</td>
<td>aruba kosmetyki</td>
<td class="num">33</td>
<td class="num">12</td>
<td class="num">36.4%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">12</td>
<td>gen factor 09</td>
<td class="num">47</td>
<td class="num">11</td>
<td class="num">23.4%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">13</td>
<td>genfactor</td>
<td class="num">111</td>
<td class="num">11</td>
<td class="num">9.9%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">14</td>
<td>podopharm verru immuno</td>
<td class="num">230</td>
<td class="num">11</td>
<td class="num">4.8%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">15</td>
<td>hurtownia aruba</td>
<td class="num">32</td>
<td class="num">10</td>
<td class="num">31.2%</td>
<td class="num">0</td>
</tr></tbody>
</table>
</section>
<!-- NEGATIVE KEYWORDS ADDED -->
<!-- SEMSTORM SEO -->
<!-- SEO ACTIVITIES -->
<!-- SEO LINKS -->
<!-- SUMMARY -->
<section class="report-section" id="summary">
<h2 class="section-title">Podsumowanie miesiąca</h2>
<div class="summary-box">
Odnotowano 199 konwersji w tym miesiącu. Ruch z reklam wzrósł o 2.5% (3826 kliknięć).
</div>
</section>
<!-- RECOMMENDATIONS moved to top -->
<!-- FOOTER -->
<div class="footer">
Raport wygenerowany przez <a href="https://www.project-pro.pl">Project-Pro</a> &mdash; marketing w wersji PRO
</div>
<script>
// Daily clicks chart
var ctx1 = document.getElementById('dailyClicksChart').getContext('2d');
new Chart(ctx1, {
type: 'line',
data: {
labels: ["04-01", "04-02", "04-03", "04-04", "04-05", "04-06", "04-07", "04-08", "04-09", "04-10", "04-11", "04-12", "04-13", "04-14", "04-15", "04-16", "04-17", "04-18", "04-19", "04-20", "04-21", "04-22", "04-23", "04-24", "04-25", "04-26", "04-27", "04-28", "04-29", "04-30"],
datasets: [{
label: 'Kliknięcia',
data: [102, 108, 72, 54, 39, 96, 130, 166, 137, 112, 95, 114, 185, 176, 164, 149, 107, 101, 114, 196, 163, 210, 170, 116, 112, 131, 144, 132, 135, 96],
borderColor: '#0d8b8b',
backgroundColor: 'rgba(13,139,139,0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#0d8b8b',
}, {
label: 'Wyświetlenia',
data: [6909, 5632, 4210, 3045, 2088, 3976, 5853, 7519, 6605, 4340, 3177, 4104, 7332, 7941, 7296, 6191, 4557, 3621, 5409, 7762, 7615, 9246, 9234, 5931, 5078, 5786, 6014, 6078, 5629, 4099],
borderColor: '#95a5a6',
backgroundColor: 'transparent',
borderDash: [5, 5],
tension: 0.3,
pointRadius: 0,
yAxisID: 'y1',
}]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
y: { beginAtZero: true, position: 'left', grid: { color: '#f0f0f0' } },
y1: { beginAtZero: true, position: 'right', grid: { display: false } },
x: { grid: { display: false }, ticks: { maxTicksLimit: 10 } }
}
}
});
</script>
</body>
</html>

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from .config import ROOT
from .table import print_table
@dataclass(frozen=True)
class Task:
id: str
name: str
description: str
group_id: str
group_name: str
number: int
group_number: int
index_in_group: int
@property
def selection(self) -> str:
return f"{self.group_number}.{self.index_in_group}"
@dataclass(frozen=True)
class TaskGroup:
id: str
name: str
number: int
def load_task_config() -> dict:
path = ROOT / "config" / "tasks.toml"
return tomllib.loads(path.read_text(encoding="utf-8"))
def load_groups() -> list[TaskGroup]:
data = load_task_config()
groups = []
for index, group in enumerate(data.get("groups", []), 1):
groups.append(TaskGroup(id=group["id"], name=group["name"], number=index))
return groups
def load_tasks() -> list[Task]:
data = load_task_config()
tasks: list[Task] = []
number = 1
for group_number, group in enumerate(data.get("groups", []), 1):
for index_in_group, row in enumerate(group.get("tasks", []), 1):
tasks.append(
Task(
id=row["id"],
name=row["name"],
description=row.get("description", ""),
group_id=group["id"],
group_name=group["name"],
number=number,
group_number=group_number,
index_in_group=index_in_group,
)
)
number += 1
return tasks
def task_by_number(tasks: list[Task], number: int) -> Task | None:
for task in tasks:
if task.number == number:
return task
return None
def task_by_selection(tasks: list[Task], selection: str) -> Task | None:
normalized = selection.strip().lower()
for task in tasks:
if task.selection.lower() == normalized:
return task
return None
def tasks_by_selection_group(tasks: list[Task], groups: list[TaskGroup], selection: str) -> list[Task]:
normalized = selection.strip().lower()
if not normalized.endswith(".0"):
return []
try:
group_number = int(normalized.split(".", 1)[0])
except ValueError:
return []
return tasks_by_group_number(tasks, groups, group_number)
def tasks_by_group_number(tasks: list[Task], groups: list[TaskGroup], number: int) -> list[Task]:
group = next((item for item in groups if item.number == number), None)
if not group:
return []
return [task for task in tasks if task.group_id == group.id]
def print_task_list(tasks: list[Task]) -> None:
groups = load_groups()
for group in groups:
group_tasks = [task for task in tasks if task.group_id == group.id]
if not group_tasks:
continue
print()
print("=" * 72)
print(f"GRUPA {group.number}: {group.name.upper()}")
print("=" * 72)
print_table(
["Nr", "Zadanie", "Opis"],
[[item.selection, item.name, item.description] for item in group_tasks],
)
print()
print("Opcje zbiorcze")
group_rows = [
[f"{group.number}.0", f"Wszystkie zadania z grupy: {group.name}"]
for group in groups
if any(task.group_id == group.id for task in tasks)
]
print_table(
["Nr", "Zakres"],
group_rows + [["ALL", "Wszystkie zadania ze wszystkich grup"]],
)

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from ..config import ClientConfig, client_dir
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_conversion_tracking"
TASK_NAME = "Sprawdzenie pomiaru konwersji"
DEFAULT_SCOPE = [
{
"area": "Konwersje Google Ads",
"check": "Sprawdz, czy glowne konwersje sa aktywne i oznaczone jako cele uzywane do optymalizacji.",
},
{
"area": "Duplikacja konwersji",
"check": "Sprawdz, czy konto nie liczy tych samych zdarzen jednoczesnie z Google Ads, GA4 i importow.",
},
{
"area": "E-commerce",
"check": "Sprawdz, czy konwersje zakupowe przekazuja wartosc i walute.",
},
{
"area": "Remarketing dynamiczny",
"check": "Sprawdz, czy tagowanie e-commerce przekazuje identyfikatory produktow.",
},
{
"area": "Enhanced Conversions",
"check": "Sprawdz, czy rozszerzone konwersje sa skonfigurowane tam, gdzie ma to sens.",
},
]
@dataclass
class ConversionTrackingPlan:
scope: list[dict]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"scope": self.scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "ConversionTrackingPlan":
return cls(
scope=data.get("scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def build_conversion_tracking_plan(client_config: ClientConfig) -> ConversionTrackingPlan:
rules = rules_for_task(TASK_ID)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules
]
warnings = []
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Uruchom `python gads.py wiedza przypisz --restart` i przypisz pasujace reguly do check_conversion_tracking."
)
if not client_config.google_ads_customer_id:
warnings.append("Klient nie ma google_ads_customer_id w config/clients.toml.")
return ConversionTrackingPlan(scope=DEFAULT_SCOPE, knowledge_rules=knowledge_rules, warnings=warnings)
def save_conversion_tracking_plan(domain: str, plan: ConversionTrackingPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie pomiaru konwersji",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Obszary audytu: {len(plan.scope)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres audytu", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_conversion_tracking_plan(plan: ConversionTrackingPlan) -> None:
print("\nPlan sprawdzenia pomiaru konwersji")
print_table(
["Metryka", "Liczba"],
[
["Obszary audytu", str(len(plan.scope))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres audytu")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_conversion_tracking_plan(
client_config: ClientConfig,
plan: ConversionTrackingPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": "",
"summary": {
"scope_items": len(plan.scope),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_conversion_tracking(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = ConversionTrackingPlan.from_dict(plan_data)
print_conversion_tracking_plan(plan)
apply_conversion_tracking_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia pomiaru konwersji...")
plan = build_conversion_tracking_plan(client_config)
print_conversion_tracking_plan(plan)
json_path, md_path = save_conversion_tracking_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": "",
"summary": {
"scope_items": len(plan.scope),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu.")
if show_navigation:
print_next_navigation(client_config.domain)

View File

@@ -1,912 +0,0 @@
from __future__ import annotations
import csv
import json
import os
import re
from collections import defaultdict
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
import requests
from google.protobuf import field_mask_pb2
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
CSV_COLS = [
"id", "offer_id", "title", "availability", "channel", "content_language",
"target_country", "feed_label", "brand", "google_product_category",
"custom_label_0", "custom_label_1", "custom_label_2", "custom_label_3",
"custom_label_4", "link",
]
@dataclass
class SyncPlan:
campaigns: list[dict]
groups_total: int
groups_with_product_id: int
create_plan: list[dict]
enable_plan: list[dict]
pause_plan: list[dict]
rename_plan: list[dict]
warnings: list[str]
unmatched_groups: list[dict] | None = None
def to_dict(self) -> dict:
def serialize_rows(rows: list[dict]) -> list[dict]:
serialized = []
for item in rows:
row = {}
for key, value in item.items():
if isinstance(value, set):
row[key] = sorted(value)
else:
row[key] = value
serialized.append(row)
return serialized
return {
"task": "sync_pla_cl1",
"campaigns": serialize_rows(self.campaigns),
"groups_total": self.groups_total,
"groups_with_product_id": self.groups_with_product_id,
"create_plan": serialize_rows(self.create_plan),
"enable_plan": serialize_rows(self.enable_plan),
"pause_plan": serialize_rows(self.pause_plan),
"rename_plan": serialize_rows(self.rename_plan),
"warnings": self.warnings,
"unmatched_groups": serialize_rows(self.unmatched_groups or []),
}
@classmethod
def from_dict(cls, data: dict) -> "SyncPlan":
return cls(
campaigns=data.get("campaigns", []),
groups_total=int(data.get("groups_total", 0)),
groups_with_product_id=int(data.get("groups_with_product_id", 0)),
create_plan=data.get("create_plan", []),
enable_plan=data.get("enable_plan", []),
pause_plan=data.get("pause_plan", []),
rename_plan=data.get("rename_plan", []),
warnings=data.get("warnings", []),
unmatched_groups=data.get("unmatched_groups", []),
)
def campaign_action_summary(plan: SyncPlan) -> list[dict]:
campaign_names = set()
for action_name in ("create_plan", "enable_plan", "pause_plan", "rename_plan"):
for row in getattr(plan, action_name):
if row.get("campaign_name"):
campaign_names.add(row["campaign_name"])
create_counts = Counter(row["campaign_name"] for row in plan.create_plan)
enable_counts = Counter(row["campaign_name"] for row in plan.enable_plan)
pause_counts = Counter(row["campaign_name"] for row in plan.pause_plan)
rename_counts = Counter(row["campaign_name"] for row in plan.rename_plan)
return [
{
"campaign_name": name,
"create": create_counts.get(name, 0),
"enable": enable_counts.get(name, 0),
"pause": pause_counts.get(name, 0),
"rename": rename_counts.get(name, 0),
}
for name in sorted(campaign_names)
]
def normalize_text(value: str) -> str:
return " ".join(
(value or "")
.lower()
.replace("", "-")
.replace("", "-")
.replace("|", "-")
.replace("", "")
.replace("", "")
.replace('"', "")
.split()
)
def parse_allowed_labels(campaign_name: str) -> set[str]:
match = re.search(r"\]\s*(.+)$", campaign_name)
raw = match.group(1).strip() if match else campaign_name
if "|" in raw:
raw = raw.split("|", 1)[0].strip()
return {part.strip() for part in raw.split(",") if part.strip()}
def fetch_adspro_products(client: ClientConfig, segments: list[str]) -> list[dict]:
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 RuntimeError("Brak ADSPRO_API_URL lub ADSPRO_API_KEY w .env.")
if not client.adspro_client_id:
raise RuntimeError(f"Brak adspro_client_id dla {client.domain} w config/clients.toml.")
by_offer_id = {}
for segment in segments:
response = requests.post(
api_url,
data={
"action": "products_get_by_cl1",
"api_key": api_key,
"client_id": client.adspro_client_id,
"custom_label_1": segment,
},
timeout=60,
)
data = response.json()
if data.get("result") == "error":
raise RuntimeError(f"adsPRO zwrocil blad dla CL1={segment}: {data.get('message')}")
for product in data.get("products", []):
offer_id = product.get("offer_id") or ""
if offer_id:
by_offer_id[offer_id] = {
"id": "",
"offer_id": offer_id,
"title": product.get("title", "") or "",
"availability": "",
"channel": "",
"content_language": "",
"target_country": "",
"feed_label": "",
"brand": "",
"google_product_category": product.get("google_product_category", "") or "",
"custom_label_0": "",
"custom_label_1": product.get("custom_label_1", "") or "",
"custom_label_2": "",
"custom_label_3": product.get("custom_label_3", "") or "",
"custom_label_4": product.get("custom_label_4", "") or "",
"link": "",
}
return list(by_offer_id.values())
def save_products_csv(domain: str, products: list[dict]) -> Path:
out = client_dir(domain) / "data"
out.mkdir(parents=True, exist_ok=True)
path = out / "merchant_produkty_adspro.csv"
with path.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=CSV_COLS)
writer.writeheader()
writer.writerows(products)
return path
def save_plan_files(domain: str, plan: SyncPlan, products_count: int) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_sync_pla_cl1"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
"products_count": products_count,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Synchronizacja kampanii PLA_CL1",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie PLA_CL1: {len(plan.campaigns)}",
f"- Produkty z adsPRO: {products_count}",
f"- Grupy reklam obecnie: {plan.groups_total}",
f"- Grupy reklam z identyfikatorem produktu: {plan.groups_with_product_id}",
f"- Do utworzenia: {len(plan.create_plan)}",
f"- Do włączenia: {len(plan.enable_plan)}",
f"- Do wstrzymania: {len(plan.pause_plan)}",
f"- Do zmiany nazwy: {len(plan.rename_plan)}",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {warning}" for warning in plan.warnings)
lines.append("")
summary = campaign_action_summary(plan)
if summary:
lines.extend(["## Podsumowanie po kampaniach", "", "| Kampania | Utworz | Wlacz | Wstrzymaj | Zmien nazwe |", "| --- | ---: | ---: | ---: | ---: |"])
for row in summary:
lines.append(
f"| {row['campaign_name']} | {row['create']} | {row['enable']} | {row['pause']} | {row['rename']} |"
)
lines.append("")
if plan.unmatched_groups:
lines.extend(["## Grupy reklam bez dopasowania w adsPRO", "", "| Kampania | Grupa reklam | Status | Identyfikator produktu |", "| --- | --- | --- | --- |"])
for row in plan.unmatched_groups:
lines.append(
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['ad_group_status']} | {row.get('offer_id', '')} |"
)
lines.append("")
if plan.create_plan:
lines.extend(["## Grupy reklam do utworzenia", "", "| Kampania | Grupa reklam | Produkt | Powod |", "| --- | --- | --- | --- |"])
for row in plan.create_plan:
lines.append(
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']} | {row['reason']} |"
)
lines.append("")
if plan.enable_plan:
lines.extend(["## Grupy reklam do wlaczenia", "", "| Kampania | Grupa reklam | Produkt | Powod |", "| --- | --- | --- | --- |"])
for row in plan.enable_plan:
lines.append(
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']} | {row['reason']} |"
)
lines.append("")
if plan.pause_plan:
lines.extend(["## Grupy reklam do wstrzymania", "", "| Kampania | Grupa reklam | Powod |", "| --- | --- | --- |"])
for row in plan.pause_plan:
lines.append(f"| {row['campaign_name']} | {row['ad_group_name']} | {row['reason']} |")
lines.append("")
if plan.rename_plan:
lines.extend(["## Nazwy grup reklam do zmiany", "", "| Kampania | Obecna nazwa | Nowa nazwa |", "| --- | --- | --- |"])
for row in plan.rename_plan:
lines.append(f"| {row['campaign_name']} | {row['old_name']} | {row['new_name']} |")
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def build_plan(client, customer_id: str, products: list[dict]) -> SyncPlan:
campaign_rows = run_query(
client,
customer_id,
"""
SELECT campaign.id, campaign.name, campaign.status
FROM campaign
WHERE campaign.name LIKE '%PLA_CL1%'
AND campaign.status = 'ENABLED'
""",
)
campaigns = [
{
"id": str(row.campaign.id),
"name": row.campaign.name,
"status": row.campaign.status.name,
"allowed": parse_allowed_labels(row.campaign.name),
}
for row in campaign_rows
]
if not campaigns:
return SyncPlan([], 0, 0, [], [], [], [], ["Nie znaleziono kampanii [PLA_CL1]."], [])
label_to_campaign = {}
for campaign in campaigns:
for label in campaign["allowed"]:
label_to_campaign[label] = campaign
by_offer_id = {}
by_title_norm = defaultdict(list)
by_label = defaultdict(list)
for product in products:
offer_id = (product.get("offer_id") or "").strip()
title = (product.get("title") or "").strip()
label = (product.get("custom_label_1") or "").strip()
if offer_id:
by_offer_id[offer_id] = product
if title:
by_title_norm[normalize_text(title)].append(product)
if label and title:
by_label[label].append(product)
campaign_ids = ", ".join(c["id"] for c in campaigns)
group_rows = run_query(
client,
customer_id,
f"""
SELECT ad_group.id, ad_group.name, ad_group.status, campaign.id, campaign.name
FROM ad_group
WHERE campaign.id IN ({campaign_ids})
AND ad_group.status != 'REMOVED'
""",
)
criterion_rows = run_query(
client,
customer_id,
f"""
SELECT ad_group.id,
ad_group_criterion.listing_group.case_value.product_item_id.value,
ad_group_criterion.listing_group.type,
ad_group_criterion.negative
FROM ad_group_criterion
WHERE campaign.id IN ({campaign_ids})
AND ad_group_criterion.type = 'LISTING_GROUP'
AND ad_group_criterion.status != 'REMOVED'
""",
)
group_to_offer = {}
for row in criterion_rows:
if row.ad_group_criterion.negative:
continue
if row.ad_group_criterion.listing_group.type.name != "UNIT":
continue
offer_id = row.ad_group_criterion.listing_group.case_value.product_item_id.value
if offer_id:
group_to_offer.setdefault(str(row.ad_group.id), offer_id)
enabled_offers_by_campaign = defaultdict(set)
existing_groups_by_campaign_offer = defaultdict(list)
existing_groups_by_campaign_name = defaultdict(list)
all_groups = []
for row in group_rows:
group_id = str(row.ad_group.id)
record = {
"ad_group_id": group_id,
"ad_group_name": row.ad_group.name,
"ad_group_status": row.ad_group.status.name,
"campaign_id": str(row.campaign.id),
"campaign_name": row.campaign.name,
"allowed": parse_allowed_labels(row.campaign.name),
"offer_id": group_to_offer.get(group_id, ""),
}
all_groups.append(record)
existing_groups_by_campaign_name[(record["campaign_id"], normalize_text(record["ad_group_name"]))].append(record)
if record["offer_id"]:
existing_groups_by_campaign_offer[(record["campaign_id"], record["offer_id"])].append(record)
if record["ad_group_status"] == "ENABLED":
enabled_offers_by_campaign[record["campaign_id"]].add(record["offer_id"])
wrong_groups = []
groups_without_match = []
active_groups_without_match = []
rename_plan = []
for group in all_groups:
product = by_offer_id.get(group["offer_id"]) if group["offer_id"] else None
match_via = "offer_id" if product else None
if not product:
candidates = by_title_norm.get(normalize_text(group["ad_group_name"])) or []
if candidates:
product = candidates[0]
match_via = "title"
if not product:
groups_without_match.append(group)
if group["ad_group_status"] == "ENABLED":
active_groups_without_match.append(group)
continue
label = (product.get("custom_label_1") or "").strip()
if not label:
if group["ad_group_status"] == "ENABLED":
active_groups_without_match.append(group)
continue
if label not in group["allowed"]:
wrong_groups.append((group, product))
continue
adspro_title = (product.get("title") or "").strip()
if (
group["ad_group_status"] == "ENABLED"
and match_via == "offer_id"
and adspro_title
and group["ad_group_name"] != adspro_title
):
rename_plan.append(
{
"ad_group_id": group["ad_group_id"],
"campaign_id": group["campaign_id"],
"campaign_name": group["campaign_name"],
"old_name": group["ad_group_name"],
"new_name": adspro_title,
}
)
create_plan = []
enable_by_id = {}
pause_by_id = {}
def plan_enable_or_create(campaign: dict, product: dict, fallback_name: str, reason: str) -> None:
offer_id = (product.get("offer_id") or "").strip()
title = (product.get("title") or "").strip() or fallback_name
if not offer_id or not title:
return
if offer_id in enabled_offers_by_campaign[campaign["id"]]:
return
existing_candidates = existing_groups_by_campaign_offer.get((campaign["id"], offer_id), [])
if not existing_candidates:
existing_candidates = existing_groups_by_campaign_name.get((campaign["id"], normalize_text(title)), [])
paused_candidate = next((group for group in existing_candidates if group["ad_group_status"] == "PAUSED"), None)
if paused_candidate:
enable_by_id[paused_candidate["ad_group_id"]] = {
"ad_group_id": paused_candidate["ad_group_id"],
"ad_group_name": paused_candidate["ad_group_name"],
"campaign_id": paused_candidate["campaign_id"],
"campaign_name": paused_candidate["campaign_name"],
"product_id": offer_id,
"reason": reason,
}
enabled_offers_by_campaign[campaign["id"]].add(offer_id)
return
existing_active = next(
(group for group in existing_candidates if group["ad_group_status"] == "ENABLED"),
None,
)
if existing_active:
enabled_offers_by_campaign[campaign["id"]].add(offer_id)
return
create_plan.append(
{
"campaign_id": campaign["id"],
"campaign_name": campaign["name"],
"ad_group_name": title,
"product_id": offer_id,
"reason": reason,
}
)
enabled_offers_by_campaign[campaign["id"]].add(offer_id)
for group, product in wrong_groups:
offer_id = (product.get("offer_id") or "").strip()
label = (product.get("custom_label_1") or "").strip()
target = label_to_campaign.get(label)
if target and offer_id:
plan_enable_or_create(target, product, group["ad_group_name"], "produkt jest w zlej kampanii")
if group["ad_group_status"] == "ENABLED":
pause_by_id[group["ad_group_id"]] = {
"ad_group_id": group["ad_group_id"],
"ad_group_name": group["ad_group_name"],
"campaign_id": group["campaign_id"],
"campaign_name": group["campaign_name"],
"reason": "produkt jest w zlej kampanii",
}
for campaign in campaigns:
for label in campaign["allowed"]:
for product in by_label.get(label, []):
offer_id = (product.get("offer_id") or "").strip()
title = (product.get("title") or "").strip()
if not offer_id or not title:
continue
if offer_id in enabled_offers_by_campaign[campaign["id"]]:
continue
plan_enable_or_create(campaign, product, title, "brakuje aktywnej grupy reklam")
for group in active_groups_without_match:
pause_by_id[group["ad_group_id"]] = {
"ad_group_id": group["ad_group_id"],
"ad_group_name": group["ad_group_name"],
"campaign_id": group["campaign_id"],
"campaign_name": group["campaign_name"],
"reason": "brak dopasowania w adsPRO",
}
grouped = defaultdict(list)
for group in all_groups:
if group["ad_group_status"] != "ENABLED" or group["ad_group_id"] in pause_by_id or not group["offer_id"]:
continue
grouped[(group["campaign_id"], group["offer_id"])].append(group)
for group_list in grouped.values():
if len(group_list) <= 1:
continue
for group in sorted(group_list, key=lambda item: int(item["ad_group_id"]))[:-1]:
pause_by_id[group["ad_group_id"]] = {
"ad_group_id": group["ad_group_id"],
"ad_group_name": group["ad_group_name"],
"campaign_id": group["campaign_id"],
"campaign_name": group["campaign_name"],
"reason": "duplikat produktu w kampanii",
}
pause_plan = [pause_by_id[key] for key in sorted(pause_by_id, key=int)]
enable_plan = [enable_by_id[key] for key in sorted(enable_by_id, key=int)]
pause_ids = set(pause_by_id)
rename_plan = [row for row in rename_plan if row["ad_group_id"] not in pause_ids]
warnings = []
if groups_without_match:
warnings.append(f"Grupy reklam bez dopasowania w adsPRO: {len(groups_without_match)}.")
return SyncPlan(
campaigns=campaigns,
groups_total=len(all_groups),
groups_with_product_id=sum(1 for g in all_groups if g["offer_id"]),
create_plan=create_plan,
enable_plan=enable_plan,
pause_plan=pause_plan,
rename_plan=rename_plan,
warnings=warnings,
unmatched_groups=groups_without_match,
)
def create_ad_group_with_listing(client, customer_id: str, campaign_id: str, product_id: str, ad_group_name: str):
service = client.get_service("GoogleAdsService")
ad_group_service = client.get_service("AdGroupService")
campaign_resource = ad_group_service.campaign_path(customer_id, campaign_id)
ad_group_temp = f"customers/{customer_id}/adGroups/-1"
root_temp = f"customers/{customer_id}/adGroupCriteria/-1~-2"
operations = []
group_op = client.get_type("MutateOperation")
group = group_op.ad_group_operation.create
group.resource_name = ad_group_temp
group.name = ad_group_name
group.campaign = campaign_resource
group.status = client.enums.AdGroupStatusEnum.ENABLED
group.type_ = client.enums.AdGroupTypeEnum.SHOPPING_PRODUCT_ADS
operations.append(group_op)
root_op = client.get_type("MutateOperation")
root = root_op.ad_group_criterion_operation.create
root.resource_name = root_temp
root.ad_group = ad_group_temp
root.status = client.enums.AdGroupCriterionStatusEnum.ENABLED
root.listing_group.type_ = client.enums.ListingGroupTypeEnum.SUBDIVISION
operations.append(root_op)
product_op = client.get_type("MutateOperation")
product = product_op.ad_group_criterion_operation.create
product.ad_group = ad_group_temp
product.status = client.enums.AdGroupCriterionStatusEnum.ENABLED
product.listing_group.type_ = client.enums.ListingGroupTypeEnum.UNIT
product.listing_group.parent_ad_group_criterion = root_temp
product.listing_group.case_value.product_item_id.value = product_id
product.cpc_bid_micros = 1_000_000
operations.append(product_op)
other_op = client.get_type("MutateOperation")
other = other_op.ad_group_criterion_operation.create
other.ad_group = ad_group_temp
other.negative = True
other.status = client.enums.AdGroupCriterionStatusEnum.ENABLED
other.listing_group.type_ = client.enums.ListingGroupTypeEnum.UNIT
other.listing_group.parent_ad_group_criterion = root_temp
client.copy_from(other.listing_group.case_value.product_item_id, client.get_type("ProductItemIdInfo"))
operations.append(other_op)
ad_op = client.get_type("MutateOperation")
ad = ad_op.ad_group_ad_operation.create
ad.ad_group = ad_group_temp
ad.status = client.enums.AdGroupAdStatusEnum.ENABLED
ad.ad.shopping_product_ad._pb.SetInParent()
operations.append(ad_op)
service.mutate(customer_id=customer_id, mutate_operations=operations)
def pause_ad_groups(client, customer_id: str, ad_group_ids: list[str]) -> int:
service = client.get_service("AdGroupService")
changed = 0
for index in range(0, len(ad_group_ids), 500):
operations = []
for ad_group_id in ad_group_ids[index:index + 500]:
op = client.get_type("AdGroupOperation")
group = op.update
group.resource_name = service.ad_group_path(customer_id, ad_group_id)
group.status = client.enums.AdGroupStatusEnum.PAUSED
op.update_mask = field_mask_pb2.FieldMask(paths=["status"])
operations.append(op)
if operations:
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
changed += len(response.results)
return changed
def enable_ad_groups(client, customer_id: str, ad_group_ids: list[str]) -> int:
if not ad_group_ids:
return 0
service = client.get_service("AdGroupService")
changed = 0
for index in range(0, len(ad_group_ids), 500):
operations = []
for ad_group_id in ad_group_ids[index:index + 500]:
op = client.get_type("AdGroupOperation")
group = op.update
group.resource_name = service.ad_group_path(customer_id, ad_group_id)
group.status = client.enums.AdGroupStatusEnum.ENABLED
op.update_mask = field_mask_pb2.FieldMask(paths=["status"])
operations.append(op)
if operations:
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
changed += len(response.results)
return changed
def rename_ad_groups(client, customer_id: str, renames: list[dict]) -> int:
service = client.get_service("AdGroupService")
changed = 0
for index in range(0, len(renames), 500):
operations = []
for row in renames[index:index + 500]:
op = client.get_type("AdGroupOperation")
group = op.update
group.resource_name = service.ad_group_path(customer_id, row["ad_group_id"])
group.name = row["new_name"]
op.update_mask = field_mask_pb2.FieldMask(paths=["name"])
operations.append(op)
if operations:
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
changed += len(response.results)
return changed
def print_plan(plan: SyncPlan) -> None:
print("\nPlan synchronizacji PLA_CL1")
print(f"Kampanie PLA_CL1: {len(plan.campaigns)}")
print(f"Grupy reklam obecnie: {plan.groups_total}")
print(f"Grupy reklam z identyfikatorem produktu: {plan.groups_with_product_id}")
print(f"Do utworzenia: {len(plan.create_plan)}")
print(f"Do włączenia: {len(plan.enable_plan)}")
print(f"Do wstrzymania: {len(plan.pause_plan)}")
print(f"Do zmiany nazwy: {len(plan.rename_plan)}")
for warning in plan.warnings:
print(f"Uwaga: {warning}")
summary = campaign_action_summary(plan)
if summary:
print("\nPodsumowanie po kampaniach:")
for row in summary:
print(
f" {row['campaign_name']} | "
f"utwórz={row['create']} | włącz={row['enable']} | "
f"wstrzymaj={row['pause']} | zmień nazwę={row['rename']}"
)
for row in plan.create_plan[:20]:
print(f" Utworz: {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']}")
if len(plan.create_plan) > 20:
print(f" ... oraz {len(plan.create_plan) - 20} kolejnych grup reklam do utworzenia")
for row in plan.enable_plan[:20]:
print(f" Włącz: {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']}")
if len(plan.enable_plan) > 20:
print(f" ... oraz {len(plan.enable_plan) - 20} kolejnych grup reklam do włączenia")
for row in plan.pause_plan[:20]:
print(f" Wstrzymaj: {row['campaign_name']} | {row['ad_group_name']} | {row['reason']}")
if len(plan.pause_plan) > 20:
print(f" ... oraz {len(plan.pause_plan) - 20} kolejnych grup reklam do wstrzymania")
for row in plan.rename_plan[:20]:
print(f" Zmien nazwe: {row['ad_group_id']} | {row['old_name'][:50]} -> {row['new_name'][:50]}")
if len(plan.rename_plan) > 20:
print(f" ... oraz {len(plan.rename_plan) - 20} kolejnych nazw do zmiany")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_sync_plan(client_config: ClientConfig, plan: SyncPlan, show_navigation: bool = True) -> None:
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
created = 0
create_errors = 0
for row in plan.create_plan:
try:
create_ad_group_with_listing(
google_client,
customer_id,
row["campaign_id"],
row["product_id"],
row["ad_group_name"],
)
created += 1
except Exception as exc:
create_errors += 1
print(f"Blad tworzenia grupy reklam {row['ad_group_name']}: {exc}")
pause_ids = [row["ad_group_id"] for row in plan.pause_plan]
enable_ids = [row["ad_group_id"] for row in plan.enable_plan]
enabled = enable_ad_groups(google_client, customer_id, enable_ids) if enable_ids else 0
paused = pause_ad_groups(google_client, customer_id, pause_ids) if pause_ids else 0
renamed = rename_ad_groups(google_client, customer_id, plan.rename_plan) if plan.rename_plan else 0
print("\nWynik wdrozenia zmian")
print(f"Utworzono grup reklam: {created}")
print(f"Włączono grup reklam: {enabled}")
print(f"Bledy tworzenia: {create_errors}")
print(f"Wstrzymano grup reklam: {paused}")
print(f"Zmieniono nazwy grup reklam: {renamed}")
rows = []
rows.extend(
{
"klient": client_config.domain,
"kampania": row["campaign_name"],
"czynnosc": "włączono grupę reklam",
"grupa reklam": row["ad_group_name"],
"produkt": row["product_id"],
}
for row in plan.enable_plan
)
rows.extend(
{
"klient": client_config.domain,
"kampania": row["campaign_name"],
"czynnosc": "utworzono grupe reklam",
"grupa reklam": row["ad_group_name"],
"produkt": row["product_id"],
}
for row in plan.create_plan
)
rows.extend(
{
"klient": client_config.domain,
"kampania": row["campaign_name"],
"czynnosc": "wstrzymano grupe reklam",
"grupa reklam": row["ad_group_name"],
"produkt": row["reason"],
}
for row in plan.pause_plan
)
rows.extend(
{
"klient": client_config.domain,
"kampania": row["campaign_name"],
"czynnosc": "zmieniono nazwe grupy reklam",
"grupa reklam": row["old_name"],
"produkt": row["new_name"],
}
for row in plan.rename_plan
)
changes_path = append_change_markdown(client_config.domain, "Synchronizacja kampanii PLA_CL1", rows)
history_path = append_history(
client_config.domain,
{
"task": "Synchronizacja kampanii PLA_CL1",
"status": "wdrozono zmiany",
"campaign": ", ".join(c["name"] for c in plan.campaigns[:10]),
"summary": {
"created": created,
"enabled": enabled,
"create_errors": create_errors,
"paused": paused,
"renamed": renamed,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_sync_pla_cl1(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
if apply_plan_path:
if confirm_apply != "TAK":
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(
f"Plan jest dla klienta {plan_data.get('client')}, "
f"a wybrano {client_config.domain}."
)
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = SyncPlan.from_dict(plan_data)
print_plan(plan)
apply_sync_plan(client_config, plan, show_navigation=show_navigation)
return
started = now_local()
print(f"\nKlient: {client_config.domain}")
print("Pobieram kampanie PLA_CL1 i produkty z adsPRO...")
google_client = get_google_ads_client(use_proto_plus=True)
customer_id = client_config.safe_customer_id
campaign_rows = run_query(
google_client,
customer_id,
"""
SELECT campaign.id, campaign.name, campaign.status
FROM campaign
WHERE campaign.name LIKE '%PLA_CL1%'
AND campaign.status = 'ENABLED'
""",
)
segments = sorted(
{
label
for row in campaign_rows
for label in parse_allowed_labels(row.campaign.name)
}
)
if not segments:
print("Nie znaleziono segmentow CL1 w kampaniach [PLA_CL1].")
append_history(
client_config.domain,
{
"task": "Synchronizacja kampanii PLA_CL1",
"status": "brak kampanii",
"campaign": "",
},
)
if show_navigation:
print_next_navigation(client_config.domain)
return
print("Segmenty CL1: " + ", ".join(segments))
products = fetch_adspro_products(client_config, segments)
products_path = save_products_csv(client_config.domain, products)
print(f"Pobrano produkty z adsPRO: {len(products)}")
print(f"Zapisano dane: {products_path}")
plan = build_plan(google_client, customer_id, products)
print_plan(plan)
json_path, md_path = save_plan_files(client_config.domain, plan, len(products))
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": "Synchronizacja kampanii PLA_CL1",
"status": "plan przygotowany",
"campaign": ", ".join(c["name"] for c in plan.campaigns[:10]),
"created_at": started.isoformat(timespec="seconds"),
"summary": {
"campaigns": len(plan.campaigns),
"products": len(products),
"create": len(plan.create_plan),
"enable": len(plan.enable_plan),
"pause": len(plan.pause_plan),
"rename": len(plan.rename_plan),
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
if not plan.create_plan and not plan.enable_plan and not plan.pause_plan and not plan.rename_plan:
print("\nBrak zmian do wdrozenia.")
append_change_markdown(client_config.domain, "Synchronizacja kampanii PLA_CL1", [])
if show_navigation:
print_next_navigation(client_config.domain)
return
answer = input("\nWpisz TAK, aby wdrozyc powyzsze zmiany: ").strip()
if answer != "TAK":
print("Przerwano. Zmiany nie zostaly wdrozone.")
append_history(
client_config.domain,
{
"task": "Synchronizacja kampanii PLA_CL1",
"status": "odrzucono wdrozenie",
"campaign": ", ".join(c["name"] for c in plan.campaigns[:10]),
},
)
if show_navigation:
print_next_navigation(client_config.domain)
return
apply_sync_plan(client_config, plan, show_navigation=show_navigation)

View File

@@ -0,0 +1,394 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from ..config import ClientConfig, client_dir
from ..google_ads import get_google_ads_client, run_query
from ..history import append_change_markdown, append_history, now_local
from ..knowledge.store import rules_for_task
from ..table import print_table
TASK_ID = "check_search_basic_settings"
TASK_NAME = "Sprawdzenie podstawowych ustawien Search"
SCOPE = [
{
"area": "Lokalizacje",
"check": "Sprawdz typ kierowania lokalizacji, zwlaszcza Obecnosc vs Obecnosc lub zainteresowanie.",
},
{
"area": "Sieci",
"check": "Sprawdz, czy kampanie Search nie maja niechcaco wlaczonej sieci reklamowej albo partnerow wyszukiwania.",
},
{
"area": "Jezyki",
"check": "Sprawdz, czy ustawienia jezykowe sa zgodne z rynkiem klienta.",
},
{
"area": "Harmonogram reklam",
"check": "Sprawdz, czy harmonogram jest swiadomie ustawiony albo czy kampania dziala caly czas.",
},
]
OUT_OF_SCOPE = [
"budzety i wykorzystanie budzetu",
"strategie stawek i uczenie strategii",
"zapytania uzytkownikow oraz wykluczenia",
"reklamy RSA i zasoby reklam",
"wyniki kampanii oraz rentownosc",
]
@dataclass
class SearchBasicSettingsPlan:
campaigns: list[dict]
scope: list[dict]
out_of_scope: list[str]
knowledge_rules: list[dict]
warnings: list[str]
def to_dict(self) -> dict:
return {
"task": TASK_ID,
"task_name": TASK_NAME,
"campaigns": self.campaigns,
"scope": self.scope,
"out_of_scope": self.out_of_scope,
"knowledge_rules": self.knowledge_rules,
"warnings": self.warnings,
"changes": [],
}
@classmethod
def from_dict(cls, data: dict) -> "SearchBasicSettingsPlan":
return cls(
campaigns=data.get("campaigns", []),
scope=data.get("scope", []),
out_of_scope=data.get("out_of_scope", []),
knowledge_rules=data.get("knowledge_rules", []),
warnings=data.get("warnings", []),
)
def enum_name(value) -> str:
name = getattr(value, "name", None)
if name:
return name
return str(value)
def human_geo(value: str) -> str:
return {
"PRESENCE": "Obecnosc",
"PRESENCE_OR_INTEREST": "Obecnosc lub zainteresowanie",
"SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem",
}.get(value, value)
def yes_no(value: bool) -> str:
return "TAK" if value else "NIE"
def fetch_search_campaigns(client_config: ClientConfig) -> list[dict]:
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,
campaign.advertising_channel_type,
campaign.geo_target_type_setting.positive_geo_target_type,
campaign.network_settings.target_google_search,
campaign.network_settings.target_search_network,
campaign.network_settings.target_partner_search_network,
campaign.network_settings.target_content_network
FROM campaign
WHERE campaign.advertising_channel_type = 'SEARCH'
AND campaign.status != 'REMOVED'
""",
)
campaigns = []
for row in rows:
campaign = row.campaign
positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type)
campaigns.append(
{
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"status": enum_name(campaign.status),
"positive_geo_target_type": positive_geo,
"positive_geo_target_type_label": human_geo(positive_geo),
"target_google_search": bool(campaign.network_settings.target_google_search),
"target_search_network": bool(campaign.network_settings.target_search_network),
"target_partner_search_network": bool(campaign.network_settings.target_partner_search_network),
"target_content_network": bool(campaign.network_settings.target_content_network),
}
)
return campaigns
def build_search_basic_settings_plan(client_config: ClientConfig) -> SearchBasicSettingsPlan:
warnings = []
try:
campaigns = fetch_search_campaigns(client_config)
except Exception as exc:
campaigns = []
warnings.append(f"Nie udalo sie pobrac kampanii Search z Google Ads API: {exc}")
if not campaigns:
warnings.append("Nie znaleziono kampanii Search albo nie udalo sie ich pobrac.")
rules = rules_for_task(TASK_ID)
knowledge_rules = [
{
"id": rule.id,
"topic": rule.topic,
"rule_type": rule.rule_type,
"condition": rule.condition,
"recommendation": rule.recommendation,
"risk": rule.risk,
"source": rule.source,
}
for rule in rules
]
if not knowledge_rules:
warnings.append(
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
"Uzyj `python gads.py wiedza przypisz --restart`, gdy bedziemy wybierac reguly dla Search."
)
return SearchBasicSettingsPlan(
campaigns=campaigns,
scope=SCOPE,
out_of_scope=OUT_OF_SCOPE,
knowledge_rules=knowledge_rules,
warnings=warnings,
)
def save_search_basic_settings_plan(domain: str, plan: SearchBasicSettingsPlan) -> tuple[Path, Path]:
ts = now_local()
base = client_dir(domain) / "plans"
base.mkdir(parents=True, exist_ok=True)
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
json_path = base / f"{stem}.json"
md_path = base / f"{stem}.md"
payload = {
"created_at": ts.isoformat(timespec="seconds"),
"client": domain,
**plan.to_dict(),
}
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# Plan: Sprawdzenie podstawowych ustawien Search",
"",
f"Klient: {domain}",
f"Utworzono: {ts.isoformat(timespec='seconds')}",
"",
"## Podsumowanie",
"",
f"- Kampanie Search: {len(plan.campaigns)}",
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
"- Zmiany do wdrozenia: 0",
"",
]
if plan.warnings:
lines.extend(["## Uwagi", ""])
lines.extend(f"- {item}" for item in plan.warnings)
lines.append("")
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
for row in plan.scope:
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
lines.append("")
lines.extend(["## Poza zakresem tego zadania", ""])
lines.extend(f"- {item}" for item in plan.out_of_scope)
lines.append("")
if plan.campaigns:
lines.extend(
[
"## Kampanie Search",
"",
"| Kampania | Status | Lokalizacje | Google Search | Search Network | Partnerzy | Siec reklamowa |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
)
for campaign in plan.campaigns:
lines.append(
f"| {campaign['campaign_name']} | {campaign['status']} | "
f"{campaign.get('positive_geo_target_type_label', campaign['positive_geo_target_type'])} | "
f"{yes_no(campaign['target_google_search'])} | "
f"{yes_no(campaign['target_search_network'])} | "
f"{yes_no(campaign['target_partner_search_network'])} | "
f"{yes_no(campaign['target_content_network'])} |"
)
lines.append("")
if plan.knowledge_rules:
lines.extend(
[
"## Reguly z bazy wiedzy",
"",
"| ID | Temat | Rekomendacja | Ryzyko |",
"| --- | --- | --- | --- |",
]
)
for rule in plan.knowledge_rules:
lines.append(
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
)
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return json_path, md_path
def print_search_basic_settings_plan(plan: SearchBasicSettingsPlan) -> None:
print("\nPlan sprawdzenia podstawowych ustawien Search")
print_table(
["Metryka", "Liczba"],
[
["Kampanie Search", str(len(plan.campaigns))],
["Reguly wiedzy", str(len(plan.knowledge_rules))],
["Zmiany do wdrozenia", "0"],
],
)
if plan.warnings:
print("\nUwagi")
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
print("\nZakres zadania")
print_table(
["Nr", "Obszar", "Co sprawdzic"],
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
)
print("\nPoza zakresem")
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
if plan.campaigns:
print("\nKampanie Search")
print_table(
["Nr", "Kampania", "Status", "Lokalizacje", "Partnerzy", "Siec reklamowa"],
[
[
str(index),
campaign["campaign_name"],
campaign["status"],
campaign.get("positive_geo_target_type_label", campaign["positive_geo_target_type"]),
yes_no(campaign["target_partner_search_network"]),
yes_no(campaign["target_content_network"]),
]
for index, campaign in enumerate(plan.campaigns, 1)
],
)
if plan.knowledge_rules:
print("\nReguly z bazy wiedzy")
print_table(
["Nr", "ID", "Temat", "Rekomendacja"],
[
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
],
)
if len(plan.knowledge_rules) > 10:
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
def print_next_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def apply_search_basic_settings_plan(
client_config: ClientConfig,
plan: SearchBasicSettingsPlan,
show_navigation: bool = True,
) -> None:
print("\nTo zadanie jest audytem ustawien i nie wdraza zmian na koncie Google Ads.")
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
history_path = append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "audyt oznaczony jako wykonany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
print(f"Historia JSONL: {history_path}")
print(f"Historia Markdown: {changes_path}")
if show_navigation:
print_next_navigation(client_config.domain)
def run_check_search_basic_settings(
client_config: ClientConfig,
global_rules: dict,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
_ = global_rules
if apply_plan_path:
if confirm_apply != "TAK":
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
if plan_data.get("client") != client_config.domain:
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
if show_navigation:
print_next_navigation(client_config.domain)
return
plan = SearchBasicSettingsPlan.from_dict(plan_data)
print_search_basic_settings_plan(plan)
apply_search_basic_settings_plan(client_config, plan, show_navigation=show_navigation)
return
print(f"\nKlient: {client_config.domain}")
print("Przygotowuje plan sprawdzenia podstawowych ustawien Search...")
plan = build_search_basic_settings_plan(client_config)
print_search_basic_settings_plan(plan)
json_path, md_path = save_search_basic_settings_plan(client_config.domain, plan)
print(f"\nPlan JSON: {json_path}")
print(f"Plan Markdown: {md_path}")
append_history(
client_config.domain,
{
"task": TASK_NAME,
"status": "plan przygotowany",
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
"summary": {
"campaigns": len(plan.campaigns),
"knowledge_rules": len(plan.knowledge_rules),
"changes": 0,
},
},
)
if plan_only:
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
if show_navigation:
print_next_navigation(client_config.domain)
return
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu podstawowych ustawien.")
if show_navigation:
print_next_navigation(client_config.domain)