commit ae25aae9ce9952e5da7d0e18d266262e0450c5bd Author: Jacek Pyziak Date: Fri May 15 09:28:11 2026 +0200 first commit diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..291a9fd --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -0,0 +1,4 @@ +# Memory Index + +- [Format listy klientów](feedback_client_list_format.md) — listy klientów prezentować jako numerowaną tabelę markdown +- [Workflow AGENTS.md](workflow_agents_md.md) — na starcie sesji przeczytaj AGENTS.md, zawiera cały przepływ pracy diff --git a/.claude/memory/feedback_client_list_format.md b/.claude/memory/feedback_client_list_format.md new file mode 100644 index 0000000..a0f3d68 --- /dev/null +++ b/.claude/memory/feedback_client_list_format.md @@ -0,0 +1,12 @@ +--- +name: feedback-client-list-format +description: Listy klientów (z config/clients.toml) prezentować w numerowanej tabeli, nie jako bullet listę +metadata: + type: feedback +--- + +Gdy wyświetlam listę klientów z `config/clients.toml` (np. przy pytaniu "którego klienta przeanalizować"), prezentuję ją jako **numerowaną tabelę markdown**, a nie jako bullet listę. + +**Why:** Użytkownik wyraźnie zażądał tego formatu — łatwiej wskazać klienta numerem niż pełną domeną. + +**How to apply:** Kolumny minimum: `#`, `Domena`, `Customer ID`, `AdsPro ID` (jeśli jest). Numeracja od 1, zgodnie z kolejnością w `clients.toml`. Stosować przy każdym kontekście wymagającym wyboru/przeglądu klientów, nie tylko przy komendzie `analiza-klienta`. diff --git a/.claude/memory/workflow_agents_md.md b/.claude/memory/workflow_agents_md.md new file mode 100644 index 0000000..ceb3a4b --- /dev/null +++ b/.claude/memory/workflow_agents_md.md @@ -0,0 +1,20 @@ +--- +name: workflow_agents_md +description: Na starcie sesji w tym projekcie przeczytaj AGENTS.md — zawiera cały przepływ pracy +metadata: + node_type: memory + type: feedback + originSessionId: ad97258d-a810-4b79-972f-a57956f9a3f7 +--- + +W projekcie "google ads ver 2" cały przepływ pracy agenta jest opisany w `AGENTS.md` w katalogu głównym. Przeczytaj go na początku każdej sesji, zanim zaczniesz działać. + +**Why:** Użytkownik komunikuje się komendami w języku naturalnym (np. `analiza-klienta`), które mapują się na komendy `python gads.py ...`. Bez AGENTS.md nie wiadomo jak prowadzić użytkownika etapami. + +**How to apply:** Kluczowe zasady z AGENTS.md: +- `analiza-klienta` → `python gads.py analiza-klienta` → pokaż pełną listę klientów (tabela Nr/Domena, nigdy nie streszczaj). +- Przepływ: klient → grupa zadań → zadanie → plan (`--plan-only`) → akceptacja → wdrożenie (`--apply-plan ... --confirm-apply TAK`). +- Zadania pokazuj pod nagłówkami grup; opcje zbiorcze w osobnej sekcji. +- W trybie zbiorczym każde zadanie osobno: plan → analiza → pytanie o wdrożenie → następne. +- Nigdy nie wdrażaj bez wyraźnej zgody. Analiza planu zawsze z tabelą. +- Pisz po polsku, bez skrótów technicznych. Patrz [[feedback_client_list_format]]. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..47a4def --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Google Ads API +GOOGLE_ADS_DEVELOPER_TOKEN= +GOOGLE_ADS_OAUTH2_CLIENT_ID= +GOOGLE_ADS_OAUTH2_CLIENT_SECRET= +GOOGLE_ADS_OAUTH2_REFRESH_TOKEN= +GOOGLE_ADS_MANAGER_ACCOUNT_ID= + +# adsPRO API +ADSPRO_API_URL= +ADSPRO_API_KEY= + +# Import wiedzy przez OpenAI API +# Uzywane tylko przez: python gads.py wiedza dodaj ... +OPENAI_API_KEY= + +# Opcjonalnie: model do ekstrakcji regul wiedzy. +# Mozesz tez podac model jednorazowo przez --model. +KNOWLEDGE_OPENAI_MODEL=gpt-4.1-mini + +# Opcjonalnie: model embeddingow do indeksu LanceDB. +KNOWLEDGE_EMBEDDING_MODEL=text-embedding-3-small diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1809d48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +config/clients.toml +clients/ +__pycache__/ +*.pyc + diff --git a/.sync/Archive/.claude/memory/MEMORY.md b/.sync/Archive/.claude/memory/MEMORY.md new file mode 100644 index 0000000..4b989b0 --- /dev/null +++ b/.sync/Archive/.claude/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [Format listy klientów](feedback_client_list_format.md) — listy klientów prezentować jako numerowaną tabelę markdown diff --git a/.sync/Archive/AGENTS.md b/.sync/Archive/AGENTS.md new file mode 100644 index 0000000..7c6fa06 --- /dev/null +++ b/.sync/Archive/AGENTS.md @@ -0,0 +1,271 @@ +# Instrukcja dla agentow AI + +Ten projekt jest terminalowym narzedziem do pracy na kontach Google Ads. +Agent AI nie uzywa API modeli. Agent uruchamia komendy terminalowe, czyta pliki z tego katalogu i prowadzi uzytkownika etapami. + +## Najwazniejszy przeplyw + +Uzytkownik nie ma pamietac nazw technicznych zadan. + +Gdy uzytkownik napisze: + +```text +analiza-klienta +``` + +uruchom: + +```powershell +python gads.py analiza-klienta +``` + +Pokaz uzytkownikowi liste klientow i popros o numer. +Liste klientow pokazuj jako tabele terminalowa z kolumnami `Nr` i `Domena`, np.: + +Zawsze pokazuj pelna liste klientow widoczna w terminalu. +Nie streszczaj jej tekstem typu `Masz 10 klientow`. +Nie ukrywaj listy za komunikatem o rozwinieciu wyniku narzedzia, np. `+24 lines` albo `ctrl+o to expand`. +Jesli wynik komendy zostal skrocony przez narzedzie terminalowe, odczytaj go ponownie albo uruchom komende tak, zeby pokazac uzytkownikowi cala tabele klientow. + +```text +┌────┬──────────────────────┐ +│ Nr │ Domena │ +├────┼──────────────────────┤ +│ 1 │ aruba.rzeszow.pl │ +└────┴──────────────────────┘ +``` + +Po wyborze numeru klienta uruchom: + +```powershell +python gads.py analiza-klienta --client-number +``` + +Pokaz uzytkownikowi liste zadan. Zadania musza byc prezentowane z podzialem na grupy nadrzedne, np. `KAMPANIE PLA`, `WYKLUCZENIA`, `KAMPANIE SEARCH`. +Nie wolno pokazywac samej tabeli zadan bez naglowka grupy. Nawet gdy jest tylko jedna grupa i jedno zadanie, pokaz naglowek grupy. +Nie wolno laczyc zadan i opcji zbiorczych w jednej tabeli. Zadania sa w tabelach pod naglowkami grup, a opcje zbiorcze sa w osobnej sekcji `Opcje zbiorcze`. + +Nie pokazuj tak: + +```text +┌─────┬─────────────────────────────────┐ +│ Nr │ Zadanie │ +├─────┼─────────────────────────────────┤ +│ 1.1 │ Synchronizacja kampanii PLA_CL1 │ +│ 1.2 │ Sprawdzenie ustawien │ +│ 1.0 │ Wszystkie z grupy PLA │ +│ ALL │ Wszystkie zadania │ +└─────┴─────────────────────────────────┘ +``` + +To jest bledne, bo miesza pojedyncze zadania z wyborami zbiorczymi i usuwa naglowek grupy. + +Format listy zadan: + +```text +Klient: investagd.pl + +======================================================================== +KAMPANIE PLA +======================================================================== +┌────┬─────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────┐ +│ Nr │ Zadanie │ Opis │ +├────┼─────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤ +│ 1.1 │ Synchronizacja kampanii PLA_CL1 │ Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam. │ +└────┴─────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +W kazdej grupie pokazuj zadania jako tabele terminalowa z kolumnami `Nr`, `Zadanie`, `Opis`. +Po tabelach zadan pokaz tez `Opcje zbiorcze`: + +```text +Opcje zbiorcze +┌─────┬────────────────────────────────────────────┐ +│ Nr │ Zakres │ +├─────┼────────────────────────────────────────────┤ +│ 1.0 │ Wszystkie zadania z grupy: Kampanie PLA │ +├─────┼────────────────────────────────────────────┤ +│ ALL │ Wszystkie zadania ze wszystkich grup │ +└─────┴────────────────────────────────────────────┘ +``` + +Numeracja ma format `grupa.zadanie`. +Przyklad: `1.1` to pierwsze zadanie w pierwszej grupie, `2.3` to trzecie zadanie w drugiej grupie. +Opcja zbiorcza grupy zawsze konczy sie na `.0`, np. `1.0`, `2.0`. +`ALL` pokazuj tylko raz na koncu listy opcji zbiorczych. + +Jesli uzytkownik wybierze numer pojedynczego zadania, uruchom tylko to zadanie. +Jesli uzytkownik wybierze `1.0`, `2.0` itd., uruchom wszystkie zadania z tej grupy po kolei. +Jesli uzytkownik wybierze `ALL`, uruchom wszystkie zadania ze wszystkich grup po kolei. + +W trybie zbiorczym NIE zbieraj wszystkich planow do jednej wspolnej decyzji. +Kazde zadanie obsluguj osobno: + +1. uruchom `--plan-only` dla pierwszego zadania, +2. przeczytaj plan pierwszego zadania, +3. pokaz krotka analize pierwszego zadania, +4. zapytaj, czy wdrozyc ten jeden plan, +5. po decyzji uzytkownika wdroz albo pomin ten jeden plan, +6. dopiero potem przejdz do kolejnego zadania. + +Nie pytaj: `Czy wdrozyc oba plany?`. +Pytaj: `Czy wdrozyc plan zadania 1/2?`. + +Po wyborze numeru zadania uruchom najpierw tylko przygotowanie planu: + +```powershell +python gads.py analiza-klienta --client-number --task-number --plan-only +``` + +Preferowana komenda dla wyboru z listy: + +```powershell +python gads.py analiza-klienta --client-number --select --plan-only +``` + +Po wyborze calej grupy uruchom: + +```powershell +python gads.py analiza-klienta --client-number --select 1.0 --plan-only +``` + +Po wyborze wszystkich grup uruchom: + +```powershell +python gads.py analiza-klienta --client-number --select ALL --plan-only +``` + +Zadania zbiorcze musza isc sekwencyjnie: nastepne zadanie startuje dopiero po zakonczeniu poprzedniego, lacznie z decyzja uzytkownika o wdrozeniu albo pominieciu planu. + +Nastepnie odczytaj zapisany plan z: + +```text +clients//plans/ +``` + +Przeanalizuj plan krotko i konkretnie. Nie wdrazaj zmian bez zgody uzytkownika. + +Po akceptacji uzytkownika uruchom wdrozenie dokladnie zapisanego planu: + +```powershell +python gads.py analiza-klienta --client-number --task-number --apply-plan --confirm-apply TAK +``` + +Po wykonaniu zadania, odrzuceniu planu albo decyzji o niewdrazaniu zmian zapytaj uzytkownika, co dalej: + +```text +Co dalej? +1. Lista zadan tego samego klienta +2. Lista klientow +3. Zakoncz +``` + +Popros uzytkownika tylko o numer. Po wyborze: + +- `1` pokaz liste zadan tego samego klienta, +- `2` pokaz liste klientow, +- `3` zakoncz. + +## Zasady komunikacji + +- Pisz po polsku. +- Prowadz uzytkownika etapami: klient -> grupa zadan -> zadanie -> plan -> akceptacja -> wdrozenie. +- Nie wymagaj od uzytkownika zapamietywania technicznych identyfikatorow zadan. +- Nie uzywaj skrotow bez potrzeby. Pisz `grupa reklam`, nie `AG`. +- Pisz `wdrozenie zmian`, nie `mutacja`. +- Pisz `Docelowy ROAS`, nie `tROAS`, chyba ze cytujesz nazwe techniczna. +- Odpowiedzi analityczne maja byc krotkie: co zostanie zrobione, ile elementow, jakie ryzyko, czy rekomendujesz wdrozenie. +- Odpowiedzi analityczne po odczytaniu planu musza zawierac tabele. Nie streszczaj planu samymi punktami. + +Minimalny format analizy planu: + +```text +Zadanie 1/2: Synchronizacja kampanii PLA_CL1 + +Podsumowanie po kampaniach +┌──────────────────────┬────────┬───────┬───────────┬─────────────┐ +│ Kampania │ Utworz │ Wlacz │ Wstrzymaj │ Zmien nazwe │ +├──────────────────────┼────────┼───────┼───────────┼─────────────┤ +│ [PLA_CL1] pozostale │ 0 │ 1 │ 0 │ 0 │ +│ [PLA_CL1] worki │ 0 │ 8 │ 0 │ 0 │ +└──────────────────────┴────────┴───────┴───────────┴─────────────┘ + +Najwazniejsze dzialania +┌────┬─────────────────────┬────────────────────────────────────────┐ +│ Nr │ Kampania │ Dzialanie │ +├────┼─────────────────────┼────────────────────────────────────────┤ +│ 1 │ [PLA_CL1] pozostale │ Wlacz grupe reklam: nazwa grupy │ +└────┴─────────────────────┴────────────────────────────────────────┘ + +Ryzyko: niskie. +Rekomendacja: wdrozyc. +``` + +Jesli lista dzialan jest dluga, pokaz tabele z pierwszymi 10 pozycjami i dopisz liczbe pozostalych. Nadal pokaz pelne podsumowanie po kampaniach. + +## Bezpieczenstwo + +- Najpierw zawsze tworz plan przez `--plan-only`. +- Nie wdrazaj planu, dopoki uzytkownik jasno nie zaakceptuje. +- Wdrazaj tylko plan zapisany w pliku JSON. +- Po wdrozeniu sprawdz i podaj sciezki historii: + - `clients//history/YYYY-MM-DD.jsonl` + - `clients//changes/YYYY-MM-DD.md` +- Po wykonaniu albo odrzuceniu zadania zawsze zaproponuj powrot do listy zadan albo listy klientow. + +## Konfiguracja zadan + +Lista grup i zadan jest w: + +```text +config/tasks.toml +``` + +Dodawaj nowe zadania do odpowiednich grup, z czytelna nazwa dla uzytkownika. + +Szczegolowa instrukcja rozbudowy narzedzia jest w: + +```text +DEVELOPMENT.md +``` + +## Zadania: Produkty + +Grupa `Produkty` jest podzielona na trzy osobne zadania: + +- `Optymalizacja tytulow produktow` pobiera z adsPRO tylko produkty bez zoptymalizowanego tytulu. +- `Optymalizacja kategorii Google` pobiera z adsPRO tylko produkty bez kategorii Google. +- `Uzupelnienie unit pricing` pobiera z adsPRO tylko produkty bez unit pricing. + +Nie mieszaj tych zakresow w jednym planie. +Tytuly produktow wybiera agent AI po analizie produktu, tytulu bazowego, strony produktu albo kontekstu klienta. +Kategorie Google wybiera agent AI po analizie produktu, tytulu, strony produktu albo kontekstu klienta. +Skrypt nie wybiera automatycznie tytulow ani kategorii Google. +Przed wdrozeniem tytulow agent musi uzupelnic docelowe wartosci tytulow w zapisanym planie JSON i dopiero wtedy zapytac uzytkownika o zgode. +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. + +## Reguly i wyjatki klientow + +Ustawienia globalne i wyjatki per klient sa w: + +```text +config/clients.toml +``` + +Przyklad globalnych regul dla kampanii PLA: + +```toml +[global_rules.pla_settings] +require_presence_only = true +require_high_priority = true +``` + +Wyjatek per klient: + +```toml +[clients."example.pl".pla_settings] +require_high_priority = false +``` + +Jesli klient ma wylaczona regule, agent nie powinien sugerowac wdrozenia tej zmiany. diff --git a/.sync/Archive/DEVELOPMENT.md b/.sync/Archive/DEVELOPMENT.md new file mode 100644 index 0000000..5c681f4 --- /dev/null +++ b/.sync/Archive/DEVELOPMENT.md @@ -0,0 +1,218 @@ +# 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//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//history/YYYY-MM-DD.jsonl` + - `clients//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 --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. diff --git a/.sync/Archive/README.md b/.sync/Archive/README.md new file mode 100644 index 0000000..cafff27 --- /dev/null +++ b/.sync/Archive/README.md @@ -0,0 +1,67 @@ +# 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//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//data/` - pobrane dane robocze. +- `clients//history/YYYY-MM-DD.jsonl` - historia do filtrowania po kliencie, dacie i kampanii. +- `clients//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. diff --git a/.sync/Archive/config/clients.example.toml b/.sync/Archive/config/clients.example.toml new file mode 100644 index 0000000..91f9da7 --- /dev/null +++ b/.sync/Archive/config/clients.example.toml @@ -0,0 +1,20 @@ +[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 diff --git a/.sync/Archive/config/clients.toml b/.sync/Archive/config/clients.toml new file mode 100644 index 0000000..1e8c3e3 --- /dev/null +++ b/.sync/Archive/config/clients.toml @@ -0,0 +1,49 @@ +[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."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" + +[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" + +[clients."ibra-makeup.pl"] +google_ads_customer_id = "818-919-2566" +adspro_client_id = "4" + +[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 diff --git a/.sync/Archive/config/tasks.toml b/.sync/Archive/config/tasks.toml new file mode 100644 index 0000000..bd97da1 --- /dev/null +++ b/.sync/Archive/config/tasks.toml @@ -0,0 +1,32 @@ +[[groups]] +id = "pla" +name = "Kampanie PLA" + +[[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" +description = "Sprawdza ustawienia lokalizacji i priorytetu kampanii PLA wedlug regul globalnych i wyjatkow klienta." + +[[groups]] +id = "products" +name = "Produkty" + +[[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." diff --git a/.sync/Archive/requirements.txt b/.sync/Archive/requirements.txt new file mode 100644 index 0000000..dc501f1 --- /dev/null +++ b/.sync/Archive/requirements.txt @@ -0,0 +1,3 @@ +google-ads>=25.0.0 +requests>=2.31.0 + diff --git a/.sync/Archive/src/gads_v2/cli.py b/.sync/Archive/src/gads_v2/cli.py new file mode 100644 index 0000000..c19a8ce --- /dev/null +++ b/.sync/Archive/src/gads_v2/cli.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import argparse +import sys + +from .config import load_config, load_env +from .table import print_table +from .task_catalog import ( + load_groups, + load_tasks, + print_task_list, + task_by_number, + task_by_selection, + tasks_by_group_number, + tasks_by_selection_group, +) +from .tasks.pla_settings_check import run_check_pla_settings +from .tasks.pla_cl1_sync import run_sync_pla_cl1 +from .tasks.product_feed_optimization import ( + run_fill_product_unit_pricing, + run_optimize_product_categories, + run_optimize_product_feed, + run_optimize_product_titles, +) + + +def choose_index(label: str, options: list[str]) -> int | None: + print(f"\n{label}") + for i, option in enumerate(options, 1): + print(f"{i}. {option}") + try: + raw = input("Wybierz numer albo Enter aby wyjsc: ").strip() + except EOFError: + print() + return None + if not raw: + return None + try: + idx = int(raw) + except ValueError: + print("Nieprawidlowy numer.") + return None + if idx < 1 or idx > len(options): + print("Nieprawidlowy numer.") + return None + return idx - 1 + + +def main() -> None: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + parser = argparse.ArgumentParser(description="Google Ads ver 2") + parser.add_argument( + "command", + nargs="?", + choices=["analiza-klienta"], + help="Tryb prowadzony etapami dla agenta lub terminala", + ) + parser.add_argument("--client", help="Domena klienta z config/clients.toml") + parser.add_argument("--client-number", type=int, help="Numer klienta z listy analiza-klienta") + parser.add_argument( + "--task", + choices=[ + "sync_pla_cl1", + "check_pla_settings", + "optimize_product_feed", + "optimize_product_titles", + "optimize_product_categories", + "fill_product_unit_pricing", + ], + help="Zadanie do uruchomienia bez menu", + ) + parser.add_argument("--select", help="Wybór z listy zadan, np. 1.1, 1.0 albo ALL") + parser.add_argument("--task-number", type=int, help="Numer zadania z listy analiza-klienta") + parser.add_argument("--group-number", type=int, help="Uruchom wszystkie zadania z grupy o podanym numerze") + parser.add_argument("--group-all-current", action="store_true", help="Uruchom wszystkie zadania z pierwszej widocznej grupy") + parser.add_argument("--all-groups", action="store_true", help="Uruchom wszystkie zadania ze wszystkich grup") + parser.add_argument("--plan-only", action="store_true", help="Tylko przygotuj plan i zapisz go do pliku") + parser.add_argument("--apply-plan", help="Wdroz zapisany plan JSON") + parser.add_argument( + "--confirm-apply", + help="Wymagane przy --apply-plan. Uzyj dokladnie: TAK", + ) + args = parser.parse_args() + + load_env() + try: + cfg = load_config() + except Exception as exc: + print(exc) + sys.exit(1) + + domains = sorted(cfg.clients) + if not domains: + print("Brak klientow w config/clients.toml.") + return + + tasks = load_tasks() + groups = load_groups() + selected_domain = args.client + if args.client_number: + if args.client_number < 1 or args.client_number > len(domains): + print(f"Nie ma klienta numer {args.client_number}.") + return + selected_domain = domains[args.client_number - 1] + + if args.command == "analiza-klienta": + if not selected_domain: + print("\nWybierz klienta:") + print_table(["Nr", "Domena"], [[str(i), domain] for i, domain in enumerate(domains, 1)]) + print("\nNastepny krok:") + print("python gads.py analiza-klienta --client-number ") + return + + if selected_domain not in cfg.clients: + print(f"Nie znaleziono klienta {selected_domain} w config/clients.toml.") + return + + if not args.select and not args.task_number and not args.group_number and not args.group_all_current and not args.all_groups: + print(f"\nKlient: {selected_domain}") + print_task_list(tasks) + print("\nNastepny krok:") + print( + "python gads.py analiza-klienta " + f"--client-number {domains.index(selected_domain) + 1} " + "--select --plan-only" + ) + return + + if args.select: + selected = args.select.strip() + if selected.upper() == "ALL": + run_task_sequence( + tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + selected_group_tasks = tasks_by_selection_group(tasks, groups, selected) + if selected_group_tasks: + run_task_sequence( + selected_group_tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + selected_task = task_by_selection(tasks, selected) + if not selected_task: + print(f"Nie ma wyboru {selected}.") + return + run_task( + selected_task.id, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + apply_plan_path=args.apply_plan, + confirm_apply=args.confirm_apply, + ) + return + + if args.group_all_current: + first_group_number = groups[0].number if groups else None + selected_tasks = tasks_by_group_number(tasks, groups, first_group_number) if first_group_number else [] + if not selected_tasks: + print("Brak zadan w pierwszej grupie.") + return + run_task_sequence( + selected_tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + + if args.group_number: + selected_tasks = tasks_by_group_number(tasks, groups, args.group_number) + if not selected_tasks: + print(f"Nie ma grupy numer {args.group_number}.") + return + run_task_sequence( + selected_tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + + if args.all_groups: + run_task_sequence( + tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + + selected_task = task_by_number(tasks, args.task_number) + if not selected_task: + print(f"Nie ma zadania numer {args.task_number}.") + return + run_task( + selected_task.id, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + apply_plan_path=args.apply_plan, + confirm_apply=args.confirm_apply, + ) + return + + if args.client or args.task: + if not args.client or not args.task: + print("Dla trybu bez menu podaj jednoczesnie --client i --task.") + return + if args.client not in cfg.clients: + print(f"Nie znaleziono klienta {args.client} w config/clients.toml.") + return + run_task( + args.task, + cfg.clients[args.client], + cfg.global_rules, + plan_only=args.plan_only, + apply_plan_path=args.apply_plan, + confirm_apply=args.confirm_apply, + ) + return + + client_idx = choose_index("Klient", domains) + if client_idx is None: + return + client = cfg.clients[domains[client_idx]] + + task_labels = [task.name for task in tasks] + task_idx = choose_index("Zadanie", task_labels) + if task_idx is None: + return + + run_task(tasks[task_idx].id, client, cfg.global_rules) + + +def run_task( + task_id, + client, + global_rules, + plan_only: bool = False, + apply_plan_path: str | None = None, + confirm_apply: str | None = None, + show_navigation: bool = True, +) -> None: + if task_id == "sync_pla_cl1": + run_sync_pla_cl1( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_pla_settings": + run_check_pla_settings( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "optimize_product_feed": + run_optimize_product_feed( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "optimize_product_titles": + run_optimize_product_titles( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "optimize_product_categories": + run_optimize_product_categories( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "fill_product_unit_pricing": + run_fill_product_unit_pricing( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + print(f"Zadanie {task_id} nie ma jeszcze implementacji.") + + +def run_task_sequence(tasks, client, global_rules, plan_only: bool = False) -> None: + total = len(tasks) + if plan_only: + print("Tryb zbiorczy plan-only przygotuje plany po kolei.") + print("Agent powinien analizowac i wdrazac kazdy plan osobno, przed przejsciem do kolejnego zadania.") + for index, task in enumerate(tasks, 1): + print() + print("#" * 72) + print(f"Zadanie {index}/{total}: {task.group_name} / {task.name}") + print("#" * 72) + run_task(task.id, client, global_rules, plan_only=plan_only, show_navigation=False) + print() + print("Zakonczono sekwencje zadan.") + print_sequence_navigation(client.domain) + + +def print_sequence_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 print_next_navigation(client_number: int | None = None) -> None: + print("\nCo dalej:") + if client_number: + print(f"1. Lista zadan klienta numer {client_number}") + print("2. Lista klientow") + print("3. Zakoncz") + print("\nKomendy:") + print(f"1 -> python gads.py analiza-klienta --client-number {client_number}") + print("2 -> python gads.py analiza-klienta") + else: + print("1. Lista klientow") + print("2. Zakoncz") + print("\nKomendy:") + print("1 -> python gads.py analiza-klienta") diff --git a/.sync/Archive/src/gads_v2/tasks/pla_cl1_sync.py b/.sync/Archive/src/gads_v2/tasks/pla_cl1_sync.py new file mode 100644 index 0000000..1369ffb --- /dev/null +++ b/.sync/Archive/src/gads_v2/tasks/pla_cl1_sync.py @@ -0,0 +1,912 @@ +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) diff --git a/.sync/FolderType b/.sync/FolderType new file mode 100644 index 0000000..43577c1 --- /dev/null +++ b/.sync/FolderType @@ -0,0 +1 @@ +d8:brandingi0e31:disable_remove_from_all_devicesi0e4:modei0e5:owner5:jacek4:typei2ee \ No newline at end of file diff --git a/.sync/ID b/.sync/ID new file mode 100644 index 0000000..b9ac809 --- /dev/null +++ b/.sync/ID @@ -0,0 +1 @@ +k?@E*3ϼ |b|+?:Ԋ \ No newline at end of file diff --git a/.sync/IgnoreList b/.sync/IgnoreList new file mode 100644 index 0000000..a7e7fc2 --- /dev/null +++ b/.sync/IgnoreList @@ -0,0 +1,54 @@ +# IgnoreList is a UTF-8 encoded .txt file that helps you specify single files, paths and rules +# for ignoring during the synchronization job. It supports "?" and "*" wildcard symbols. +# +# +# OS generated files # +$RECYCLE.BIN +$Recycle.Bin +System Volume Information +ehthumbs.db +desktop.ini +Thumbs.db +lost+found +.DocumentRevisions-V100 +.TemporaryItems +.fseventsd +.icloud +.iCloud +.DS_Store +.DS_Store? +.Spotlight-V100 +.Trashes +.Trash-* +.trashed-* +~* +*~ +.~lock.* +*.part +*.filepart +.csync_journal.db +.csync_journal.db.tmp +*.swn +*.swp +*.swo +*.crdownload +.@__thumb +.thumbnails +._* +*.tmp +*.tmp.chck +.dropbox +.dropbox.attr +.dropbox.cache +.streams +.caches +.Statuses +.teamdrive +.SynologyWorkingDirectory +@eaDir +@SynoResource +#SynoRecycle +#snapshot +#recycle +.!@#$recycle +DfsrPrivate diff --git a/.sync/StreamsList b/.sync/StreamsList new file mode 100644 index 0000000..4231fd0 --- /dev/null +++ b/.sync/StreamsList @@ -0,0 +1,8 @@ +# StreamsList is a UTF-8 encoded .txt file that helps you specify alternate streams, +# xattrs and resource forks white list. It supports "?" and "*" wildcard symbols. +# +# +# +com.apple.metadata:_kMDItemUserTags +com.apple.ResourceFork +com.apple.metadata:kMDItemFinderComment diff --git a/.sync/root_acl_entry b/.sync/root_acl_entry new file mode 100644 index 0000000..e69de29 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..315414f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,397 @@ +# Instrukcja dla agentow AI + +Ten projekt jest terminalowym narzedziem do pracy na kontach Google Ads. +Agent AI nie uzywa API modeli. Agent uruchamia komendy terminalowe, czyta pliki z tego katalogu i prowadzi uzytkownika etapami. + +## Najwazniejszy przeplyw + +Uzytkownik nie ma pamietac nazw technicznych zadan. + +Gdy uzytkownik napisze: + +```text +analiza-klienta +``` + +uruchom: + +```powershell +python gads.py analiza-klienta +``` + +Pokaz uzytkownikowi liste klientow i popros o numer. +Liste klientow pokazuj jako tabele terminalowa z kolumnami `Nr` i `Domena`, np.: + +Zawsze pokazuj pelna liste klientow widoczna w terminalu. +Nie streszczaj jej tekstem typu `Masz 10 klientow`. +Nie ukrywaj listy za komunikatem o rozwinieciu wyniku narzedzia, np. `+24 lines` albo `ctrl+o to expand`. +Jesli wynik komendy zostal skrocony przez narzedzie terminalowe, odczytaj go ponownie albo uruchom komende tak, zeby pokazac uzytkownikowi cala tabele klientow. + +```text +┌────┬──────────────────────┐ +│ Nr │ Domena │ +├────┼──────────────────────┤ +│ 1 │ aruba.rzeszow.pl │ +└────┴──────────────────────┘ +``` + +Po wyborze numeru klienta uruchom: + +```powershell +python gads.py analiza-klienta --client-number +``` + +Pokaz uzytkownikowi liste zadan. Zadania musza byc prezentowane z podzialem na grupy nadrzedne, np. `KAMPANIE PLA`, `WYKLUCZENIA`, `KAMPANIE SEARCH`. +Nie wolno pokazywac samej tabeli zadan bez naglowka grupy. Nawet gdy jest tylko jedna grupa i jedno zadanie, pokaz naglowek grupy. +Nie wolno laczyc zadan i opcji zbiorczych w jednej tabeli. Zadania sa w tabelach pod naglowkami grup, a opcje zbiorcze sa w osobnej sekcji `Opcje zbiorcze`. + +Nie pokazuj tak: + +```text +┌─────┬─────────────────────────────────┐ +│ Nr │ Zadanie │ +├─────┼─────────────────────────────────┤ +│ 1.1 │ Synchronizacja kampanii PLA_CL1 │ +│ 1.2 │ Sprawdzenie ustawien │ +│ 1.0 │ Wszystkie z grupy PLA │ +│ ALL │ Wszystkie zadania │ +└─────┴─────────────────────────────────┘ +``` + +To jest bledne, bo miesza pojedyncze zadania z wyborami zbiorczymi i usuwa naglowek grupy. + +Format listy zadan: + +```text +Klient: investagd.pl + +======================================================================== +KAMPANIE PLA +======================================================================== +┌────┬─────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────┐ +│ Nr │ Zadanie │ Opis │ +├────┼─────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤ +│ 1.1 │ Synchronizacja kampanii PLA_CL1 │ Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam. │ +└────┴─────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +W kazdej grupie pokazuj zadania jako tabele terminalowa z kolumnami `Nr`, `Zadanie`, `Opis`. +Po tabelach zadan pokaz tez `Opcje zbiorcze`: + +```text +Opcje zbiorcze +┌─────┬────────────────────────────────────────────┐ +│ Nr │ Zakres │ +├─────┼────────────────────────────────────────────┤ +│ 1.0 │ Wszystkie zadania z grupy: Kampanie PLA │ +├─────┼────────────────────────────────────────────┤ +│ ALL │ Wszystkie zadania ze wszystkich grup │ +└─────┴────────────────────────────────────────────┘ +``` + +Numeracja ma format `grupa.zadanie`. +Przyklad: `1.1` to pierwsze zadanie w pierwszej grupie, `2.3` to trzecie zadanie w drugiej grupie. +Opcja zbiorcza grupy zawsze konczy sie na `.0`, np. `1.0`, `2.0`. +`ALL` pokazuj tylko raz na koncu listy opcji zbiorczych. + +Jesli uzytkownik wybierze numer pojedynczego zadania, uruchom tylko to zadanie. +Jesli uzytkownik wybierze `1.0`, `2.0` itd., uruchom wszystkie zadania z tej grupy po kolei. +Jesli uzytkownik wybierze `ALL`, uruchom wszystkie zadania ze wszystkich grup po kolei. + +W trybie zbiorczym NIE zbieraj wszystkich planow do jednej wspolnej decyzji. +Kazde zadanie obsluguj osobno: + +1. uruchom `--plan-only` dla pierwszego zadania, +2. przeczytaj plan pierwszego zadania, +3. pokaz krotka analize pierwszego zadania, +4. zapytaj, czy wdrozyc ten jeden plan, +5. po decyzji uzytkownika wdroz albo pomin ten jeden plan, +6. dopiero potem przejdz do kolejnego zadania. + +Nie pytaj: `Czy wdrozyc oba plany?`. +Pytaj: `Czy wdrozyc plan zadania 1/2?`. + +Po wyborze numeru zadania uruchom najpierw tylko przygotowanie planu: + +```powershell +python gads.py analiza-klienta --client-number --task-number --plan-only +``` + +Preferowana komenda dla wyboru z listy: + +```powershell +python gads.py analiza-klienta --client-number --select --plan-only +``` + +Po wyborze calej grupy uruchom: + +```powershell +python gads.py analiza-klienta --client-number --select 1.0 --plan-only +``` + +Po wyborze wszystkich grup uruchom: + +```powershell +python gads.py analiza-klienta --client-number --select ALL --plan-only +``` + +Zadania zbiorcze musza isc sekwencyjnie: nastepne zadanie startuje dopiero po zakonczeniu poprzedniego, lacznie z decyzja uzytkownika o wdrozeniu albo pominieciu planu. + +Nastepnie odczytaj zapisany plan z: + +```text +clients//plans/ +``` + +Przeanalizuj plan krotko i konkretnie. Nie wdrazaj zmian bez zgody uzytkownika. + +Po akceptacji uzytkownika uruchom wdrozenie dokladnie zapisanego planu: + +```powershell +python gads.py analiza-klienta --client-number --task-number --apply-plan --confirm-apply TAK +``` + +Po wykonaniu zadania, odrzuceniu planu albo decyzji o niewdrazaniu zmian zapytaj uzytkownika, co dalej: + +```text +Co dalej? +1. Lista zadan tego samego klienta +2. Lista klientow +3. Zakoncz +``` + +Popros uzytkownika tylko o numer. Po wyborze: + +- `1` pokaz liste zadan tego samego klienta, +- `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 --month +``` + +Komenda najpierw pobiera dane i zatrzymuje sie przed generowaniem HTML. Tworzy plik roboczy: + +```text +scripts/reports/output/__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 --month --confirm-recommendations TAK +``` + +Dopiero wtedy komenda generuje lokalny raport HTML w: + +```text +scripts/reports/output///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 --month --confirm-upload TAK +``` + +Po wysylce podaj URL: + +```text +https://adspro.projectpro.pl/raporty/// +``` + +## Zasady komunikacji + +- Pisz po polsku. +- Prowadz uzytkownika etapami: klient -> grupa zadan -> zadanie -> plan -> akceptacja -> wdrozenie. +- Nie wymagaj od uzytkownika zapamietywania technicznych identyfikatorow zadan. +- Nie uzywaj skrotow bez potrzeby. Pisz `grupa reklam`, nie `AG`. +- Pisz `wdrozenie zmian`, nie `mutacja`. +- Pisz `Docelowy ROAS`, nie `tROAS`, chyba ze cytujesz nazwe techniczna. +- Odpowiedzi analityczne maja byc krotkie: co zostanie zrobione, ile elementow, jakie ryzyko, czy rekomendujesz wdrozenie. +- Odpowiedzi analityczne po odczytaniu planu musza zawierac tabele. Nie streszczaj planu samymi punktami. + +Minimalny format analizy planu: + +```text +Zadanie 1/2: Synchronizacja kampanii PLA_CL1 + +Podsumowanie po kampaniach +┌──────────────────────┬────────┬───────┬───────────┬─────────────┐ +│ Kampania │ Utworz │ Wlacz │ Wstrzymaj │ Zmien nazwe │ +├──────────────────────┼────────┼───────┼───────────┼─────────────┤ +│ [PLA_CL1] pozostale │ 0 │ 1 │ 0 │ 0 │ +│ [PLA_CL1] worki │ 0 │ 8 │ 0 │ 0 │ +└──────────────────────┴────────┴───────┴───────────┴─────────────┘ + +Najwazniejsze dzialania +┌────┬─────────────────────┬────────────────────────────────────────┐ +│ Nr │ Kampania │ Dzialanie │ +├────┼─────────────────────┼────────────────────────────────────────┤ +│ 1 │ [PLA_CL1] pozostale │ Wlacz grupe reklam: nazwa grupy │ +└────┴─────────────────────┴────────────────────────────────────────┘ + +Ryzyko: niskie. +Rekomendacja: wdrozyc. +``` + +Jesli lista dzialan jest dluga, pokaz tabele z pierwszymi 10 pozycjami i dopisz liczbe pozostalych. Nadal pokaz pelne podsumowanie po kampaniach. + +## Bezpieczenstwo + +- Najpierw zawsze tworz plan przez `--plan-only`. +- Nie wdrazaj planu, dopoki uzytkownik jasno nie zaakceptuje. +- Wdrazaj tylko plan zapisany w pliku JSON. +- Po wdrozeniu sprawdz i podaj sciezki historii: + - `clients//history/YYYY-MM-DD.jsonl` + - `clients//changes/YYYY-MM-DD.md` +- Po wykonaniu albo odrzuceniu zadania zawsze zaproponuj powrot do listy zadan albo listy klientow. + +## Konfiguracja zadan + +Lista grup i zadan jest w: + +```text +config/tasks.toml +``` + +Dodawaj nowe zadania do odpowiednich grup, z czytelna nazwa dla uzytkownika. + +Szczegolowa instrukcja rozbudowy narzedzia jest w: + +```text +DEVELOPMENT.md +``` + +## Zadania: Produkty + +Grupa `Produkty` jest podzielona na trzy osobne zadania: + +- `Optymalizacja tytulow produktow` pobiera z adsPRO tylko produkty bez zoptymalizowanego tytulu. +- `Optymalizacja kategorii Google` pobiera z adsPRO tylko produkty bez kategorii Google. +- `Uzupelnienie unit pricing` pobiera z adsPRO tylko produkty bez unit pricing. + +Nie mieszaj tych zakresow w jednym planie. +Tytuly produktow wybiera agent AI po analizie produktu, tytulu bazowego, strony produktu albo kontekstu klienta. +Kategorie Google wybiera agent AI po analizie produktu, tytulu, strony produktu albo kontekstu klienta. +Skrypt nie wybiera automatycznie tytulow ani kategorii Google. +Przed wdrozeniem tytulow agent musi uzupelnic docelowe wartosci tytulow w zapisanym planie JSON i dopiero wtedy zapytac uzytkownika o zgode. +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: + +```text +config/clients.toml +``` + +Przyklad globalnych regul dla kampanii PLA: + +```toml +[global_rules.pla_settings] +require_presence_only = true +require_high_priority = true +``` + +Wyjatek per klient: + +```toml +[clients."example.pl".pla_settings] +require_high_priority = false +``` + +Jesli klient ma wylaczona regule, agent nie powinien sugerowac wdrozenia tej zmiany. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..6efcd85 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,337 @@ +# 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//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//history/YYYY-MM-DD.jsonl` + - `clients//changes/YYYY-MM-DD.md` + +Agent AI prowadzi uzytkownika, ale logika pobierania danych, analizy i wdrozenia zmian ma byc w Pythonie. + +## Granice zadan i rytm kontroli + +Jedno zadanie powinno miec jeden rytm kontroli i jeden typ decyzji. Nie mieszaj w jednym zadaniu prostych ustawien konfiguracyjnych, budzetow, strategii stawek, zapytan uzytkownikow i reklam, bo te obszary sprawdza sie z rozna czestotliwoscia i niosa inne ryzyka. + +Przyklady podzialu: + +- ustawienia podstawowe, np. lokalizacje, sieci, jezyki - zwykle raz w miesiacu albo po zmianach, +- budzety i pacing - zwykle co tydzien, +- strategie stawek - zwykle co tydzien albo co dwa tygodnie, z ocena wolumenu konwersji, +- zapytania i wykluczenia - zwykle co tydzien, +- reklamy i zasoby - zwykle co dwa do czterech tygodni, +- struktura konta i kampanii - zwykle miesiecznie albo kwartalnie. + +Opis zadania w `config/tasks.toml` ma jasno mowic, co jest w zakresie i czego zadanie nie analizuje. Jesli obszar wymaga innej czestotliwosci, dodaj osobne zadanie albo osobna grupe. + +## 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 --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. + +## Rozbudowa bazy wiedzy + +Baza wiedzy projektu jest w: + +```text +knowledge/ +``` + +Najwazniejsze pliki: + +- `knowledge/sources/` - materialy zrodlowe, np. artykuly, notatki, eksport ze starej LanceDB. +- `knowledge/rules.jsonl` - atomowe reguly uzywane przez narzedzie. +- `knowledge/imports.jsonl` - historia importow. +- `knowledge/lancedb/` - metadane indeksu semantycznego. Fizyczny indeks LanceDB jest domyslnie w `%LOCALAPPDATA%\google-ads-ver2-knowledge-lancedb`, bo katalog projektu moze byc synchronizowany i blokowac operacje zapisu LanceDB. + +Dodawanie wiedzy: + +```powershell +python gads.py wiedza dodaj --file "knowledge/sources/plik.md" --source "czytelna_nazwa_zrodla" --dry-run +python gads.py wiedza dodaj --file "knowledge/sources/plik.md" --source "czytelna_nazwa_zrodla" +``` + +Jednorazowy import starej bazy LanceDB: + +```powershell +python gads.py wiedza import-stare --from "D:\google ads\lancedb" +python gads.py wiedza indeksuj +``` + +Ten import nie przypisuje regul do zadan. `task_ids` oraz `suggested_task_ids` pozostaja puste. + +Wznawialny przeglad regul bez przypisan: + +```powershell +python gads.py wiedza przypisz +python gads.py wiedza przypisz --restart +``` + +Domyslnie `wiedza przypisz` pokazuje jedna regule, pelny kontekst decyzji i konczy porcje po jednej odpowiedzi. Reguly sa przegladane w kolejnosci zapisanej w `knowledge/rules.jsonl`, a nie sortowane po tematach. To ulatwia uzytkownikowi porownanie ekranu przegladu z plikiem i kolejnoscia importu. Wieksze partie uruchamiaj tylko jawnie, np. `python gads.py wiedza przypisz --limit 10`. + +Zasady przegladu: + +- `Q` przerywa bez przesuniecia kursora, wiec wznowienie zacznie od tej samej reguly. +- `P` pomija regule i zapisuje postep. +- `U` pyta o potwierdzenie `USUN`, a potem trwale usuwa biezaca regule z `knowledge/rules.jsonl`. +- Numer zadania lub `task_id` dopisuje regule do zadania. +- Po dodaniu nowych zadan do `config/tasks.toml` uruchom `python gads.py wiedza przypisz --restart`. +- Stan kursora jest w `knowledge/review_state.json`. +- Po usunieciu reguly stan kursora zapisuje takze klucz sortowania, zeby wznowienie zaczelo od nastepnej reguly mimo braku usunietego `id` w pliku. +- Po fizycznym usunieciu regul trzeba odbudowac LanceDB przez `python gads.py wiedza indeksuj`, bo indeks jest tylko lokalna kopia do wyszukiwania. + +Zasady: + +- Najpierw uruchom `--dry-run`, zeby sprawdzic plik bez kosztu API. +- Import przez API wymaga `OPENAI_API_KEY` w `.env`. +- Opcjonalny model ustaw przez `KNOWLEDGE_OPENAI_MODEL` albo `--model`. +- Reguly przypisuj tylko do istniejacych `task_id` z `config/tasks.toml`. +- Importer moze zapisac propozycje w `suggested_task_ids`, ale nie wolno uznawac ich za aktywne przypisanie do zadania. +- Regula staje sie regula zadania dopiero po akceptacji uzytkownika przez pytanie skryptu albo komenda: + +```powershell +python gads.py wiedza zatwierdz --rule-id "" --task "" +``` + +- Odrzucenie propozycji: + +```powershell +python gads.py wiedza odrzuc --rule-id "" --task "" +``` + +- Jesli wiedza dotyczy zadania, ktorego jeszcze nie ma, zostaw `task_ids` i `suggested_task_ids` puste i zaplanuj osobne zadanie w `config/tasks.toml`. +- Reguly z `knowledge/rules.jsonl` wspieraja analize i uzasadnienie planu. Nie wolno traktowac ich jako automatycznej zgody na wdrozenie zmian. +- `rules.jsonl` jest zrodlem prawdy. LanceDB, gdy zostanie dodane, ma byc tylko indeksem semantycznym odbudowywanym z JSONL. +- Po zmianach w `rules.jsonl` odswiez indeks: + +```powershell +python gads.py wiedza indeksuj +``` + +- Wyszukiwanie semantyczne: + +```powershell +python gads.py wiedza szukaj-ai "zapytanie opisowe" +``` + +- Kazda regula powinna miec metadane: `status`, `created_at`, `updated_at`, `source_file`, `duplicate_of`, `supersedes`. +- Nie kasuj regul recznie przy porzadkowaniu bazy. Zmieniaj status: + +```powershell +python gads.py wiedza archiwizuj --rule-id "" +python gads.py wiedza aktywuj --rule-id "" +python gads.py wiedza duplikat --rule-id "" --duplicate-of "" +``` + +- Po imporcie sprawdz wynik: + +```powershell +python gads.py wiedza szukaj "temat" +python gads.py wiedza propozycje +python gads.py wiedza reguly --task check_pla_settings +python gads.py wiedza lista --topic shopping +python gads.py wiedza statystyki +``` diff --git a/KNOWLAGE.md b/KNOWLAGE.md new file mode 100644 index 0000000..6d8b5b6 --- /dev/null +++ b/KNOWLAGE.md @@ -0,0 +1,29259 @@ +# KNOWLAGE.md + +> Celowo zachowano nazwe pliku zgodna z poleceniem uzytkownika: `KNOWLAGE.md`. + +## Zakres przegladu + +- Zrodlo: `D:\google ads\lancedb` +- Glowna tabela wiedzy: `fakty` +- Liczba rekordow w tabeli `fakty`: 1789 +- Liczba tematow: 37 +- Liczba zrodel: 86 +- Pominiete jako niereklamowe/testowe: `_test_` oraz `ksiega_lazarza` (tekst testowy/fabularny, bez regul Google Ads). +- Data wygenerowania: 2026-05-14 + +## Jak wykorzystac w tym projekcie + +- Nowe automatyczne kontrole dodawaj jako zadania w `config/tasks.toml` i moduly w `src/gads_v2/tasks/`. +- Kazda regula powinna najpierw tworzyc plan, a dopiero po zgodzie uzytkownika wdrazac zapisany plan JSON. +- Reguly globalne i wyjatki per klient trzymaj w `config/clients.toml`, zeby klient mogl miec inne wymagania niz standard konta. +- Przy analizie planu pokazuj tabele: ile elementow dotyczy kampanii, grup reklam, feedu, wykluczen, konwersji lub ustawien. +- Wiedza ponizej jest baza do kontroli, ale wdrozenie powinno zawsze opierac sie o aktualne dane klienta i zapisany plan. + +## Priorytetowe reguly do uwzglednienia w narzedziu + + +### Wykluczenia i jakosc ruchu + +- Wykluczenia slow kluczowych buduj z search terms, ale nie blokuj zapytan, ktore moga miec wartosc asystujaca lub inny etap lejka bez sprawdzenia danych. +- W Display i Demand Gen kontroluj placementy, aplikacje mobilne i optimized targeting. Kampania remarketingowa nie powinna wydawac budzetu na zimne grupy bez decyzji. +- Placements z wysokim kosztem i zerem konwersji albo podejrzanie wysokim wspolczynnikiem konwersji traktuj jako kandydatow do wykluczenia. +- Listy wykluczen powinny byc przypisane na odpowiednim poziomie konta/kampanii, a plan powinien pokazywac zakres i potencjalne ryzyko odciecia ruchu. + +### Kreacje, RSA i zasoby + +- RSA oceniaj po wynikach i dopasowaniu do intencji, nie tylko po Ad Strength. Pinning moze obnizyc ocene Google, ale bywa potrzebny dla kontroli przekazu. +- Naglowki RSA powinny pokrywac intencje, cechy i korzysci, cene/promocje/gwarancje, social proof oraz wezwanie do dzialania. +- Male konta moga szybciej uczyc sie na mniejszej liczbie mocnych wariantow niz na maksymalnie wypelnionej reklamie bez danych. +- Assety PMax i Demand Gen powinny byc oceniane pod katem roli w lejku, spojnosc z landing page i faktycznych sygnalow skutecznosci, nie tylko statusow w interfejsie. + +### Remarketing, Display i Demand Gen + +- Demand Gen moze byc prospectingiem lub remarketingiem; oceniaj je innymi metrykami. Prospecting czesto wymaga oceny asyst i wplywu na Search/e-commerce. +- Remarketing w Display/Demand Gen wymaga kontroli, czy optimized targeting nie rozszerza odbiorcow poza listy remarketingowe. +- Dla dynamicznego remarketingu e-commerce sprawdz feed, tagowanie produktowe i zgodnosc identyfikatorow produktu. +- View-through conversions traktuj ostroznie: moga pokazywac wplyw gornego lejka, ale nie powinny samodzielnie uzasadniac skalowania bez danych backendowych. + +## Temat: assets + +### Broad Match + Dynamic Keyword Insertion — bezcelowe połączenie, trik z parallel exact match + +- **YT_jsg_47_broad_match_dki_bezcelowe_polaczenie** (`YT_jyll-saskin-gales`): DKI (Dynamic Keyword Insertion) z broad match jest praktycznie bezużyteczne, bo DKI wstawia keyword (nie search term), a przy broad match keyword może matchować tysiące zapytań niepodobnych do słowa kluczowego — CTR nie wzrośnie. Trik: przy broad match warto dodać top 10-20 search terms jako exact match keywords w tym samym ad group — nie zmieni to obsługi reklam, ale umożliwi sensowne używanie DKI. Przy kampaniach broad match + DKI — sprawdź czy DKI faktycznie cokolwiek daje. DKI jest bezpieczne TYLKO przy Exact Match (wiemy, co wejdzie do nagłówka). Przy Broad Match lub PMax DKI może tworzyć dziwne/niezgodne nagłówki. + +### Call Ads deprecated — migracja na RSA + Call Asset od teraz, pełna deprecacja 2027 + +- **YT_jsg_78_call_ads_deprecated_migracja** (`YT_jyll-saskin-gales`): Google ogłosiło deprecację Call Ads (call-only ads): Od 2026: brak możliwości tworzenia nowych Call Ads. Do 2027: pełna deprecacja (istniejące przestają działać). Zamiennik: RSA + Call Asset + konwersja 'calls from ads' jako PRIMARY conversion action. Precyzyjny lejek migracji dla advertiserów call-only: (1) Call asset z call trackingiem w ustawieniach konta, (2) Calls from ads = PRIMARY conversion action (reszta secondary), (3) Bid strategy: Maximize Conversions → po ~1 call/dzień → target CPA, (4) RSA z DKI obok call ads, (5) Optymalizuj RSA → wyłącz call ads gdy RSA gotowe. Dotyczy każdego klienta usługowego (hydraulik, prawnik, serwis, transport) gdzie główny KPI to telefon. + +### Call asset na poziomie konta — nie ad group + +- **YT_sp_83_assets_call_asset_na_poziomie_konta** (`YT_surfside-ppc`): Call asset ustawiaj na poziomie konta, nie na poziomie ad group. Harmonogram call asset = godziny pracy biura. Możliwość granularnego harmonogramowania: np. do 16:45 zamiast 17:00 — unikasz połączeń na ostatnie minuty pracy. Po godzinach: wyłącz call asset całkowicie (ruch idzie na stronę → formularz zamiast na połączenie). Dotyczy każdego lokalnego klienta usługowego. + +### DKI + Exact Match w RSA — ultra-trafność reklamy dla małych budżetów + +- **YT_jsg_75_dki_exact_match_wysoki_ctr** (`YT_jyll-saskin-gales`): Kombinacja Exact Match + DKI w nagłówku RSA = ultra-trafność reklamy. DKI jest bezpieczne TYLKO przy Exact Match (wiemy, co wejdzie do nagłówka). Przy Broad Match lub PMax DKI może tworzyć dziwne/niezgodne nagłówki. Efekt: CTR wzrósł z 4% do 13% (jeden z czynników obok wyłączenia Search Partners). Template: {KeyWord:Nazwa usługi} jako pierwszy nagłówek + USP w drugim + CTA z urgency w trzecim. Stosować dla klientów usługowych z ograniczonym budżetem i dobrze zdefiniowanym słownikiem fraz. + +### Harmonogram call asset — ukryty trick + +- **YT_sp_12_conv_call_asset_harmonogram** (`YT_surfside-ppc`): Call assets można ograniczyć harmonogramem na poziomie assetu (nie tylko kampanii). Jeśli klient nie odbiera po godzinach, ustaw call asset np. do 16:45 zamiast 17:00 — unikasz połączeń na ostatnie minuty pracy. Alternatywnie: wyłącz call asset po godzinach, żeby ruch szedł na stronę (formularz) zamiast na połączenie. Ustaw call asset na poziomie konta (nie ad group) — dotyczy każdego klienta lokalnego. + +### Irresistible Offers — framework Alex Hormozi i case study +77% CVR + +- **YT_pm_13_irresistible_offers_copy** (`YT_ppc-mastery`): Case study (subscription coffee): CPA 2x powyżej targetu. Proces: (1) budowanie person (2 segmenty wiekowe), (2) identyfikacja obiekcji (lojalność Starbucks/Pret, cena, wpływ środowiskowy, ryzyko), (3) stworzenie rozwiązań dla każdej obiekcji w copy, (4) Value measures (dream outcome), (5) Risk reducers (cancel anytime, pause holiday, money back). Reklamy napisane na podstawie analizy bez zmian w landing page: CTR +67%, CVR +77%, CPA -55%. Bezpośredni efekt samego ad copy — bez CRO na stronie. Framework: Alex Hormozi '$100M Offers'. Zastosowanie: każda kampania gdzie CPA jest za wysokie, szczególnie gdy są wyraźne obiekcje do zakupu (subskrypcje, usługi premium). Kolejność: najpierw analiza persona + obiekcje, potem copy. + +### RSA assets — 100 kliknięć per asset przed oceną CTR, 50 konwersji przed oceną CPA + +- **YT_jsg_74_rsa_assets_progi_statystyczne** (`YT_jyll-saskin-gales`): Konkretne progi przed oceną RSA assets: 100 kliknięć per asset zanim ocenisz CTR. 50 konwersji per asset zanim ocenisz conversion rate / CPA. Poniżej tych progów dane są przypadkowe. CPC nie jest metryką do oceny RSA w ogóle. Kolejność: najpierw pauzuj najgorsze CTR, potem stwórz warianty najlepszych CTR, na końcu (gdy masz konwersje) oceniaj conversion rate. Przy małych budżetach może minąć kwartał zanim masz dane do decyzji. + +## Temat: automatyzacja-ai + +### AI Campaign Builder — workflow budowy kampanii Search z AI + +- **YT_gma_84_ai_campaign_builder_workflow** (`YT_grow-my-ads`): Grow My Ads zbudowali AI Campaign Builder w Claude Code. Workflow narzędzia: (1) Podaj URL strony klienta → Gemini crawluje i analizuje stronę → wykrywa usługi/produkty. (2) Użytkownik wybiera tematy/usługi do kampanii. (3) Narzędzie pobiera dane CPC z Google Keyword Planner (real-time, nie z LLM). (4) Generuje strukturę kampanii: ad groups, keywords (exact/phrase/broad), RSA. (5) Opcje: tight/balanced/broad match strategy, kontrola rozmiaru ad group, auto-exclude tablets, auto-exclude competitors, osobna brand campaign. (6) Budget planner: conservative/balanced/aggressive na bazie real CPC z KP i benchmarków CR dla branży. (7) Pre-launch checklist → export do CSV (Editor) lub bezpośredni import do Google Ads. Case study: kampania meblowa (sectional sofas), budżet $50/dzień, Max Clicks na start → $900 sprzedaży w 1. dzień. Kluczowa lekcja: AI przyspiesza budowę kampanii z godzin do minut, ale wymaga manualnego fine-tuningu ad copy i ad groups przed publikacją. + +### AI Competitor Analysis — workflow analizy konkurencji + +- **YT_sp_93_ai_competitor_analysis_workflow** (`YT_surfside-ppc`): Surfside PPC — workflow analizy konkurencji z AI: (1) Zbierz dane: eksportuj top pages konkurentów z narzędzi SEO (Ahrefs, SEMrush) lub Google Ads Auction Insights. Zbierz: top keywords, ad copy, landing pages. (2) Wklej do AI (ChatGPT/Claude): 'Here are the top pages for 3 competitors. Analyze keyword gaps — which keywords are my competitors targeting that I am not?' (3) AI identyfikuje: keyword gaps (frazy których nie targetujesz), content gaps (tematy bez pokrycia na Twojej stronie), ad copy patterns (jakie CTA i USP używa konkurencja). (4) Actionable output: lista nowych keywords do targetowania, pomysły na landing pages, benchmarki ad copy. Zastrzeżenie: dane z narzędzi SEO to estymacje, nie realne dane Google Ads. Traktuj jako kierunek, nie jako dokładne liczby. + +### AI Copywriting — workflow generowania RSA z ChatGPT/Claude + +- **YT_sp_92_ai_copywriting_rsa_workflow** (`YT_surfside-ppc`): Surfside PPC — workflow AI do generowania ad copy dla Google Ads RSA: (1) Prompt: 'Read all content from [URL strony]. Use that content to write ads in the following format: 20 Google Ads headlines (under 30 chars), 7 long headlines, 5 descriptions (under 90 chars), Meta primary text, Meta headlines.' (2) AI generuje szerokie ad copy → doprecyzuj: 'Write ad copy for my [specific ad group] targeting these keywords: [lista keywords]'. (3) WAŻNE: AI NIE zastąpi manualnego fine-tuningu — traktuj output jako draft. Sprawdź: character limits (30 znaków headline, 90 opis), keyword insertion, CTA. (4) Nie generuj tysięcy wariantów — 15-20 headlines + 4-5 descriptions per ad group wystarczy. (5) Personalizuj per ad group — broad ad copy z URL strony to punkt startu, ale każdy ad group potrzebuje specific copy dopasowanego do keyword theme. Najczęstszy błąd: używanie AI output bez edycji → generyczne, nieskuteczne reklamy. + +### Architektura systemu AI do Google Ads: wiedza domenowa i struktura per klient + +- **W127_automatyzacja_architektura_wiedza_klient** (`W127`): Jakość automatyzacji AI zależy krytycznie od jakości wiedzy domenowej wgranej do systemu — sam model AI bez dobrych instrukcji nie zastąpi specjalisty Google Ads. Zalecana architektura dla agencji/freelancera zarządzającego wieloma kontami: (1) Globalna baza wiedzy: ogólne zasady Google Ads, sprawdzone reguły, wzorce optymalizacji, typowe pułapki — wgrana raz, używana dla wszystkich klientów. (2) Per-klient folder z indywidualnymi plikami: — CLAUDE.md lub instrukcje specyficzne dla klienta (branża, priorytety, KPI), — config z ID konta, docelowe ROAS per kampania, limity budżetów, — historia decyzji i kontekst biznesowy klienta. (3) Funkcje/komendy per typ zadania: każde powtarzalne zadanie ('sprawdź konto', 'keyword research', 'wycena dla klienta') zapisuj jako gotową funkcję — wystarczy powiedzieć AI 'wykonaj to zadanie, a potem zapisz jako funkcję /nazwa'. Praca zespołowa: przemyśl architekturę gdy wiele osób ma swoje instancje AI — gdzie przechowywane są wspólne dane, jak synchronizowane są zmiany, jak różne instancje AI mają ze sobą współpracować. + +### ChatGPT do pisania reklam — 3-etapowy framework + +- **YT_gma_73_chatgpt_ad_copy_3_metody** (`YT_grow-my-ads`): 3 metody pisania reklam z ChatGPT wg Grow My Ads: Metoda 1 (podstawowa): 'napisz 10 headlines' — generyczne, powtarzalne. Metoda 2 (analiza strony): ekstrakcja ze strony → generowanie z kontrolą znaków — lepiej, ale 'fluffy'. Metoda 3 (benefit extraction — najlepsza): (1) Ekstrakcja ze strony, (2) Analiza psychologiczna klienta — motywacje, obawy, triggery, hesitations, (3) Wyciągnij 10 features + 10 benefits, (4) 3 tematyczne RSA do split testów (np. 'craftsmanship' vs 'value' vs 'reliability'). Wynik: konkretne, compelling, tematycznie spójne reklamy. + +### Claude Code + Google Ads API: setup, koszty i wymagania wejścia + +- **W127_automatyzacja_claude_code_google_ads_api_setup** (`W127`): Claude Code (narzędzie terminalowe Anthropic) połączone z Google Ads API tworzy środowisko do autonomicznej automatyzacji zarządzania kontami Google Ads. Koszty i progi wejścia: (1) Subskrypcja Claude: od ~17 USD/mies. (Claude Pro) do ~100 USD/mies. (Max 5x). Przy intensywnym użyciu (analiza kont, generowanie raportów, zmiany kampanii): realistyczny koszt 400–600 PLN/mies. (2) Google Ads API: bezpłatne — płatne tylko wg. standardowych kosztów kliknięć; dostęp do API uzyska się przez formularz z poziomu konta MCC (NIE z konta klienta — to kluczowy błąd przy próbie uzyskania dostępu). Czas oczekiwania na zatwierdzenie: 3–10 dni roboczych. (3) Czas wdrożenia: kilkanaście godzin na konfigurację od zera; gotowe rozwiązania 'z pudełka' (np. bdos.ai) kosztują 3500–6000 PLN. Wymagania techniczne: Python, konto MCC z podpiętymi kontami klientów, OAuth2 credentials, developer token z Google. Claude Opus 4.6 jest na dziś najlepszym modelem do pisania skryptów i komunikacji z API Google Ads — radzi sobie lepiej z GAQL i biblioteką google-ads niż inne modele. Alternatywy: Cursor (edytor kodu z AI), n8n (automatyzacja bez kodu), Gemini CLI — wszystkie słabsze w kontekście złożonych zadań Google Ads API. + +### Dry run i halucynacje AI: weryfikacja przed wdrożeniem zmian na koncie + +- **W127_automatyzacja_dry_run_halucynacje** (`W127`): Przy automatyzacji Google Ads przez AI (Claude Code + API) kluczowe zasady bezpieczeństwa: (1) ZAWSZE używaj trybu 'dry run' przed wdrożeniem zmian na konto — dry run symuluje działanie skryptu i wypisuje co ZOSTAŁOBY zmienione bez faktycznego wykonania zapisu do API. Implementacja: w skryptach Python dodaj flagę `dry_run=True` do operacji mutate i loguj planowane zmiany zamiast je wykonywać. (2) Halucynacje i false positive AI: Claude może raportować 'wdrożyłem zmiany' kiedy w rzeczywistości tego nie zrobił (np. błąd autoryzacji, timeout, problem z API) — zawsze weryfikuj w historii konta. Odwrotnie: może też raportować błąd, gdy zmiana faktycznie się powiodła. Kalibracja systemu: im więcej powtórzeń tego samego zadania (5., 10., 15. raz), tym mniej halucynacji — AI 'uczy się' wzorca zadania. (3) Weryfikacja wprowadzonych zmian: wszystkie zmiany przez API widoczne są w historii zmian konta Google Ads z adnotacją 'Google API' (odróżnia od zmian ręcznych i skryptowych). Sprawdź historię po każdej sesji automatyzacji zanim zaraportuje wyniki klientowi. + +### Kompresja list wykluczeń przez n-gramy: AI obchodzi limit Google Ads + +- **W127_automatyzacja_ngramy_wykluczenia** (`W127`): Google Ads ma limit liczby wykluczających słów kluczowych per kampania/konto. AI (Claude Code + Python) może automatycznie kompresować długie listy wykluczeń przez analizę n-gramów (powtarzających się sekwencji słów). Jak działa kompresja przez n-gramy: (1) Eksport aktualnej listy wykluczeń z Google Ads API. (2) Analiza n-gramów: znajdź słowa/frazy pojawiające się w wielu wykluczeniach (np. 'jak zrobić', 'DIY', 'instrukcja', 'samemu'). (3) Zastąp grupę wykluczeń jednym nadrzędnym wykluczeniem obejmującym wszystkie — np. zamiast 50 fraz z 'jak' wgraj jedno wykluczenie 'jak' (broad match). (4) Wgraj skompresowaną listę przez API. Efekt: lista wykluczeń z 500 pozycji może zmieścić się w 50–100 bez utraty ochrony przed niechcianymi frazami. AI szczególnie dobrze radzi sobie z tym zadaniem bo widzi wzorce semantyczne między frazami, które człowiek przeoczyłby przy ręcznej analizie. Dodatkowe zastosowanie: identyfikacja luk w wykluczeniach — AI może sprawdzić search terms z ostatnich 30 dni i zaproponować brakujące wykluczenia. + +## Temat: brand + +### Auction Insights — interpretacja metryk i cele impression share + +- **YT_sp_49_competitive_auction_insights** (`YT_surfside-ppc`): Interpretacja kolumn Auction Insights: Impression Share (wyższy = lepiej), Overlap Rate (25% to dobry poziom), Position Above Rate (NIŻSZY = lepiej — % czasu gdy konkurent jest nad tobą), Top of Page Rate (wyższy = lepiej), Outranking Share (wyższy = lepiej). Cel impression share dla lokalnych firm: 70%+ przy zachowaniu efektywności kosztowej — nie dąż do 100%, brakujące 20-30% to często słabsze wyszukiwania mniej skłonne do konwersji. Przy niskim IS i wysokim Position Above Rate u konkurenta: prawie zawsze oznacza wyższy budżet + wyższe bidy + więcej danych = lepszy QS po ich stronie. + +### Branded kampania na start konta — przyspiesza zbieranie danych konwersji + +- **YT_sp_26_brand_kampania_rownolegle** (`YT_surfside-ppc`): Uruchamiaj branded kampanię równolegle z pierwszą kampanią non-branded — nie dlatego że brand jest zagrożony, ale dlatego że branded keywords konwertują łatwiej i szybciej. To przyspiesza zebranie danych konwersji, co pomaga 'rozruszać' algorytm smart bidding dla głównej kampanii. Brand i non-brand muszą być w oddzielnych kampaniach — branded keywords mają conversion rate 3-5x wyższy, CPC kilkukrotnie niższy. Mieszanie zniekształca metryki: CPA kampanii wygląda świetnie bo branded 'ciągnie' wyniki w górę, maskując słabość non-branded. + +### PMax a brand — pułapka kanibalizacji branded traffic + +- **YT_sp_68_brand_pmax_kanibalizuje_brand** (`YT_surfside-ppc`): PMax często 'zjada' branded traffic zamiast non-branded. Dla branded search → dedykowana Search kampania jest lepsza niż PMax. PMax przy lead gen ma tendencję do 'driving junk' szczególnie gdy konwersją jest click-to-call (Google liczy każde 60-sekundowe połączenie jako konwersję, nawet spam). PMax działa lepiej gdy masz 'qualified conversions' (zakup, wypełniony formularz z wartością). Weryfikuj przy audycie klientów lead gen z PMax + click-to-call jako jedyną konwersją. + + +## Temat: budzety + +### Limited by Budget — czerwone = realny problem (target), żółte = ignoruj (maximize) + +- **YT_jsg_52_limited_by_budget_czerwone_zolte** (`YT_jyll-saskin-gales`): Czerwone 'limited by budget' = prawdziwy problem wydajnościowy — pojawia się TYLKO przy Target CPA, Target ROAS lub Manual CPC. Oznacza: jest więcej możliwości, wystarczy zwiększyć budżet. Żółte/niebieskie 'limited by budget' = tylko rekomendacja, można ignorować — pojawia się przy Maximize Conversions/Clicks. Kampanie Max nie mogą technicznie być 'limited by budget' w sensie performancowym. Podczas audytów — rozróżniaj kolor ostrzeżenia. Żółte można zignorować, czerwone przy target strategiach = realny sygnał do działania. + +### Minimum budżetu — 10 kliknięć dziennie, $1000/miesiąc absolute minimum + +- **YT_jsg_13_budzet_minimum_10_klinkiec_dziennie** (`YT_jyll-saskin-gales`): Minimum sensownego budżetu = 10 kliknięć/dzień. Przy CPC $10 to $100/dzień = ~$3000/miesiąc. Przy mniejszym budżecie (np. $33/dzień = 3 kliki/dzień przy CPC $10): z CVR 6% → tylko ~6 konwersji/miesiąc → potrzeba minimum 5-6 miesięcy żeby zebrać dane do przejścia na tCPA. $1000/miesiąc to absolute minimum — ale uczciwie powiedz, że kampania będzie się uczyć wolno. Prawdziwy koszt pozyskania klienta (CAC) = budżet / liczba prawdziwych klientów (nie leadów). Trzy dźwignie do zmniejszenia CAC: obniżyć CPC, podnieść CVR formularza, podnieść sales conversion rate — ta trzecia jest często największą dźwignią i nie ma nic wspólnego z Google Ads. + +### Pacing budżetu — nie zmieniaj budżetu często, Google się uczy rytmu + +- **YT_sp_36_bid_pacing_nie_zmieniej_czesto** (`YT_surfside-ppc`): Google automatycznie przesuwa wydatki na godziny/dni z najlepszym CR (jeśli nie ma ustawionych harmonogramów). Częste zmiany budżetu zaburzają ten mechanizm. Zamiast manipulować budżetem — usuń niskie-efektywności dni (np. niedziela) z harmonogramu. Raport: Insights → 'When and where ads showed' → filtr po dniu/godzinie. Forecast z kreatora kampanii (Weekly Estimates przy końcu) jest dokładniejszy niż Keyword Planner Forecast. + +### Priorytety mix kampanii: Search → PMax → Demand Gen, nigdy Display + +- **YT_sp_37_bid_mix_kampanii** (`YT_surfside-ppc`): Hierarchia uruchamianych kampanii: (1) Search — zawsze pierwsza, (2) Performance Max — szczególnie local + ecom, (3) Demand Gen — głównie YouTube, nie Display. Smart Campaigns — nigdy nie uruchamiać. Display campaigns — jawnie odradza, nigdy nie uruchamia. Stosunek budżetu dla lead gen: Search 90-95% / PMax 5-10%. Dla e-commerce: PMax może być głównym kanałem. Demand Gen: tylko przy budżecie >$3k/miesiąc, przy mniejszym to zbędny luksus. + +### Przy starcie nowej kampanii — ustaw budżet niżej niż docelowy + +- **YT_sp_35_bid_niska_stawka_startowa** (`YT_surfside-ppc`): Świadomie startuj z budżetem niższym niż rekomendowany (np. $75/dzień zamiast $100). Cel: zebrać pierwsze konwersje jak najtaniej, zanim Google nauczy się wydawać agresywnie. Przy chęci obniżenia CPC na starcie: Manual CPC + Exact Match — Maximize Conversions bez danych konwersji powoduje że Google przebija za pierwsze kliknięcia. Formuła przy braku danych: Max CPC $15-20 dla home services, $5-10 dla mniej konkurencyjnych branż. Dla branded: max CPC $3-7. + +### Próg konwersji do przełączenia na Maximize Conversions: 7 w 10-15 dni + +- **YT_sp_32_bid_progi_smart_bidding** (`YT_surfside-ppc`): Autorski próg do przełączenia z Manual CPC/Maximize Clicks na Maximize Conversions: 7 konwersji w ciągu 10–15 dni (nie czekaj na oficjalny próg Google 15/30 dni). Przy 5 konwersjach w ciągu 30 dni — zostań na manual. Minimum sensownej optymalizacji smart bidding: 30 konwersji/miesiąc. Dla nowych kont pierwsze $1500 to 'inwestycja w dane'. Nie dawaj dużego budżetu + Maximize Conversions od razu bez danych konwersji — Google przepala pierwsze kliknięcia za kilkaset dolarów. + +### Rule of Two — filtr czy warto uruchamiać Google Ads + +- **YT_jsg_14_rule_of_two_kiedy_nie_uruchamiac_ads** (`YT_jyll-saskin-gales`): Conversion rate (%) × Average Order Value ($) musi dawać co najmniej $2 (revenue per session). Przykłady: 2% CVR × $100 AOV = $2 ✓. 0.5% CR × $50 AOV = 0.25 ✗ — nie warto uruchamiać. Logika: w dzisiejszym Google Ads trudno znaleźć CPC < $2, więc każde kliknięcie kosztuje więcej niż zarabiasz. Dla e-commerce i SaaS. Dla lead gen inny framework: przelicz realny CAC i porównaj do LTV. Dodatkowe warunki: minimum $1000/miesiąc przez minimum 3 miesiące bez oczekiwania ROI + fundament SEO. Reklamy to 'benzyna na ogień' — działają gdy ogień już płonie. Najpierw napraw ofertę/stronę przez organic, potem odpal ads. + +### Sezonowość — nie walcz z trendem rynkowym, dostosuj kreacje lub budżet + +- **YT_jsg_15_sezonowosc_nie_optymalizuj_z_trendu** (`YT_jyll-saskin-gales`): Gdy spada sezonowy popyt (np. grille BBQ w UK jesienią): pierwsza oznaka to spadek conversion rate (ludzie klikają ale nie kupują), potem spadek impression share (mniej wyszukiwań). Nie ma sensu agresywnie zmieniać keywords/bid strategy żeby 'walczyć z sezonowością' — walczysz z ludzkim zachowaniem. Opcje: (1) dostosuj kreacje do sezonu (out-of-season messaging, np. 'book for next summer now'), (2) poluzuj cele (wyższy CPA, niższy tROAS) jeśli chcesz utrzymać wolumen, (3) zmniejsz budżet lub pauza do następnego sezonu. Miej frank conversation z klientem przed sezonem o tym co się stanie po. + +### Skalowanie z €28K do €550K — etapy i lekcje z 20x wzrostu + +- **YT_pm_46_skalowanie_case_20x** (`YT_ppc-mastery`): Case study 20x wzrost (Niklas Buschner): klient z branży nieruchomości (usługi lokalne w Niemczech), 1.5 roku. Etapy: (1) Audit 10 kampanii, dedykowane conversion actions per etap lejka, przejście z Max Clicks na tCPA. (2) Wdrożenie OCT (qualified leads z CRM) → +20% spend, +100% deal value. (3) Local Search Strategy (keyword placeholder + dynamiczne LP) → CTR z 11% do 15%, ROAS +84%. (4) Demand Gen gdy Search nasycony → 'free check' jako lead magnet. Kluczowe lekcje: bez OCT decyzje byłyby odwrotne (wycinanie lepszych kanałów), bez dynamicznych LP nie byłoby lokalnej przewagi, bez Demand Gen wzrost by się zatrzymał. Każdy etap wymagał innych danych do podejmowania decyzji. + +### Strategia bidding przy uruchamianiu — 3 scenariusze + +- **YT_sp_55_strategy_bidding_3_scenariusze** (`YT_surfside-ppc`): Scenariusz 1 — konto bez danych: Exact match + Maximize Clicks + Max CPC limit. Scenariusz 2 — konto z danymi historycznymi (ostatnie 3 miesiące): Phrase match + Maximize Conversions bez target CPA. Scenariusz 3 — agresywny launch z dużym budżetem (klient akceptuje początkowe straty): High-intent Broad match + Maximize Conversions. Preferowane podejście: Exact match + Maximize Clicks + Max CPC limit → zbierasz trafne kliknięcia, minimalizujesz wasted spend, czekasz na konwersje. + +### Target CPA — ustaw wyżej niż rzeczywisty koszt gdy zależy Ci na wolumenie + +- **YT_sp_33_bid_tcpa_wyze_dla_wolumenu** (`YT_surfside-ppc`): Celowo ustawiaj tCPA znacznie powyżej rzeczywistego kosztu/konwersję u klientów, którym zależy na wolumenie. Przykład: rzeczywisty CPA = 60 zł → tCPA ustawiony na 125 zł. Powód: zbyt niski tCPA powoduje, że Google zatrzymuje się gdy nie jest pewien czy wygeneruje konwersję poniżej limitu — traci aukcje. Zbyt niskie tCPA = mniej leadów. Odwrotnie dla klientów wrażliwych na koszt: tCPA niższy od rzeczywistego CPA = mniejszy wolumen, ale większa efektywność. Przy portfolio bidding strategy z tCPA + limitem max CPC: przewidywalne wyniki kosztem wolumenu. + +## Temat: client-management + +### Błędy w zarządzaniu relacjami z klientem + +- **YT_sol8_81_bledy_relacje_klient** (`YT_solutions-8`): Największy błąd: NIE sam błąd, ale brak wzięcia odpowiedzialności za niego — przyznaj się, overcommunicate, pokaż że działasz nad rozwiązaniem. Milczenie podczas optymalizacji = katastrofa. Klient wydaje pieniądze i nie słyszy od Ciebie -> tworzy negatywne narracje w głowie. Wysyłaj krótkie tygodniowe update'y nawet jeśli nie ma wiele do powiedzenia. Szczególnie ważne dla nowych klientów — nie znają Twojego sposobu pracy. Edukuj klienta o learning phase Google Ads z góry — zapobiega panice i żądaniom przedwczesnych zmian. Struktura komunikacji: regularne check-in calls, Looker Studio raporty (uproszczone dane), hub komunikacyjny (Slack/Monday/ClickUp). + +### Customer meltdown — framework agencyjny + +- **YT_sol8_57_customer_meltdown_framework** (`YT_solutions-8`): Klient mówi 'telefony przestały dzwonić': (1) Bądź PIERWSZYM który to komunikuje — proaktywnie, nie reaktywnie. (2) Mały spend: max tygodniowy reporting lag. (3) Duży spend ($30K+): codzienne notyfikacje agencji do klienta. (4) Sprawdź ZANIM klient zadzwoni: backend revenue, conversion tracking, strona, zmiany w koncie. Najgorsza sytuacja: klient dowiaduje się o problemie ZANIM agencja. Brand search volume jako proxy brand lift: jeśli top-of-funnel ads działają, brand search volume powinien rosnąć. Narzędzia: Google Trends, Search Console, brand campaign impressions/clicks over time. + +### Odpowiadanie na emaile od zdenerwowanych klientów + +- **YT_sol8_84_email_zdenerwowany_klient** (`YT_solutions-8`): Jeśli klient jest wściekły -> proponuj call, nie email. Jeśli klient jest rozczarowany (ale nie wściekły) -> email OK, 6 kroków: (1) Acknowledge + Apologize — 'Rozumiem, przepraszam za niedogodność'. (2) Offer Insight (jeśli jest) — np. 'zauważyliśmy że patrzyłeś na zły dashboard'. Jeśli to błąd po Twojej stronie — pomiń ten krok. (3) Explain Plan of Action — co dokładnie robisz żeby naprawić sytuację. (4) Estimated Timeline — NIGDY nie obiecuj ('w 6 dni poprawi się o 45%'), dawaj szacunek ('przewidujemy poprawę w ciągu 7-8 dni roboczych'). (5) Offer to hop on a call. (6) Apologize again + Encouragement — zakończ pozytywnie. + +### Odzyskiwanie niezadowolonych klientów + +- **YT_sol8_82_odzyskiwanie_klientow** (`YT_solutions-8`): Krok 1: Słuchaj — nie broń się od razu. Krok 2: Zoom out — spójrz na szerszy obraz, nie tylko ostatni tydzień. Krok 3: Jeśli klient ma rację -> wprowadź zmiany, ale wyjaśnij dlaczego dotychczasowa strategia miała sens w danym momencie. Krok 4: Jeśli to krótkoterminowy dip -> wejdź w tryb danych: GA, inne narzędzia, pokaż trendy. Krok 5: Robiaj małe zmiany które nie niszczą learningu, ale pokazują klientowi akcję. Kadencja spotkań: ustal na kickoff callu. Nowy klient: weekly zawsze. Stabilny klient: można przejść na bi-weekly. Duży/złożony klient: weekly meetings obowiązkowe. + +### Onboarding nowego klienta i pierwszy call + +- **YT_sol8_83_onboarding_klienta** (`YT_solutions-8`): Discovery call: cele performance'owe, cele biznesowe, definicja sukcesu. Zrozum osobowość klienta — to kieruje całą przyszłą komunikację: hands-on vs. hands-off, data-savvy vs. top-line metrics. Klient który 'zna się na marketingu' ale nie zna Google Ads = najtrudniejszy (chce kierować, ale nie rozumie learning phase). Realistyczne oczekiwania: co jest możliwe w 30, 60, 90 dni. To-Do na pierwszym callu: (1) poznaj klienta — buduj 'professional friendship', (2) zapytaj o cele performance (CPA/ROAS goal), (3) potwierdź budżet miesięczny, (4) ustaw oczekiwania — learning period, specyfika typów kampanii, (5) ustaw tygodniowe spotkanie — minimum 90 dni weekly, (6) zapytaj 'czy jest coś jeszcze?' — klient może ujawnić kluczowe info. + +### Rozmowy o jakości leadów z klientami lead gen + +- **YT_sol8_86_jakosc_leadow_rozmowy** (`YT_solutions-8`): Cost per lead vs. Cost per QUALIFIED lead — kluczowe rozróżnienie. Rozmowę o jakości leadów zaczynaj jak najwcześniej (najlepiej na onboardingu) — nie czekaj 3 miesiące aż klient powie 'te leady są do niczego'. CRM access jest prawie obowiązkowy — bez niego jesteś ślepy na to co się dzieje po konwersji. Przy długich cyklach sprzedaży (miesiące): regularne follow-upy o statusie konkretnych leadów. Import konwersji z CRM do Google Ads (np. przez HubSpot) = optymalizacja pod prawdziwe konwersje, nie form submissions. Case study: klient z kalkulatorem podatkowym -> importowali tylko leady powyżej progu dochodowego do Google Ads. + +### Zarządzanie klientami — upselling bez niszczenia relacji + +- **YT_sol8_80_upselling_klienci** (`YT_solutions-8`): Upselling powinien brzmieć jak rekomendacja, NIE jak pitch sprzedażowy. Podkreślaj DLACZEGO to jest ważne dla ich biznesu, nie 'kup to'. Framework PAS (Problem-Agitation-Solution): pokaż problem -> konsekwencje -> rozwiązanie. Brak natychmiastowej odpowiedzi: NIE wymyślaj na poczekaniu — powiedz że potrzebujesz czasu. Potwierdź otrzymanie pytania, podaj timeline odpowiedzi, dotrzymaj go. Klienci szanują szczerość bardziej niż błędną odpowiedź na poczekaniu. Leadership: klient przychodzi po Twoją ekspertyzę — dawaj strategie i rekomendacje proaktywnie, nie czekaj na instrukcje. Granice komunikacji: ustaw od początku godziny pracy — jeśli odpowiadasz o 22:00, klient oczekuje tego stale. + +### Zarządzanie sfrustrowanymi klientami (problem ze sprzedażą) + +- **YT_sol8_85_sfrustrowany_klient_sprzedaz** (`YT_solutions-8`): Klient który nie potrafi sprzedawać panikuje i wini reklamy. Technika ROAS breakdown: rozłóż ROAS na składowe (CPC, CTR, conv rate, AOV) i zapytaj klienta gdzie widzi możliwości. 'Zrobiłem już product title overrides, ad copy jest zoptymalizowane — co jeszcze widzisz w search terms?' Pomaga klientowi dojść do własnego wniosku że problem jest po stronie sprzedaży. Pokaż search terms + ad copy klientowi: 'Zrobiłem wszystko co w mojej mocy po tej stronie równania. Jako ekspert branży — widzisz coś czego ja nie widzę?' + +## Temat: creative + +### Ad Copy — effective words for conversions + +- **YT_sol8_40_ad_copy_effective_words** (`YT_solutions-8`): 'Free' jest nadal najpotężniejszym słowem w reklamie — free consultation, free quote, free trial. Liczby konkretne > ogólniki: 'Save $347' > 'Save money', '4.9 stars from 2,847 reviews' > 'highly rated'. Urgency bez fałszu: 'Limited spots' OK jeśli prawdziwe, 'Last chance' bez powodu = utrata zaufania. Problem-first headlines: zacznij od bólu klienta, nie od features — 'Tired of [problem]?' > 'We offer [solution]'. + +### Hack na ad copy: Google NLP + ChatGPT + +- **YT_sol8_77_google_nlp_ad_copy_hack** (`YT_solutions-8`): Workflow: strona klienta -> Google Cloud Natural Language API (demo) -> analiza sentymentu -> wyciągnij tekst z pozytywnym sentymentem (zielony) -> wrzuć do ChatGPT. Google preferuje pozytywny sentyment w ad copy — NLP API pokazuje co Google uważa za pozytywne. Prompt do ChatGPT: 'Działaj jako ekspert Google Ads copywriter, wykorzystaj style 14 legendarnych copywriterów (Ogilvy, Bernbach itd.), napisz 15 headlines i 4 opisy w stylu każdego'. Po wygenerowaniu copy, przepuść wynik ponownie przez NLP API — powinien być zielony (pozytywny sentyment). GPT-4 daje dużo lepsze wyniki niż GPT-3.5. + +### Kreacje brand-aligned i skalowanie kampanii + +- **YT_sol8_76_brand_aligned_creatives** (`YT_solutions-8`): Buduj direct response creatives które są jednocześnie conversion-optimized i brand-aligned. Optymalizuj kreacje per placement: 1x1 feed, 4x5 feed, 9x16 story — złe rozmiary = blank space = niski engagement. Emocje > cechy produktu: zamiast 'koszulka z dobrego materiału' mów 'będziesz wyglądać świetnie, poczujesz się pewnie'. Spójność brandowa przez cały funnel (ToF awareness -> MoF prospecting -> BoF remarketing) jest krytyczna. Małe zmiany (kolor przycisku, placement elementów) mogą mieć ogromny wpływ — testuj w ramach brand guidelines. Pytanie do każdego nowego klienta: 'Co Cię wyróżnia? Co sprawia, że jesteś inny od konkurencji?' + +### Meta Ads — Copywriting guide + +- **YT_sol8_69_meta_copywriting** (`YT_solutions-8`): Primary text (na górze): rekomendacja Meta 50-150 znaków (short form bez 'See more'). Headline (bold pod obrazem): max 27 znaków; widoczny tylko na FB feed, nie na IG. Long form: hook w pierwszych 2-3 liniach, reszta pod 'See more'. Framework testowy: 5 hooków + 1 body + 5 headlines. Okres testu: ~2 tygodnie (mniejsze budżety). Kąty przekazu: Target the pain — najskuteczniejszy angle, Benefits/USP, US vs Them, Direct callout, Luksus/Social status, Cena/Jakość. Emojis dozwolone (dopasuj do brand voice); nie cały tekst CAPS (odrzucenie). Optimized text (Meta feature) — zazwyczaj wyłączaj. Osobne copy dla remarketing. + +### Meta Creative — hierarchia testowania i best practices + +- **YT_sol8_68_meta_creative_best_practices** (`YT_solutions-8`): Hierarchia testowania: 1) Audience — priorytet nr 1 (najlepszy produkt przed złą audiencją = 0 sprzedaży), 2) Creative — priorytet nr 2 (większy impact niż copy), 3) Copy — priorytet nr 3 (~50% pominie tekst). Format: 1:1 Facebook feed, 4:5 Instagram feed (preferowany), 9:16 Stories/Reels. Creative fatigue: 1-2 miesiące życia typowego creative. Testuj radykalnie różne kreacje — nie warianty tego samego zdjęcia z innym overlayem. 10-15% budżetu na ongoing creative testing. Engagement silnie wpływa na performance — Meta priorytetyzuje kreacje z dobrym engagement (niższe CPM). ~30% ludzi czyta komentarze przed kliknięciem. Post ID trick: zachowaj engagement przy kopiowaniu ada do nowych kampanii. Video wymagane dla L1 — minimum 30 sek. (50% view = silny intent). + +### Metoda CUB do oceny ad copy + +- **YT_sol8_78_metoda_cub** (`YT_solutions-8`): Metoda CUB (Perry Belcher): daj innym osobom przeczytać Twoje copy i oznaczyć które części są: C(urious) — ciekawe, U(nbelievable) — niewiarygodne, B(oring) — nudne. Zespół copywriterów powinien wzajemnie recenzować swoje prace PRZED prezentacją klientowi. Nie operuj solo — peer review drastycznie poprawia jakość ad copy. Metoda jest prosta ale skuteczna w identyfikacji słabych punktów tekstu reklamowego. + +### Naprawa niskiego CTR w kreacjach Meta + +- **YT_sol8_75_niski_ctr_naprawa** (`YT_solutions-8`): Benchmark CTR dla Meta Ads: 1% średnia, alarm poniżej 1.2% — czas na nowe kreacje. Dotyczy zarówno e-commerce jak i lead gen / home services. Checklist przy niskim CTR: (1) Czy kreacja jest zgodna z brandingiem? (2) Czy jest zapamiętywalna i wyróżnia się? (3) Czy łączy użytkownika z marką? (4) Czy zachęca do kliknięcia i poznania więcej? Potrzeba wielu różnych kreacji (nie powtarzaj tej samej) — użytkownik widzi tę samą kreację kilka razy i przestaje reagować. Porównuj CTR z benchmarkami branży. Testuj różne hooki i messaging. + +### Segmentacja kreacji reklamowych wg demografii + +- **YT_sol8_74_segmentacja_kreacji_demografia** (`YT_solutions-8`): Generyczne kreacje (one-size-fits-all) zabijają performance — wyższe CPA, wyższe CPM, niższy engagement. Segmentuj kreacje wg demografii (wiek, płeć) — każda grupa ma inne purchase triggers i pain points. Dla Gen Z: używaj ich slangu/vernacular w ad copy — to zatrzymuje scroll. Dla 65+: podkreślaj edukację, porównania, informacje — starsza demografika potrzebuje więcej czasu na decyzje. Segmentuj kampanie per produkt, a w ramach produktu per demografia. URL docelowy powinien prowadzić bezpośrednio na stronę produktu (nie wymuszaj 3-5 kliknięć). Narzędzie Motion pozwala analizować performance na poziomie elementów kreacji (headline, CTA, kolor przycisku). Kreacje segmentowane dają measurable YoY gains po wdrożeniu. + +## Temat: demand-gen + +### Demand Gen remarketing > Display remarketing (z wyjątkiem Dynamic Shopping) + +- **YT_ay_52_demand_gen_vs_display_remarketing** (`YT_aaron-young`): Thomas Eccel i Aaron Young: Demand Gen remarketing > Display remarketing w 2025. Wyjątek: Dynamic Shopping Remarketing (Display z feedem) — nadal działa dobrze, bo PMax i DG są zmuszane do szukania nowych klientów. Display remarketing bez feeda = lepiej zastąpić Demand Gen. Nie włączaj Display Network w DG — tracisz brand safety (YT properties = bezpieczne). Content Suitability: ustaw na Limited (nie Expanded/Standard). + +### Demand Gen — metryki video i testowanie kreacji + +- **YT_ay_51_demand_gen_video_metryki** (`YT_aaron-young`): Kluczowa metryka: Video Played to 25% (dodaj kolumnę: Columns → szukaj '25'). Cel: Video Watched to End > 20-25%. Krótsze reklamy = wyższy watch rate, dłuższe = potencjalnie wyższy conversion rate. Testowanie kreacji: start z 3-5 różnych anglei/kreacji per AG. Co 3-4 tygodnie: zachowaj winners, dodaj nowe kreacje w 3 kategoriach: (1) iteracje winnera, (2) zupełnie nowe angle, (3) różne oferty. Geo-test demand gen: testuj DG w jednym mieście/stanie przez 3 miesiące — porównaj uplift vs reszta kraju. Case study: nowy brand w Australii, DG w jednym mieście → +35% purchases, CPA spadł z $120 do <$70 w 6 tygodni. + +### Demand Gen — optymalizacja placementów i audience + +- **YT_ay_50_demand_gen_optymalizacja** (`YT_aaron-young`): Kluczowa optymalizacja: Where Ads Were Shown → filtruj placements z wysokim kosztem i 0 konwersji lub conv rate > 500% (scam) → wyklucz bulk. Placementy YouTube = odpowiednik Search Terms w kampaniach Search. Analiza segmentów audience: Ad group level → Audience → Audience Segment → Show → widzisz performance każdego segmentu. Jeśli lookalike ma wysoki conv rate ale niski spend → wydziel do osobnego AG. Seed list: minimum 100, ale lepiej 1000+. Dane zakupowe (purchases) > dane o page views/newsletter signups. View Through Conversions: DG często undereportuje — user widzi ad na YT, potem konwertuje przez Search. Case study: po $3k wydanych — Google raportował 14 konwersji, ale faktyczny revenue $8.5k w 30 dni. + +### Demand Gen — ustawienia, struktura i budżet + +- **YT_ay_49_demand_gen_setup_struktura** (`YT_aaron-young`): Jedna kampania Demand Gen (nie rozbijać na wiele) — DG uczy się na poziomie ad group. Ad groups rozdzielone wg placements: osobny AG dla Shorts, In-Stream, Display. [SPRZECZNOŚĆ z GMA] GMA zaleca segmentację po audience type, Aaron po placements. Budżet: Google zaleca $100/dzień lub 20x CPA. Thomas Eccel: minimum $50/dzień. Aaron widział sukces od $30-50/dzień. Przygotuj budżet na minimum 90 dni testu. Bidding: start Maximize Conversions (nie tCPA/tROAS). Eccel: zaczynaj od micro-konwersji (Add to Cart) aby nakarmić algorytm. Optimized Targeting = WYŁĄCZYĆ. Produkty: minimum 10 per AG, optimum 40-50. Brand Guidelines w DG: kolory brandu i font. + +### Kiedy uruchamiać Demand Gen — 3 scenariusze i progi + +- **YT_ay_48_demand_gen_progi_scenariusze** (`YT_aaron-young`): Scenariusz 1: Saturacja Search/Shopping — DG dopiero gdy Search Impression Share i Click Share przekraczają 60%. Poniżej 60% bezpieczniej skalować Search/Shopping. CPC Resistance = podwyżka budżetu o 10-15% ale CPC rośnie o 25-30%. Scenariusz 2: Produkt wymagający edukacji — brak bezpośrednich fraz zakupowych. YouTube daje średni czas sesji ~40 minut (3x więcej niż TikTok/Instagram). Scenariusz 3: Diminishing returns na Meta. Case study $500k/miesiąc konto: DG wydawał $10k/miesiąc przez 6 miesięcy — zero wymiernych wyników. Search IS poniżej 15%. Po przeniesieniu $10k z DG do Search: +15% nowego revenue. + +### PMax vs Demand Gen — 5 kluczowych różnic + +- **YT_ay_09_pmax_vs_demand_gen** (`YT_aaron-young`): PMax = conversion-focused, all networks, low control, automated targeting. Demand Gen = awareness/consideration, YouTube+Display+Gmail, higher control, audience-based targeting. Demand Gen: możliwość wyboru placements (Shorts only, midstream only, display only) — pozwala na testowanie kreacji per format. Demand Gen ustawienie: WYŁĄCZ Optimized Targeting — inaczej audiences stają się signalami (jak w PMax) i tracisz kontrolę. Demand Gen reporting: śledź View-through conversions (nie tylko click conversions) — to core metryka DG. Aaron coraz bardziej lubi DG: 'the more I'm using it, the more I'm starting to like it'. + +## Temat: display-demand-gen + +### Boty w GDN: fałszywe leady niskiej jakości — diagnoza i ochrona + +- **W069_display_demand_gen_boty_gdn_falszywe_leady** (`W069`): Kampanie GDN (Display Network) są podatne na kliknięcia botów i ruchu nieludzkiego, który może generować fałszywe wypełnienia formularzy (leady). Sygnały ostrzegawcze: leady z adresami gmail.com, hotmail.com, yahoo.com zamiast adresów firmowych (w kontekście B2B); bardzo krótki czas sesji (kilka sekund); brak zaangażowania po konwersji (nikt nie odpowiada na maile follow-up); leady z niezidentyfikowanych krajów lub o podejrzanych adresach IP. Działania ochronne: (1) Walidacja pola e-mail na poziomie formularza — odrzucaj publiczne domeny (gmail, hotmail, wp.pl, onet.pl) jeśli celem są leady B2B. (2) Wykluczaj aplikacje mobilne z kampanii GDN (kategoria placement: Mobile apps) — generują dużo nieludzkiego ruchu. (3) Sprawdzaj raport miejsc docelowych i wykluczaj podejrzane domeny/aplikacje. (4) Dodaj pole anty-botowe do formularza (honeypot lub reCAPTCHA). Fałszywe leady z GDN nie mają wpływu na jakość konta Google Ads, ale psują dane konwersji i uczą algorytm na złych przykładach — im szybciej wykluczysz złe placements, tym lepiej. + +### CPV w reklamach TrueView (YouTube): benchmark 6–12 groszy przy dobrym targetowaniu + +- **W123_display_cpv_trueview_benchmark** (`W123`): CPV (Cost Per View) w kampaniach TrueView (reklamy wideo pomijalne na YouTube) — orientacyjny benchmark na polskim rynku: 6–12 groszy za obejrzenie. Ten zakres osiągasz przy odpowiednim targetowaniu: precyzyjne listy odbiorców (remarketing, Customer Match, in-market) lub targetowanie po zainteresowaniach w powiązaniu z tematyką kanałów. Przy szerokim targetowaniu lub słabym dopasowaniu kreacji CPV rośnie — powyżej 20 groszy/obejrzenie to sygnał, że targetowanie jest zbyt szerokie lub reklama nie przyciąga uwagi (wysoki skip rate). Formaty TrueView: In-Stream (pomijalne po 5 sekundach, płacisz za obejrzenie 30 sekund lub do końca), In-Feed (reklamy w wynikach wyszukiwania YouTube, płacisz za kliknięcie miniatury). Zastosowanie benchmarku: szacowanie budżetu kampanii YouTube — jeśli chcesz dotrzeć do 10 000 unikalnych użytkowników z 1 obejrzeniem, budżet orientacyjny to 600–1200 zł (10 000 × 0,06–0,12 zł). + +### CTR w kampaniach Demand Gen (Discovery/Gmail): benchmark 0,5–1% + +- **W120_display_demand_gen_ctr_benchmark** (`W120`): Benchmarkowy CTR dla kampanii Demand Generation (następca Discovery) w kanałach Discovery i Gmail wynosi 0,5–1% — analogiczny do kampanii GDN (Display). CTR poniżej 0,5% sygnalizuje problem z kreacją, targetowaniem lub jakością placementów (np. zbyt dużo wyświetleń na nieodpowiednich placementach YouTube). Ważny niuans Gmaila: otwarcie reklamy w Gmailu (rozwinięcie maila) jest liczone jako pierwsze kliknięcie w statystykach CTR, ale NIE oznacza przejścia na stronę docelową — rzeczywisty ruch na stronę pochodzi dopiero z kliknięcia w link wewnątrz rozwiniętej reklamy. Dlatego CTR w Gmail może być zawyżony relative do faktycznego ruchu na stronie. Przy analizie skuteczności Demand Gen: sprawdzaj sesje w GA4 z source/medium odpowiadającym Demand Gen, nie tylko CTR w panelu Google Ads — CTR w panelu obejmuje otwarcia Gmailu, a GA4 pokazuje faktyczne wejścia na stronę. + +### ClickFraud w GDN remarketingowym: wykluczaj strony z grami dla dzieci + +- **W080_gdn_clickfraud_gry_dla_dzieci** (`W080`): Kampanie GDN (Display Network) remarketingowe są szczególnie narażone na fałszywe kliknięcia z serwisów z grami dla dzieci — dzieci przypadkowo klikają reklamy w trakcie zabawy, co generuje fałszywe sygnały konwersji (np. odwiedziny strony liczące jako mikrokonwersja). Skutek: algorytm uczy się na złych danych, ROAS kampanii spada, budżet jest marnowany na bezużyteczny ruch. Jak wykryć problem: sprawdź raport Miejsc docelowych (Placements) — szukaj domen z grami (np. gry-dla-dzieci.pl, minigames, friv itp.) oraz aplikacji mobilnych skategoryzowanych jako 'Games' → 'Kids Games'. Działania naprawcze: (1) Wyklucz kategorie placementów: 'Gry' → 'Gry dla dzieci' w ustawieniach kampanii. (2) Wyklucz konkretne domeny i aplikacje z raportu miejsc docelowych. (3) Regularnie monitoruj nowe placements — lista nie jest statyczna. (4) Jeśli problemy z ClickFraud są poważne, rozważ wyłączenie sieci reklamowej z kampanii DSA i remarketingowych GDN lub skorzystanie z zewnętrznych narzędzi anty-clickfraud. + +### Demand Gen i YouTube przy małym budżecie (~2000 zł): najtańszy test zasięgowy, gdy Search jest drogi + +- **W112_demand_gen_youtube_tani_zasieg_maly_budzet** (`W112`): Gdy kampania Search jest droga (wysokie CPC w branży B2B lub niszach specjalistycznych) i budżet jest ograniczony (np. 1500-3000 zł/mies.), Demand Generation i YouTube stanowią najtańszy sposób dotarcia z komunikatem do potencjalnych klientów. Dlaczego warto testować przy małym budżecie: (1) CPM (koszt tysięca wyświetleń) w Demand Gen/YouTube jest wielokrotnie niższy niż CPC w Search — za tę samą kwotę dotrzesz do znacznie szerszej grupy. (2) Przy bardzo drogim Search (np. CPC 20-50 zł w B2B) budżet 2000 zł to ledwie 40-100 kliknięć — to za mało na naukę algorytmu. Ten sam budżet w YouTube to tysiące wyświetleń wideo i szansa na budowanie rozpoznawalności. (3) Demand Gen i YouTube mogą wspomagać konwersje asystowane — nie generują konwersji bezpośrednich (last-click), ale zwiększają prawdopodobieństwo powrotu przez inne kanały (remarketing, direct). Jak podejść do testu: uruchom Demand Gen lub kampanię wideo (bumper/in-stream) z remarketingiem na odwiedzających stronę + niestandardowe segmenty z intencjami branżowymi. Budżet: 30-40% łącznego budżetu konta. Metryki sukcesu: wzrost ruchu direct/branded w GA4, poprawa CR w kampaniach Search (efekt asystowania), wzrost subskrypcji kanału lub zaangażowania wideo. Nie oczekuj konwersji last-click z Demand Gen przy małym budżecie — to kanał zasięgowy, nie sprzedażowy. + +### Demand Gen — 6x assist ratio + +- **YT_gma_47_demand_gen_assist_ratio** (`YT_grow-my-ads`): Demand Gen to NIE kampania last-click. Typowy assist:last-click ratio = 6x — kampania 6 razy częściej asystuje niż konwertuje bezpośrednio. Sprawdzaj: Goals → Measurement → Attribution → Assisted Conversions. Case study z Wicked Reports: Demand Gen w customer journey — Shopping (first click) → swatch order → Demand Gen touchpoint → direct → email → final purchase (30+ dni). Demand Gen 'podgrzewa' klienta w środku ścieżki. Analiza kanałów: Segment → Network — sprawdź YouTube, Gmail, Discover, Display osobno. Wyłącz kanały które nie performują. Wyłącz 'show screenshot of landing page' — wygląda dziwnie. + +### Demand Gen — prawidłowa konfiguracja 2026 + +- **YT_gma_46_demand_gen_konfiguracja_2026** (`YT_grow-my-ads`): Konfiguracja Demand Gen 2026 (Grow My Ads): Cel: conversions, wylącznie purchases. Bid strategy: Max Conversions BEZ target CPA na start. Budżet: min = docelowy CPA. Customer Acquisition: WYŁĄCZONY. Urządzenia: ZAWSZE wyłącz TV screens i tablety. KLUCZOWE: Remarketing first approach: (1) zacznij od remarketing audience (All Visitors 90 days), (2) Optimized Targeting = WYŁĄCZONE, (3) dopiero gdy remarketing działa → WŁĄCZ Optimized Targeting (otwiera cold prospecting). Demand Gen uczy się na poziomie Ad Group — konsoliduj AG. 3 typy reklam: Video + Products, Image + Products, Product Only. + +### Demand Generation vs Discovery: szerszy zasięg, spadek jakości ruchu i ograniczenia + +- **W078_display_demand_gen_vs_discovery_zasiag** (`W078`): Kampanie Demand Generation (następca Discovery) mają istotnie szerszy zasięg niż Discovery — wyświetlają się silniej na YouTube i w szerszym spektrum placementów. Konsekwencja: część reklamodawców po aktualizacji z Discovery na Demand Gen zaobserwowała spadek jakości ruchu (niższy CR, gorsze leady) przy podobnym budżecie. Powód: algorytm Demand Gen sieje szerzej, wychodzi poza 'bezpieczne' placementy Discovery. Nie da się cofnąć Demand Gen do Discovery — stary format nie jest już dostępny. Możliwe mitygacje: (1) Wykluczenia placementów na poziomie konta (Narzędzia → Zasoby wspólne → Wykluczenia placementów) — blokują niechciane strony/aplikacje/kanały dla całego konta. (2) Wykluczenia urządzeń: wyłącz ekrany telewizyjne (Smart TV) — reklamy na TV mają zerową wartość konwersyjną (nikt nie klika). (3) Monitoruj zakładkę 'Treść' → 'Miejsca docelowe' i wykluczaj nieefektywne kanały YouTube. Demand Gen w e-commerce: może być skuteczna przy odpowiednim remarketingu i kreacjach wideo — ale wymaga więcej pracy z wykluczeniami niż Discovery. + +### Demand Generation z feedem dla e-commerce: podział na 3 osobne kampanie wg kanału + +- **W117_demand_gen_feed_podzial_trzy_kampanie** (`W117`): Przy wdrażaniu Demand Generation z feedem produktowym dla e-commerce zaleca się podział na 3 osobne kampanie, każda z osobnym budżetem i monitoringiem: (1) Discovery + Gmail — reklamy w kartach Gmail i stronie startowej YouTube; działają dobrze dla segmentów remarketingowych o wysokiej intencji. (2) GDN (Google Display Network) — banery na stronach partnerskich Google; wymagają monitoringu placementów i agresywnych wykluczeń nieefektywnych stron. (3) YouTube — reklamy wideo i produktowe w YouTube; inne parametry efektywności niż Display (wyższe CPV, inne metryki zaangażowania). Powód rozdzielenia: każdy z tych kanałów ma inne metryki efektywności, inne stawki i inne zachowania algorytmu — wrzucone do jednej kampanii mieszają dane i uniemożliwiają ocenę co faktycznie działa. Osobne kampanie pozwalają optymalizować budżet per kanał i wyłączyć nieefektywny kanał bez wyłączania całej kampanii. Demand Generation na dole lejka (remarketing) nie koliduje z PMax feed-only — Demand Gen działa wyłącznie w kanałach display/wideo, nie wchodzi w aukcje wyszukiwarki ani Shopping, więc obie kampanie mogą działać równolegle bez kanibalizacji. + +### Demand Generation: formaty reklam w jednej grupie + YouTube Shorts w osobnej kampanii + +- **W082_demand_gen_formaty_shorts_osobna_kampania** (`W082`): W kampaniach Demand Generation w jednej grupie reklam można umieszczać różne formaty jednocześnie: grafiki statyczne, wideo i karuzele. Wszystkie formaty rywalizują ze sobą w ramach tego samego targetowania — algorytm dobiera format, który w danym momencie ma najlepszą skuteczność. To upraszcza strukturę kampanii, ale utrudnia analizę per format. Ważne ograniczenie dotyczące Shorts: Demand Gen faworyzuje banery graficzne — reklamy wideo w Demand Gen nie są priorytetyzowane na YouTube Shorts. Jeśli zależy Ci na ekspozycji w YouTube Shorts (format pionowy, młodsza grupa), użyj osobnej kampanii wideo YouTube (nie Demand Gen). Optymalizacja Demand Gen jest ograniczona do: (1) Zarządzania celami konwersji (podpinanie/odpinanie mikro i makro). (2) Ustawiania docelowego CPA. (3) Dodawania mikrokonwersji przy zbyt małej liczbie makrokonwersji. Nie ma tu ręcznej kontroli CPC ani dostępu do raportu search terms — kampania działa jak 'czarna skrzynka' podobnie do PMax. + +### Demand Generation: wykluczenie telewizorów i monitoring kanałów YouTube + +- **W073_display_demand_gen_demand_generation_konfiguracja_wykluczen** (`W073`): Kampanie Demand Generation (następca Discovery) wyświetlają reklamy na YouTube, Gmail i kartach Google Discover. Kluczowe niuanse konfiguracji: (1) Wykluczenie telewizorów — Demand Gen może wyświetlać reklamy na Smart TV (YouTube na telewizorze). Ruch z TV ma zerową wartość konwersyjną (nikt nie klika reklam na TV). Wyklucz urządzenia TV: Ustawienia kampanii → Urządzenia → odznacz 'Ekrany telewizyjne'. (2) Monitoring zakładki 'Treść' — sprawdzaj, na jakich kanałach YouTube wyświetlają się Twoje reklamy (Demand Gen → Treść → Miejsca docelowe). Wykluczaj kanały/filmy niepassujące do grupy docelowej lub kontrowersyjne. (3) Konwersje view-through — Demand Gen może generować konwersje po wyświetleniu; żeby je zobaczyć, potrzebujesz piksela Google Ads (nie importu z GA4). (4) Formaty reklamowe — Demand Gen obsługuje wideo, obrazy i karuzele; testuj różne formaty, bo wyniki między formatami mogą się bardzo różnić. + +### Deweloper nieruchomości: optymalny miks kampanii Google Ads wg konkurencji + +- **W082_nieruchomosci_deweloper_kampanie_mix** (`W082`): Dla dewelopera nieruchomości (mieszkania, domy, inwestycje) optymalny miks kampanii Google Ads zależy od poziomu konkurencji w regionie. Zawsze wchodzi: (1) Wyszukiwarka Search — najwyższy współczynnik konwersji (leady od aktywnie szukających), ale najdroższy kanał; frazy: 'mieszkania [miasto]', 'deweloper [miasto]', 'nowe mieszkania [dzielnica]'. (2) Remarketing GDN — powracający odwiedzający; nieruchomości mają długi cykl decyzyjny (tygodnie/miesiące), remarketing przez 90–180 dni jest konieczny. Przy dużej konkurencji dodaj: (3) Display (GDN cold) — targetowanie po zainteresowaniach (in-market: nieruchomości, kredyty hipoteczne). (4) YouTube wideo — prezentacje inwestycji, animacje, spacery 3D; budowanie świadomości i zaufania w długim cyklu decyzyjnym. Proporcje budżetu: im większa konkurencja w wyszukiwarce (wyższy CPC), tym bardziej opłaca się przesuwać budżet ku display/wideo — Search pozostaw jako podstawę, ale nie wrzucaj tam 100% budżetu. Search jako 'żniwa', Display/Video jako 'sianie' popytu — przy inwestycjach powyżej 500 tys. zł użytkownicy potrzebują wielu punktów kontaktu. + +### Discovery: kreacje, CTR, brak kontroli tła + eksperymenty PMax + +- **W057_discovery_kreacje_ctr_eksperymenty_pmax** (`W057`): Kampanie Discovery — tło reklamy: system dobiera je automatycznie na podstawie dostarczonych kreacji (np. białe tło → białe tło reklamy). Brak możliwości ręcznego sterowania tłem w Discovery — to ograniczenie systemowe. W GDN: opcja 'Ulepszenia' w formatach reklamowych pozwala wyłączyć automatyczne generowanie elementów. W Discovery tej opcji nie ma. CTR w Discovery = suma WSZYSTKICH interakcji: kliknięcia w reklamę na YouTube i kartach Discover + PIERWSZE kliknięcie (otwarcie) reklamy w Gmailu. Otwarcie maila ≠ przejście na stronę — to ważny niuans przy analizie wyników. Eksperymenty PMax (nowość, ~5 tygodni od W057): nowa funkcja pozwalająca porównywać Performance Max z kampaniami produktowymi Standard Shopping. Dostępna w sekcji kampanii PMax. Nie służy do tworzenia nowych kampanii strategicznie — to narzędzie do testów A/B. + +### GDN i Discovery: szukanie sufitu i prawidłowe targetowanie + +- **W057_gdn_sufit_targetowanie** (`W057`): Szukanie sufitu w GDN/Discovery: brak parametrów odpowiadających 'udziałowi w wyświetleniach' ze Search — nie wiemy, jak duży jest dostępny target. Metoda: podnoś budżet i pilnuj CPA/ROAS. Dopóki kampania się spina finansowo — zwiększaj. Gdy CPA/ROAS przekracza próg opłacalności — osiągnąłeś sufit dla tego kanału przy tym budżecie. Targetowanie w GDN (lejek e-commerce): (1) Słowa kluczowe kontekstowe — strony z treściami związanymi z produktem. (2) Strony internetowe konkurencji — targetowanie po URL konkretnych stron. NIE stosuj targetowania po zainteresowaniach — zbyt szerokie, niska jakość ruchu. Wyłącz 'zoptymalizowane kierowanie' w ustawieniach kampanii GDN remarketingowej — inaczej kampania wyjdzie poza listę remarketingową i pójdzie za szeroko. GDN i Discovery uruchamiaj równolegle — nie kanibalizują się, pokrywają różne przestrzenie reklamowe. Po czasie wyłącz słabszy kanał. + +### GDN: targetowanie ustawia się w grupach reklam, nie na poziomie kampanii + +- **W062_gdn_targetowanie_grupy_reklam** (`W062`): W kampaniach GDN (Display) targetowanie NIE jest ustawiane na poziomie kampanii — kampania to tylko kontener z budżetem i optymalizatorem. Każda grupa reklam = osobne targetowanie do testowania (np. słowa kluczowe, miejsca docelowe, segmenty niestandardowe, aplikacje). Dwa różne rodzaje targetowania w GDN: (1) Miejsca docelowe (np. kwestiasmaku.com): reklama wyświetla się KONKRETNIE na tej stronie. (2) Segment niestandardowy z URL: system targetuje OSOBY, które odwiedzały podobne strony — reklama pojawia się w całym internecie, nie tylko na tej stronie. ZAWSZE wyłączaj 'kierowanie zoptymalizowane' w każdej grupie reklam — ten guzik potrafi sam się włączyć podczas zapisywania kampanii i kompletnie rozmywa targetowanie. Sprawdzaj go na każdym etapie zapisu. + +### GDN: wykluczanie YouTube i budowanie list placementów + +- **W058_gdn_wykluczenie_youtube** (`W058`): W kampaniach GDN (Display) warto wykluczyć całego YouTube — reklamy displayowe wyświetlają się tam jako mały 'potykacz' pod filmem, co daje niekorzystne umiejscowienie i niską skuteczność. Brak gotowych publicznych list kanałów YouTube do wykluczenia (np. kanałów dla dzieci) — trzeba budować własne listy per projekt. Raz zbudowaną listę placementów do wykluczenia można ponownie wykorzystywać w kolejnych kampaniach na tym samym koncie lub w podobnych projektach na innych kontach. Listy szybko się dezaktualizują (nowe kanały), więc wymagają regularnego rozbudowywania. + +### GDN: zasada separacji — kampania zasięgowa i remarketingowa zawsze osobno + +- **KD09_01_gdn_separacja_zasieg_remarketing** (`KD09`): Podstawowa zasada struktury kampanii GDN: nigdy nie łącz w jednej kampanii targetowania na nowych użytkowników (zimny ruch, zasięg) z listami remarketingowymi (osoby, które już były na stronie). Każda z tych strategii wymaga osobnej kampanii z własnym budżetem. Dlaczego separacja jest konieczna: (1) Różne cele i metryki sukcesu: kampania zasięgowa mierzy się kosztem na nowego użytkownika i skalą dotarcia; kampania remarketingowa — kosztem konwersji i ROAS wśród ciepłego ruchu. Zmieszanie obu powoduje, że algorytm optymalizuje pod konwersję i całość budżetu leci na remarketing (łatwiejsze konwersje) — nowi użytkownicy nie są pozyskiwani. (2) Różne stawki i strategie stawek: remarketing uzasadnia wyższe CPC (użytkownicy z intencją), zasięg wymaga niskich CPC (zimny ruch, niski CTR). (3) Różne kreacje i komunikaty: reklama dla kogoś, kto nigdy nie widział marki, musi działać inaczej niż reklama dla kogoś, kto porzucił koszyk. Praktyczny podział: — Kampania 1 (zasięgowa/prospecting): segmenty in-market, zainteresowania, podobni do klientów — BEZ list remarketingowych. — Kampania 2 (remarketingowa): listy GA4 (porzucone koszyki, product viewers) — BEZ nowych użytkowników. Obie kampanie wyłącz 'kierowanie zoptymalizowane' w grupach reklam. + +### Gmail w kampaniach Demand Gen: praktycznie martwy placement — kliknięcie ≠ wejście na stronę + +- **W125_display_gmail_demand_gen_martwy** (`W125`): Gmail jako placement w kampaniach Demand Generation (i wcześniej Discovery) jest historycznie nieskuteczny i w praktyce traktowany jako martwy placement. Kluczowe nieporozumienie: w panelu Google Ads 'kliknięcie' w Gmail oznacza OTWARCIE reklamy (rozwinięcie maila), a NIE przejście na stronę docelową. Faktyczne wejście na stronę następuje dopiero po kliknięciu linku wewnątrz rozwiniętej reklamy mailowej — co jest drugim krokiem i zdarza się znacznie rzadziej. Efekt: CTR w Gmail w panelu Ads wygląda świetnie, ale sesje w GA4 z tego placement są minimalne. Praktyczna rekomendacja: (1) Jeśli chcesz przetestować Gmail — zrób to z minimalnym budżetem (np. 5–10 zł/dzień przez 2 tygodnie) i porównaj sesje GA4 z kliknięciami Ads. (2) Przy pierwszych wynikach — jeśli GA4 nie pokazuje ruchu, wyklucz Gmail z kampanii. (3) W raportach Demand Gen zawsze sprawdzaj Miejsca docelowe (Placements) i monitoruj mail.google.com osobno. Alternatywa dla dotarcia do skrzynek mailowych: Email Marketing (własna lista) jest znacznie skuteczniejszy niż reklamy w Gmailu. + +### Kampania Discovery: piki o północy i wyzerowanie w ciągu dnia — jak stabilizować + +- **W060_discovery_piki_polnoc_stabilizacja** (`W060`): Problem: kampania Discovery ma piki kliknięć/wyświetleń o północy, potem spada i zatrzymuje się w ciągu dnia. Przyczyny: zbyt szerokie targetowanie + zbyt mało konwersji do nauki algorytmu. Discovery działa od razu na maksymalizacji liczby konwersji — potrzebuje danych. Rozwiązania (od najbardziej zalecanego): (1) Zawęź targetowanie: usuń szerokie grupy (np. segmenty niestandardowe), zostaw tylko targetowanie po słowach kluczowych lub węższych segmentach. (2) Dodaj mikrokonwersje: więcej sygnałów = szybsza nauka algorytmu. (3) Harmonogram: ogranicz godziny np. 6:00–20:00, aby uciąć pik przełomu północy. (4) W ostateczności: skopiuj kampanię i uruchom od nowa — zdarzało się, że sam restart kampanii rozwiązywał problem. Gwałtowne zwiększenie budżetu nie pomoże — jeśli system nie rozciąga kampanii przez cały dzień, większy budżet zostanie wystrzelony jeszcze szybciej. + +### Kampania Google Ads dla autora: strategia zależna od rozpoznawalności marki + +- **W084_autor_znany_nieznany_strategia_kampanii** (`W084`): Strategia kampanii Google Ads dla autorów książek i e-booków zależy fundamentalnie od rozpoznawalności autora: Znany autor (marka osobista, duża baza fanów): (1) Kampania brandowa tekstowa Search — ochrona marki, wyświetlanie na frazy z imieniem/nazwiskiem autora. (2) Kampania produktowa Shopping (dla książek papierowych) — wyświetlanie w wynikach zakupowych. (3) Remarketing Display/Video — powracający użytkownicy, osoby z listy mailingowej (Customer Match). (4) Opcjonalnie: YouTube zasięgowy do budowania zasięgu przy nowej książce. Mało znany autor (brak rozpoznawalności): Kampania tekstowa Search na frazy brandowe nie ma sensu — nikt nie szuka po imieniu. (1) Od razu zasięgowo: YouTube Video + GDN Display z targetowaniem segmentami niestandardowymi (np. URL-e stron z recenzjami książek, słowa kluczowe 'jak wybrać książkę...'). (2) Kampania tekstowa Search na frazy tematyczne/problemowe — np. dla poradnika: 'jak schudnąć', 'dieta dla początkujących'. (3) Kampania produktowa Shopping — nawet bez marki, tytuł i okładka mogą przyciągnąć kliknięcia w wynikach zakupowych. Dla e-booków: Shopping nie działa (produkty cyfrowe), skup się na Search + Display + remarketing. + +### Kampania wyborcza — strategia podziału budżetu na start + +- **W067_kampania_wyborcza_budzet** (`W067`): Przy pierwszej kampanii wyborczej (np. kandydat w wyborach parlamentarnych) brak danych historycznych uniemożliwia z góry wskazanie najefektywniejszego kanału. Strategia na start: podziel budżet równomiernie między dostępne typy kampanii — GDN (sieć reklamowa), Discovery/Demand Gen, YouTube. Po 2-4 tygodniach kampanii zbierz dane o koszcie konwersji per kanał (np. koszt kliknięcia w CTA, koszt wyświetlenia strony programu, koszt pozyskania subskrybenta newslettera) i przenieś budżet do kanałów o najniższym CPA. Kampanie wyborcze w Google Ads wymagają weryfikacji tożsamości politycznej i ujawnienia informacji o finansowaniu — sprawdź aktualne wymagania Google dla reklam politycznych w Polsce. + +### Kreacje GDN/Discovery: tekst na grafikach i brak jednego słusznego szablonu + +- **W059_kreacje_gdn_tekst_na_grafikach** (`W059`): Kreacje graficzne w kampaniach remarketingowych GDN/Discovery — dwa wnioski: (1) Brak jednego szablonu: nie wiadomo z góry, czy lepiej działają kreacje produktowe, liniowe czy sklepowe — zależy od produktu, grupy odbiorców, branży. Reklamy elastyczne pozwalają dodać 15 kreacji — dodaj wszystkie koncepcje i testuj. (2) Tekst na grafikach: jeśli dodajesz tekst do grafiki, ogranicz do jednego hasła/claimu. Powód: na smartfonach grafika się mocno kurczy — dużo tekstu = nieczytelne. Google (w przeciwieństwie do Facebooka) nie ogranicza ilości tekstu na kreacji, więc technicznie możesz przesadzić — ale efekt jest zły. Sprawdź w podglądzie przy tworzeniu reklamy, jak kreacja wygląda na smartfonie. + +### Lookalikes (odbiorcy podobni) powracają w Demand Gen + +- **W067_display_lookalikes_demand_gen** (`W067`): Funkcja lookalikes (odbiorcy podobni do listy remarketingowej) została usunięta z Google Ads w 2023 roku. Wraca jednak w nowym typie kampanii Demand Gen (dawna nazwa: Discovery). W Demand Gen można ponownie targetować użytkowników podobnych do własnych list klientów lub osób, które weszły w interakcję z marką. Warto śledzić dostępność bety w Polsce i zapisać się gdy jest dostępna — szczególnie wartościowe dla kampanii zasięgowych budujących świadomość marki i przy targetowaniu B2B, gdzie precyzyjna lookalike lista np. menedżerów HR może drastycznie poprawić jakość leadów. + +### Mikrokonwersje jako sygnał przy szerokim targetowaniu B2B + +- **W067_display_mikrokonwersje_szerokie_targetowanie** (`W067`): Przy kampaniach Display/Demand Gen z szerokim targetowaniem, gdzie główna konwersja (np. wypełnienie formularza przez decydenta) jest rzadka, algorytm ma problem z uczeniem się — za mało danych konwersji. Rozwiązanie: dodaj mikrokonwersje jako dodatkowe cele (np. wejście na stronę kontaktu, pobranie materiału, czas na stronie >2 min, przewinięcie strony do 75%). Mikrokonwersje dostarczają algorytmowi częstszych sygnałów o tym, kto reaguje na reklamy, co przyspiesza naukę i poprawia targetowanie — system szybciej identyfikuje profil wartościowego odbiorcy i optymalizuje zasięg pod tym kątem. Po zebraniu wystarczającej liczby głównych konwersji (50-60/miesiąc) można rozważyć usunięcie mikrokonwersji z celów kampanii. + +### Nieprawidłowe kliknięcia i botowy ruch: ochrona w GDN, DSA i PMax + +- **W077_display_nieprawidlowe_kliki_gdn_dsa_pmax** (`W077`): Przy dużej liczbie podejrzanych kliknięć (boty, nieludzki ruch) w różnych typach kampanii: GDN (Google Display Network): Sprawdź raport Miejsc docelowych (Placements) — wyklucz aplikacje mobilne (kategoria 'Aplikacje mobilne' w wykluczeniach miejsc docelowych), wyklucz podejrzane domeny z dużą liczbą wyświetleń i zerowymi konwersjami. DSA (Dynamic Search Ads): Wyłącz 'Sieć partnerów wyszukiwania' w ustawieniach kampanii — partnerzy wyszukiwania generują ruch gorszej jakości niż google.com. Upewnij się, że 'Sieć reklamowa' (Display) jest WYŁĄCZONA w kampanii DSA (domyślnie może być włączona i otwiera drogę botom). PMax: Jeśli zależy Ci wyłącznie na ruchu produktowym (Shopping), usuń grafiki i teksty z grup komponentów — PMax bez zasobów kreacyjnych ogranicza się do kanałów Shopping/Search i nie wyświetla się w GDN, co drastycznie redukuje botowy ruch. Ogólna zasada: im szerszy zasięg kampanii (GDN, Discovery, PMax z zasobami), tym większe ryzyko nieludzkiego ruchu — zawęź placementy lub usuń kanały nie będące Search. + +### Nowy, nieznany produkt niszowy: strategia kampanii na start + +- **W068_display_demand_gen_nowy_produkt_niszowy** (`W068`): Dla nowego, nieznanego produktu niszowego (np. książki tematyczne, prezenty specjalistyczne) przy małym budżecie najlepsze kampanie startowe to Display (GDN) i Discovery/Demand Gen, a nie kampanie tekstowe Search ani produktowe Shopping. Powód: nowy produkt nie ma jeszcze popytu — użytkownicy go nie szukają, więc Search/Shopping nie dostarczą ruchu. Celem na start jest dotarcie do potencjalnych nabywców i zbudowanie świadomości. Targetowanie: niestandardowe segmenty odbiorców oparte o wyszukiwane hasła (frazy charakterystyczne dla grupy docelowej, np. 'pomysł na prezent dla...' 'gdzie kupić...') oraz zainteresowania zbieżne z tematem produktu. Uzupełnienie: reklamy wideo na YouTube — dotarcie do użytkowników zainteresowanych daną tematyką. Kluczowy krok: od pierwszego dnia ustaw mikrokonwersje (scroll strony, wejście na podstronę produktu, dodanie do koszyka) — nawet przy zerowych zamówieniach algorytm zbiera sygnały o tym, kto reaguje na reklamy, co przyspiesza późniejszą optymalizację i identyfikuje profil wartościowego odbiorcy. + +### Promocja kanału YouTube w B2B: kampania wideo na subskrypcje z ?sub_confirmation=1 + +- **W073_display_demand_gen_youtube_b2b_promocja_kanalu_subskrypcje** (`W073`): Strategia budowania zasięgu kanału YouTube w sektorze B2B przez kampanie Google Ads: (1) Połącz konto Google Ads z kanałem YouTube (Narzędzia → Połączone konta → YouTube). (2) Utwórz kampanię wideo 'Zachęcająca do konwersji' (Video action) z celem ustawionym na subskrypcję kanału YouTube. Ten cel pojawia się po połączeniu konta Ads z kanałem YT. (3) W URL docelowym linku dodaj parametr ?sub_confirmation=1 do adresu kanału (np. youtube.com/c/TwójKanal?sub_confirmation=1) — wyświetla użytkownikowi pop-up z zachętą do subskrypcji po kliknięciu. (4) Targetuj na niestandardowe segmenty odbiorców pasujące do branży B2B (firmy z określonych kategorii, słowa kluczowe branżowe w wyszukiwaniach). Efekt: tańsze pozyskiwanie subskrybentów niż organicznie, budowanie bazy do remarketingu video (lista osób, które oglądały filmy na kanale). + +### Reklamy elastyczne GDN: brak kontroli kombinacji grafik i tekstu — opcje zarządzania + +- **W107_display_gdn_elastyczne_kombinacje** (`W107`): W reklamach elastycznych GDN (Responsive Display Ads) Google samodzielnie dobiera kombinacje wgranych grafik i tekstów — reklamodawca NIE ma możliwości wskazania, które grafikę połączyć z którym nagłówkiem. Efekt: mogą pojawiać się kombinacje nieodpowiednie kontekstowo lub estetycznie (np. zdjęcie dziecka z nagłówkiem o produktach dla dorosłych). Opcje zarządzania tym ograniczeniem: (1) Pogodzić się — elastyczne reklamy są domyślnie skuteczniejsze zasięgowo niż statyczne; niedopasowane kombinacje pojawiają się rzadziej, bo algorytm uczy się. (2) Dodać reklamy statyczne obok elastycznych — wgrać konkretne grafiki 1:1, 4:1 itp. jako statyczne reklamy display; Google serwuje te, które działają lepiej. (3) Przejść w 100% na reklamy statyczne — pełna kontrola nad wyglądem, kosztem mniejszego zasięgu (elastyczne docierają do większej liczby formatów/rozmiarów). Dodatkowe ustawienie: opcja 'Ulepszanie elementów' (Asset Enhancements) w ustawieniach kampanii GDN — pozwala Google na automatyczne modyfikacje grafik (przycinanie, zmiana jasności). Warto sprawdzić i ewentualnie wyłączyć, jeśli chcesz zachować oryginalny wygląd kreacji. Wyłączenie nie gwarantuje pełnej kontroli nad kombinacjami, ale ogranicza ingerencję w same grafiki. + +### Szacowanie budżetu GDN i Discovery: metodologia od celu konwersji wstecz + +- **W061_budzet_gdn_discovery_szacowanie** (`W061`): Brak narzędzia Google do szacowania budżetu GDN/Discovery (system podaje nierealne zasięgi). Metodologia szacowania budżetu od celu wstecz: (1) Określ cel konwersji i jej szacowany CR (np. zapis do newslettera = 1%). (2) Oblicz potrzebne kliknięcia: 1 konwersja ÷ CR = 100 kliknięć. (3) Pomnóż przez średni CPC w GDN/Discovery: 0,60–1,00 zł → ~80 zł na konwersję. (4) Pomnóż ×5–10 na testy i statystyczną istotność: min. 400–800 zł/miesiąc. Przykład: CR 1%, CPC 0,80 zł → 100 kliknięć = 80 zł → ×10 = 800 zł minimum. Po pierwszym miesiącu: jeśli konwersje się pojawiają — skaluj budżet; jeśli nie — kanał nie działa dla tego biznesu. Metodologia działa też dla GDN remarketingowego i Discovery leadowego. + +### Taboola News i portale clickbaitowe: scam w sieci reklamowej — sygnały i ochrona + +- **W125_display_taboola_scam_alarm** (`W125`): Taboola News i podobne serwisy content discovery (Outbrain, portale z clickbaitowymi nagłówkami) to środowiska z wysokim udziałem botów i fałszywych kliknięć w kampaniach displayowych. Sygnały alarmowe wskazujące na scam w placemencie: (1) Ponadprzeciętny CTR w display (np. 5–7%) — normalny CTR GDN to 0,3–1%; wyższy wynik jest prawie zawsze efektem botów, nie zainteresowania użytkowników. (2) Duża liczba kliknięć bez konwersji i bez ruchu w GA4 — boty klikają reklamę, ale nie wchodzą na stronę. (3) Mikrokonwersje zliczające się w panelu Ads, ale niewidoczne w GA4 — fałszywe sygnały konwersji generowane przez boty na samej stronie placement. Zasada: ponadprzeciętny CTR lub konwersje w display to nie jednorożec — to scam. Działania: (1) ZAWSZE wykluczaj Taboola, Outbrain i podobne portale z kampanii GDN i PMax. (2) Regularnie sprawdzaj raport Miejsc docelowych (Placements) — wykluczaj domeny z wysokim CTR i zerowymi konwersjami w GA4. (3) Porównuj kliknięcia Google Ads z sesjami GA4 per placement — rozbieżność >30% to sygnał botów. + +### Targetowanie YouTube: niestandardowe segmenty wg wyszukiwanych haseł, odwiedzonych stron i aplikacji + +- **W113_youtube_niestandardowe_segmenty_targetowanie** (`W113`): Niestandardowe segmenty odbiorców (Custom Segments) to najskuteczniejsza metoda precyzyjnego targetowania kampanii wideo YouTube i Demand Gen — szczególnie przy targetowaniu B2B lub niszowych grup. Trzy typy niestandardowych segmentów: (1) WYSZUKIWANE HASŁA — docierasz do osób, które w wyszukiwarce Google szukały konkretnych fraz (np. 'oprogramowanie do fakturowania', 'maszyna CNC'). Google identyfikuje tę grupę na podstawie historii wyszukiwania zalogowanych użytkowników. Najsilniejszy sygnał intencji zakupowej. (2) ODWIEDZONE STRONY — docierasz do osób odwiedzających konkretne strony (np. strony konkurencji, branżowe portale, sklepy z daną kategorią). Wpisz domenę (np. 'konkurent.pl') — Google dobierze użytkowników o podobnym profilu do odwiedzających tę domenę. (3) UŻYWANE APLIKACJE — docierasz do użytkowników korzystających z konkretnych aplikacji mobilnych (np. aplikacje branżowe, narzędzia biznesowe). Jak tworzyć: Google Ads → Narzędzia → Zarządzanie odbiorcami → Niestandardowe segmenty → Utwórz → wybierz typ. Zastosowanie w B2B: połącz segmenty wyszukiwanych haseł branżowych (np. 'outsourcing IT', 'systemy ERP dla produkcji') z targetowaniem demograficznym (wiek 25-55, zainteresowania biznesowe) — bardzo precyzyjne dotarcie do decision-makerów bez kosztów reklamy Search. + +### Targetowanie branżowe B2B — segmenty niestandardowe zamiast zainteresowań + +- **W067_display_segmenty_niestandardowe_b2b** (`W067`): Przy targetowaniu konkretnej branży (np. HR, IT, logistyka) w kampaniach Display/Demand Gen, segmenty niestandardowe dają znacznie lepsze dopasowanie niż gotowe zainteresowania Google. Gotowe zainteresowania są zbyt szerokie i generują ruch niskiej jakości (np. leady z prywatnych Gmaili zamiast maili firmowych). Segmenty niestandardowe buduj z trzech typów danych, każdy w osobnej grupie reklam dla lepszej kontroli: (1) Słowa kluczowe wyszukiwane przez docelową grupę — frazy charakterystyczne dla branży, które wyszukuje Twój odbiorca (znajdź je w Planerze słów kluczowych); (2) Strony internetowe odwiedzane przez branżę — domeny branżowych portali, stowarzyszeń, konferencji, narzędzi B2B; (3) Aplikacje używane przez branżę — narzędzia SaaS, komunikatory biznesowe, aplikacje HR/ERP. Podział na osobne grupy reklam pozwala zobaczyć, który typ segmentu daje najlepsze wyniki i optymalizować niezależnie. + +### Walidacja adresu e-mail na landing page — filtrowanie złych leadów B2B + +- **W067_b2b_walidacja_email_formularz** (`W067`): Problem: kampania B2B generuje leady z prywatnych adresów e-mail (Gmail, wp.pl, onet.pl) zamiast firmowych, co znacznie obniża jakość bazy i utrudnia konwersję sprzedażową. Rozwiązania po stronie landing page (bez zmiany kampanii): (1) Walidacja pola e-mail w formularzu — odrzucaj zgłoszenia z publicznych domen (gmail.com, yahoo.com, wp.pl, onet.pl, o2.pl itp.) i wyświetlaj komunikat 'Prosimy o podanie służbowego adresu e-mail'; (2) Sugestia tekstowa — obok pola e-mail dodaj etykietę lub placeholder 'np. jan.kowalski@firma.pl'. Kombinacja obu metod eliminuje większość prywatnych adresów na wejściu, zanim lead trafi do CRM. + +### Wykluczanie aplikacji mobilnych w GDN: ID kategorii i metoda wykluczenia na poziomie kampanii + +- **W108_display_aplikacje_mobilne_wykluczenie** (`W108`): Aplikacje mobilne w kampaniach GDN generują ruch bardzo niskiej jakości (boty, przypadkowe kliknięcia dzieci, niski CR). Wykluczenie aplikacji mobilnych działa dwustopniowo: (1) Poziom konta: Narzędzia → Zasoby wspólne → Wykluczenia miejsc docelowych → dodaj kategorię 'Aplikacje mobilne' — blokuje aplikacje we wszystkich kampaniach Display na koncie. (2) Poziom kampanii: w sekcji 'Wykluczenia' (Placements → Exclusions) dodaj ręcznie ID: mobileapp::2-69500 — to globalny identyfikator kategorii 'wszystkie aplikacje mobilne' w sieci Google. Wpisanie tego ID jako wykluczenia miejsca docelowego na poziomie kampanii eliminuje wyświetlenia w aplikacjach. Dlaczego oba poziomy: wykluczenie na koncie może nie obejmować nowo tworzonych kampanii; wykluczenie na poziomie kampanii jest pewniejsze. Weryfikacja: po wykluczeniu sprawdź raport Miejsc docelowych (Placements) — nie powinny pojawiać się pozycje 'Aplikacje mobilne' ani domeny z prefiksem 'mobileapp::'. Dotyczy kampanii: GDN, Demand Gen — w PMax wykluczenie aplikacji jest ograniczone do poziomu konta przez Google Ads Editor. + +### Wykluczenia witryn GDN na poziomie MCC: lista działa we wszystkich zarządzanych kontach + +- **W092_display_gdn_wykluczenia_mck_poziom** (`W092`): Listy wykluczeń miejsc docelowych (witryn, aplikacji) w kampaniach GDN warto budować na poziomie MCC (konto menedżera / Menedżer kont klientów) — nie tylko na poziomie pojedynczego konta. Korzyść: lista zbudowana raz w MCC jest dostępna i działa we WSZYSTKICH zarządzanych kontach klientów, bez konieczności ręcznego kopiowania między kontami. Jak skonfigurować: MCC → Narzędzia → Zasoby wspólne → Wykluczenia miejsca docelowego → utwórz listę na poziomie MCC. Następnie przypisz listę do kampanii GDN w poszczególnych kontach klientów. Listy wymagają regularnego rozbudowywania — nowe witryny i aplikacje o niskiej jakości ruchu pojawiają się stale. Typowe kategorie do wykluczenia: aplikacje mobilne (gry, narzędzia), kanały YouTube z treściami dla dzieci, serwisy o bardzo wysokim bounce rate. Inwestycja w budowanie listy MCC procentuje w długim terminie — każdy nowy klient korzysta od razu z wypracowanej bazy wykluczeń. + +### Wyświetlenia w Google Ads ≠ licznik YouTube: różne definicje widoków między systemami + +- **W113_youtube_wyswietlenia_vs_licznik_google_ads** (`W113`): Wyświetlenia raportowane w panelu Google Ads dla kampanii wideo (YouTube) nie przekładają się 1:1 na licznik wyświetleń widoczny na filmie w serwisie YouTube. Przyczyna: oba systemy używają różnych definicji 'wyświetlenia': GOOGLE ADS liczy wyświetlenie (impression) gdy reklama stała się widoczna dla użytkownika — nawet przez chwilę (format bumper 6s jest liczony praktycznie zawsze, in-stream po pierwszych sekundach). LICZNIK YOUTUBE na filmie: liczy 'view' gdy użytkownik obejrzał minimum 30 sekund wideo (lub całość jeśli film krótszy niż 30s) i pochodzi z wiarygodnej sesji (system antyfraudowy YouTube jest bardziej rygorystyczny). Efekty praktyczne: (1) Kampania Google Ads może raportować 10 000 wyświetleń, ale licznik YouTube wzrośnie tylko o 2000-4000 — to normalne, nie błąd. (2) Formaty reklamowe najsłabiej przekładające się na licznik YT: bumper 6s (za krótki na 30s wymóg), in-stream pomijalne (większość pomija po 5s), display/nakładki (nie są 'wideowyświetleniami'). (3) Formaty przekładające się lepiej: in-stream niepomijalne 15-20s, in-feed (użytkownik sam klika i ogląda). Wskazówka: jeśli celem jest wzrost licznika/widoczności kanału, używaj formatów in-feed lub promuj organiczny film, nie standardowych reklam in-stream w kampaniach konwersyjnych. + +### YouTube B2B: targetuj kanały biznesowe i inwestorskie, nie branżowe + +- **W076_display_youtube_b2b_targetowanie_inwestorzy** (`W076`): Przy reklamowaniu produktów B2B na YouTube (np. namioty sferyczne do glampingu, maszyny, wyposażenie gastronomiczne) targetuj kanały, gdzie siedzą DECYDENCI I INWESTORZY, nie konsumenci korzystający z usług końcowych. Przykład: namioty glampingowe do kupienia przez inwestorów/hotelarzy — targetuj kanały o: inwestowaniu, pomysłach na biznes, hotelarstwie i turystyce B2B, nieruchomościach komercyjnych — NIE kanały o glampingach i podróżach (tam są turyści-konsumenci, nie kupujący namioty). Zasada: pytaj 'kto to kupuje (decydent), a nie 'kto to konsumuje (użytkownik końcowy)'. Narzędzia targetowania: miejsca docelowe → konkretne kanały YouTube branżowe, segmenty niestandardowe z URL-ami branżowych portali inwestorskich/biznesowych. Alternatywa: targetowanie po słowach kluczowych wyszukiwanych przez tę grupę (np. 'jak otworzyć glamping', 'rentowność glamping', 'inwestycja turystyczna'). + +### YouTube kampanie wideo: formaty pomijalne vs. niepomijalne i kanibalizacja między nimi + +- **W064_youtube_formaty_pomijalne_niepomijalne** (`W064`): Kampanie wideo YouTube — główne formaty: (1) Pomijalne (In-stream Skippable): użytkownik może pominąć po 5 sekundach. Płacisz za obejrzenie (30+ sek lub do końca) lub kliknięcie. (2) Niepomijalne (In-stream Non-Skippable): 15 sekund, użytkownik NIE może pominąć. Płacisz za 1000 wyświetleń (CPM). (3) Bumper Ads: 6 sekund, niepomijalne, CPM. Osobne kampanie na różne formaty: technicznie wymagane (typy kampanii są inne). Kanibalizacja: Przy szerokim targetowaniu (dużej grupie odbiorców) kanibalizacja między kampaniami pomijalną i niepomijalną jest MAŁA — trafiają do różnych momentów uwagi użytkownika. Ryzyko kanibalizacji rośnie przy bardzo wąskim targetowaniu (ta sama mała lista + te same formaty). Praktyczna zasada: różnicuj formaty i targetowanie między kampaniami, monitoruj overlap w raportach miejsc docelowych. + +### YouTube konwersje: segmentacja po działaniach konwersji ujawnia nieoczekiwane źródła + +- **W071_display_demand_gen_youtube_konwersje_segmentacja_dzialan** (`W071`): Kampanie YouTube mogą raportować więcej konwersji niż spodziewane — część z nich to konwersje view-through (po obejrzeniu reklamy bez kliknięcia) lub konwersje z innych kanałów przypisane do YouTube w modelu atrybucji. Jak rozbić dane o konwersjach w YouTube: W Google Ads → Raporty → Kolumny → dodaj segmentację 'Działanie powodujące konwersję'. Wyświetla tabelę z podziałem konwersji na konkretne działania (Purchase, Lead, Phone call itp.). Ujawnia nieoczekiwane źródła: np. czy YouTube generuje faktyczne zakupy czy tylko mikrokonwersje (czas na stronie, scroll). Ważna zasada: jeśli kampania YouTube ma podpięte mikrokonwersje jako 'podstawowe' — wyłącz je lub zmień na 'dodatkowe' — inaczej algorytm optymalizuje pod łatwe mikrokonwersje zamiast zakupów. Agency Savvy pmax — skrypt do analizy fraz konwertujących w kampaniach PMax: wyszukaj 'agency savvy pmax script' — pozwala zobaczyć, które zapytania rzeczywiście generują konwersje w PMax (normalnie niedostępne w interfejsie). + +### YouTube reklamy wideo: cechy skutecznego spotu sprzedażowego vs wizerunkowego + +- **W070_display_demand_gen_youtube_cechy_skutecznej_reklamy** (`W070`): Reklamy wideo YouTube dzielimy na dwa typy z różnymi celami i zasadami tworzenia: Reklamy sprzedażowe (konwersyjne): Długość: 15–30 sekund. Pierwsze 5 sekund KLUCZOWE — w tym czasie użytkownik decyduje czy oglądać dalej (po 5 sek. może pominąć). W pierwszych 5 sekundach musi paść: co oferujemy, dla kogo jest oferta, konkretna korzyść lub intrygujące pytanie. Zakończenie: wyraźne CTA (kup teraz, sprawdź, zarejestruj się) z linkiem. Format: bezpośredni, konkretny, bez długiego wstępu. Reklamy wizerunkowe (świadomość marki): Długość: może być dłuższa (60 sek., 2–3 min). Cel: emocje, historia, skojarzenia z marką — nie bezpośrednia sprzedaż. Inspiracja: YouTube wyróżnia najlepsze reklamy roku w rankingach (YouTube Ads Leaderboard) — warto analizować zwycięzców pod kątem struktury narracji. Różnica w targetowaniu: reklamy sprzedażowe targetuj na remarketing i gorące grupy; wizerunkowe na szersze grupy podobnych odbiorców i segmenty zainteresowań. + +### YouTube/GDN: targetowanie niszowych grup (np. rolnicy) — 3 podejścia + +- **W066_youtube_targetowanie_niszowe_rolnicy** (`W066`): Targetowanie bardzo niszowych grup (np. rolnicy pod fotowoltaikę) gdy nie ma gotowych segmentów w Google: Podejście 1 — Odbiorcy niestandardowi z URL/aplikacjami: Stwórz segment niestandardowy wpisując strony www związane z branżą (np. portale rolnicze, serwisy agrarne) i aplikacje używane przez tę grupę. System targetuje osoby, które odwiedzały podobne strony. Podejście 2 — Miejsca docelowe: konkretne kanały YouTube: Wybierz kanały YouTube tematycznie związane z branżą (np. kanały rolnicze). OGRANICZENIE: wybór konkretnych kanałów YouTube działa TYLKO w kampaniach zasięgowych / na obejrzenia — NIE działa w kampaniach na konwersje. Podejście 3 — Miejsca docelowe: strony www: Wpisz konkretne branżowe strony www jako miejsca docelowe. Dodatkowa wskazówka: targetowanie po tym co wpisują w Google (tematy/słowa kluczowe) może być nieskuteczne — niszowa grupa często nie szuka 'swojej' tematyki. TikTok Ads dla niszowych leadów: 4-5x tańszy koszt leada niż YouTube — warto testować równolegle. + +### YouTube: Sieć partnerów wideo Google — kiedy wyłączyć, kiedy zostawić + +- **KD13_01_youtube_siec_partnerow_wideo** (`KD13`): Kampanie YouTube konwersyjne (typ 'Zachęta do konwersji' / Video Action) domyślnie mogą wyświetlać reklamy nie tylko na YouTube, ale też w 'Sieci partnerów wideo Google' — zewnętrznych portalach i aplikacjach poza YouTube. Strategia optymalizacji: Dla kampanii na zimny ruch (cold traffic, nowi użytkownicy): sprawdź raport 'Gdzie wyświetlały się Twoje reklamy' — jeśli placements z sieci partnerów wideo zużywają budżet bez generowania konwersji, wyłącz 'Sieć partnerów wideo Google' w ustawieniach kampanii. Sieć partnerów wideo jest gorszej jakości niż sam YouTube — niższe zaangażowanie, inny kontekst oglądania, wyższy procent nieludzkich wyświetleń. Dla kampanii remarketingowych (ciepłe audytorium, osoby znające markę): warto przetestować pozostawienie sieci partnerów — trafia do osób, które już miały kontakt z marką, więc konwersja jest łatwiejsza nawet w gorszym kontekście. Monitoruj wyniki i wyłącz jeśli CPA wyraźnie gorsze. Analogia: tak samo jak w DSA wyłącza się 'Sieć partnerów wyszukiwania' i w GDN wyklucza się YouTube Display — sieć partnerów wideo YouTube wymaga takiej samej świadomej decyzji zamiast działania domyślnego. + +### YouTube: częstotliwość reklam wideo — benchmarki i jak sprawdzić capping + +- **W066_youtube_czestotliwosc_wyswietlen_benchmarki** (`W066`): Benchmarki częstotliwości dla kampanii wideo YouTube: Maksymalna bezpieczna częstotliwość: ~5 wyświetleń tygodniowo (20 miesięcznie). Maksymalna częstotliwość obejrzeń: ~1 obejrzenie tygodniowo (4 miesięcznie). Poziom alarmowy: 10-15 wyświetleń tygodniowo = zdecydowanie za dużo — reklama się opatrzy. Ważne rozróżnienie: wyświetlenie ≠ obejrzenie. Obejrzenie = użytkownik widział 30+ sek lub całość. Wyświetlenie = reklama pojawiła się, użytkownik mógł ją pominąć po 5 sek. Pięciokrotne obejrzenie tej samej reklamy przez jedną osobę = dużo za dużo. Jak sprawdzić: Google Ads → kampania wideo → Kolumny → dodaj kolumnę 'Średnia częstotliwość wyświetleń na użytkownika'. Capping: ustaw limit częstotliwości w ustawieniach kampanii wideo. Rekomendacja: zawsze zakładaj capping w kampaniach wideo, szczególnie przy wąskich grupach odbiorców. + +### YouTube: kierowanie na konkretne kanały/filmy możliwe tylko w kampaniach zasięgowych (CPM) + +- **W081_youtube_kanaly_tylko_kampanie_zasiegowe** (`W081`): Kierowanie reklam YouTube na konkretne kanały, filmy lub słowa kluczowe jest możliwe TYLKO w kampaniach zasięgowych (typ 'Skuteczny zasięg', model CPM). W kampaniach konwersyjnych (typ 'Zachęta do konwersji') nie można targetować konkretnych kanałów ani filmów — Google zarządza placementami automatycznie na podstawie optymalizacji pod konwersję. Praktyczne konsekwencje: jeśli chcesz wyświetlać się na konkretnym kanale (np. branżowym, konkurencyjnym), musisz to zrobić w kampanii zasięgowej CPM, nie w kampanii konwersyjnej. Kampania zasięgowa CPM z targetowaniem na konkretne kanały = świadomy wybór placementu, ale bez optymalizacji algorytmu pod konwersję — płacisz za wyświetlenia. Alternatywa: w kampaniach konwersyjnych możesz używać segmentów niestandardowych z URL-ami odwiedzanych stron (np. kanałów YouTube jako URL witryny) — to nie targetuje wprost tych kanałów, ale trafia do osób, które odwiedzają te URL-e poza YouTube. + +### YouTube: typy kampanii wideo — przegląd formatów, obejrzenia, zasięg, konwersje, audio + +- **W065_youtube_typy_kampanii_wideo** (`W065`): Aktualne typy kampanii wideo YouTube (2023): (1) Zachęta do konwersji — cel: konwersja na stronie. CPV (cost per view). Optymalizacja pod akcję użytkownika po obejrzeniu. (2) Zwiększenie liczby obejrzeń — CPV, cel: jak najwięcej obejrzeń całego wideo (30 sek lub do końca). Wskaźnik 'obejrzenia' pojawia się tylko w tej kampanii. (3) Skuteczny zasięg — CPM, cel: dotarcie do jak największej liczby unikalnych osób. Nie gwarantuje obejrzenia — użytkownik może zobaczyć tylko 5 sek. (4) Częstotliwość — CPM, cel: kontrola ile razy jedna osoba widzi reklamę. (5) In-stream niemożliwe do pominięcia — 15 sek max, CPM, cały przekaz musi wybrzmieć. Uwaga: ZERO obejrzeń w statystykach — to normalne, bo nie mierzy tego wskaźnika. (6) Sekwencja reklam — seria filmów w określonej kolejności; świetna do remarketingu (po obejrzeniu A → pokaż B). (7) Audio (NOWY) — reklama dźwiękowa w podcastach i contencie audio. Strategie testowania: przy kampaniach wizerunkowych testuj równolegle kilka typów i porównuj parametry (CPV, CPM, zasięg, obejrzenia). Format wideo: pionowy (9:16) system wyświetla na mobile, poziomy (16:9) na YouTube. Jeśli masz oba formaty — wgraj oba; jeśli nie — system wyświetli poziomy wszędzie. + +## Temat: display-demandgen + +### Awareness video — target CPV lepszy niż target CPM ze względu na remarketing + +- **YT_jsg_21_awareness_target_cpv_vs_cpm** (`YT_jyll-saskin-gales`): Dla awareness w video: Jyll preferuje Video Views Campaign z target CPV zamiast Video Reach Campaign z target CPM. Powody: (1) view = zaangażowanie (ktoś wybrał oglądać), nie tylko 'reklama była na ekranie'. (2) Możesz budować remarketing list z osób które OBEJRZAŁY — przy impression-based (reach campaign) nie możesz remarketować do tych osób. Video Reach Campaign → NIE budujesz listy remarketingowej. Bid strategies per typ kampanii: Search = target impression share, Video = target CPM (reach) lub target CPV (views — preferowane), Display = viewable CPM (manual bid), Demand Gen = maximize clicks. + +### Demand Gen dla nasyconych kont Search — case study 20x wzrost + +- **YT_pm_37_demand_gen_case_study_20x** (`YT_ppc-mastery`): Gdy Search osiągnął nasycenie aktywnego popytu — przejście do Demand Gen. Case study lead gen (nieruchomości, Niemcy): klient z 1.5 roku: €28K → €550K spend. Kluczowy element Demand Gen: 'free check' (darmowa kalkulacja oszczędności online) jako lead magnet dla zimnych użytkowników. Tanie leady z DG (YouTube, Gmail, Discovery), które konwertowały dobrze dzięki silnemu procesowi sprzedaży. Lekcja: komunikacja do 'problem aware' musi być inna niż do 'solution aware' — nie 'kup teraz', ale 'sprawdź ile możesz zaoszczędzić'. Dla klientów z silnym produktem i dobrym zespołem sprzedaży, którzy 'wycisnęli' Search i szukają kolejnego skoku. + +### Demand Gen nowości 2025 — Maps inventory, Creator Partnership Ads + +- **YT_pm_30_demand_gen_mapy_creator_ads** (`YT_ppc-mastery`): Demand Gen rozszerza placements o Google Maps (Q3-Q4 2025, znaleziono w dokumentacji support po GML) — nowa okazja dla local business clients. Creator Partnership Ads (GML 2025): w Google Ads pojawi się narzędzie do wyszukiwania twórców YouTube bezpośrednio — widać audiencje, zasięg, ceny. Umożliwia bezpośrednią współpracę bez agencji influencer marketingu. Badanie neuroscience Google: 50% subskrybentów twórcy nie przeszkadza mu reklama — bo 'zawdzięczają mu' darmowe treści. Dynamiczny estymator budżetu i CPA na etapie tworzenia kampanii Demand Gen — pokazuje prognozowany budżet, CPA i oczekiwaną liczbę konwersji w pierwszych dniach. + +### Demand Gen video — rekomendacja click-based bid strategy zamiast conversion-based + +- **YT_jsg_19_demand_gen_video_click_based** (`YT_jyll-saskin-gales`): Jyll (6 lat w Google) rekomenduje Demand Gen video na click-based bid strategy zamiast conversion-based, bo: osoba która widzi film po raz pierwszy i od razu konwertuje to rzadkość. Klik z video = opuszczenie YouTube → duże zaangażowanie → wchodzi do remarketingu website → konwertuje w search/shopping/display później. Jeśli wolumen konwersji w DG video jest niski → zmień na CPC/maximize clicks. Buduj listę remarketingową z video viewers i website visitors → targetuj w search/shopping/display. + +### Demand Gen — 3 osobne kampanie per typ urządzenia + +- **YT_sp_44_demandgen_3_kampanie_per_urzadzenie** (`YT_surfside-ppc`): Prowadź Demand Gen w 3 osobnych kampaniach: mobile, desktop, TV screen. Cel: obserwacja cost per view per urządzenie i optymalizacja. Preferuj YouTube inventory (In-Feed + Watch Next) zamiast pre-roll i display. Budget rekomendowany przez Google repów ($200/dzień) to ich interes — nie Twój. Przy małym budżecie ($3k/miesiąc) — Demand Gen to zbędny luksus, priorytet to Search + PMax. + +### Demand Gen — Maximize Clicks dla prospectingu, konwersje dla remarketingu + +- **YT_jsg_18_demand_gen_bidding_prospecting** (`YT_jyll-saskin-gales`): Dla Demand Gen prospecting (zimna audiencja): zacznij od Maximize Clicks — bo nie docierasz do ludzi którzy teraz szukają, tylko do audiencji którą chcesz wciągnąć do lejka. Target CPC też jest opcją. Dla DG remarketing: zacznij od Maximize Conversions lub Maximize Conversion Value, potem przejdź na tCPA/tROAS. Nie startuj DG prospecting od razu na conversion-based bid strategy — najpierw zwaliduj audiencję, kreacje i ofertę. Demand Gen dla klientów uruchamiających po raz pierwszy: błąd to start od tCPA dla zimnej audiencji = brak danych, kampania się nie uczy. Demand Gen pokazuje reklamy na: YouTube, Gmail, Discover; nigdy nie wybieraj opcji display network. + +### Demand Gen — budżet minimalny, próg 50 konwersji i struktura + +- **YT_pm_27_demand_gen_budzet_min** (`YT_ppc-mastery`): Google rekomenduje 15x target CPA jako dzienny budżet (np. tCPA 100€ → 1500€/dzień → ~45k€/mies). W praktyce Thomas Eccel testował z 3-4x tCPA i działało. Absolutne minimum: ~1000 EUR/mies total. Demand Gen wymaga minimum 50 konwersji zanim algorytm 'rozumie' gdzie jest audiencja — przez pierwsze 2 tygodnie: ZERO zmian. Przejście z Max Conversions → tCPA dopiero po osiągnięciu progu 50 konwersji wewnątrz tej kampanii. Demand Gen to PIERWSZY typ kampanii w Google Ads uczący się na poziomie ad group, nie kampanii — ZAWSZE jedna kampania, podział na ad groupy według: lokalizacji, języka, typu kreacji, grupy odbiorców. Dwie kampanie = podwójny próg budżetowy, brak efektu skali. + +### Demand Gen — lookalike z Mety jako seed, po 60 dniach tylko lookalike + +- **YT_pm_28_demand_gen_audiencje_lookalike** (`YT_ppc-mastery`): Strategia cross-platform: uruchom lead gen na Meta z filtrami jakościowymi (pytania kwalifikacyjne w formularzu) → zbierz wysokiej jakości leady → uploaduj jako Customer Match do Google Ads → użyj jako seed list dla lookalike w Demand Gen. Logika: Meta i Demand Gen mają podobną dynamikę (entertainment), więc audiencje się krzyżują. Po ~60 dniach testów, kampanie pracujące na audiencjach lookalike (z customer match jako seed) osiągają lepszy wynik niż third-party in-market audiences — trzecia partia ma większy reach na starcie, ale po zebraniu danych lookalike wygrywa. OCT słabo atrybuje konwersje do kampanii upper-funnel (Display, Discovery, YouTube, Demand Gen) — dla Demand Gen używaj standardowego Gtech pixela z data-driven attribution, nie OCT. + +### Demand Gen — różnica nowi klienci vs remarketing i kiedy co uruchamiać + +- **YT_pm_50_demand_gen_nowi_klienci_remarketing** (`YT_ppc-mastery`): Demand Gen ma dwa tryby zastosowania: (1) Prospecting (nowi klienci) — upper funnel, cold audience, cel: awareness i consideration. Wymaga cold CTA, storytellingu, przedstawienia wartości. Ocena: assist rate w raporcie atrybucji, nie ROAS kampanii. (2) Remarketing — mid/lower funnel, ciepłe audiencje z wcześniejszych wizyt. Demand Gen remarketing > Display remarketing w 2025 (z wyjątkiem Dynamic Shopping Remarketing z feedem — nadal działa). Nie włączaj Display Network w DG — tracisz brand safety. Content Suitability: ustaw na Limited (nie Expanded/Standard). View Through Conversions: DG często undereportuje — user widzi ad na YT, potem konwertuje przez Search. Case: po $3k wydanych Google raportował 14 konwersji, ale faktyczny revenue $8.5k w 30 dni. + +### Demand Gen — struktura kreacji, placement per video i kontrola temperatury + +- **YT_pm_29_demand_gen_kreacje_placements** (`YT_ppc-mastery`): Struktura kreacji: osobny ad group dla obrazów i osobny dla wideo (nie mieszać). Po 60 dniach porównaj wyniki i wyłącz gorszy typ. Nowa beta: możliwość wyboru placements per video kreacja (np. 'to wideo tylko na Shorts', 'to wideo tylko na In-Stream'). Sitelinki w Demand Gen wyświetlają się widocznie na YouTube i znacząco podnoszą CTR — dodatkowe zastosowanie: USP jako sitelink = więcej informacji perswadujących przed kliknięciem. Błąd 'channel temperature': kopiowanie CTA z performance kampanii do Demand Gen = błąd. 'Zamów teraz', 'Sprawdź ceny', 'Umów demo' = zbyt gorące dla cold audience. Demand Gen wymaga 'zimnego' CTA: przedstaw brand, rozwiązanie, wartość — bez twardego zakupowego push. Attribution report: patrz na 'assist rate', nie tylko ROAS. Benchmark: ROAS 2-2.2x typowy, 4.5x wynik dobry dla mid-funnel. + +### Display Network — wyłącz zawsze, zero wartości redemptywnej + +- **YT_jsg_24_display_network_zero_wartosci** (`YT_jyll-saskin-gales`): 'I genuinely can't think of any redeeming features of the display network or search partners anymore.' Nie włączaj GDN do kampanii search ani shopping. Nie włączaj do video/DemandGen. Nie można wykluczyć z PMax, ale przy dobrym conversion tracking PMax sam nie będzie tam inwestował dużo. Nie pamięta kiedy ostatnio poleciła standalone display campaign. Alternatywa dla branding: Video Reach campaigns lub DemandGen na click-based bid strategy. Przy każdym setup search/shopping — upewnij się że GDN i search partners są wyłączone. + +### Nie duplikuj kampanii DemandGen/Display przy spadku performance — stracisz learning + +- **YT_jsg_17_demand_gen_nie_duplikuj_kampanii** (`YT_jyll-saskin-gales`): Tworzenie nowej kampanii DemandGen/Display z tymi samymi ustawieniami gdy stara 'nie działa' = wyrzucenie całego historical learning w koszu. Nowa kampania musi uczyć się od zera tych samych rzeczy co stara. Zamiast tego: dodaj nowe audiencje do istniejącej kampanii, znajdź root cause słabości. Jedynym powodem dla nowej kampanii byłaby fundamentalna zmiana (nowy produkt, nowa oferta, nowe creative). Dotyczy szczególnie DG i Display — nie Search (gdzie struktura ma inne znaczenie). + +### Video Campaigns for Conversion deprecated — zamiennik: Demand Gen + +- **YT_jsg_20_video_vac_deprecated_demand_gen** (`YT_jyll-saskin-gales`): Video Campaigns for Conversion (znane też jako: True View for Action, video action campaigns, VAC, drive conversions) zostały wycofane. Teraz video + conversion goal = Demand Gen. PLUS migracji: w Demand Gen można WYŁĄCZYĆ Google Display Network (w starym VAC było wymuszone 'video partners'). PLUS: lookalike segments dostępne teraz dla video. MINUS: tylko audience targeting, brak content targeting w Demand Gen. Przy tworzeniu kampanii video z celem konwersji — wybieraj Demand Gen zamiast Video. Pamiętaj odpiąć GDN. Jeśli klient chce content targeting (konkretne kanały YT) — jedyna opcja to Video na celu Views/Reach, nie konwersje. + +### YouTube Studio 'Promote' — tani Demand Gen, $1/subskrybent, CPM $3 + +- **YT_jsg_23_youtube_promote_tanie_subskrypcje** (`YT_jyll-saskin-gales`): Kliknięcie 'Promote' w YouTube Studio tworzy automatycznie kampanię Demand Gen z optymalizowanym targetowaniem. Jyll przetestowała własnym budżetem: CPM $3, koszt/subskrybent $1. Porównanie: YouTube Promotion (przez Studio): 35 subs @ $1.00/sub, CPM $3. Demand Gen optimized targeting (ręcznie): 24 subs @ $2.19/sub, CPM $3. Demand Gen custom segment (własna lista fraz): 2 subs @ $26/sub, CPM $70. NIE stosować custom segment do Demand Gen video dla celów subskrypcji — CPM $70 vs $3 to 23x drożej za gorsze wyniki. YouTube Promotion serwuje wyłącznie jako infeed (rekomendowana miniatura) — brak instream/pre-roll. Niższy view rate w YouTube Promotion koreluje z niższym kosztem/subskrybent — system optymalizuje pod szybkie subskrypcje. + +### YouTube dla B2B — mierz halo effect na Search, nie direct conversions + +- **YT_jsg_22_youtube_b2b_halo_effect** (`YT_jyll-saskin-gales`): YouTube B2B rzadko generuje bezpośrednie konwersje, ale ma silny wpływ na lead generation przez halo effect. Case study 1: po uruchomieniu video campaigns CPL spadł o 30% i 25% w dwóch B2B SaaS produktach, conversion rate podwoił się. Case study 2: po pauzie YouTube CPL brand campaigns wzrósł o 47%; po wznowieniu YT + Demand Gen → CPL spadł kolejne 47% poniżej baseline. Budżet: mniej niż 5% całkowitego budżetu wystarczy (lokalny B2B: $500/mies.). Targeting: custom segments z high-performing search keywords — NIE in-market ani affinity audiences (zbyt szerokie, zbyt consumer-focused dla B2B). Bidding: target cost per view → CPV poniżej $0.01. Video: 15-30 sekund, brand/logo w pierwszych sekundach, min. 2 warianty. + +## Temat: display-remarketing + +### Display Campaigns — niedoceniany typ kampanii z darmowym brandingiem + +- **YT_ay_55_display_campaigns_niedoceniane** (`YT_aaron-young`): 90-95% reklamodawców nie używa Display poprawnie. Najtańszy traffic w Google Ads — skalowalność praktycznie nieograniczona. Darmowy branding: user widzi reklamę 3-4 razy, nie klika, potem szuka markę w Search → Display nie kosztuje nic, a Search dostaje konwersje. Metryki: View Through Conversions — kluczowa metryka (case: 6320 VTC w 4 tygodnie). Porównuj 3 miesiące przed Display vs 3 po — szukaj spadku CPA w całym koncie. Case study: dodanie Display obniżył CPA całego konta z $76-105 do $51-68. Struktura: jeden AG = jeden custom segment (audience). Tworzenie 15-20 custom segments: keywords, websites, apps. 80% nie zadziała — pauzuj, skup się na 15-20% winners → skaluj. Remarketing Display: 5-10% całkowitego budżetu konta. + +### Display — testowanie kreacji i optymalizacja placements + +- **YT_ay_56_display_testowanie_kreacji** (`YT_aaron-young`): Start od jednego rozmiaru (np. 300x250 — najpopularniejszy). Testuj 5 różnych obrazków → winner → testuj 5 headlinów → winner → testuj kolor przycisku → testuj CTA text. Dopiero po znalezieniu winnera: twórz wszystkie 10+ rozmiarów. Optymalizacja placements: Insights & Reports → Where Ads Were Shown → filtruj 'app' → wyklucz nierelewantne (gry dla dzieci itp.). Prowadź exclusion list (tysiące domen) + script monitorujący. Display setup: Campaign without goals → Display. Lokalizacja: Presence only. Bidding: Maximize Conversions (nie tCPA na start). Remarketing: wyłącz Optimized Targeting. Audience: website visitors (wyklucz konwerterów). + +### Mierzenie wpływu YouTube — proxy metrics i halo effect + +- **YT_ay_57_youtube_mierzenie_wplywu** (`YT_aaron-young`): YouTube ≠ lastclick — jak mierzyć: In-platform: views, impressions, view-through rate, CPM, cost per view, video played to 25/50/75/100%. Google Brand Lift Studies: dostępne przy wyższym wydatku. Post-purchase surveys: 'Skąd się o nas dowiedziałeś?' Branded search lift: wzrost branded queries po uruchomieniu YT = wskaźnik skuteczności. Całościowy CPA konta: jeśli spada po dodaniu YT — kampania działa. Budżet na video dla początkujących: 5-10% całkowitego ad spend. Minimum $5/dzień na In-Feed. Jakość kreacji: iPhone + lapel mic wystarczy. Autentyczność > produkcja. + +### Video Ads — 3 kluczowe formaty YouTube i podzial budżetu + +- **YT_ay_53_video_ads_formaty** (`YT_aaron-young`): Shorts (15-30 sek): UGC, testimoniale, autentyczność. Niski CPM. In-Stream (60-90 sek): edukacyjny, funny, storytelling. Średni CPM. In-Feed (dowolna, landscape): inspiracyjny, founder story, long-form. CPM <$2, nawet <$1 — najlepszy stosunek cena/wartość dla brand awareness. Podział budżetu video (Corey, Variable Media): 50% CPM campaigns (In-Feed + TV), 30% Cost Per View campaigns, 20% Demand Gen retargeting. In-Stream best practices: logo widoczne od początku, view rate cel 50-70% (60% sweet spot). Shorts best practices: UGC > polished ads, szybki start, wartość, CTA w 15-30 sek. Video Ad Sequencing: jedno AG, wiele filmów w sekwencji (pain point → testimonial → founder story → oferta). + +### YouTube — dane rynkowe i argument za inwestycją + +- **YT_ay_54_youtube_dane_rynkowe** (`YT_aaron-young`): Czas na platformie (dane US): YouTube 23.1h/miesiąc (średnia sesja ~40 min), Instagram 12h/miesiąc, TikTok 9.5h/miesiąc. 70% czasu YouTube = na TV — TV-level exposure za ułamek kosztu. Demografia: TikTok 47% poniżej 30 lat, YouTube równomierny rozkład 25-54 — peak buying years. YouTube = drugi największy search engine. 90% użytkowników (dane Ipsos) odkryło nową markę na YouTube. Spadek clicków w Search: Wordstream — do 44% mniej clicków w niektórych branżach (AI Overviews). Wzrost wyszukiwań ALE spadek clicków → rosną CPC → YouTube staje się relatywnie tańsze. + +## Temat: dsa + +### DSA jako uzupełnienie Search — auto-exclusion script i kontrola + +- **YT_pm_53_dsa_search_komplementarny** (`YT_ppc-mastery`): DSA (Dynamic Search Ads) jako kampania uzupełniająca Search: pokrywa frazy, których nie ma w keyword liście. Kluczowe: DSA auto-exclusion script — gdy dodasz keyword do kampanii search, skrypt automatycznie wyklucza go z DSA, zapobiegając kanibalizacji ruchu i zapewniając że search bierze priorytet nad DSA dla znanych fraz. Search Max (w fazie beta): technicznie DSA + broad match + auto-generated assets = ewolucja DSA w kierunku keywordless. Sprawdzaj Search Terms z DSA regularnie — to źródło nowych keywords do dodania do kampanii search. Negative keywords z DSA: wyklucz branded terms (PMax też je bierze) i frazy z innych kampanii search żeby uniknąć duplikacji. + +## Temat: ecommerce + +### Best Sellers Report w Merchant Center — darmowy research produktowy + +- **YT_sol8_11_best_sellers_report** (`YT_solutions-8`): Lokalizacja: Merchant Center > Growth > Best Sellers. Dane: ranking popularności, zmiana tydzień-do-tydzień, relative demand, cena, brand, GTIN, kategoria Google. Widok Top Brands: ranking brandów w kategorii, możliwość drill-down do konkretnych produktów brandu. Dane historyczne do 2 lat wstecz (tygodniowo lub miesięcznie). Dostępność: nie w każdym koncie — koreluje z 'Excellent' na Shopping Experience Scorecard. Zastosowanie: identyfikacja produktów do rozszerzenia asortymentu, analiza sezonowości, research konkurencji. Darmowe narzędzie, często pomijane przez reklamodawców. + +### Black Friday / Cyber Monday — timeline planowania i pułapki + +- **YT_ay_70_bfcm_planning** (`YT_aaron-young`): Timeline BFCM: Czerwiec-lipiec: strategia, budżety, plan promocji. Sierpień-wrzesień: testowanie kreacji, nowych produktów w feedzie (sprawdź disapprovals). Październik: ramp-up budżetów (inkrementalnie, NIE nagle). Listopad pre-BF: obniż tROAS/tCPA o 20-30% żeby algorytm był bardziej agresywny. BF/CM: full budget, monitoruj co godzinę. Post-BF: przywróć normalne ustawienia w ciągu 3-5 dni. Cory Lindholm (data scientist): brak learning runway = główny błąd — nowe kampanie uruchomione w dniu BF nie mają danych. Creative shock — jeśli pierwszy wariant kreacji nie działa, nie masz czasu na test B. Codziennie sprawdzaj Products > Needs Attention w MC. + +### Customer list upload — Shopify do Google Ads + +- **YT_sol8_49_customer_list_upload** (`YT_solutions-8`): Segment: country + first order date exists (= kupujący klienci). Format CSV: email, phone, first, last, zip, country (dokładnie ta kolejność). Min. 1000 matched customers żeby lista działała. Match rate processing: 24-48h po uploadzie. Automatyzacja: Klaviyo integration z Google Ads (admin account required, nie MCC). Best practice: upload co tydzień lub częściej. Replace existing list, nie append — dzięki temu lista jest zawsze aktualna. + +### Email marketing — Elite 7 system (Klaviyo) + +- **YT_sol8_51_email_elite_7** (`YT_solutions-8`): 7 flow'ów Klaviyo (Dan Nichols, Solutions 8): 1) Welcome/nurture sequence, 2) Abandoned cart, 3) Browse abandonment, 4) Post-purchase, 5) Winback, 6) VIP/loyalty, 7) Sunset (inactive subscribers). Metryki email do monitorowania: open rate, click rate, revenue per email, list growth rate, unsubscribe rate. Email ratio: 4 value emails : 1 sale email (Alex Hormozi: 'give until they ask'). Lead magnet + email sequence: exit-intent popup z lead magnetem -> email nurture -> countdown offer (evergreen timer per user). Email marketing uzupełnia Google Ads i Meta — Klaviyo domyka konwersje, które reklamy zainicjowały. + +### Klaviyo — 7 flow'ów i synergia z ads + +- **YT_sol8_72_klaviyo_7_flows** (`YT_solutions-8`): 7 Flow'ów Klaviyo (Dan Nickas): 1) Leads Retarget — signup form, nie kupili jeszcze, 2) Abandoned Browse — odwiedzili produkt, bounce, 3) Abandoned Cart — dodali do koszyka, nie wpisali danych, 4) Abandoned Checkout — wpisali dane, nie kupili (najwyższy intent), 5) Welcome (New/Return Customer) — split na nowych i powracających, 6) Cross-sell / Upsell — komplementarne produkty, 7) Winback — re-engagement nieaktywnych. Synergia: Klaviyo segmenty sync do Meta Custom Audiences i Google Customer Match. Email subscribers -> Meta lookalike audiences. Email generuje ~20-40% total revenue (średnia 30%). Flows = 40% revenue z emaili; campaigns = 60%. + +### Merchant Center — Shop Quality jako czynnik rankingu + +- **YT_ay_19_merchant_center_shop_quality** (`YT_aaron-young`): 3 czynniki rankingu Shopping ads: (1) Bid, (2) Product relevance (tytuły, atrybuty), (3) Product feed quality (w tym Shop Quality). Shop Quality score w Merchant Center: Products > Shop > Shop Quality — scorecard z kategoriami: images, payment options, website speed, shipping, returns. Exceptional ranking = możliwość aplikowania o badge na reklamach. Priorytet poprawy: przejście z Good→Great i Great→Exceptional daje największy uplift. Sprawdzaj Merchant Center minimum raz w tygodniu — Aaron widzi to jako SOP w swojej agencji. + +### Promotions w Merchant Center — strategia unikania 'death spiral' + +- **YT_ay_20_promotions_merchant_center** (`YT_aaron-young`): Case study 'death spiral': marka doszła do 70% off sitewide po eskalacji miesiąc-po-miesiącu. Naprawa zajęła ponad rok. 3 strategie promocji bez dewaluacji marki: (1) Kategorie sezonowe: promocja na produkty wychodzące z sezonu — klienci nie czują się oszukani. (2) Bundle/multiple buy: rabat tylko powyżej obecnego AOV (AOV $70 → rabat na bundle $140+, lub 'buy 2 get 3rd X% off'). (3) Produkty z follow-on: rabat na produkt wymagający subskrypcji/części zamiennych (np. fitness band za darmo przy 12-mies. subskrypcji). Tytuł promocji MUSI zawierać kwotę/procent: 'Summer Sale' → odrzucona, '20% Off Summer Sale' → zaakceptowana. + +### Remarketing Display z feedem produktowym — mały budżet, duży wpływ + +- **YT_sol8_13_display_remarketing_feed** (`YT_solutions-8`): Kampania Display Remarketing z shopping feedem: targetuj only website visitors, Google pokaże im produkt który oglądali. Budżet: $2-3/dzień, ale podnosi ROAS Standard Shopping — użytkownik klika Shopping -> czeka -> widzi remarketing -> wraca direct -> kupuje -> konwersja przypisana do Shopping. Nawet przy $1000/mies. budżecie warto uruchomić Display Remarketing z feedem — low cost, high impact. Lifestyle images w Display: używaj lifestyle (twarz, użycie produktu), nie product shots na białym tle — product shots rezerwuj dla Shopping network. Video remarketing: minimum na każdym koncie. Dla impulse-buy produktów: outbound video ($5/dzień) + remarketing video = kluczowe. + +### Retencja klientów — non-brand > brand campaigns + +- **YT_sol8_52_retencja_nonbrand** (`YT_solutions-8`): John Moran (kluczowy insight): większość powracających klientów NIE przychodzi przez brand campaigns, ale przez NON-brand shopping campaigns. Case study (last 7 days): Shopping campaigns: 2000 new + 2500 returning, Brand campaign: 333 new + 751 returning. Shopping ma wyższy WOLUMEN returning, brand ma wyższy RATIO. Dlaczego: 'jesteśmy pokoleniem Amazon' — ludzie szukają produktu, nie marki. Klient który kupił lekarstwa nie szuka 'Twoja marka dog bed', szuka 'dog bed' i Cię znajduje ponownie. Wniosek: żeby poprawić retencję, skaluj NON-BRAND spend, nie brand spend. Case study Travalo (luggage): klient kupił walizkę za $1000, 20 dni później szukał 'Bellagio cover', kliknął shopping ad, kupił pokrowiec za $50. Koszt pozyskania powracającego = $2. + +### Segmentacja popup-ów w Klaviyo + +- **YT_sol8_89_klaviyo_popup_segmentacja** (`YT_solutions-8`): Średni conversion rate popup-a: 5% (50 nowych leadów na 1000 odwiedzin). Radio buttons w popup-ie do segmentacji od początku (np. 'Gdzie Cię boli?' -> Back / Foot & Leg / Hand / Other). Każdy wybór = custom tag w Klaviyo -> osobny flow emailowy z relevantnym contentem. Nie wysyłaj produktów na ból ręki osobie z bólem pleców — to generuje spam/unsubscribes. Quiz na stronie: pytania o preferencje -> custom properties -> segmentowane wyniki + flow emailowy. 7 flow'ów minimum, 5-7 emaili w każdym = 35-50 emaili do zbudowania per klient. Segmenty z Klaviyo pushuj do Facebook Ads jako custom audiences -> reklamy produktów na ból pleców do osób które w popup-ie wybrały 'back pain'. Buduje omnipresence: email + FB + Google = klient myśli że marka jest ogromna. + +### Shopify — 3 raporty new vs return customers + +- **YT_sol8_48_shopify_new_return_raporty** (`YT_solutions-8`): Raport 1: First-time vs Returning Customer Sales (domyślny) + kolumna 'Customers' + filtr product title -> znajdź hero product (najlepiej pozyskujący nowych klientów). Raport 2: AOV by Customer Type (custom) — bazowy 'Sales by Channel' + kolumny: customers, AOV, avg units ordered, customer type. Raport 3: Customer Type by Sales Channel (custom) — new vs return per kanał (Online Store, POS, Meta Shop). Rozwiązuje problem: 'ile nowych klientów pochodzi z POS vs online?'. Save as: zapisz każdy raport custom -> następnym razem jedno kliknięcie. + +### Strategia na okres post-holiday + +- **YT_sol8_87_post_holiday_strategia** (`YT_solutions-8`): Micro-sales zamiast jednej wielkiej wyprzedaży: różne oferty każdy dzień Black Friday (od 5% do 50%+ clearance) — nie wysysasz marginu jednorazowo, budujesz rozpoznawalność marki u nowych klientów. NIE robić ciągłych wyprzedaży po sezonie -> stajesz się 'discount brand'. Zamiast tego: follow-up z klientami 6 miesięcy po zakupie, ekskluzywne kody rabatowe tylko dla nich. Oddzielny email blast dla first-time buyers (nie mieszaj z ogólną listą). Autentyczność > perfekcja: zdjęcia telefonem, relatable content, bez filtrów — to teraz lepiej działa niż profesjonalne sesje. + +### Tips dla drogich produktów — conversion window i remarketing + +- **YT_sol8_41_high_priced_products** (`YT_solutions-8`): Conversion window: 60-90 dni — drogie produkty mają długi cykl decyzyjny. Nie tnij kampanii po 2 tygodniach — daj czas na time lag. Remarketing lists: 90-180 dni — ludzie wracają po miesiącach. Phone calls jako conversion action — drogie produkty ludzie wolą kupować po rozmowie. Testimoniale i case studies w reklamach — social proof jest krytyczny przy wysokim ticket. Dla produktów >$500 kluczowe jest mierzenie micro-conversions (phone calls, form fills) nie tylko sprzedaży online. + +### eCommerce — diagnostyka 'Clicks with No Sales' i konsolidacja kampanii + +- **YT_ay_74_ecommerce_diagnostyka** (`YT_aaron-young`): Proces audytu konta eCommerce: (1) Auto-apply recommendations = WYŁĄCZ. (2) Conversion tracking = sprawdź double counting. (3) Widok 250-dniowy = trend revenue/ROAS. (4) Posortuj kampanie po spend. (5) Search kampanie z conversion rate < 3% = red flag. (6) Konsolidacja: zbyt wiele kampanii z <10 konwersji/miesiąc = połącz. 'Clicks with No Sales' diagnostyka: (1) Landing page (speed, relevance, mobile). (2) Pricing vs konkurencja. (3) Search terms. (4) Audience (demographics). (5) Device performance (mobile vs desktop CR). (6) Conversion tracking. 3 przyczyny niskiego CR: siła oferty, landing page, jakość ruchu. + +## Temat: feed + +### AI Mode i przyszłość feedu — bogaty feed jako fundament widoczności + +- **YT_pm_40_ai_mode_feed_priorytet** (`YT_ppc-mastery`): Google AI Mode = Gemini 2.5 bezpośrednio w wyszukiwarce z agentami zakupowymi. Agentic checkout — zakupy bez opuszczania Google. Feed produktowy staje się jeszcze ważniejszy: bogaty feed (opisy, obrazy, dane strukturalne) = lepsze 'rozumienie' produktu przez AI. Atrybuty opcjonalne które prawie nikt nie wysyła: lifestyle_image_link, product_highlight, product_detail, virtual_model_link, 3D_image_link (US) — dają przewagę bez podnoszenia bidów. GMC A/B Testing (GML 2025): narzędzie do testowania tytułów, obrazów i opisów produktów bezpośrednio w GMC — rollout Q3-Q4 2025. Priorytet: jakość danych w Merchant Center jako fundament zarówno płatnych jak i organicznych wyników Shopping. + +### Feed — opcjonalne atrybuty dające przewagę konkurencyjną + +- **YT_pm_23_feed_atrybuty_opcjonalne** (`YT_ppc-mastery`): Prawie nikt nie wysyła: lifestyle_image_link, product_highlight, product_detail, virtual_model_link, 3D_image_link (US). Te atrybuty dają przewagę w konkurencyjnych rynkach — wzbogacają kontekst produktu i zwiększają ad rank bez podnoszenia bidów. GMC Next ma raport konkurencji pokazujący: (a) produkty danej marki z wysokim popytem, których klient nie ma w asortymencie, (b) podobne marki, których klient nie sprzedaje, a popyt rośnie. Przegląd co kwartał → materiał do rozmów ze stakeholderami o rozszerzeniu asortymentu. GMC A/B Testing (GML 2025): narzędzie A/B testu bezpośrednio w GMC dla Shopping Ads (tytuły, obrazy, opisy) — rollout Q3-Q4 2025. Priorytet testowania po udostępnieniu. + +### Optymalizacja product feed — jeden z 5 kluczowych lewarów wzrostu + +- **YT_sp_40_shopping_feed_optymalizacja** (`YT_surfside-ppc`): Uzupełnienie opisów produktów powoduje bezpośredni wzrost wyników — efekt widoczny miesiąc po zmianie. Szczególnie ważne dla produktów niszowych/unikatowych z długim ogonem. Feed daje Google kontekst do targetowania long-tail — bez opisów kampania nie może tego targetować. Minimum 500 znaków opisu (można do 5000), pierwsze 150-160 znaków najważniejsze. Przy audycie e-commerce: sprawdź czy produkty mają wypełnione opisy i tytuły. + +### Product feed — opisy produktów minimum 500 znaków + +- **YT_sp_74_feed_opisy_produtow_minimum_500** (`YT_surfside-ppc`): Przy audycie e-commerce sprawdź czy produkty mają wypełnione opisy i tytuły. Opis produktu: minimum 500 znaków, pierwsze 150-160 znaków najważniejsze. Feed daje Google kontekst do targetowania long-tail — bez opisów kampania nie może targetować niszowych wyszukiwań. Efekt uzupełnienia opisów dla 200 produktów: widoczny wzrost wyników miesiąc po zmianie. Szczególnie ważne dla produktów niszowych/unikatowych. + +## Temat: feed-merchant + +### 4 ukryte raporty w Merchant Center + +- **YT_gma_43_merchant_center_4_raporty** (`YT_grow-my-ads`): 4 ukryte raporty w MC wg Grow My Ads: (1) Shopping Experience Scorecard (Overview/Growth) — badge 'Top Quality Store', porównanie z konkurencją w branży: shipping, return, seller rating. (2) Price Competitiveness (Growth) — benchmark price per produkt, produkty 13%+ powyżej benchmarku prawie nie mają szans w aukcji. Widok per brand: ile % produktów za drogie. Price Insights: sugerowane zmiany cen z prognozą wzrostu. (3) Competitive Visibility (Performance) — 'Auction Insights w MC': relative visibility, page overlap rate, higher position rate, ad/organic ratio. (4) Promotions Report (Performance) — które promocje faktycznie przesuwały metryki. + +### AI-generated product images — tematyczne warianty SKU + +- **YT_gma_41_ai_generated_sku_warianty** (`YT_grow-my-ads`): Taktyka 2026 Grow My Ads: ten sam produkt → różne konteksty wizualne (AI-generated) → różne keyword themes → osobne item ID w feedzie. Przykład (sectional sofa): 'Dog friendly sectional sofa' z psem, 'Cat friendly sectional sofa' z kotem, 'Apartment sized sectional' z tłem high-rise. Każdy wariant = osobny item ID, tytuł, opis, obraz. Legalne w Google Merchant Center pod warunkiem unikalnych: item ID, tytuł, opis, zdjęcie. Zwiększa ekspozycję na niszowe keyword themes i lepiej matchuje intencję → wyższy CTR. + +### Atrybut sale_price w Google Shopping: brak kontroli nad etykietą 'Promocja' + +- **W080_sale_price_shopping_etykieta_brak_kontroli** (`W080`): Atrybut 'sale_price' (cena promocyjna) w feedzie Google Merchant Center NIE gwarantuje wyświetlania etykiety 'Promocja' lub 'Wyprzedaż' na reklamie produktowej. Decyzja o wyświetleniu tej etykiety należy wyłącznie do Google — reklamodawca nie ma nad tym kontroli. Google wyświetla etykietę promocyjną według własnych algorytmów, uwzględniając m.in.: historię cen produktu, poziom obniżki, czas trwania promocji i inne czynniki. Praktyczne konsekwencje: (1) Nawet poprawnie skonfigurowany atrybut sale_price może nie generować etykiety. (2) Minimalna wymagana obniżka: zazwyczaj co najmniej kilka procent w stosunku do price (ceny regularnej). (3) Google wymaga podania daty końca promocji (atrybut 'sale_price_effective_date') — bez daty etykieta często nie pojawia się. Rekomendacja: zawsze dodawaj sale_price + sale_price_effective_date razem; nie licz na etykietę jako gwarantowany element wizualny reklamy. + +### Ceny referencyjne w kampaniach produktowych: monitoring skryptami i etykietowanie + +- **W070_feed_merchant_ceny_referencyjne_skrypty_etykietowanie** (`W070`): Ceny referencyjne (benchmark prices) w Google Shopping to ceny, przy których produkt wyświetla się ze znacznikiem 'Niższa cena niż u innych sprzedawców' lub podobnym. Wpływają na CTR i pozycję w wynikach produktowych. Monitoring i optymalizacja przez skrypty Google Ads: Skrypty mogą automatycznie sprawdzać ceny referencyjne produktów w feedzie (dostępne przez Google Ads API / Merchant Center API) i porównywać je z cenami własnym. Na podstawie porównania skrypt może automatycznie etykietować produkty za pomocą etykiet niestandardowych (custom_label_0 do custom_label_4). Przykład etykietowania: produkty z ceną poniżej ceny referencyjnej → etykieta 'competitive', produkty z ceną powyżej → etykieta 'expensive'. Kampania produktowa lub PMax może następnie wyświetlać TYLKO produkty z etykietą 'competitive' (przez podział grupy produktów według etykiety), skupiając budżet na produktach z przewagą cenową i wyższą szansą na konwersję. Skrypty Google Ads uruchamiane cyklicznie (np. codziennie) automatyzują cały proces. + +### Duplikaty produktów w Google Merchant Center: dozwolone i zalecane przy synonimach + +- **W126_feed_duplikaty_synonimy_gmc** (`W126`): Duplikowanie produktów w Google Merchant Center (tworzenie wielu wpisów dla fizycznie tego samego produktu) jest DOZWOLONE i ZALECANE gdy produkt ma różne potoczne nazwy lub synonimy, pod którymi użytkownicy go szukają. Przykład: produkt 'oprawa oświetleniowa' ma kilku synonimów wyszukiwanych przez klientów: 'lampa sufitowa', 'plafon', 'kinkiet', 'żyrandol' — każdy synonim jako osobny wpis w feedzie z tytułem zawierającym tę konkretną nazwę daje ekspozycję na wszystkie warianty zapytań. Jak implementować: narzędzia jak Sembot automatycznie generują duplikaty z podmienionymi tytułami; alternatywnie ręcznie przez supplemental feed w GMC lub przez reguły w DataFeedWatch/Feedink. Każdy duplikat musi mieć unikalny ID produktu (item_id) — użyj sufiksu: 'produkt-123-lampa', 'produkt-123-kinkiet'. Ograniczenie: przy bardzo dużej liczbie duplikatów GMC może ograniczyć widoczność przez algorytm jakości feedu — monitoruj status produktów w GMC. Technika szczególnie skuteczna dla kategorii oświetlenia, mebli, elektroniki gdzie nomenkatura jest niejednoznaczna. + +### Edycja atrybutów produktów: przez feed/sklep lub Feedink — nie bezpośrednio w GMC + +- **W072_feed_merchant_edycja_atrybutow_feedink_nie_bezposrednio_gmc** (`W072`): Gdy produkt w Merchant Center ma błędne atrybuty (np. zły kolor, kategoria, tytuł), masz trzy miejsca do ich poprawienia — w kolejności od najlepszego: (1) Źródło (sklep/platforma) — popraw dane bezpośrednio w sklepie (CMS/platforma). Najtrwalsza zmiana; feed automatycznie zaktualizuje GMC przy kolejnym pobraniu. (2) Narzędzie pośredniczące (Feedink, DataFeedWatch, Channable) — narzędzia do zarządzania feedem pozwalają dodawać reguły transformacji (np. 'jeśli kolor = Biały → zmień na White') bez ingerowania w sklep. Feedink pozwala też dodawać dodatkowe kolumny i etykietować produkty. (3) Bezpośrednio w GMC — Google pozwala edytować atrybuty w interfejsie GMC, ale ta zmiana zostanie NADPISANA przy kolejnym pobraniu feedu. Nadaje się tylko do jednorazowych poprawek gdy nie możesz zmienić źródła. Dodatkowy feed w GMC: możesz dodać osobny plik feedu (np. CSV) z etykietami niestandardowymi (custom labels) dla wybranych produktów — GMC połączy dane z głównego feedu i z dodatkowego feedu po GTIN/ID produktu. + +### Feed Attribute Rules — optymalizacja bez narzędzi zewnętrznych + +- **YT_gma_42_feed_attribute_rules** (`YT_grow-my-ads`): Feed Attribute Rules w Merchant Center (Products > View Data Sources > feed > Attribute Rules): pozwalają na optymalizację feedu bez DataFeedWatch/Channable. Możliwości: (1) Custom label przez reguły warunkowe ('title contains Christmas OR Santa' → custom_label_3 = 'Christmas'). (2) Prepend/Append do tytułów bez zmiany źródłowego feedu. (3) Draft mode do testowania (Show Preview) przed zastosowaniem. Case study: dodano 'Kid and Pet Friendly Washable' na początek + 'Home Reserve' na koniec tytułu. Zastosowanie: automatyczne tagowanie sezonowe, dodawanie brandów do tytułów. + +### Feed produktowy WordPress: CTX Feed do generowania XML + narzędzia do optymalizacji + +- **W063_feed_wordpress_ctx_feed** (`W063`): Feed produktowy dla Google Shopping na WordPress/WooCommerce: Krok 1 — Generowanie pliku XML: wtyczka CTX Feed (prosta konfiguracja, darmowa wersja wystarczająca na start). Krok 2 — Zaawansowana optymalizacja feedu: zewnętrzne narzędzia: Feedink lub DataFeedWatch — pozwalają modyfikować tytuły, dodawać etykiety, tworzyć reguły transformacji bez edycji wtyczki. Etykiety niestandardowe (custom_labels 0-4): ustawiaj w feedzie do segmentacji produktów w kampaniach (top-seller, wysoka marża, sezonowe, zalegające) — pozwalają dzielić PMax/Shopping bez osobnych feedów. Weryfikacja Merchant Center na PrestaShop: kod weryfikacyjny HTML wgraj przez FTP do katalogu głównego sklepu. + +### Feed produktowy: optymalizacja tytułów/opisów/parametrów i etykiety do zarządzania + +- **W061_feed_optymalizacja_etykiety** (`W061`): Optymalizacja feedu produktowego — trzy poziomy: (1) Tytuły i opisy: zoptymalizowane pod wyszukiwania użytkowników (nie nazwy brandingowe produktów) — słowa kluczowe w tytułach = ekspozycja na frazy. (2) Parametry produktów (kolor, rozmiar, materiał, płeć itp.): uzupełnij maksymalnie — poprawiają dopasowanie i jakość feedu w Merchant Center. (3) Etykiety niestandardowe (custom_labels): kluczowe narzędzie zarządzania. Przykłady etykiet biznesowych: wysoka/niska marża, top-seller, long-tail, zalegające/pułkownicy, sezonowe, wyprzedaż. Etykiety umożliwiają podział produktów w grupach wizytówek PMax bez osobnych feedów. Zwiększanie ekspozycji poza budżetem: dodatkowy CSS (Comparison Shopping Service) — drugi CSS z podobnymi produktami może zwiększyć zasięg bez podnoszenia budżetu głównej kampanii. + +### Feed produktowy: tytuły i opisy mogą (i powinny) różnić się od strony sklepu + +- **W059_feed_tytuly_opisy_optymalizacja** (`W059`): Feed produktowy NIE musi mieć takich samych tytułów i opisów jak sklep. Automatyczne wtyczki do feedów najczęściej kopiują dane ze sklepu, ale to nie jest optymalne podejście. Feed powinien być zoptymalizowany pod wyszukiwania użytkowników — słowa w tytułach i opisach feedu determinują, na jakie zapytania wyświetlają się reklamy. Przykład: produkt 'Sukienka Aleksandra' → w feedzie zmień na 'Sukienka wiosenna [kolor] midi długość' — tak szukają użytkownicy. Dzięki temu nie psujesz nazewnictwa na sklepie (branding) a feed jest zoptymalizowany pod wyszukiwania. Optymalizacja tytułów feedu to jeden z kluczowych dźwigni w kampaniach Shopping/PMax. + +### Feed produktowy: zestawy i produkty bez GTIN — parametr identifier_exists + +- **W087_identifier_exists_feed_zestawy_bez_gtin** (`W087`): Niektóre produkty nie mają kodu GTIN (EAN/UPC) — np. własne zestawy (bundles), produkty handmade, artykuły niestandardowe, zestawy składające się z wielu elementów gdzie żaden nie ma własnego kodu. Problem: Google Merchant Center domyślnie wymaga GTIN dla produktów nowych — brak GTIN powoduje ostrzeżenia lub obniżenie jakości feedu. Rozwiązanie: dodaj atrybut 'identifier_exists' z wartością 'no' (lub 'false') do każdego produktu bez identyfikatora. Co to robi: informuje GMC, że produkt NIE posiada globalnego identyfikatora z powodu swojej natury (nie z powodu przeoczenia) — Google przestaje wymagać GTIN i nie obniża za to jakości feedu. Kiedy NIE używać identifier_exists=no: gdy produkt faktycznie ma GTIN, ale nie chce Ci się go dodawać — to obniża widoczność produktu (Google preferuje produkty z GTIN). Użyj TYLKO dla produktów, które z definicji GTIN nie mają. Zestawy własne (bundles): użyj atrybutu 'is_bundle=yes' razem z identifier_exists=no — GMC traktuje takie produkty jako zestawy złożone i nie wymaga GTIN komponentów. MPN (Manufacturer Part Number): jeśli nie ma GTIN, ale jest MPN (numer katalogowy) — dodaj MPN zamiast GTIN; to lepsze niż identifier_exists=no. + +### Google Merchant Center: tylko jedno GMC może mieć potwierdzoną domenę + +- **W076_feed_merchant_jedna_domena_gmc** (`W076`): W Google Merchant Center obowiązuje zasada: tylko jedno konto GMC może mieć potwierdzoną i zweryfikowaną daną domenę sklepu w danym czasie. Jeśli chcesz przenieść domenę do nowego GMC lub masz dwa konta GMC dla tej samej domeny — musisz najpierw usunąć potwierdzenie domeny w starym koncie. Konsekwencje praktyczne: — Nie możesz mieć dwóch aktywnych GMC z tą samą domeną (np. główne i testowe). — Przy tworzeniu nowego GMC dla istniejącej domeny: najpierw cofnij potwierdzenie w starym. — Przy tworzeniu kont dla wielu rynków: każdy rynek może używać tej samej domeny w jednym GMC (przez targeting po kraju) lub osobnych domen z osobnymi GMC. Weryfikacja domeny w GMC: przez GTM, DNS, plik HTML lub Google Search Console (jeśli GSC jest już zweryfikowana dla tej samej domeny, GMC może się automatycznie zweryfikować). + +### Klasyfikacja produktów wg ROAS: Hero, Sidekick, Villain, Zombie — progi dla custom_label + +- **PRAK_produkty_klasyfikacja_roas_hero_zombie** (`audyt_innsi_2026_03`): Klasyfikacja produktów do custom_label w Merchant Center (wzorowana na Mike Rhodes / FlowBoost): Hero (bestseller): ROAS > 1,5× faktyczny ROAS konta lub top 5% produktów wg wartości konwersji. Sidekick (dobry): ROAS ≥ tROAS kampanii (powyżej celu). Villain (kosztowny): kliknięcia > 0, konwersje = 0 — produkt generuje koszt bez zwrotu; rozważ wykluczenie z kampanii lub poprawę feedu/strony produktu. Zombie (martwy): 0 kliknięć, 0 konwersji — produkt ignorowany przez algorytm i użytkowników; nie generuje kosztu, ale zaśmieca feed; rozważ wyłączenie w Merchant Center. Typowa dystrybucja w e-commerce: 85-90% produktów to zombie (0 konwersji), 10% asortymentu generuje 85-90% wartości konta. Ważna zasada: zombie produkty w PMax TOP nie szkodzą — algorytm ignoruje je naturalnie gdy nie wygrywają aukcji. Nie trzeba ich wykluczać z PMax ani tworzyć osobnej kampanii. Villain produkty (kliknięcia bez konwersji) to realne straty — warto je wykluczyć przez custom_label lub wykluczenie produktu w grupie zasobów PMax. Progi są względne — dostosuj do faktycznego ROAS konta i tROAS kampanii. Przykład: konto tROAS 6,5, faktyczny ROAS 7,6 → Hero ≥ 10, Sidekick 6,5-10. + +### Kursy online i produkty cyfrowe niematerialne: blokada regulaminowa w Google Merchant Center + +- **W123_feed_kursy_online_blokada_gmc** (`W123`): Kursy online i treści edukacyjne (e-learning, nagrania wideo, webinary) NIE mogą być reklamowane jako produkty przez Google Merchant Center / Shopping. Google Merchant Center jest przeznaczony wyłącznie dla produktów fizycznych (i nielicznych wyjątków produktów cyfrowych jak oprogramowanie z możliwością pobrania). Próba wgrania kursu online do feedu produktowego skutkuje odrzuceniem przez politykę Google. Właściwe kanały reklamowania kursów online: (1) Kampanie Search — reklamy tekstowe na frazy intencyjne. (2) Kampanie Display / Demand Gen — banery i wideo do budowania świadomości. (3) YouTube Ads — szczególnie skuteczne dla edukacji, wideo jako format nauki. (4) Remarketing — do odwiedzających stronę kursu. Konta reklamujące kursy muszą też spełniać dodatkowe wymagania Google dla branży edukacyjnej (weryfikacja certyfikatów, transparentność oferty) — analogicznie do branży finansowej i zdrowotnej. + +### Lifestyle images z AI — ChatGPT/Gemini workflow + +- **YT_gma_40_lifestyle_images_ai** (`YT_grow-my-ads`): Workflow lifestyle images z AI: (1) Wrzuć zdjęcie produktu, (2) Wrzuć zdjęcie referencyjne sceny, (3) Prosty prompt ('Create a living room scene from the first image similar to the reference'). ChatGPT Image Generator i Gemini Nano Banana Pro dają lepsze wyniki niż Google Product Studio. UWAGA: AI czasem zmienia wygląd produktu — ZAWSZE weryfikuj. Workaround: nowy chat bez zdjęcia referencyjnego. Case study meble: CTR z 0.6% na 1.35% (benchmark Shopping ~0.8%). Case study 10x konwersji: lifestyle image zamiast białego tła → kliknięcia z ~200/tyg. na ~2000-3000/tyg., konwersje z 1/tyg. na 10-11/tyg. + +### Limity produktów w Google Merchant Center: 150 tys. do 40 mln w zależności od konta + +- **W124_feed_merchant_limity_produktow** (`W124`): Google Merchant Center ma limity liczby produktów w feedzie, które różnią się w zależności od typu i historii konta: Standardowe konto GMC: limit od ok. 150 000 produktów. Konta z rozszerzonymi możliwościami (zweryfikowane, starsze, z dużym wolumenem): do 40 000 000 (40 mln) produktów. Dla bardzo dużych sklepów (milion+ produktów) zalecane jest przesyłanie feedu przez Content API (Google Content API for Shopping) zamiast pliku XML — API jest bardziej niezawodne przy dużych wolumenach i pozwala na aktualizacje per produkt (nie trzeba przesyłać całego feedu przy każdej zmianie). Format feedu: XML czy API — bez różnicy dla algorytmu Google Ads; Google widzi dane tak samo niezależnie od metody przesyłania. Częstotliwość aktualizacji feedu: raz na dobę jest w pełni wystarczająca dla zdecydowanej większości sklepów — częstsze aktualizacje mają sens tylko przy bardzo dynamicznych cenach lub stanach magazynowych (flash sale, marketplace). + +### Merchant Center: usuwanie zablokowanych produktów z feedu + +- **W057_merchant_center_blokowane_produkty** (`W057`): Problem: produkty niezgodne z polityką Google (np. określone kategorie) blokują remarketing i zbieranie user ID. Kolejne importy sklepu nadpisują ręcznie usunięte produkty z Merchant Center. Rozwiązanie u źródła: we wtyczce/module sklepu generującym feed produktowy wyklucz te produkty z eksportu — nie trafią do pliku XML/CSV, więc Merchant Center ich nie zobaczy. Alternatywa: edycja feedu w Merchant Center za pomocą reguł feedu (feed rules) lub zewnętrznych narzędzi do zarządzania feedem. Ważne: blokada nawet jednego niezgodnego produktu może zablokować remarketing dla całego konta — priorytetowe usunięcie przed uruchomieniem kampanii. + +### Narzędzia do monitorowania cen konkurencji w Google Shopping: LivePrice, Dealavo, PriceShape + +- **W109_feed_narzedzia_ceny_konkurencji** (`W109`): Monitorowanie cen konkurencji w Google Shopping jest kluczowe dla optymalizacji CTR i konwersji — produkty droższe od rynku mają niższy CTR i trudniej konwertują. Dostępne narzędzia według rynku: (1) LivePrice (liveprice.eu) — rynek polski; monitoring cen produktów w Google Shopping, porównywarki i marketplace'y; alerty przy zmianie pozycji cenowej; integracja z feedem. (2) Dealavo — rynek polski; monitoring cen + automatyczna repricing; sprawdza pozycje cenowe w czasie rzeczywistym i może automatycznie dostosowywać ceny według reguł (np. zawsze 2% poniżej najtańszej konkurencji). (3) PriceShape — rynki zagraniczne (DE, UK, FR, inne); szczególnie przydatny przy ekspansji cross-border; monitoring cen i pozycji w Google Shopping na rynkach europejskich. Jak używać danych cenowych w kampaniach: etykietuj produkty (custom_label) według pozycji cenowej (competitive/expensive) i kieruj większy budżet na produkty, gdzie jesteś tańszy — wyższy CTR i lepsza konwersja przy tych samych kosztach reklamy. Integracja ze skryptami Google Ads pozwala automatycznie etykietować produkty na podstawie danych z tych narzędzi. + +### Narzędzia do optymalizacji feedów produktowych: Feedink, DataFeedWatch, ProductHero + +- **W083_narzedzia_feedow_feedink_datafeedwatch** (`W083`): Przegląd narzędzi do zarządzania i optymalizacji feedów produktowych dla Google Merchant Center: (1) Feedink (feedink.com) — optymalizacja feedów produktowych, dostępna opcja darmowa. Prostsze w obsłudze niż DataFeedWatch. Dobre dla mniejszych sklepów. (2) DataFeedWatch — bardziej rozbudowane narzędzie, pozwala transformować pola feedu, mapować atrybuty, tworzyć reguły warunkowe (if/then). Płatne, wyższy próg wejścia. Lepsze dla sklepów z rozbudowanym katalogiem wymagającym złożonej transformacji danych. (3) ProductHero — optymalizator feedów z wbudowanym CSS (Comparison Shopping Service) dającym zniżki na kliknięcia w Shopping. Kombinacja feed + CSS w jednym narzędziu. Alternatywne podejście bez narzędzi zewnętrznych: Supplemental feed w Merchant Center — dodatkowy plik CSV/Google Sheets nakładany na główny feed przez ID produktu; pozwala nadpisywać wybrane atrybuty (np. tytuły, etykiety) bez dostępu do systemu sklepu. Szczególnie przydatne gdy brakuje dostępu do backendu sklepu lub gdy chcesz testować różne wersje tytułów bez zmian na stronie. + +### Nazewnictwo produktów w feedzie: traktuj jak słowa kluczowe + +- **W056_nazewnictwo_produktow_feed** (`W056`): Tytuły produktów w feedzie to odpowiednik słów kluczowych w kampaniach Search — algorytm dopasowuje reklamy PLA do zapytań użytkowników na podstawie tytułów. Zasada: nazwa w feedzie NIE musi być identyczna z nazwą na stronie. Optymalizacja: research najpopularniejszych fraz wyszukiwania dla każdego produktu (Planer słów kluczowych, Google Trends, Search Terms z kampanii). Najważniejsze słowa (najczęściej wyszukiwane) umieść na początku tytułu — Google często obcina długie tytuły. Przykład: zamiast 'Płaszcz Analityka Premium' → 'Płaszcz damski wiosenny długi beżowy'. Schemat (brand + płeć + typ + atrybuty) to punkt wyjścia — weryfikuj kolejność elementów wg faktycznego wolumenu wyszukiwań w Planerze. + +### Nowe konto Google Merchant Center: jeden Gmail = jedno GMC — jak założyć kolejne + +- **W066_google_merchant_center_nowe_konto_gmail** (`W066`): Google Merchant Center nie pozwala na założenie drugiego/kolejnego konta GMC w ramach tego samego adresu Gmail. Jeden Gmail = jedno konto Google Merchant Center. Jak założyć GMC dla kolejnej strony: (1) Utwórz NOWY adres Gmail (może być techniczny, np. gmc.sklep2@gmail.com). (2) Na tym nowym Gmail zaloguj się do Merchant Center i utwórz konto. (3) Po założeniu: wejdź w Ustawienia GMC → Dostęp do konta → dodaj główny mail roboczy jako użytkownika (Administrator). Efekt: główny mail ma dostęp do wielu kont GMC bez potrzeby logowania na każdy Gmail osobno. Google Product Category: Uzupełniaj od razu przy konfiguracji feedu — nie trzeba czekać na automatyczne dopasowanie przez Google. Większość narzędzi do generowania feedu (CTX Feed, DataFeedWatch, Feedink) pozwala przypisać kategorię już na etapie konfiguracji. + +### Opinie konsumenckie Google: gwiazdki w Google Shopping — jak wdrożyć + +- **W061_opinie_konsumenckie_google_gwiazdki** (`W061`): Gwiazdki widoczne przy reklamach produktowych w Google Shopping to program 'Opinie konsumenckie Google' (Google Customer Reviews). Jak wdrożyć: (1) Zarejestruj się w programie Opinie konsumenckie Google w Merchant Center. (2) Zacznij zbierać opinie: po każdym zakupie system wysyła email do klienta z prośbą o opinię (opt-in). Platformy z bezpośrednią integracją: Shoper, IdoSell (IAI), SkyShop — można wdrożyć bez dewelopera. Alternatywy (zewnętrzne platformy opinii): Zaufane.pl, Trusted Shops — zbierają opinie i przekazują do Google jako 'opinie o sklepie'. Można wdrożyć samodzielnie bez zewnętrznych agencji — szczegóły zależą od platformy sklepowej. Minimalna liczba opinii do pojawienia się gwiazdek: ~150 opinii. + +### Opinie o produktach w Google Merchant Center: wymagania i format + +- **W123_feed_opinie_produktowe_gmc** (`W123`): Opinie produktowe (product reviews) wyświetlane w Google Shopping wymagają osobnego wdrożenia w Google Merchant Center — nie są pobierane automatycznie ze strony. Wymagania techniczne: (1) Format pliku: XML zgodny ze schematem Google Product Reviews Feed. (2) Minimalna liczba opinii: co najmniej 50 łącznie w całym feedzie — bez tego próg Google nie aktywuje wyświetlania gwiazdek w Shopping. (3) Produkty muszą być aktywne i dostępne na stronie w momencie przesyłania opinii. (4) Feed opinii przesyła się osobno od feedu produktowego — przez Merchant Center → Zawartość → Opinie produktów → Nowe źródło danych. Treść opinii: każda opinia musi zawierać: ID produktu (product_id), ocenę (1-5), treść recenzji, datę, ID recenzenta (może być zaszyfrowane). Alternatywa: integracja z platformami opinii (Ceneo, Opineo, Trusted Shops, Google Customer Reviews) — automatycznie wysyłają opinie do GMC po konfiguracji. Gwiazdki z opinii produktowych zwiększają CTR w Shopping, szczególnie dla nowych produktów. + +### Opinie produktowe w GMC: historycznych nie można zaimportować + +- **W068_feed_merchant_opinie_gmc_idosell** (`W068`): Historycznych opinii produktowych ze sklepu (np. IdoSell, WooCommerce, Shoper) nie da się zaimportować do programu ocen produktów w Google Merchant Center. Opinie muszą być zebrane dopiero po oficjalnym przystąpieniu do programu ocen — tylko wtedy system je honoruje. Nie ma mechanizmu retroaktywnego importu istniejących recenzji. Konsekwencja praktyczna: sklep przystępujący do programu zaczyna zbieranie opinii od zera, niezależnie od liczby wcześniejszych recenzji w systemie sklepu. Dlatego warto przystąpić do programu jak najwcześniej — nawet przy małej liczbie zamówień — żeby budować bazę opinii w GMC równolegle z rozwojem sklepu. + +### Opisy produktów w feedzie: pierwsze 150–500 znaków kluczowe, nie powtarzaj tytułu + +- **W083_feed_opis_150_znakow_bez_powtorzen** (`W083`): Przy optymalizacji opisów produktów w feedzie Merchant Center: najważniejsze jest pierwsze 150–500 znaków opisu — Google przycina opis w wynikach i tyle jest faktycznie czytane/indeksowane. Opis NIE musi mieć 5000 znaków — długość nie poprawia skuteczności. Ważna zasada: nie powtarzaj słów kluczowych z tytułu w opisie. Tytuł i opis razem tworzą jeden dokument — duplikowanie fraz z tytułu marnuje cenną przestrzeń opisu. W opisie umieszczaj: — Synonimy i alternatywne frazy, których nie ma w tytule. — Cechy techniczne i atrybuty (materiał, wymiary, przeznaczenie). — Frazy long-tail (np. 'idealny na prezent', 'do ogrodu', 'dla dzieci 3-6 lat'). Tytuły wariantów (np. kolory, rozmiary) powinny się od siebie różnić — wariant czerwony i niebieski tego samego produktu powinny mieć różne tytuły uwzględniające kolor. Unikaj duplikatów tytułów między wariantami — Google może traktować je jako ten sam produkt i ograniczać ekspozycję. + +### Optymalizacja feedu jako sposób na wyższą widoczność produktów w PMax bez zasobów + +- **W071_pmax_feed_optymalizacja_widocznosc_produktow** (`W071`): W kampaniach PMax działających wyłącznie na feedzie produktowym (bez zasobów) jedynym skutecznym sposobem poprawy widoczności i pozycji produktów jest optymalizacja feedu. Kluczowe obszary optymalizacji feedu: (1) Tytuły produktów — najważniejszy element. Tytuł powinien zawierać słowa kluczowe, które wpisują użytkownicy szukający produktu. Struktura: [Marka] + [Typ produktu] + [Kluczowe atrybuty: kolor, rozmiar, model]. Sprawdź realne zapytania w Search Terms. (2) Wszystkie parametry obowiązkowe i zalecane uzupełnione — brak GTIN, MPN, kategorii produktu, stanu (nowy/używany) obniża jakość feedu. (3) Zdjęcia wysokiej jakości — Google preferuje zdjęcia produktu na białym tle, min. 800×800 px. Dodaj dodatkowe zdjęcia (additional_image_link). (4) Opisy produktów — uzupełnij frazy długoogonowe, zastosowania, materiały. (5) custom_label — segmentuj produkty według marżowości, sezonu, bestsellera, aby kampanią PMax zarządzać na poziomie grup produktów ze różnymi tROAS. Narzędzie do oceny jakości feedu: Merchant Center → Diagnostyka feedu → wskazuje brakujące atrybuty i problemy z produktami. + +### Optymalizacja feedu — priorytet: cena > tytuły > GTIN > obrazy + +- **YT_gma_45_feed_cena_najwazniejsza** (`YT_grow-my-ads`): Hierarchia optymalizacji feedu wg Grow My Ads: (1) Cena — absolutnie najważniejszy czynnik w Shopping. Jeśli konkurencja sprzedaje 20% taniej — żadna optymalizacja tego nie przebije. NIGDY price baiting (niższa cena w feedzie, wyższa na checkout) = zawieszenie. (2) Tytuły — zasada 80/20 (80% czasu na tytuły, 20% na resztę). Źródło keywords: Search Term Report. (3) GTIN — krytyczny dla resellerów. Brak GTIN: identifier_exists = no. (4) Obrazy — lifestyle dają 2-3x wzrost CTR vs białe tło. (5) Opisy — niższy priorytet. Narzędzia: Channable (rekomendacja nr 1), Feedonomics (enterprise, 200K+/rok), DataFeedWatch (tani, DIY). + +### Połączenie Google Merchant Center z GA4: co daje i co weryfikować + +- **KD02_02_gmc_ga4_polaczenie_korzysci** (`KD02`): Połączenie Google Merchant Center (GMC) z Google Analytics 4 (GA4) przynosi dwie kluczowe korzyści: (1) Dane o konwersjach spływają z powrotem do panelu GMC — Merchant Center widzi, które produkty faktycznie generują sprzedaż, co poprawia sygnały dla bezpłatnych wyników Shopping. (2) W GA4 pojawia się możliwość analizy skuteczności zarówno bezpłatnych, jak i płatnych list produktów — można porównać, które produkty przyciągają ruch organiczny (bezpłatne listingi) a które wymagają wsparcia reklamą. Ważne szczegóły techniczne połączenia: synchronizacja danych między GMC a GA4 po nawiązaniu połączenia może zająć do 24 godzin — nie weryfikuj od razu. Automatyczne tagowanie (auto-tagging) dla produktów GMC musi zostać ręcznie sprawdzone lub aktywowane podczas procesu łączenia — bez niego dane o kampaniach Shopping nie będą prawidłowo atrybuowane w GA4. + +### Produkty z kalkulatorem/konfiguratorem ceny: jak uniknąć odrzucenia w Merchant Center + +- **W109_feed_cena_konfigurator** (`W109`): Google Merchant Center wymaga zgodności ceny w feedzie z ceną widoczną na stronie po kliknięciu w reklamę. Problem z produktami konfigurowanymi (np. szafy na wymiar, meble, maszyny z opcjami): cena zależy od konfiguracji i jest wyższa niż cena bazowa. Jeśli landing page nie pokazuje żadnej ceny lub pokazuje inną niż w feedzie — GMC odrzuca produkt z powodu 'niezgodnej ceny'. Rozwiązanie: ustaw w feedzie cenę bazową (najniższą możliwą konfigurację) i zadbaj, żeby ta sama cena bazowa była wyraźnie widoczna na landing page — np. 'Cena od [X] zł, zależy od konfiguracji'. Formulacja 'cena od' z wartością bazową jest zgodna z polityką GMC — Google akceptuje tego typu cenę otwartą, jeśli na stronie jest transparentnie pokazana wartość wyjściowa. Nie wpisuj ceny 0 ani 1 zł w feedzie — to skutkuje odrzuceniem za niezgodność lub nieuczciwą cenę. Dodatkowe ryzyko: boty Google (Googlebot) automatycznie crawlują strony produktów i porównują ceny w feedzie z tym, co widzą na stronie — rozbieżności są wykrywane automatycznie, nie tylko przy ręcznym zgłoszeniu. + +### Różne ID produktów w Merchant Center i na stronie: remarketing dynamiczny wyświetla produkty losowo + +- **W106_feed_rozne_id_mc_remarketing** (`W106`): Remarketing dynamiczny Google Ads dopasowuje produkty wyświetlane użytkownikowi na podstawie parametru ecomm_prodid (ID produktu) przekazywanego przez piksel Ads. Jeśli ID produktu w Merchant Center (feed GMC) jest INNE niż ID przekazywane przez piksel na stronie sklepu — dopasowanie jest niemożliwe i remarketing dynamiczny wyświetla produkty losowo (nie te oglądane przez użytkownika). Typowe przyczyny rozbieżności: sklep wysyła ID SKU w pikselu, a GMC używa ID wariantu lub odwrotnie; prefix/suffix dodawany przez system sklepu w feedzie; Merchant Center z Google Shopping ma inne ID niż feed remarketingowy. Diagnostyka: Google Ads → Narzędzia → Zarządzanie odbiorcami → Źródła danych → Tag Google Ads → sprawdź raport produktów — jeśli widzisz ID które nie pasują do GMC, masz rozbieżność. Naprawa: ujednolic ID produktu — albo zaktualizuj feed GMC, albo zmień parametr piksela na stronie. Przy systemach wielomagazynowych lub wariantach: użyj item_group_id jako ID grupujący i sprawdź, które ID Google preferuje w danym kontekście. + +### Sezonowe hasła w Shopping: duplikat produktu z podmienionym tytułem + osobna kampania + +- **W126_feed_sezonowe_haslo_duplikat_tytul** (`W126`): Strategia dla krótkich sezonowych haseł (np. 'rower na komunię', 'prezent na Dzień Matki', 'lampka choinkowa') w kampaniach produktowych: (1) Stwórz duplikat produktu w feedzie GMC z tytułem zawierającym sezonowe hasło — np. 'Rower dziecięcy 20 cali — komunia 2025'. Algorytm Shopping/PMax dopasowuje frazy głównie na podstawie tytułu, więc duplikat z sezonowym hasłem w tytule wyświetli się na to zapytanie. (2) Uruchom osobną kampanię PMax lub Search wyłącznie na ten produkt na czas sezonu — izoluje budżet sezonowy od kampanii głównej. (3) Opcjonalnie: równoległa kampania Search z frazą sezonową jako słowem kluczowym do wychwycenia ruchu tekstowego. Po sezonie: wstrzymaj duplikat w feedzie i sezonową kampanię — oryginalny produkt w kampanii głównej działa dalej bez zakłóceń. Alternatywa do duplikatu: supplemental feed nakładany na czas sezonu z dodatkowym polem custom_label = 'sezon' + podmiana tytułu przez reguły feedu. + +### Sprzedaż międzynarodowa: wiele feedów językowych/krajowych w jednym Merchant Center + +- **W123_feed_miedzynarodowy_wiele_feedow** (`W123`): Google Merchant Center obsługuje sprzedaż do wielu krajów przez jeden account — nie trzeba zakładać osobnych kont per kraj. Struktura dla sprzedaży międzynarodowej: (1) Jeden główny feed z atrybutem 'target_country' i 'feedLabel' — możesz wskazać, do których krajów dany feed jest skierowany. (2) Osobne feedy per język/kraj — jeśli treści (tytuły, opisy) muszą być przetłumaczone; każdy feed ma ustawione docelowe kraje i język. (3) Supplemental feedy (dodatkowe) — nakładka na główny feed, np. przetłumaczone tytuły w języku docelowym bez duplikowania całego feedu. Waluty i ceny: GMC automatycznie obsługuje różne waluty — podaj cenę w walucie docelowego kraju lub skonfiguruj auto-przeliczanie. Ważne: kampanie Google Ads (Shopping/PMax) targetujące inne kraje muszą być osobnymi kampaniami z odpowiednimi ustawieniami lokalizacji — nie ma jednej kampanii automatycznie działającej we wszystkich krajach feedu. + +### Testowanie zdjęć produktów w feedzie: podmiana głównego zdjęcia i duplikat produktu do A/B testu + +- **W107_feed_testowanie_zdjec_ctr** (`W107`): Zdjęcie produktu w reklamie produktowej (Shopping) ma duży wpływ na CTR — białe tło vs. zdjęcie z aranżacją/kontekstem może dawać istotnie różne wyniki w zależności od branży i grupy docelowej. Dwie metody testowania zdjęć w feedzie: (1) Podmiana na żywca — zamień główne zdjęcie produktu (image_link w feedzie) i obserwuj CTR przez 2–4 tygodnie. Prosta metoda, ale zaburza historię produktu i nie pozwala na porównanie równoległe. (2) Kopia produktu z innym zdjęciem — zduplikuj wpis produktu w feedzie z nowym ID i innym zdjęciem; oba warianty rywalizują w aukcjach, Google sam wysuwa lepiej klikaną wersję. Wadą jest duplikat w Merchant Center — może powodować ostrzeżenia o duplikatach. Uwaga: Google Merchant Center domyślnie pokazuje główne zdjęcie produktu; dodatkowe zdjęcia (additional_image_link) są rzadko używane w reklamach Shopping — są dostępne, ale system preferuje zdjęcie główne. Metryka do monitorowania: CTR w raporcie produktów kampanii Shopping (Merchant Center → Kampanie → per produkt). Zmiana zdjęcia to jeden z najszybszych lewisów poprawy CTR przy braku możliwości zmiany ceny. + +### Tytuły feedu — formuły per kategoria + +- **YT_gma_44_tytuly_formuly_per_kategoria** (`YT_grow-my-ads`): Formuły tytułów per kategoria wg Grow My Ads: Odzież: Brand + Gender + Product Type + Color + Size + Material (np. 'Nike Men's React Flyknit Running Shoes Black Gray Size 10.5'). Consumables: Brand + Product Type + Atrybuty (waga, ilość). Hard goods/Electronics: Brand + Model + Kluczowe specyfikacje. Sezonowe: Okazja + Product Type + Atrybuty. Generyczne (bez znanego brandu): Key Benefits + Product Type + Use Case + Brand na końcu. Reguła: znana marka → na początek, nieznana → na koniec. Pierwsze 30-70 znaków najważniejsze (reszta obcinana w SERP). Google może dynamicznie zmieniać kolejność słów w tytule wg zapytania — nie dodaje słów, tylko zmienia kolejność. + +### Warianty produktów w feedzie: więcej = lepiej; selekcja na poziomie kampanii + +- **W074_feed_merchant_warianty_etykiety** (`W074`): Przy produktach z wieloma wariantami (rozmiary, kolory, wersje) w Merchant Center: im więcej wariantów w pliku produktowym, tym lepiej — nie filtruj ich na poziomie feedu. Selekcję produktów/wariantów rób na poziomie kampanii (grupy produktów), nie na etapie feedu. Etykiety niestandardowe (custom labels) w pliku produktowym znacznie ułatwiają filtrowanie produktów w kampaniach — pozwalają grupować produkty wg własnych kryteriów (np. 'bestseller', 'sezonowy', 'marża_wysoka', 'zombie'). Etykiety mają 5 poziomów (label_0 do label_4) — zaplanuj ich użycie z wyprzedzeniem. Bez etykiet niestandardowych grupowanie produktów w kampaniach opiera się tylko na atrybutach Google (kategoria, marka, ID) — mniej elastyczne. + +### Zdjęcia aranżacyjne vs. packshoty w kampaniach produktowych: co wolno i co działa lepiej + +- **W126_feed_zdjecia_aranzacyjne_shopping** (`W126`): Zdjęcia aranżacyjne (produkty pokazane w kontekście użytkowania, z tłem, z modelami lub dekoracjami) są DOZWOLONE w kampaniach produktowych Google Shopping — Google nie wymaga whitebox/packshot jako jedynego formatu. Co jest NIEDOZWOLONE w feedzie produktowym: tekst nałożony na zdjęcie (promocje, ceny, napisy 'SALE'), ramki i loga (za wyjątkiem marki produktu), wielokrotne zdjęcia produktu w jednym kadrze. Zdjęcia aranżacyjne vs. packshoty — co działa lepiej: zależy od kategorii produktowej; w meblach, dekoracjach, odzieży zdjęcia lifestyle'owe (aranżacyjne) często mają wyższy CTR bo pokazują produkt w kontekście i pomagają wyobrazić go w domu/użyciu. W elektronice, narzędziach, precyzyjnych akcesoriach — packshoty na białym tle są często skuteczniejsze (jasny produkt, czytelne detale). Nowość Google: funkcja AI do automatycznej zmiany tła zdjęcia produktowego dostępna bezpośrednio w Merchant Center — generuje warianty tła bez edytora graficznego. Warto testować: A/B test różnych zdjęć możliwy przez dodanie additional_image_link w feedzie — Google testuje różne zdjęcia automatycznie. + +## Temat: ga4 + +### 3 kluczowe raporty GA4 dla e-commerce + +- **W057_ga4_raporty_ecommerce** (`W057`): Trzy obszary raportów GA4 niezbędne przy sklepach internetowych: (1) Skąd mamy przychody: źródło/medium × łączne przychody (model atrybucji). Wariant: sesja źródło/medium — pokazuje ruch nawet bez konwersji, pozwala ocenić mikrokonwersje na wczesnym etapie. Tabela przestawna (eksploracje): wymiar 'miesiąc' do kolumn → porównanie źródeł ruchu na przestrzeni wielu miesięcy w jednym widoku. (2) Analiza contentu: strona docelowa + ciąg zapytania — które podstrony generują ruch i przychody. Pozwala ocenić skuteczność landing pages. (3) Analiza produktów (e-commerce scope): nazwa produktu / kategoria × przychody, dodania do koszyka, wyświetlenia — identyfikacja bestsellerów i produktów ciągnących wyniki w dół. Problem: filtrowanie produktów po nazwie/ID w eksploracjach GA4 jest wygaszone (stan: W057) — obejście: eksport do Excel, BigQuery lub Looker Studio. Czwarty obszar uzupełniający: eksploracja ścieżek — przepustowość kroków zakupowych (gdzie użytkownicy porzucają ścieżkę). + +### 4 kluczowe raporty GA4 dla projektów leadowych + +- **W058_ga4_raporty_leady** (`W058`): Raporty GA4 dla leadów — 4 główne obszary (dostosuj do potrzeb biznesu): (1) Źródła konwersji: źródło/medium × ilość konwersji (z filtrem na nazwę zdarzenia) — skąd pochodzi konkretna liczba leadów z każdego kanału, model atrybucji. (2) Wolumen ruchu: sesja źródło/medium × sesje + współczynnik zaangażowania + mikrokonwersje — ile użytkowników weszło z każdego kanału, nawet bez konwersji. (3) Landing pages z konwersjami: strona docelowa + ciąg zapytania × konwersje — które podstrony są punktem wejścia kończącym się leadem. (4) Popularność podstron usług: ścieżka do strony + ciąg zapytania × sesje + mikrokonwersje (np. przeczytanie oferty) — które podstrony usług są najczęściej odwiedzane i jak zaangażowani są użytkownicy. Zasada ogólna: nie szukaj 'standardowych raportów' — pytaj biznes, jakich odpowiedzi potrzebuje, i buduj raporty pod te pytania. + +### Analiza skuteczności newslettera w GA4: identyfikator transakcji jako łącznik + +- **W075_ga4_newsletter_analiza_transakcja** (`W075`): Aby przeanalizować efekty wysyłki newslettera w GA4 (jakie produkty kupili odbiorcy, jaki był przychód, które strony odwiedzili), zbuduj eksplorację w GA4: Wymiary: sesja źródło/medium (lub atrybucja źródło/medium), identyfikator transakcji, nazwa produktu (w GA4 e-commerce: 'item_name'). Metryki: przychód, liczba transakcji, liczba zakupionych sztuk. Kluczowy trick: użyj identyfikatora transakcji jako łącznika między źródłem ruchu a szczegółami zakupu — GA4 nie łączy bezpośrednio 'skąd przyszedł' z 'co kupił' w jednym raporcie, ale przez wspólny transaction_id możesz to zestawić. Filtruj po źródle/medium = 'email / newsletter' lub odpowiedni UTM. Pamiętaj: dane potrzebują 48–72h na pełne przetworzenie — nie analizuj bezpośrednio po wysyłce. Używaj konta demo GA4 (Google Merchandise Store) do nauki budowania eksploracji bez ryzyka uszkodzenia danych produkcyjnych. + +### Atrybucja konwersji: Google Ads (czas kliknięcia) vs GA4 (czas konwersji) + +- **W059_atrybucia_ads_vs_ga4** (`W059`): Różnica w przypisaniu konwersji między Google Ads a GA4: Google Ads przypisuje konwersję do dnia kliknięcia reklamy (dokładnie: czas ostatniego zapytania wyświetlającego reklamę przed kliknięciem). GA4 przypisuje konwersję do dnia, kiedy konwersja faktycznie miała miejsce. Przykład: kliknięcie 14 kwietnia, zakup 16 kwietnia → Ads raportuje konwersję w dniu 14, GA4 w dniu 16. To normalny, oczekiwany rozjazd między systemami — nie błąd. Przy analizie danych uwzględniaj tę różnicę, zwłaszcza przy porównywaniu raportów. + +### Backup danych Universal Analytics: termin przesunięty do lipca 2024, BigQuery + +- **W060_ua_backup_bigquery_termin** (`W060`): Aktualizacja (W060): termin usunięcia danych z Universal Analytics (GA3) przesunięty z końca 2023 do lipca 2024. Masz więcej czasu — ale backup jest konieczny przed tą datą. Najlepsze rozwiązanie: BigQuery — możliwość przechowywania danych z GA3 i GA4 w jednym miejscu. Stan na W060: dostępne konektory GA3 → BigQuery są płatne; warto poczekać na tańsze/bezpłatne rozwiązanie, które powinno pojawić się na rynku w ciągu następnego roku. Priorytet: zrób backup przed lipcem 2024 — po tej dacie dane znikają bezpowrotnie. + +### BigQuery: krzyżowanie haseł wyszukiwania z URL-ami docelowymi — analiza niedostępna w standardowych raportach + +- **W107_ga4_bigquery_krzyzowanie_hasel_url** (`W107`): Standardowe raporty Google Ads i GA4 nie pozwalają na połączenie dwóch kluczowych wymiarów jednocześnie: hasło wyszukiwania (search term) + URL strony docelowej (landing page), do której trafia użytkownik po kliknięciu w reklamę. BigQuery umożliwia takie niestandardowe krzyżowanie danych: (1) Eksport danych z GA4 do BigQuery (GA4 → Administracja → Połączenia z produktem → BigQuery) dostarcza zdarzenia z URL-ami stron. (2) Eksport Search Terms z Google Ads API (lub przez raporty CSV) dostarcza hasła. (3) Łącznik: GCLID (identyfikator kliknięcia Google Ads) — jest w obu źródłach i pozwala połączyć sesję GA4 z konkretnym kliknięciem w reklamę Search/Shopping. Wynik: tabela BigQuery z kolumnami: hasło wyszukiwania → URL docelowy → liczba konwersji/sesji. Zastosowanie: wykrywanie haseł, które trafiają na nieoptymalne landing page'e; analiza, które frazy przynoszą ruch na strony produktów vs. kategorie vs. stronę główną. Uwaga: wdrożenie wymaga znajomości SQL i Google Ads API lub użycia gotowych narzędzi BI (Looker Studio z BigQuery jako źródłem). + +### Cross-domain tracking GA4 i problem krótkiego czasu na stronie + +- **W056_cross_domain_tracking_ga4** (`W056`): Cross-domain tracking GA4: ten sam ID pomiaru GA4 musi być wdrożony na obu domenach. W GA4: Administracja → Strumienie danych → Konfiguracja tagów → sekcja 'Konfiguracja domen' — wpisz obie domeny. Po tej konfiguracji przechodzenie między domenami jest traktowane jak nawigacja po podstronach, nie jak nowa sesja — eliminuje błędne przypisanie do direct/medium not set. Problem krótkiego czasu na stronie (<2 sek.): GA4 mierzy czas na podstawie pieczątek zdarzeń — jeśli użytkownik wchodzi i wychodzi bez żadnej akcji i bez przejścia na podstronę, czas = 0. Nie jest to wina kampanii — to brak konfiguracji mikrokonwersji (scroll depth, czas >30 sek. na stronie). Wdróż mikrokonwersję mierzącą zaangażowanie zanim zaczniesz oceniać kampanię. + +### Direct/None w GA4 zamiast kampanii: diagnostyka i przyczyny utraty atrybucji + +- **W070_ga4_direct_none_zamiast_kampania_przyczyny_diagnostyka** (`W070`): Problem: GA4 pokazuje ruch ze źródłem 'direct / (none)' zamiast poprawnego przypisania do kampanii Google Ads (np. google / cpc). Oznacza to utratę informacji o źródle konwersji. Najczęstsze przyczyny i co sprawdzić: (1) Autotagowanie wyłączone — sprawdź w Google Ads: Narzędzia → Ustawienia konta → Autotagowanie musi być WŁĄCZONE. Bez autotagowania GCLID nie jest dodawany do URL. (2) Rozłączone konta — sprawdź połączenie Google Ads z GA4 w obu narzędziach (GA4: Admin → Połączenia Google Ads; Ads: Narzędzia → Połączone konta → Google Analytics). (3) Przekierowania ucinające GCLID — jeśli landing page ma przekierowanie 301/302 przed załadowaniem właściwej strony, GCLID może być zgubiony. Sprawdź w nagłówkach HTTP (DevTools → Network). (4) Consent management (CMP) blokuje GCLID — pop-up ciasteczkowy może blokować odczyt parametrów URL przed udzieleniem zgody; sprawdź konfigurację Consent Mode v2. (5) Szablony śledzenia modyfikujące URL — na poziomie konta/kampanii/grupy/reklamy sprawdź, czy szablony śledzenia nie nadpisują lub nie usuwają parametrów URL. (6) Brak parametrów UTM przy braku GCLID — dodaj UTM jako backup (patrz iOS 17 LTP). + +### Facebook Ads vs GA4: rozbieżności w konwersjach — skąd i jak interpretować + +- **W062_facebook_vs_ga4_rozbiezone_konwersje** (`W062`): Rozbieżności między Menedżerem reklam Facebook a Google Analytics są strukturalne: Facebook: piksel przypisuje sobie 100% wartości konwersji niezależnie od miejsca w ścieżce zakupowej — nawet jeśli był tylko pierwszym z kilku kanałów. Okno atrybucji Facebook (7 dni po kliknięciu + 1 dzień po wyświetleniu): wyświetlenie reklamy Facebooka podczas konwersji przez inny kanał = Facebook przypisze sobie całą konwersję. GA4 raporty (zakładka Pozyskiwanie): last click — Facebook często jest na początku ścieżki, więc jego konwersje są tu niedoszacowane. Punkt środka (najbliższy prawdy): GA4 Eksploracje → wymiar 'źródło/medium' (nie 'sesja źródło/medium') → model atrybucji oparty o dane, uwzględnia cały udział kanałów w ścieżce. Facebook zawsze zawyży, GA4 last-click zawsze zaniży — eksploracje GA4 z 'źródło/medium' to najlepsze dostępne przybliżenie. + +### GA3 (Universal Analytics) wyłączanie po 1 lipca 2023 — status i co robić + +- **W064_ga3_wylaczanie_status** (`W064`): Po 1 lipca 2023: Google stopniowo wygasza konta Universal Analytics (GA3). Wygaszanie nie jest jednorazowe — odbywa się etapami przez kilka tygodni/miesięcy. Skutki: - GA3 przestaje zbierać nowe dane. - Dostęp do historycznych raportów GA3 jest zachowany przez pewien czas (Google planuje finalne wyłączenie dostępu do historii). - Listy remarketingowe z GA3 w Google Ads: tracą nowych użytkowników, stare dane wygasają wg okna czasowego listy. Co zrobić: (1) GA4 jako główna analityka — jeśli nie skonfigurowane, zrób teraz. (2) Konwersje w Google Ads: podmień źródło z GA3 na GA4 lub piksel Google Ads. (3) Listy remarketingowe: odtwórz w GA4 (dane nie przenoszą się automatycznie). (4) BigQuery export z GA3: ostatni termin zapisu danych historycznych. + +### GA4 + Search Console: jaka część ruchu organicznego i przychodów pochodzi z fraz brandowych + +- **W064_ga4_organika_frazy_brandowe** (`W064`): GA4 samodzielnie NIE pokazuje fraz organicznych (Google ukrywa te dane — '(not provided)'). Jak sprawdzić udział fraz brandowych w organice + przychodach: Metoda 1 — Search Console integration w GA4: Połącz GA4 z Search Console → w GA4 pojawi się raport 'Search Console' → widoczne frazy, kliknięcia, wyświetlenia. Ale BEZ danych o przychodach per fraza. Metoda 2 — BigQuery (najbardziej kompletna): Wyeksportuj GA4 do BigQuery + dane Search Console. Łącznik: strona docelowa (landing page). Zapytanie: przychód z GA4 per landing page ↔ frazy na te strony z Search Console. Metoda 3 — Looker Studio (częściowe): Połącz Search Console + GA4 w Looker Studio przez wspólny wymiar strony docelowej. Wniosek praktyczny: zidentyfikuj top strony docelowe z organiki, sprawdź w Search Console jakie frazy na nie wchodzą, oceń czy to brand czy non-brand. + +### GA4 brak procentów w raportach — Looker Studio lub Excel jako obejście + +- **W065_ga4_brak_procentow_looker_studio** (`W065`): Universal Analytics (GA3) miał wbudowane procenty w raportach tabelarycznych (np. udział kanału w całości ruchu). GA4 NIE MA tej funkcji — brak procentowych udziałów w standardowych raportach i eksploracjach. Jak uzyskać procenty: Metoda 1 — Looker Studio: Połącz GA4 jako źródło danych → stwórz własne pole obliczone (np. kliknięcia / suma kliknięć × 100). Pełna elastyczność, wykres + tabela z procentami. Metoda 2 — eksport do Excela / Google Sheets: Wyeksportuj dane z GA4 → oblicz procenty w arkuszu kalkulacyjnym. Mniej eleganckie, ale szybkie dla jednorazowych analiz. Współczynnik konwersji: w GA4 jest dostępny, ale w eksploracje czasem nie wyświetla się poprawnie — to znany problem. Obejście: oblicz ręcznie (konwersje / sesje × 100) w Looker Studio. + +### GA4 eksploracje: raport kampanii Google Ads i raport źródeł ruchu + +- **W060_ga4_eksploracje_kampanie_zrodla_ruchu** (`W060`): Jak zbudować dwa kluczowe raporty w GA4 (Eksplorowanie → pusta eksploracja): (A) Raport kampanii Google Ads: Wymiar: 'Kampania Google Ads' z sekcji Atrybucja → Dane: Wyświetlenia, Kliknięcia, Wydatki, Łączne przychody (lub Konwersje). Tip: po wizualizacji wymiaru system podświetla tylko pasujące metryki (reszta wyszarzona). ROAS nie jest dostępny bezpośrednio w eksploracji — trzeba liczyć ręcznie. (B) Raport źródeł ruchu (odpowiednik domyślnego raportu z UA): Wymiar: 'Źródło/medium' z sekcji Atrybucja → Dane: Łączne przychody + Całkowita liczba użytkowników + Sesje + Współczynnik zaangażowania. Obie eksploracje można umieścić na osobnych kartach w jednej eksploracji ('Google Ads' i 'Cały ruch') — zawsze pod ręką do decyzji budżetowych. + +### GA4 eksploracje: segmenty użytkowników — powtarzalność zakupów i porzucone koszyki + +- **W065_ga4_segmenty_powtarzalnosc_zakupow** (`W065`): GA4 Eksploracje umożliwiają tworzenie segmentów użytkowników do zaawansowanych analiz: Segment: kupili DOKŁADNIE 1 raz: Warunek: zdarzenie 'purchase', liczba zdarzeń = 1. Segment: kupili 2+ razy: Warunek: zdarzenie 'purchase', liczba zdarzeń > 1. Segment: porzucone koszyki (wracali, nie kupili): Warunek 1: session_start, liczba zdarzeń > 2 (minimum 3 wizyty). Warunek 2: add_to_cart, liczba zdarzeń > 2. Wykluczenie: zdarzenie 'purchase' (WYKLUCZ to zdarzenie). Segment: kupili w poprzednim roku, nie kupili w tym: Manipuluj zakresem dat w ustawieniach segmentu. Ograniczenia i pułapki: GA4 przechowuje dane tylko 14 miesięcy — analizy 2-letnie wymagają BigQuery. Jakość danych = żywotność ciasteczek (Safari 7 dni → użytkownik 'nowy'). Rozwiązanie dla dokładnych analiz: Wymiary niestandardowe z User ID, liczbą zakupów, timestampami — niezależne od ciasteczek, dużo dokładniejsze wyniki. + +### GA4 próbkowanie danych: zmiana tożsamości raportowania eliminuje progi + +- **W064_ga4_probkowanie_tozsamosc_urzadzenia** (`W064`): GA4 stosuje próbkowanie danych w raportach eksploracyjnych przy dużym ruchu (wyraźna informacja w interfejsie: '% próbki danych'). Domyślna tożsamość raportowania w GA4: 'Modelowanie' (łączy dane z User ID, Google signals i deviceId) — ta opcja aktywuje progi prywatności → próbkowanie. FIX: Zmień tożsamość raportowania na 'Identyfikacja zależna od urządzenia' (Device-based identity). Gdzie: GA4 → Administracja → właściwość → Tożsamość raportowania → wybierz 'Identyfikacja zależna od urządzenia'. Efekt: GA4 nie stosuje progowania (thresholding) — więcej danych widocznych w raportach. Kompromis: tracisz cross-device insights (łączenie sesji z różnych urządzeń), ale dane stają się bardziej kompletne i niezduplikowane. Alternatywa dla próbkowanych raportów: Looker Studio (ma własne połączenie z GA4, nie próbkuje danych w standardowych raportach). + +### GA4 retencja danych: domyślnie 2 miesiące — zmień na 14 miesięcy; BigQuery dla długich analiz + +- **W106_ga4_przechowywanie_danych_zmiana** (`W106`): GA4 domyślnie przechowuje dane użytkowników i zdarzeń przez zaledwie 2 miesiące — ustawienie to należy zmienić natychmiast po wdrożeniu. Jak zmienić: GA4 → Administracja → Ustawienia usługi → Przechowywanie danych → zmień na 14 miesięcy (maksimum dostępne w GA4 bez BigQuery). Zmiana dotyczy przyszłych danych — historyczne dane sprzed zmiany nie są odtwarzane. 14 miesięcy to absolutne maksimum GA4, co uniemożliwia analizy rok-do-roku (np. porównanie sezonu świątecznego Q4 z Q4 roku poprzedniego). Rozwiązanie dla długich analiz: podłącz BigQuery — eksport GA4 do BigQuery przechowuje dane bez limitu czasowego. Konfiguracja: GA4 → Administracja → Połączenia z produktem → BigQuery → Połącz. Eksport jest bezpłatny do określonego wolumenu (pierwsze 10 GB/mies. przechowywania w BigQuery za darmo, zapytania pierwsze 1 TB/mies. gratis). Praktyczna zasada: jeśli klient prowadzi działalność sezonową lub chce porównywać dane YoY — BigQuery nie jest opcją, jest koniecznością. + +### GA4 tryb zanonimizowany: małe firmy często nie osiągają progu modelowania + +- **W080_ga4_anonimizacja_prog_male_firmy** (`W080`): GA4 modeluje dane (uzupełnia brakujące konwersje użytkowników bez zgód) tylko wtedy, gdy zbiera wystarczającą liczbę zdarzeń — istnieje minimalny próg ruchu, który musi być osiągnięty. Dla małych i średnich firm (np. sklep z kilkoma tysiącami sesji miesięcznie) GA4 często nie gromadzi wystarczająco dużej próbki danych z użytkownikami G111 (pełna zgoda), żeby modelować dane dla G100 (odmowa zgody). Praktyczny skutek: zanonimizowanie użytkownika = całkowita utrata jego danych, nie uzupełnienie przez model. Małe firmy nie powinny traktować Consent Mode jako 'bezbolesnego' rozwiązania — przy niskim ruchu oznacza ono realną, nieodwracalną utratę danych analitycznych. Co robić: minimalizuj odsetek odmów przez dobrze zaprojektowany banner zgód (właściwy układ przycisków, jasny komunikat), rozważ Server-Side Tagging (SST) jako sposób na wydłużenie żywotności first-party cookies do ~540 dni i redukcję zależności od zgód przeglądarkowych. + +### GA4: 'sesja źródło/medium' (last click) vs 'źródło/medium' (model oparty o dane) + +- **W062_ga4_atrybucja_sesja_zrodlo_vs_zrodlo** (`W062`): W GA4 Eksploracjach istnieją dwa różne wymiary — ZAWSZE używaj właściwego: 'sesja źródło/medium': konwersje przypisane po LAST CLICK — niedoszacowuje kanały 'górne' (social, display, YouTube). 'źródło/medium' (z sekcji Atrybucja): model atrybucji OPARTY O DANE — uwzględnia wszystkie kanały na ścieżce konwersji, najbliższy rzeczywistości. Raporty ogólne GA4 (zakładka Pozyskiwanie, Generowanie przychodu): ZAWSZE last click — nie analizuj tu konwersji multi-channel. Do rzetelnej analizy konwersji: Eksploracje → wymiar 'źródło/medium' (nie 'sesja źródło/medium'). Zakładka Reklamy w GA4: porównanie różnych modeli atrybucji per kanał. + +### GA4: Eksploracje vs Biblioteka raportów — dwie osobne funkcjonalności + +- **W077_ga4_eksploracje_vs_biblioteka_raportow** (`W077`): W Google Analytics 4 istnieją dwie osobne funkcjonalności do tworzenia analiz: (1) Eksploracje (Explorations / Analizy niestandardowe): zaawansowane, elastyczne raporty z drag-and-drop, segmentami, lejkami, ścieżkami. Dostępne przez zakładkę 'Eksploracje' w GA4. NIE można ich dodać do 'Biblioteki raportów' — to osobny obszar. (2) Biblioteka raportów (Reports Library): standardowe raporty GA4 widoczne w głównym menu 'Raporty'. Można tu tworzyć własne raporty i dodawać je do nawigacji — ale są mniej elastyczne. Błąd użytkowników: próba 'zapisania' eksploracji jako standardowego raportu — taka funkcja nie istnieje. Eksploracje żyją tylko w sekcji Eksploracje i nie pojawiają się w standardowych raportach. Obejście: jeśli chcesz 'raport' z zaawansowaną analizą widoczny w menu — użyj Looker Studio połączonego z GA4 zamiast natywnych raportów GA4. + +### GA4: User ID do identyfikacji konkretnego użytkownika i jego źródła ruchu + +- **W069_ga4_user_id_identyfikacja_uzytkownika** (`W069`): Problem: klient pyta 'z jakiej reklamy przyszedł konkretny użytkownik/lead' — GA4 standardowo nie pozwala zidentyfikować konkretnej osoby po imieniu/mailu. Rozwiązanie przez User ID: (1) Skonfiguruj User ID w GA4 — przesyłaj identyfikator zalogowanego użytkownika (np. ID z CRM, hash maila) przez dataLayer lub GA4 config w GTM. (2) W GA4 Eksploracje → Eksploracja swobodna: dodaj wymiar 'Identyfikator użytkownika' (User-ID) do wierszy, dodaj wymiar 'Sesja źródło/medium' lub 'Pierwsza sesja źródło/medium', dodaj metrykę 'Sesje' lub 'Konwersje'. (3) Filtruj po konkretnym User ID jeśli znasz identyfikator danego użytkownika. Ograniczenia: User ID działa tylko dla zalogowanych użytkowników; do identyfikacji anonimowych leadów (np. z formularza) trzeba alternatywnie przekazywać ID leadu lub hash maila przez zdarzenie konwersji jako parametr niestandardowy i analizować go w eksploracjach. Uwaga RODO: nie przekazuj bezpośrednio danych osobowych (pełny mail, imię) do GA4 — używaj anonimowych identyfikatorów lub hashy. + +### GA4: czas do konwersji i szybkość witryny — niestandardowa implementacja + +- **W057_ga4_czas_do_konwersji_szybkosc** (`W057`): Czas do konwersji w GA4 (jak długo od wejścia do zakupu): brak gotowej funkcji — wymaga niestandardowej implementacji pieczątki czasowej. Mechanizm: JS wyciąga timestamp przy wejściu na stronę i przy zdarzeniu konwersji → różnica = czas do konwersji → wysyłany jako parametr niestandardowy. Implementacja przez GTM jako niestandardowy HTML. Zasoby: poradnik Simo Ahava (link pod webinarem W057) — step-by-step z kodem. Szybkość witryny w GA4 (odpowiednik raportu z UA): też brak gotowej funkcji. Implementacja: skrypt JS mierzący Web Vitals (czas odpowiedzi serwera, DNS, poszczególne metryki) wdrożony przez GTM → wysyłany do GA4 jako zdarzenie z parametrami niestandardowymi. Link do poradnika również pod W057. + +### GA4: jak sprawdzić słowa kluczowe z Google Ads powiązane z konwersjami (raport eksploracyjny) + +- **W064_ga4_slowa_kluczowe_konwersje_raport** (`W064`): Jak w GA4 znaleźć konwersje per słowo kluczowe Google Ads: Standardowe raporty GA4 (zakładka Pozyskiwanie) NIE pokazują słów kluczowych per konwersja. Rozwiązanie — Eksploracje GA4: (1) Idź do GA4 → Eksploracje → nowa eksploracja (dowolna forma). (2) Wymiary: 'Kampania' (z sekcji Atrybucja), 'Słowo kluczowe'. WAŻNE: wybierz wymiary z sekcji 'Atrybucja', nie z 'Sesja' — atrybucja używa modelu opartego o dane (data-driven), nie last-click. (3) Metryki: 'Konwersje', 'Przychód'. (4) Filtr: kampania = [Twoja kampania Google Ads]. Analogicznie: jak sprawdzić przychody z konkretnej kampanii → ta sama ścieżka, wymiar 'Kampania' z Atrybucji + metryka 'Przychód e-commerce' lub 'Konwersje'. Uwaga: słowa kluczowe widoczne w GA4 to te, które Google przekazuje przez autotag — frazy może być mniej niż w Google Ads (agregacja prywatności). + +### GA4: jedna usługa, wiele strumieni dla www + aplikacje iOS/Android + +- **W059_ga4_uslugi_strumienie** (`W059`): Dla strony www + aplikacji iOS + Android: twórz JEDNĄ usługę GA4 z wieloma strumieniami danych (nie osobne usługi). Powód: w jednej usłudze można śledzić przepływy użytkowników między platformami (np. kto oglądał na www, a kupił w appce). Filtrowanie danych per platforma: w sekcji Eksplorowanie użyj wymiaru 'id strumienia' lub 'nazwa strumienia' jako filtra — pozwala tworzyć dedykowane raporty np. tylko dla iOS lub tylko dla www. Osobne usługi GA4 uniemożliwiają śledzenie cross-platform. + +### GA4: kiedy osobna usługa, kiedy jedna — subdomeny, landing page, wiele domen + +- **W061_ga4_uslugi_subdomeny_kiedy_osobna** (`W061`): Kiedy jedna usługa GA4 (jeden kod śledzący): jeśli ruch przepływa między subdomeną a domeną główną (np. landing page → strona główna → konwersja). Powód: trzeba prześledzić całą ścieżkę użytkownika bez przerw w sesji. Filtrowanie danych per subdomena: w eksploracjach użyj parametru 'nazwa hosta'. Kiedy osobna usługa GA4: jeśli to całkowicie odrębne byty (ruch nie przepływa między nimi), potrzebujesz osobnych raportów lub osobnych dostępów użytkowników. Kilka domen głównych: każda ma własną usługę GA4 + opcjonalnie jedna 'zbiorcza' usługa do śledzenia cross-domain przepływu ruchu między wszystkimi domenami. Ważne: GA4 nie ma widoków (jak UA) — podział możliwy tylko przez eksploracje z filtrami. + +### GA4: konwersje niewidoczne — tożsamość raportowania jako główna przyczyna + +- **W065_ga4_konwersje_niewidoczne_tozsamosc_raportowania** (`W065`): Problem: konwersje w GA4 są zebrane (GTM debug widzi zdarzenia), ale w raportach i eksploracjach GA4 pokazują 0 lub są niewidoczne. Checklist diagnostyczny — sprawdź w tej kolejności: (1) Czy kontener GTM jest OPUBLIKOWANY? (debug mode ≠ produkcja). (2) Czy analizujesz właściwy zakres dat? (konwersje mogły być w innych datach). (3) TOŻSAMOŚĆ RAPORTOWANIA → to najczęstsza przyczyna. GA4 → Administracja → właściwość → Tożsamość raportowania. Domyślne ustawienie 'Modelowanie mieszane' aktywuje progi prywatności → pojedyncze konwersje są ukrywane przez system. FIX: zmień na 'Identyfikacja zależna od urządzenia' → konwersje stają się widoczne. (4) Cross-domain tracking: przy śledzeniu konwersji na zewnętrznej domenie upewnij się, że parametr _gl jest przekazywany między domenami. Dodatkowa uwaga: Pomiar zaawansowany GA4 (form submit) bywa niedokładny — lepiej stworzyć własny trigger w GTM dla formularzy. + +### GA4: odejmowanie zwrotów od przychodów — brak natywnej funkcji, obejście przez Looker Studio + +- **W076_ga4_zwroty_refund_obejscie** (`W076`): GA4 nie ma natywnej funkcji odejmowania zwrotów (refund) od raportowanych przychodów. Zdarzenie 'refund' w GA4 istnieje (można je wysyłać przez GTM/dataLayer), ale przychody w raportach GA4 nie są automatycznie korygowane o wartość zwrotów. Obejścia: (1) Looker Studio — najprościej: stwórz obliczone pole (Calculated Field): Przychód_netto = Sum(purchase_revenue) - Sum(refund_revenue). Wymaga przekazywania zdarzeń refund z wartością do GA4. (2) Obliczone dane w GA4 (Calculated Metrics) — zaawansowana funkcja GA4 pozwalająca tworzyć własne metryki na podstawie istniejących danych. Dostępna w ustawieniach usługi GA4 (Admin → Obliczone dane). (3) BigQuery — pełna kontrola: eksportuj surowe dane GA4 do BigQuery i obliczaj przychód netto na poziomie zapytania SQL. Praktyczna wskazówka: zanim wdrożysz zwroty w GA4, upewnij się że zdarzenie 'refund' jest poprawnie wysyłane z platformy sklepu — większość wtyczek Shopify/WooCommerce ma tę opcję. + +### GA4: odpowiedniki metryk z Universal Analytics (czas na stronie, procent wyjść) + +- **W063_ga4_odpowiedniki_metryk_ua** (`W063`): Odpowiedniki kluczowych metryk UA w GA4: 'Średni czas na stronie' (UA) → 'Średni czas trwania sesji' w GA4 (dostępne w Eksploracjach, nie w raportach standardowych). 'Procent wyjść' (Exit Rate w UA): NIE istnieje jako gotowa metryka w GA4. Alternatywy: (1) Współczynnik odrzuceń (Bounce Rate) — sesje bez zaangażowania / wszystkie sesje. (2) Looker Studio: oblicz ręcznie: wyjścia ze strony / sesje na stronie. 'Liczba wejść' (Entrances w UA): odpowiednik w GA4 to 'Liczba sesji' dla danej strony jako pierwszej w sesji — dostępne w Eksploracjach z wymiarem 'Strona docelowa'. + +### GA4: odwrotna ścieżka konwersji — eksploracja sekwencji od punktu końcowego + +- **W060_ga4_odwrotna_sciezka_konwersji** (`W060`): Jak zbadać kroki poprzedzające konwersję w GA4 (odwrotna ścieżka): Eksplorowanie → Eksploracja sekwencji ścieżki → w prawym górnym rogu wybierz 'Zacznij od punktu końcowego'. Ustaw punkt końcowy: zdarzenie konwersji (np. purchase, generate_lead, view_item). System pokazuje krok –1 (bezpośrednio poprzedzający) i pozwala rozwijać w lewo kolejne kroki: –2, –3 itd. Zmień wymiar z 'Nazwa zdarzenia' na 'Ścieżka strony i klasa ekranu' — zobaczysz, jakie podstrony użytkownicy odwiedzali przed konwersją. Zastosowanie: identyfikacja stron przygotowawczych (np. strony FAQ, kategorie, porównania produktów) poprzedzających zakup. + +### GA4: puste wiersze w raportach stron — diagnostyka błędu implementacji + +- **W063_ga4_puste_wiersze_raporty_stron** (`W063`): Puste wartości (puste wiersze lub '(not set)') w raportach stron GA4 — najczęstsza przyczyna: błąd implementacji śledzenia. Typowy winowajca: cookie banner/consent management tool wysyłający hity do GA4 przed załadowaniem pełnej informacji o stronie (np. page_view bez page_location/page_title). Jak diagnozować: GA4 DebugView lub GTM Preview — sprawdź, czy event page_view zawiera parametry page_location i page_title. Inne przyczyny: kod GTM umieszczony przed zamiast w , SPA (Single Page Application) z niezarejestrowanymi zmianami 'strony'. Fix: upewnij się, że consent mode nie wyzwala page_view zanim strona zdąży załadować swój URL i tytuł. + +### GA4: raport produktów + źródło atrybucji — łączenie przez ID transakcji w Looker Studio + +- **W083_ga4_raport_produktow_transaction_id_looker** (`W083`): GA4 nie pozwala łączyć nazwy kupionego produktu ze źródłem/medium atrybucji w jednym natywnym raporcie — są to dwa osobne wymiary w osobnych raportach. Obejście przez Looker Studio: (1) Zbuduj raport 1: produkty + ID transakcji (item_name + transaction_id) z danymi e-commerce GA4. (2) Zbuduj raport 2: ID transakcji + źródło/medium atrybucji (transaction_id + atrybucja źródło/medium lub sesja źródło/medium). (3) Połącz oba raporty w Looker Studio przez wspólny klucz = transaction_id. Efekt: widzisz, które produkty zostały kupione z jakiego kanału/kampanii. Alternatywa dla zaawansowanych: BigQuery — eksport surowych danych GA4 do BigQuery + SQL JOIN po transaction_id daje pełną elastyczność. Ważne: używaj wymiaru 'atrybucja źródło/medium' (data-driven) zamiast 'sesja źródło/medium' (last click) dla bardziej rzetelnej analizy wielokanałowej ścieżki do zakupu. Ta sama technika (łączenie raportów przez transaction_id) działa dla analizy newsletterów, kampanii sezonowych i każdego innego przypadku, gdzie chcesz połączyć 'co kupiono' z 'skąd przyszedł klient'. + +### GA4: strona docelowa vs ścieżka do strony — różnica i kiedy używać + +- **W069_ga4_strona_docelowa_vs_sciezka_do_strony** (`W069`): W GA4 istnieją dwa kluczowe wymiary dotyczące stron, które są często mylone: Strona docelowa (Landing Page) — PIERWSZA strona, którą użytkownik odwiedza w ramach danej sesji (landing page). Dotyczy tylko początku sesji. Ścieżka do strony (Page Path) — każda strona odwiedzona w ramach sesji, niezależnie od kolejności. Kiedy używać 'strony docelowej + ciąg zapytania': — analiza które artykuły/podstrony otwierają ścieżkę do konwersji (entry points), — analiza skuteczności ruchu płatnego i organicznego (co widzi użytkownik jako pierwsze), — analiza artykułów blogowych, które przyciągają ruch i generują konwersje w tej samej sesji. Kiedy używać 'ścieżki do strony + ciąg zapytania': — analiza które strony są w ogóle odwiedzane (niezależnie od kolejności), — analiza wszystkich stron wpływających na ścieżkę konwersji (asystowane). Praktyczna zasada: do analizy, czy artykuł blogowy 'generuje' konwersje, użyj wymiaru strona docelowa — sprawdzasz, czy użytkownik zaczął sesję od tego artykułu. + +### GA4: tylko 2 modele atrybucji (stare modele usunięte) + pseudo first click + +- **W074_ga4_modele_atrybucji_tylko_dwa** (`W074`): GA4 ma obecnie wyłącznie dwa modele atrybucji: last click i model oparty o dane (data-driven). Stare modele znane z Universal Analytics (liniowy, pozycyjny, rozkład czasowy) zostały usunięte. W eksploracjach GA4 działają trzy różne wymiary dające różne perspektywy: (1) 'źródło/medium' (sesja) = atrybucja last click — niedoszacowuje kanały górne lejka (social, display, YouTube). (2) 'atrybucja źródło/medium' = model oparty o dane (data-driven) — uwzględnia całą ścieżkę konwersji. (3) 'pierwsze źródło/medium użytkownika' = pseudo first click — przypisuje konwersję do pierwszego kontaktu użytkownika. Do rzetelnej analizy ścieżek konwersji używaj wymiaru 'atrybucja źródło/medium' (DDA), nie 'sesja źródło/medium' (last click). Raporty standardowe GA4 (Pozyskiwanie, Generowanie przychodu) używają last click — nie analizuj tu efektywności kanałów multi-touch. + +### GA4: współczynnik konwersji — błędy w raportach ogólnych i eksploracje + +- **W061_ga4_wspolczynnik_konwersji_problemy** (`W061`): Współczynnik konwersji w GA4 — trzy poziomy i ich problemy: (1) Raporty ogólne GA4: dane oparte o last click (nie model atrybucji oparty o dane) — zły model do analizy biznesowej; unikaj wyciągania wniosków z tych raportów. (2) Eksploracje GA4: współczynnik konwersji sumuje WSZYSTKIE konwersje — nie można odfiltrować po konkretnej konwersji (np. tylko zakupy); przy wielu zdarzeniach konwersji daje zawyżony, bezużyteczny wynik (np. 100%). (3) Looker Studio: jedyne miejsce, gdzie można poprawnie wyliczyć CR per konkretna konwersja — dziel wybraną konwersję przez sesje lub użytkowników. Wniosek: do analizy współczynnika konwersji używaj Looker Studio, nie natywnych raportów GA4. + +### GA4: wyszukiwarka wewnętrzna — automatyczne śledzenie i analiza luk w ofercie + +- **KDGA03_01_wyszukiwarka_wewnetrzna_ga4** (`KDGA03`): GA4 automatycznie śledzi wyszukiwania na stronie jeśli URL wynikowy zawiera jeden ze standardowych parametrów zapytania: q, s, search, query, keyword. Jeśli Twoja strona używa innego parametru (np. 'p', 'szukaj', 'phrase'): zrób testowe wyszukiwanie i sprawdź URL wynikowy — parametr widoczny jest przed wpisaną frazą (np. /?szukaj=fraza → parametrem jest 'szukaj'). Dodaj go w GA4 → Strumień danych → Pomiar zaawansowany → Wyszukiwanie w witrynie → wpisz po przecinku obok domyślnych parametrów. Dane o wyszukiwaniach dostępne są przez zdarzenie view_search_results z parametrem search_term (termin wyszukiwania). Jak zbudować raport: Eksploracje → pusty raport → wymiar 'search_term' + metryka 'Liczba zdarzeń' → filtr: zdarzenie = view_search_results. Analityczna wartość wyszukiwarki wewnętrznej: (1) Luki w asortymencie — produkty, których klienci szukają i których nie ma w ofercie → bezpośrednia informacja co warto dodać do sklepu. (2) Problemy z nawigacją — jeśli ten sam produkt jest wyszukiwany wielokrotnie, użytkownicy nie mogą go znaleźć organicznie → optymalizuj kategoryzację i wyszukiwarkę. (3) Słowa kluczowe do kampanii Search i feedu produktowego — frazy wpisywane w wyszukiwarkę wewnętrzną to udowodniony popyt na Twojej stronie, często bardziej precyzyjny niż Planer słów kluczowych. + +### GA4: ścieżki wielokanałowe i atrybucja — gdzie szukać w nowym interfejsie + +- **W063_ga4_sciezki_wielokanałowe** (`W063`): Ścieżki wielokanałowe (multi-touch attribution) w GA4: Zakładka: Reklama → Atrybucja → Ścieżki konwersji. Pokazuje wszystkie kanały, które były na ścieżce konwersji i ich udział w różnych modelach atrybucji. Raport sekwencji ścieżki (odwrotna analiza): Eksplorowanie → Eksploracja sekwencji ścieżki → zacznij od punktu końcowego (zdarzenia konwersji) → rozwijaj kroki w lewo (–1, –2, –3). Jeśli chcesz uwzględnić kanały (źródło/medium) w sekwencji: zmień wymiar ścieżki z 'Nazwa zdarzenia' na 'Źródło/medium sesji'. + +### Kopiowanie eksploracji GA4 między kontami — ograniczenie i obejście + +- **W067_ga4_kopiowanie_eksploracji** (`W067`): GA4 nie pozwala kopiować eksploracji (analiz niestandardowych) między różnymi kontami Analytics — funkcja jest dostępna tylko w obrębie tego samego konta. Obejście przy raportowaniu wieloprojektowym: użyj Looker Studio (dawny Google Data Studio) z funkcją wymiennego źródła danych (data source selector). Tworzysz jeden szablon raportu w Looker Studio, a następnie przełączasz źródło danych na inne konto GA4 bez przebudowywania raportu. Daje to efekt 'kopii eksploracji' działającej dla wielu klientów z jednego szablonu. + +### Metryki obliczeniowe w GA4: ograniczenia i przenoszenie analiz do Looker Studio + +- **W092_ga4_metryki_obliczeniowe_looker** (`W092`): Metryki obliczeniowe w GA4 (Konfiguracja → Metryki obliczeniowe) mają istotne ograniczenie: NIE można w nich łączyć wymiarów z metrykami w jednej formule. Przykład: obliczenie procentowego udziału kategorii w przychodzie (kategoria A / łączny przychód × 100%) jest niemożliwe bezpośrednio w GA4, bo wymaga zestawienia wymiaru (kategoria) z metryką (przychód). Rozwiązanie: przenieś taką analizę do Looker Studio — połącz GA4 jako źródło danych i twórz niestandardowe pola obliczeniowe bezpośrednio w raporcie. Looker Studio daje pełną elastyczność w tworzeniu własnych metryk łączących wymiary i dane. Alternatywa dla zaawansowanych: BigQuery + eksport surowych danych GA4, gdzie można pisać dowolne zapytania SQL. Metryki obliczeniowe w GA4 sprawdzają się natomiast do prostych przeliczników: np. wartość na sesję (przychód / liczba sesji). + +### Migracja danych z GA3 (Universal Analytics) do GA4 — co jest niemożliwe + +- **W067_ga4_migracja_z_ga3** (`W067`): Danych historycznych z Google Analytics 3 (Universal Analytics) nie można przenieść do GA4 — dane z UA i GA4 mają fundamentalnie różne modele danych (sesje vs zdarzenia) i nie są kompatybilne. Narzędzie migracji GA4 (jeśli było dostępne) nie przenosi danych analitycznych, tylko konfigurację. Rekomendacja: zamiast próbować migrować historyczne dane, załóż nowe konto GA4 od zera i zacznij zbierać świeże dane. Dane historyczne z UA można zachować do przeglądania bezpośrednio w interfejsie UA (dostęp był dostępny do końca 2024 roku) lub wyeksportować do BigQuery/arkuszy dla archiwum. + +### Model atrybucji Data-Driven: różnica między Google Ads (tylko kampanie Ads) a Analytics (wszystkie kanały) + +- **W109_atrybucja_dda_ads_vs_analytics** (`W109`): Model atrybucji Data-Driven (DDA) działa inaczej w Google Ads i Google Analytics — to fundamentalna różnica często pomijana przy analizie wyników: W GOOGLE ADS: DDA przypisuje konwersje i wartość TYLKO między kampaniami Google Ads. Jeśli ścieżka konwersji zawierała Google Ads + email + SEO, Google Ads DDA redistrybuuje wartość wyłącznie między zaangażowane kampanie Ads — email i SEO są niewidoczne w tym modelu. W GOOGLE ANALYTICS (GA4): DDA uwzględnia WSZYSTKIE kanały w ścieżce konwersji (organic search, direct, email, paid, social) i redistrybuuje wartość między nimi proporcjonalnie do wkładu każdego kanału. Praktyczna konsekwencja: ten sam zakup może być w 100% przypisany do Google Ads w panelu Ads i w 40% do Ads + 30% do email + 30% do organic w GA4. Żaden z tych widoków nie jest 'prawdziwszy' — patrzą na ten sam fakt przez inną soczewkę. Do optymalizacji stawek kampanii używaj danych z panelu Ads; do oceny ogólnej efektywności kanałów używaj GA4. W małych/średnich projektach GA4 wystarczy do analizy wielokanałowej; BigQuery (eksport GA4) potrzebny dopiero przy bardzo dużej skali wymagającej niestandardowych modeli atrybucji. + +### Raport czasu rzeczywistego GA4: ograniczenia i czas przetwarzania danych + +- **W075_ga4_czas_rzeczywisty_ograniczenia** (`W075`): Raport czasu rzeczywistego w GA4 (Real Time) NIE nadaje się do analizy źródeł konwersji ani do oceny skuteczności kampanii po wysyłce newslettera lub uruchomieniu akcji. Ograniczenia raportu czasu rzeczywistego: (1) Pokazuje aktywnych użytkowników z ostatnich ~30 minut — brak historii. (2) Dane konwersji i atrybucji wymagają 24–72h na pełne przetworzenie przez GA4. (3) Źródła ruchu w czasie rzeczywistym mogą być niedokładne lub niekompletne. Po wysyłce newslettera: poczekaj minimum 48–72h, a następnie sprawdź dane w eksploracjach GA4 z wymiarem 'sesja źródło/medium' (filtrując po 'email'). Raport czasu rzeczywistego przydaje się tylko do: weryfikacji czy tag GA4 działa, sprawdzenia czy konkretna konwersja się triggeruje, ogólnego monitoringu ruchu 'na żywo'. Nie podejmuj decyzji optymalizacyjnych na podstawie danych real-time. + +### Rozbieżność kliknięć Google Ads vs sesji GA4: Gmail, spam placements, blokowanie cookies + +- **W072_ga4_rozbieznosc_klikniec_vs_sesji_przyczyny** (`W072`): Problem: Google Ads raportuje np. 500 kliknięć, ale GA4 pokazuje tylko 300 sesji z Google Ads. Duża rozbieżność (powyżej 15–20%) wymaga diagnostyki. Najczęstsze przyczyny: (1) Wyświetlenia w Gmailu — kampanie Demand Gen i starsze Discovery wyświetlają reklamy w skrzynce Gmail. 'Kliknięcie' = OTWARCIE wiadomości w Gmailu, nie wejście na stronę. Dopiero kliknięcie linku w otwartej wiadomości to faktyczna sesja na stronie. Sprawdź raport Miejsc docelowych (Placements) — jeśli mail.google.com ma dużo kliknięć, to źródło rozbieżności. (2) Spamowe placements w GDN — aplikacje mobilne i podejrzane witryny generują boty klikające w reklamy bez wchodzenia na stronę. (3) Blokowanie ciasteczek przez pop-up zgody (CMP) — pop-up blokuje wczytanie GA4 zanim użytkownik wyrazi zgodę; sesja się nie rejestruje. (4) Szybkie zamknięcie strony — użytkownik klika w reklamę i zamyka przed załadowaniem GA4. Diagnostyka: Raporty Ads → Placements + porównanie kliknięć per segment urządzenia/sieci. + +### Rozjazd leadów GA4 vs CRM (40–45%): diagnostyka i metody naprawy + +- **W073_ga4_rozjazd_leadow_crm_vs_ga4_diagnostyka** (`W073`): Problem: CRM rejestruje X leadów, ale GA4 pokazuje tylko 55–60% tej liczby. Rozbieżność 40–45% między CRM a GA4 jest poważna i wymaga diagnostyki. Najczęstsze przyczyny i rozwiązania: (1) Ustawienie tożsamości raportowania w GA4 — sprawdź i zmień z 'Mieszanego' na 'Zależne od urządzenia' (Admin → Usługa → Tożsamość raportowania). Tryb mieszany może łączyć sesje cross-device i zaburzać liczenie konwersji. (2) Consent mode blokuje tracking — dla użytkowników bez zgody GA4 nie rejestruje zdarzeń (nawet w trybie zanonimizowanym bez modelowania gdy poniżej progu 1000 użytkowników). (3) Brak unikalnego ID w zdarzeniu konwersji — dodaj do zdarzenia 'generate_lead' parametr z unikalnym ID formularza/transakcji. Porównaj listę ID z CRM vs GA4 — zobaczysz dokładnie, które leady gubi system. (4) Pop-up CMP blokuje wczytanie GA4 przed wypełnieniem formularza — sprawdź kolejność ładowania skryptów na stronie. (5) Formularze w iframe — GTM/GA4 z głównej strony nie widzi zdarzeń w iframe (patrz: oddzielne śledzenie w iframe). + +### Ruch Direct/None w GA4: przyczyny i jak go minimalizować + +- **W079_ga4_direct_none_minimalizowanie** (`W079`): Ruch oznaczony jako 'direct / none' w GA4 to sesje, dla których GA4 nie potrafi zidentyfikować źródła ruchu. Główne przyczyny nadmiernego direct/none: (1) Brak UTM-ów w linkach — emaile, SMS-y, posty social media bez parametrów UTM trafiają jako direct. Rozwiązanie: dodawaj UTM do WSZYSTKICH linków poza organic/paid search. (2) Przekierowania wieloetapowe — każde przekierowanie HTTP może skasować parametr referrer. Sprawdź, czy skracacze URL lub redirect-y nie gubią UTM-ów. (3) Bramki płatności — powrót ze strony płatności (np. Przelewy24) może być liczony jako direct. Rozwiązanie: dodaj domeny bramek do wykluczeń ruchu referral w GA4 (Admin → Zbieranie danych → Wykluczenia ruchu referral). (4) HTTPS → HTTP — przejście z HTTPS na HTTP kasuje referrer. (5) Linki z aplikacji mobilnych (in-app browser). Cel: direct/none poniżej 10–15% całego ruchu. Powyżej 20%: problem z konfiguracją UTM lub bramkami płatności. + +### Różnice w liczbie konwersji między Google Ads a GA4: to norma, nie błąd + +- **W071_ga4_roznice_konwersji_ads_vs_analytics_modele_atrybucji** (`W071`): Rozbieżności między liczbą konwersji raportowaną w Google Ads a Google Analytics 4 są normalne i wynikają z fundamentalnych różnic w obu systemach: (1) Modele atrybucji — Google Ads domyślnie używa atrybucji opartej na danych (data-driven) lub last-click licząc konwersje do reklam; GA4 ma własny model atrybucji (DDA lub last-click), który może inaczej przypisywać konwersje do kanałów. (2) Okno atrybucji — Google Ads może mieć dłuższe okno (30/60/90 dni dla konwersji), GA4 domyślnie używa 30-dniowego okna dla konwersji. (3) Sposób wysyłki danych — konwersje importowane z GA4 do Ads mogą mieć opóźnienie. (4) Konwersje telefoniczne i Store Visits — widoczne w Ads, niekoniecznie w GA4. (5) Cross-device — Google Ads częściej łączy ścieżki między urządzeniami (zalogowani użytkownicy), GA4 bazuje na ciasteczkach i może traktować to jako osobne sesje. Praktyczna zasada: nie próbuj doprowadzić do idealnej zgodności — sprawdzaj trendy i proporcje, nie absolutne liczby. Wybierz jedno źródło prawdy do optymalizacji stawek (zalecane: dane Ads). + +### Universal Analytics (GA3): można usuwać kody ze stron; backup danych do ~lipca 2024 + +- **W077_ga4_ga3_backup_usuwanie_kodow** (`W077`): Kody Google Analytics 3 (Universal Analytics / GA3) można już usuwać ze stron internetowych — GA3 przestało zbierać dane i nic już nie rejestruje. Usunięcie kodu GA3 ze strony nie wpływa na żadne działające systemy. Konta GA3 w panelu Google Analytics pozostały dostępne do ~lipca 2024 wyłącznie w celu przeglądania i exportu danych historycznych. Backup danych historycznych z GA3: Natywny eksport: raporty GA3 → eksport do CSV/PDF (ograniczony zakres danych). Przez 'przejściówkę' do BigQuery: GA3 nie ma natywnego połączenia z BigQuery (to jest funkcja GA4), ale dane można wyeksportować pośrednio. Rekomendacja: wyeksportuj kluczowe raporty historyczne do Google Sheets lub Excel przed utratą dostępu do konta. Po lipcu 2024: konta GA3 przestają być dostępne — dane przepadają bezpowrotnie. + +### Universal Analytics (UA/GA3): koniec zbierania danych i usunięcie — zrób backup + +- **W059_ua_koniec_backup** (`W059`): Universal Analytics (GA3) kończy zbieranie danych 1 lipca 2023. 6 miesięcy po tej dacie Google usuwa wszystkie historyczne dane z UA — do końca 2023 roku dane znikną bezpowrotnie. Działanie: przed końcem 2023 zbackupuj dane historyczne z UA (eksport do Looker Studio, BigQuery lub ręcznie do arkuszy). Brak informacji o jakichkolwiek przesunięciach tych terminów przez Google. Jeśli dane historyczne nie są potrzebne — po prostu przepadną z GA3. + +### Wizualizacja lejka konwersji e-commerce w Looker Studio: Funnel Chart + +- **W074_ga4_looker_studio_funnel** (`W074`): Wizualizację lejka konwersji e-commerce (np. sesja → koszyk → zakup) najprościej zrobić w Looker Studio za pomocą komponentu 'Funnel Chart' z wizualizacji społeczności (community visualizations). Wizualizacje społeczności to darmowe/zewnętrzne komponenty tworzone przez społeczność Google — dostępne bezpośrednio w Looker Studio przez 'Dodaj komponent → Wizualizacje społeczności'. Alternatywny komponent: 'Metric Funnel'. Natywne raporty lejka w GA4 są dostępne w zakładce 'Eksploracje' (Analiza lejka), ale eksport do Looker Studio wymaga własnej wizualizacji — stąd komponenty społeczności. Przed użyciem komponentów społeczności wymagana jest akceptacja uprawnień dostępu do danych. + +### Współczynnik konwersji sesji w GA4: definicja i błędne interpretacje + +- **W077_ga4_wspolczynnik_konwersji_sesji_definicja** (`W077`): Współczynnik konwersji sesji (Session Conversion Rate) w GA4 ma specyficzną definicję różniącą się od intuicyjnej formuły: GA4: Sesje z co najmniej jedną konwersją / Wszystkie sesje × 100%. NIE jest to: Liczba konwersji / Liczba sesji × 100%. Różnica kluczowa: jeśli jedna sesja wygeneruje 3 konwersje (np. 3 zakupy), GA4 liczy ją jako jedną sesję z konwersją — nie jako 3 konwersje do sesji. To sprawia, że CR sesji jest zawsze niższy lub równy 'intuicyjnemu' CR. Dodatkowe utrudnienie w GA4: metryka 'Współczynnik konwersji sesji' sumuje WSZYSTKIE zdarzenia konwersji (mikro + makro razem) — przy wielu konwersjach równocześnie wynik może być zawyżony i nieczytelny. Do poprawnej analizy CR: użyj Looker Studio z ręcznym obliczeniem per konkretna konwersja (zakupy / sesje z organiki, etc.). + +### Wykluczanie bramek płatności w GA4: filtr działa od momentu wdrożenia, nie wstecz + +- **W072_ga4_wykluczanie_bramek_platnosci_filtr_od_teraz** (`W072`): Problem: strony bramek płatności (np. przelewy24.pl, payu.pl, stripe.com) pojawiają się w raportach GA4 jako referral — zakłócają atrybucję, bo sesja po powrocie ze strony płatności jest przypisywana do bramki, nie do reklamy. Rozwiązanie: dodaj domeny bramek płatności do listy 'Wykluczenia ruchu referral' w GA4 (Admin → Usługa → Zbieranie danych i modyfikacja → Wykluczenia ruchu referral). WAŻNE OGRANICZENIE: zmiana działa TYLKO od momentu wdrożenia do przodu (prospektywnie). Historyczne dane w GA4 NIE zostaną poprawione — sesje sprzed wdrożenia nadal będą pokazywać bramkę jako źródło. Dodatkowe domeny do wykluczenia: wszystkie bramki płatności używane w sklepie, własne subdomeny (np. checkout.sklep.pl jeśli checkout jest na subdomenie). Własne subdomeny dodaj do listy 'Domeny' w GA4 (nie do wykluczeń referral) — inaczej każde przejście między domeną główną a subdomeną tworzy nową sesję. + +## Temat: gtm-tracking + +### Autotagowanie vs ręczne UTM: GA4 daje priorytet autotagowaniu — jak to obejść + +- **W071_gtm_tracking_autotagowanie_vs_utm_priorytet_ga4** (`W071`): Gdy jednocześnie w URL jest GCLID (z autotagowania Google Ads) i ręczne parametry UTM, GA4 domyślnie daje PRIORYTET autotagowaniu (GCLID) i ignoruje UTM. Skutek: nie możesz 'nadpisać' atrybucji kampanii ręcznymi UTM-ami w GA4 tak długo, jak autotagowanie jest włączone. Wyjątek — iOS 17 i Safari prywatny: GCLID jest usuwany przez iOS Link Tracking Protection, więc UTM-y w szablonie śledzenia stają się jedyną informacją o źródle. Obejście dla scenariuszy wymagających priorytetowych UTM: Wyłącz autotagowanie w Google Ads (Ustawienia konta → Autotagowanie = wyłączone) i stosuj WYŁĄCZNIE ręczne UTM-y w szablonach śledzenia kampanii. Uwaga: wyłączenie autotagowania powoduje, że dane importowane z GA4 do Google Ads mogą nie działać poprawnie (brak GCLID uniemożliwia precyzyjne łączenie sesji z kliknięciem). Zalecane tylko gdy masz konkretny powód — standardowo zostawiaj autotagowanie włączone i używaj UTM jako backup (np. w szablonie śledzenia: {lpurl}?utm_source=google&...). + +### Blokada third-party cookies w Chrome: NIE wpływa na Google Ads, GA4 ani Facebook + +- **W077_gtm_third_party_cookies_nie_dotycza_ads** (`W077`): Blokada third-party cookies (ciasteczek stron trzecich) w Chrome NIE wpływa na: Google Ads (remarketing, śledzenie konwersji), Google Analytics 4, Facebook Ads. Powód: wszystkie te systemy korzystają z first-party cookies (ciasteczek pierwszej strony), nie z third-party cookies. First-party cookies są ustawiane przez domenę, którą odwiedza użytkownik — przeglądarka je akceptuje i nie blokuje. Third-party cookies to ciasteczka ustawiane przez domeny inne niż odwiedzana (np. przez skrypty reklamowe z zewnętrznych domen) — te Chrome blokuje od 2024. Co faktycznie jest zagrożone przez blokadę 3rd party cookies: starsze piksele reklamowe nie zaktualizowane do first-party (rzadkie), cross-site tracking bez server-side taggingu, niektóre narzędzia analityczne oparte na third-party skryptach. Server-side tagging (GTM server-side): przedłuża żywotność first-party cookies i uniezależnia śledzenie od przeglądarek — warto rozważyć przy rosnących ograniczeniach. + +### Blokowanie ruchu wewnętrznego w GA4 przy zmiennym IP: wtyczka Google Analytics Opt-Out + +- **W106_gtm_ga4_opt_out_wewnetrzny_ruch** (`W106`): GA4 umożliwia wykluczanie wewnętrznego ruchu (pracownicy, agencja) przez filtr IP w ustawieniach (GA4 → Administracja → Strumienie danych → Definiuj ruch wewnętrzny). Problem: przy dynamicznym/zmiennym IP (np. praca zdalna, różne sieci) filtr IP nie działa skutecznie — adres zmienia się przy każdej sesji. Rozwiązanie przy zmiennym IP: wtyczka do przeglądarki 'Google Analytics Opt Out' (oficjalna wtyczka Google, dostępna dla Chrome/Firefox/Safari) — po zainstalowaniu przeglądarka przestaje wysyłać dane do GA4 niezależnie od adresu IP. Każda osoba z zespołu powinna zainstalować wtyczkę na swojej przeglądarce roboczej. Alternatywne rozwiązanie: parametr URL `?notrack=1` lub podobny + reguła w GTM wyłączająca tagi dla sesji z tym parametrem — wymaga konfiguracji technicznej w GTM. Dlaczego to ważne: ruch wewnętrzny zaburza dane konwersji (np. testy zakupu), wskaźniki zaangażowania i dane remarketingowe. + +### Calendly + GA4: integracja bez GTM, automatyczne eventy konwersji + +- **W059_calendly_ga4_integracja** (`W059`): Calendly ma wbudowaną integrację z GA4 — nie trzeba GTM ani własnego kodu. Jak wdrożyć: w Calendly połącz z GA4 podając numer usługi GA4 (można połączyć z tą samą GA4 co strona). Po połączeniu Calendly automatycznie wysyła zdarzenia do GA4 — wystarczy je oznaczyć jako konwersje w GA4. Brak bezpośredniej integracji Calendly z GTM. Jeśli masz przycisk na stronie z linkiem do Calendly (z UTM): ruch będzie normalnie widoczny w GA4 jako ruch z danego UTM, bo Calendly działa jak podstrona serwisu po integracji z GA4. + +### Cel konwersji przy tworzeniu: kategoryzacja, nie optymalizacja + +- **W068_gtm_tracking_cel_konwersji_kategoryzacja** (`W068`): Pole 'Cel konwersji' wybierane podczas tworzenia nowej konwersji w Google Ads (np. 'Zakup', 'Kontakt', 'Rejestracja') służy wyłącznie do kategoryzacji — porządkowania konwersji w folderach w interfejsie. Cel konwersji NIE determinuje bezpośrednio, pod jaką konwersję optymalizuje kampania. Optymalizacja kampanii zależy od tego, które konwersje są zaznaczone jako 'podstawowe' w ustawieniach kampanii lub na poziomie konta (cel kampanii → konwersje). Praktyczna implikacja: można nazwać konwersję 'Zakup', przypisać cel 'Zakup', ale w kampanii optymalizować pod inną konwersję — ustawiony cel konwersji nie blokuje tej elastyczności. Kategorie wykorzystuje się do filtrowania i raportowania, nie do sterowania algorytmem. + +### Consent Management (cookie banery): koszty i dostępne rozwiązania + +- **W063_consent_management_koszty** (`W063`): Cookie Bot (Cookiebot): darmowy dla stron do 50 podstron. Dla sklepów internetowych (więcej podstron): ok. 220 zł/miesiąc. Pisanie własnego rozwiązania consent management jest droższe niż gotowe narzędzie — wymaga programisty i utrzymania. Inne popularne narzędzia: OneTrust (enterprise), UserCentrics. Pamiętaj: sam baner to nie wszystko — musi być zintegrowany z GTM, żeby faktycznie blokować/włączać kody analityczne i reklamowe w zależności od zgody. Bez tej integracji baner jest tylko informacyjny i nie wpływa na zbieranie danych. + +### Consent Mode i Consent Management: spadek ruchu po wdrożeniu + +- **W058_consent_mode_spadek_ruchu** (`W058`): Wdrożenie Consent Management Platform (CMP, np. CookieBot) z opcją odmowy zgody powoduje spadek mierzonego ruchu o 10-20 punktów procentowych na rynku polskim. Część użytkowników klika 'odmów' → ich dane nie są zbierane przez GA4 ani piksele reklamowe. Jeśli banner nie daje jasnej opcji odmowy (tylko 'akceptuj' lub ukryta odmowa) — spadek jest znikomy, bo większość klika akceptację. Aspekt prawny: brak opcji odmowy jest dyskusyjny prawnie — zasięgnij opinii prawnika. Rozwiązania bez jasnego 'odmów' mogą rodzić ryzyko prawne. Consent Mode v2 (tryb podstawowy): Google modeluje dane użytkowników, którzy odmówili — częściowo rekompensuje utratę danych w kampaniach i raportach Google Ads. Wartość dla porównań: jeśli wdrożono CMP, odnotuj datę wdrożenia przy analizie trendów ruchu w GA4 — naturalne 'tąpnięcie'. + +### Consent Mode v2: nowe parametry i weryfikacja wdrożenia przez Analytics Debugger + +- **W076_gtm_consent_mode_v2_parametry** (`W076`): Consent Mode v2 (względem v1) dodaje dwa nowe parametry zgody: 'ad_user_data' — zgoda na wysyłanie danych użytkownika do Google dla celów reklamowych; 'ad_personalization' — zgoda na personalizację reklam (remarketing). W v1 istniały tylko: ad_storage i analytics_storage. Większość platform CMP (np. Cookiebot, CookieYes, Usercentrics) zaktualizowała lub zaktualizuje automatycznie swoje szablony GTM do obsługi v2 — sprawdź wersję szablonu w GTM. Weryfikacja poprawności wdrożenia Consent Mode v2: Użyj wtyczki Analytics Debugger (żółta kaczka z irokezem) do przeglądarki Chrome. W zdarzeniach GA4 sprawdzaj statusy consent: G100 = użytkownik zanonimizowany (odmówił zgody, dane modelowane), G111 = pełne zgody (użytkownik wyraził wszystkie zgody). Weryfikacja przez BigQuery: sprawdź rozkład statusów zgód w danych surowych GA4 — pozwala ocenić jaki procent użytkowników wyraża pełną zgodę. + +### Consent Mode vs klasyczny baner cookie: co faktycznie robi każde rozwiązanie + +- **W060_consent_mode_vs_baner_cookie** (`W060`): Klasyczny baner cookie (informacyjny): NICZEGO nie zmienia w zbieraniu danych — kliknięcie 'odmów' nie wyłącza żadnych kodów analitycznych ani reklamowych. Consent Mode (właściwy): baner MUSI być sprzężony z kodami w GTM. Jak działa: zgoda → kody uruchamiają się normalnie; brak zgody → kody uruchamiają się w trybie zanonimizowanym (niepełne dane). Przy 20% odmów: mamy 80% pełnych danych + 20% szczątkowych (zanonimizowanych). Na podstawie pełnych danych Google modeluje brakujące 20% — częściowo rekompensuje utratę w kampaniach i raportach. Consent Mode to KOMPROMIS, nie zaleta analityczna — bez niego mielibyśmy dokładniejsze dane. Wymagane do tego: narzędzie CMP (CookieBot, OneTrust) + integracja z GTM + konfiguracja consent mode w GTM dla każdego kodu. Sam baner bez GTM = tylko wizualny element, bez wpływu na zbieranie danych. + +### Consent Mode: GA4 musi ładować się w G100 przy odmowie zgód — brak ładowania = błąd + +- **W080_consent_mode_ga4_g100_odmowa_blad** (`W080`): Poprawne działanie Consent Mode wymaga, żeby GA4 uruchamiał się w trybie G100 (zanonimizowanym) już w momencie wyświetlenia pop-upu zgód — jeszcze przed interakcją użytkownika. Tryb G100 = użytkownik nie wyraził zgód, dane są anonimizowane i modelowane przez Google. Tryb G111 = pełna zgoda, dane zbierane normalnie. Błąd wdrożenia: GA4 nie odpala się w ogóle do momentu, aż użytkownik kliknie 'Akceptuj' lub 'Odrzuć' w pop-upie. Skutek: użytkownicy, którzy opuszczają stronę bez interakcji z banerem (czyli często większość ruchu z mobile), nie są w ogóle rejestrowanie — ich wizyty przepadają całkowicie. Poprawna konfiguracja: tag GA4 odpalany na zdarzeniu 'Consent Initialization' w GTM (pierwsze zdarzenie w kolejce), jeszcze przed załadowaniem CMP. Weryfikacja: użyj wtyczki Analytics Debugger — po wejściu na stronę i odmowie zgód powinieneś widzieć zdarzenia GA4 z oznaczeniem G100, nie ich brak. Po wdrożeniu Consent Mode poczekaj 48h i sprawdź zakładkę Strumienie danych w GA4 oraz Konwersje → Diagnostyka w Google Ads. + +### Consent Mode: obowiązek wdrożenia, konsekwencje braku — blokada konta i remarketing + +- **W065_consent_mode_obowiazek_konsekwencje** (`W065`): Consent Mode (zarządzanie zgodami użytkowników) jest OBOWIĄZKOWY dla kont Google Ads reklamujących się w UE (od maja 2023). Google coraz częściej wysyła ostrzeżenia i BLOKUJE konta bez wdrożonego Consent Mode. Dwa ostrzeżenia pojawiające się na kontach: 'Brak Consent Mode' + 'Zweryfikuj swoje konto' — oba mogą skutkować blokadą. Skutki braku Consent Mode: (1) Ryzyko całkowitej blokady konta reklamowego. (2) Brak możliwości remarketingu do użytkowników, którzy odmówili zgody na śledzenie. Mitygacja: dobrze zaprojektowane pop-upy Consent Management sprawią, że większość użytkowników WYRAZI ZGODĘ — wtedy remarketing działa normalnie. Ważne rozróżnienie: baner cookie 'informacyjny' (stary typ) nie aktywuje Consent Mode. Potrzebny jest prawdziwy CMP (Consent Management Platform) z opcją akceptuj/odrzuć i integracją z GTM (Consent Mode v2). + +### Consent Mode: próg 1000 użytkowników do modelowania — poniżej dane przepadają + +- **W072_gtm_tracking_consent_mode_prog_1000_uzytkownikow** (`W072`): Google Consent Mode v2 stosuje modelowanie behawioralne dla użytkowników, którzy odmówili zgody na ciasteczka reklamowe (ad_storage = denied). Kluczowe ograniczenie — próg 1000 użytkowników: Modelowanie konwersji i zachowań działa TYLKO jeśli kampania / konto zbiera wystarczającą liczbę obserwacji — minimum ~1000 użytkowników ze zgodą w danym segmencie. Poniżej tego progu Google nie modeluje brakujących danych — dane od użytkowników bez zgody są po prostu tracone. Konsekwencja dla małych kont i kampanii: przy małym ruchu (np. niszowy B2B, lokalne usługi) Consent Mode nie przyniesie korzyści z modelowania — i tak będziesz tracić dane od osób bez zgody. Pomimo tego Consent Mode v2 jest wymagany przez Google od marca 2024 dla wszystkich reklamodawców targetujących użytkowników w EOG (w tym PL) — jego brak może ograniczyć działanie remarketingu i konwersji. + +### Conversions by Time — kluczowe custom columns + +- **YT_gma_62_conversions_by_time** (`YT_grow-my-ads`): Kolumna 'Conversions' w Google Ads raportuje dzień KLIKNIĘCIA, nie dzień zakupu. Dla obecnych okresów (7-30 dni) dane są niekompletne. Rozwiązanie: 'Conversions by Conversion Time' — raportuje dzień faktycznej konwersji. Custom columns do ustawienia: CPA by Time = Cost / Conversions by Conv Time, ROAS by Time = Conv Value by Conv Time / Cost. Case study: Conv column = 82, Conv by Time = 89 (różnica 8.5%). ROAS standardowy 3.17 vs by time 3.40 — na standardowym możesz być zbyt konserwatywny. Szczególnie ważne: drogie produkty (meble, elektronika), długi conversion window (30-90 dni). + +### Cookie consent CookieBot + GTM: konfiguracja krok po kroku + +- **W056_cookie_consent_cookiebot_gtm** (`W056`): Wdrożenie cookie consent przez CookieBot + GTM: (1) Załóż konto CookieBot, pobierz ID. (2) W GTM dodaj tag CookieBot z ID, reguła: Consent Initialization. (3) W GTM: Administracja → Ustawienia kontenera → włącz 'Przegląd ustawień uzyskiwania zgody'. (4) W obszarze roboczym: ikona tarczy (prawy górny róg) → przejrzyj wszystkie tagi. Tagi z wbudowaną zgodą — nic nie zmieniaj. Tagi BEZ wbudowanej zgody — kliknij tarczę z zębatką → 'Wymaga dodatkowej zgody' → dodaj: ad_storage (tagi reklamowe) i/lub analytics_storage (tagi analityczne). Zasada: tagi Google Ads wymagają ad_storage, tagi GA4 wymagają analytics_storage, dla bezpieczeństwa dodaj oba do każdego tagu reklamowo-analitycznego. Po konfiguracji tagi wstrzymują się do momentu udzielenia zgody przez użytkownika. + +### CookieBot overlay i konfiguracja zgód dla kodów nie-Google w GTM + +- **W080_cookiebot_overlay_kody_nie_google** (`W080`): Dwie ważne kwestie przy wdrożeniu CookieBot z GTM: (1) Tryb overlay (blokada klikania w tło): CookieBot powinien mieć włączoną opcję blokowania interakcji z tłem strony, dopóki użytkownik nie podejmie decyzji o zgodach. Bez overlay użytkownicy mogą klikać 'przez banner' w elementy strony — to psuje dane w GA4 (zdarzenia rejestrowane bez ustalonego statusu zgód). (2) Kody nie-Google wymagają ręcznej konfiguracji: wbudowana zgoda w GTM działa automatycznie tylko dla tagów Google (GA4, Google Ads, Floodlight, itp.). Tagi platform zewnętrznych (np. Microsoft/Bing Ads, Meta, TikTok) wymagają ręcznego skonfigurowania warunku zgody w GTM — należy dodać odpowiednie uprawnienia zgody do każdego takiego tagu osobno przez ikonę tarczy → 'Wymaga dodatkowej zgody'. Rekomendacja ogólna: trzymaj WSZYSTKIE kody (GA4, CookieBot, piksele reklamowe) w Google Tag Managerze — to znacznie ułatwia zarządzanie zgodami i debugowanie przez GTM Preview. + +### CookieBot: monitoring poziomu zgód użytkowników (User Consents) w panelu + +- **W083_cookiebot_user_consents_monitoring** (`W083`): CookieBot (platforma zarządzania zgodami) udostępnia raport 'User Consents' w panelu administracyjnym — pozwala sprawdzić, jaki procent użytkowników akceptuje (opt-in) vs odrzuca (opt-out) ciasteczka. Gdzie znaleźć: panel CookieBot → zakładka 'Consent' lub 'Statistics' → raport z podziałem na kategorie zgód (niezbędne, statystyczne, marketingowe). Co monitorować: — Współczynnik akceptacji pełnych zgód (marketing + statystyki) — typowy poziom to 80–90%. — Jeśli poziom akceptacji spada poniżej 70%: banner może być źle zaprojektowany (przycisk odrzucenia bardziej widoczny niż akceptacji, brak opcji 'Akceptuj wszystkie'). — Porównaj trendy w czasie — nagły spadek zgód może wskazywać na zmianę w wyglądzie bannera po aktualizacji lub zmianie regulaminu. Wpływ na kampanie: każdy procent odmowy = utrata danych remarketingowych i konwersyjnych. Przy 20% odmów z 10 000 sesji/mies. = 2000 użytkowników niewidocznych dla GA4 i Google Ads. Inwestycja w poprawę bannera (UX, układ przycisków, jasna komunikacja korzyści) zwraca się szybko przez poprawę jakości danych. + +### Cross-consent cookie: subdomeny dziedziczą zgodę, między różnymi domenami wymagana nowa zgoda + +- **W110_consent_subdomeny_vs_domeny** (`W110`): Zgoda cookie (consent) udzielona przez użytkownika na jednej domenie lub subdomenie nie przenosi się automatycznie między różnymi domenami — ale zachowuje się inaczej dla subdomen tej samej domeny vs zupełnie różnych domen: SUBDOMENY TEJ SAMEJ DOMENY (np. sklep.firma.pl i blog.firma.pl): ciasteczko zgody ustawione na domenie główną (.firma.pl) jest dostępne dla wszystkich subdomen — użytkownik, który zaakceptował na sklepie, nie powinien zobaczyć ponownego pop-upu na blogu tej samej firmy. Test weryfikacyjny: przejdź z jednej subdomeny na drugą po udzieleniu zgody — pop-up nie powinien się pojawić ponownie. Jeśli się pojawia: sprawdź konfigurację domeny ciasteczka w CMP (powinna być ustawiona na '.firma.pl', nie na 'sklep.firma.pl'). RÓŻNE DOMENY (np. firma.pl i firma-sklep.pl): zgoda z jednej domeny NIE przenosi się na drugą — użytkownik musi wyrazić zgodę osobno na każdej domenie. Jest to ograniczenie techniczne przeglądarek (ciasteczka są izolowane per domena). Wyjątek: przy pełnym SST (server-side tracking) z własną subdomeną ciasteczko serwerowe może być ustawiane przez serwer na dowolnej subdomenie z tym samym certyfikatem SSL — ale consent nadal musi być udzielony osobno per domena. + +### Cross-domain tracking: debugging utraty źródła ruchu przez test ścieżki z UTM-ami + +- **W082_cross_domain_debugging_utms** (`W082`): Przy problemach z cross-domain tracking między dwiema witrynami (np. sklep.pl → kasa.pl lub strona.pl → formularz.pl) i utratą źródła ruchu (sesja na domenie B pokazuje direct zamiast właściwego źródła) zastosuj ręczny test diagnostyczny: (1) Zbuduj link z domeny A do domeny B z ręcznie dodanymi UTM-ami (utm_source=test&utm_medium=crossdomain&utm_campaign=debug). (2) Przejdź przez całą ścieżkę użytkownika: A → przejście → B. (3) Sprawdź w GA4 DebugView na domenie B, czy sesja ma źródło 'crossdomain/test' czy wciąż 'direct/(none)'. Jeśli UTM-y giną: — Przekierowanie HTTP między A i B gubi parametry URL — sprawdź czy nie ma pośredniego przekierowania bez parametrów. — Link dekoracja (cross-domain linker) nie działa — GA4 musi być skonfigurowany z 'Konfiguracja domen' zawierającą obie domeny. — Sesja jest przerywana przez pop-up cookie na domenie B ładujący się zanim GA4 odczyta parametry z URL. Poprawna konfiguracja GA4 cross-domain: GA4 → Administracja → Strumienie danych → Konfiguracja tagów → sekcja 'Konfiguracja domen' → dodaj obie domeny. + +### Data Exclusions — przy awarii trackingu + +- **YT_gma_61_data_exclusions** (`YT_grow-my-ads`): Data Exclusions (Tools → Bid Strategies → Advanced Controls → Data Exclusions): stosuj przy awarii conversion tracking. Bufor: dodaj 2 dni PRZED oficjalną awarią (latency konwersji) do dnia naprawy +1-2 dni. Można per kampania, per device lub całe konto. Case study: tracking zepsuty 3-8 lutego, CPA z $200 do $500. Data exclusion: 1-9 lutego. Google podpowiada: 'select a date range that accounts for the typical conversion delay of X days'. Po naprawie + exclusion: smart bidding NIE wraca od razu — kilka dni do tygodnia gorszej wydajności, nie panikuj. + +### Diagnostyka spadku konwersji po zmianie (consent, integracja) — 4 kroki weryfikacji + +- **W087_diagnostyka_spadku_konwersji_4_kroki** (`W087`): Gdy po wdrożeniu consent managera (pop-upu zgód) lub innej zmianie technicznej nagłe spada liczba rejestrowanych konwersji — wykonaj diagnostykę w tej kolejności: (1) Sprawdź procent odrzuceń zgód: wejdź do panelu CMP (CookieBot, Axeptio itp.) → sprawdź ile % użytkowników kliknęło 'Odrzuć'. Jeśli 30-40% odrzuca → tyle konwersji jest niewidocznych. Priorytet: popraw UX bannera (układ przycisków, komunikat). (2) Porównaj ID transakcji GA4 vs dane CRM/sklepu: pobierz listę transaction_id z GA4 (raport e-commerce) i porównaj z listą zamówień ze sklepu za ten sam okres — luka pokazuje % transakcji niewidocznych dla Google. (3) Sprawdź podział wg typów płatności: które metody płatności dominują w zamówieniach bez konwersji? Bramki płatności przekierowujące na zewnętrzny URL przerywają śledzenie (np. PayU na osobnej domenie). Sprawdź czy masz wdrożone wykluczenie referral dla bramki w GA4. (4) Zweryfikuj konfigurację bramek płatności: przetestuj ręcznie zakup przez każdą bramkę i sprawdź w GA4 DebugView, czy zdarzenie 'purchase' odpala się po powrocie ze strony bramki. Jeśli nie — wdróż dodatkową konwersję na stronie potwierdzenia zamówienia lub skorzystaj z Measurement Protocol do korekty danych. + +### Elementor Pro nie łączy się z Data Layer — Contact Form 7 + GTM for WordPress jako rozwiązanie + +- **W117_gtm_elementor_wordpress_contact_form_7** (`W117`): Przy śledzeniu wysłania formularzy na stronach WordPress zbudowanych w Elementor Pro może wystąpić problem: Elementor Pro nie wysyła automatycznie danych formularza do Data Layer GTM — brak zdarzenia w Data Layer po wysłaniu formularza, mimo że formularz działa poprawnie. Skutek: nie można śledzić wysyłki formularza jako konwersji bez niestandardowego kodu. Rozwiązanie: zamiast formularzy Elementora użyj Contact Form 7 (darmowa wtyczka WordPress) w połączeniu z wtyczką 'GTM for WordPress'. Wtyczka GTM for WordPress automatycznie przechwytuje zdarzenia Contact Form 7 i wysyła je do Data Layer z parametrami (form ID, form name) bez dodatkowego kodowania. Dzięki temu można w GTM zbudować trigger na zdarzenie wysłania formularza CF7 i uruchomić tag konwersji Google Ads lub GA4. Alternatywa jeśli musisz zostać przy Elementorze: użyj niestandardowego JavaScript w GTM wykrywającego kliknięcie przycisku submit lub sukces wysłania formularza na podstawie zmiany URL/pojawienia się komunikatu potwierdzającego. + +### Facebook CAPI: test event code — musi być usunięty po testowaniu + Lookup Table w GTM + +- **YT_am_01_facebook_capi_test_event_code** (`YT_analytics-mania`): Przy konfiguracji Facebook Conversion API (CAPI) przez GTM Server-Side: pole 'Test Event Code' w tagu Facebook CAPI służy do debugowania — umożliwia śledzenie zdarzeń w trybie testowym w Events Manager Facebooka. KRYTYCZNY BŁĄD: pozostawienie test event code w produkcji → zdarzenia NIE są rejestrowane w docelowych metrykach Facebooka (trafiają tylko do widoku testowego). To cichy błąd: tag odpala, Events Manager pokazuje zdarzenia w zakładce Test, ale kampanie Facebooka nie widzą tych konwersji. Rozwiązanie w GTM Server-Side (Lookup Table): zamiast ręcznego usuwania kodu po testach, stwórz zmienną GTM typu 'Lookup Table': wejście = zmienna {{debug_mode}} (automatycznie = 'true' w trybie Preview), jeśli wartość = 'true' → zwróć test event code (np. TEST12345), w przeciwnym razie → zwróć pustą wartość ''. Lookup Table automatycznie włącza test event code w trybie podglądu GTM (Preview) i wyłącza go na produkcji — bez ryzyka pomyłki przy ręcznym usuwaniu. Weryfikacja GTM Server-Side i consent: w żądaniu wychodzącym do Facebook API sprawdź parametr gcs — wartość 'G111' oznacza pełną zgodę, 'G100' brak zgody. + +### GA4 + GTM: true referer (rzeczywiste odesłanie) dla dark social / ruchu direct + +- **W061_ga4_true_referer_dark_social** (`W061`): Problem: ruch z social media, Slacka, newsletterów wchodzi jako 'Direct' w GA4 — brakuje referera (dark social). Rozwiązanie (przeniesienie z UA do GA4): W GTM stwórz zmienną JavaScript pobierającą wbudowaną zmienną GTM 'Referer'. W tagu konfiguracji GA4 (tag GA4) dodaj właściwość użytkownika 'true_referer' z wartością tej zmiennej JS. W GA4: Administracja → Dane niestandardowe → Właściwości użytkownika → dodaj 'true_referer'. Ograniczenie (W061): nie można jeszcze użyć true_referer w grupowaniu kanałów GA4 — dostępny tylko w eksploracjach jako wymiar do filtrowania ruchu direct. Wartość: identyfikacja rzeczywistego źródła sesji sklasyfikowanych jako direct. + +### GA4 e-commerce: prawidłowa struktura zmiennej items i weryfikacja w GTM + DebugView + +- **W065_ga4_ecommerce_items_konfiguracja_debug** (`W065`): Prawidłowa struktura tablicy 'items' w dataLayer dla GA4 e-commerce: Pola WYMAGANE: item_id (lub item_name — jedno z nich obowiązkowe). Pola zalecane: item_name, item_brand, item_category, price, quantity. Pełna specyfikacja: dokumentacja Google 'Pomiar e-commerce w GA4' (szukaj: 'pomiar e-commerce GA4 dokumentacja'). Jak weryfikować poprawność wdrożenia: Krok 1 — GTM Preview: Otwórz GTM Debug → znajdź zdarzenie 'purchase' → w prawym górnym rogu PRZEŁĄCZ z 'Names' na 'Values' → sprawdź czy tablica items zawiera poprawne wartości (item_id, name, price, quantity). Krok 2 — GA4 DebugView: GA4 → Administracja → DebugView → kliknij zdarzenie 'purchase' → rozwiń parametry → sprawdź items array. Jeśli event purchase nie pojawia się w GTM Preview: Prawdopodobnie brakuje pola 'event: purchase' w dataLayer.push(). GTM Trigger 'Custom Event' wymaga tego pola — bez niego trigger nie strzela. + +### GA4 podwójne zliczanie purchase: problem w kodzie strony (dataLayer), nie w GTM + +- **W065_ga4_podwojne_zliczanie_purchase_kod_strony** (`W065`): Symptom: w GTM Preview zdarzenie 'purchase' pojawia się 2 razy → konwersje zliczane podwójnie w GA4 i Google Ads. Błędna diagnoza: próba naprawy przez GTM (deduplication, zmiana triggera). Prawidłowa diagnoza: problem leży w KODZIE STRONY (lub wtyczce e-commerce), nie w GTM. GTM tylko ODBIERA sygnały z dataLayer i przesyła je dalej — nie generuje zdarzeń samodzielnie. Przyczyny podwójnego push do dataLayer: (1) Kod strony wywołuje dataLayer.push({'event': 'purchase'}) 2 razy (np. raz w kodzie customowym, raz przez wtyczkę). (2) Strona potwierdzenia zamówienia ładuje się 2 razy (przekierowanie → reload). (3) Dwa osobne fragmenty kodu (IT + wtyczka) pushują ten sam event. FIX: zidentyfikuj wszystkie miejsca pushujące event 'purchase' w kodzie strony → zostaw tylko jedno. Nie łataj GTM deduplikacją jako docelowym rozwiązaniem — napraw źródło sygnału. + +### GA4 pomiar zaawansowany: wyłącz przy własnym śledzeniu — inaczej konwersje duplikują się wstecz + +- **W064_ga4_pomiar_zaawansowany_duplikaty_konwersji** (`W064`): GA4 'Pomiar zaawansowany' (Enhanced Measurement) automatycznie śledzi kliknięcia, scrolle, odtwarzanie wideo, pobrania plików — bez GTM. Problem: jeśli JEDNOCZEŚNIE masz własne śledzenie w GTM dla tych samych zdarzeń → DUPLIKATY eventów. Szczególnie niebezpieczne: automatyczne zdarzenie 'purchase' z pomiaru zaawansowanego + własny tag GTM dla 'purchase' → podwójne zliczanie zakupów w GA4. Efekt w Google Ads: konwersje mogą pojawiać się WSTECZ (Google Ads importuje je z GA4 i przypisuje do wcześniejszych kampanii) — trudne do wykrycia. REKOMENDACJA: Przy własnym wdrożeniu przez GTM — wyłącz pomiar zaawansowany w ustawieniach strumienia GA4 lub zostaw tylko te auto-eventy, których nie śledzisz przez GTM. Weryfikacja: w GTM Preview + DebugView GA4 sprawdź czy ten sam event pojawia się 2x. + +### GA4: usuwanie danych wrażliwych (PII) — ryzyko blokady konta i jak konfigurować + +- **KDGA03_02_pii_usuniecie_danych_ga4** (`KDGA03`): Google rygorystycznie zabrania przesyłania do Google Analytics danych umożliwiających identyfikację osób (PII — Personally Identifiable Information): adresów e-mail, numerów telefonów, imion, nazwisk, adresów. Naruszenie tej polityki może prowadzić do zablokowania konta GA4. Kiedy PII może trafić do GA4: gdy w URL strony podziękowania lub potwierdzenia pojawia się parametr z danymi klienta, np. /?email=jan@firma.pl&name=Jan. Jak usunąć: GA4 → Administracja → Strumień danych → wybierz strumień → 'Usuwanie danych' (zakładka w ustawieniach strumienia) → wpisz parametry URL, które system ma ignorować (np. email, name, firstname, phone). Parametry wpisuj dokładnie tak, jak pojawiają się w adresach URL witryny — wielkość liter ma znaczenie. Test wbudowany: wbudowany tester pozwala wkleić przykładowy URL z danymi wrażliwymi i sprawdzić, czy system poprawnie je maskuje przed zapisem w raportach. Dodatkowe zabezpieczenie: w GTM nie przekazuj PII jako parametrów zdarzeń — np. nie wysyłaj adresu e-mail klienta jako user_property bez wcześniejszego zahashowania. Konwersje rozszerzone (Enhanced Conversions) to wyjątek — Google sam hashuje te dane przed zapisem, co jest celowym i dozwolonym przepływem. + +### GDN brak konwersji w panelu mimo leadów: diagnostyka krok po kroku + +- **W069_gtm_tracking_gdn_brak_konwersji_diagnostyka** (`W069`): Kampania GDN generuje leady (potwierdzają to UTM-y w CRM lub narzędziu do formularzy), ale panel Google Ads nie pokazuje konwersji — lista rzeczy do sprawdzenia: (1) Wdrożenie piksela konwersji — czy tag konwersji Google Ads w ogóle odpala na stronie podziękowania / po wysłaniu formularza (sprawdź GTM Preview lub GA4 DebugView). (2) Gubienie GCLID — czy parametr gclid jest prawidłowo przekazywany przez formularz (niektóre narzędzia do formularzy nie zapisują GCLID w ukrytym polu lub tracą go przy przekierowaniu); bez GCLID konwersja nie jest przypisana do kliknięcia reklamowego. (3) Autotagowanie — czy w ustawieniach konta Google Ads jest włączone autotagowanie (dodaje parametr gclid do URL). (4) Mikrokonwersje — jeśli skonfigurowano mikrokonwersje, upewnij się, że są ustawione jako 'podstawowe' jeśli chcesz je widzieć w kolumnie konwersji. (5) Szablony śledzenia — sprawdź czy na poziomie konta/kampanii/grupy nie ma szablonu śledzenia modyfikującego URL, który 'gubi' GCLID przy przekierowaniach. (6) Dane w Analytics — jeśli ruch z GDN widoczny jest w GA4 ze źródłem 'google/cpc' i UTM-y pasują, ale Google Ads nie widzi konwersji, problem leży w powiązaniu kont lub konfiguracji importu konwersji z GA4 (zalecane rozwiązanie: natywny piksel Google Ads). + +### GTM + Magento: Purchase nie pojawia się w preview — brak pola 'event' w dataLayer + +- **W064_gtm_magento_purchase_brak_nazwy_eventu** (`W064`): Problem: na sklepie Magento konfiguracja GA4 przez GTM — event 'purchase' nie pojawia się w podglądzie GTM (GTM Preview/Debug). Dane o transakcji są widoczne w dataLayer (ecommerce.purchase, ecommerce.items itd.), ale tag GA4 nie odpala. Przyczyna: brak pola 'event' w push do dataLayer. GTM trigger 'Custom Event' wymaga, żeby dataLayer.push() zawierało pole 'event': {'event': 'purchase', 'ecommerce': {...}} Bez pola 'event' — GTM nie wie kiedy triggerować tagi. FIX: w kodzie Magento (lub wtyczce GA4) znajdź push do dataLayer na stronie potwierdzenia zamówienia i dodaj pole event: 'purchase'. Jeśli nie masz dostępu do kodu: użyj GTM trigger 'Page View' na URL strony thank-you + odczyt zmiennych ecommerce z dataLayer przez Data Layer Variable. + +### GTM + Server-Side Tracking: architektura dwóch kontenerów — nie usuwaj GTM ze strony + +- **W111_gtm_sst_dwa_kontenery** (`W111`): Wdrożenie Server-Side Trackingu (SST) przez GTM Server-Side lub Google Tag Gateway nie oznacza usunięcia standardowego kontenera GTM ze strony. Architektura SST wymaga DWÓCH kontenerów działających równolegle: (1) KONTENER WITRYNOWY (Web Container) — standardowy GTM na stronie (tag + + + + + +
+
+ +

Raport z działań marketingowych

+
{client_name} — {month_name} {year}
+
+
+ + + {"" if not recommendations else ''' +
+

Wnioski i rekomendacje

+
+ ''' + "".join(f'
{r.get("icon", "➤")}
{r["title"]}

{r["text"]}

' for r in recommendations) + ''' +
+
+ '''} + + {"" if not client_questions else ''' +
+
+

Pytania do Pana

+

Aby lepiej zaplanować dalsze działania, chcielibyśmy poznać Pana zdanie w kilku kwestiach:

+
+ ''' + "".join(f'
{i+1}
{q["title"]}

{q["text"]}

' for i, q in enumerate(client_questions)) + ''' +
+
+
+ '''} + + + {ga4_section} + + + {ecom_section} + + + {product_opts_section} + + + {top_ads_products_section} + + + {yoy_section} + + +
+

Google Ads — Podsumowanie

+
{kpi_html}
+
+ + +
+

Google Ads — Aktywność dzienna

+
+ +
+
+ + +
+

Kampanie

+ + + + + + + + + + + + + + + {campaign_rows} +
KampaniaTypWyświetleniaKliknięciaCTRKonwersjeWartość konwersjiKosztCPA
+
+ + +
+

Najpopularniejsze frazy wyszukiwania

+ + + + + + + + + + + + {search_rows} +
#FrazaWyświetleniaKliknięciaCTRKonwersje
+
+ + + {negatives_section} + + + {semstorm_section} + + + {seo_activities_section} + + + {seo_links_section} + + +
+

Podsumowanie miesiąca

+
+ {summary_text} +
+
+ + + + + + + + + +""" + + return html + + +def main(): + parser = argparse.ArgumentParser(description="Generuj raport HTML") + parser.add_argument("--data", required=True, help="Sciezka do pliku JSON z danymi") + parser.add_argument("--client", default=None, help="Nazwa klienta do wyswietlenia (np. 'INNSI')") + parser.add_argument("--output", help="Sciezka do pliku wyjsciowego") + args = parser.parse_args() + + data_path = Path(args.data) + if not data_path.is_absolute(): + data_path = ROOT / "scripts" / "reports" / data_path + + with open(data_path, "r", encoding="utf-8") as f: + data = json.load(f) + + if args.client: + data["client_display_name"] = args.client + + html = build_html(data) + + if args.output: + out_path = Path(args.output) + else: + domain = data["client"].replace(".", "-") + out_dir = ROOT / "scripts" / "reports" / "output" / data["client"] / data["month"] + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / "index.html" + + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + f.write(html) + + print(f"Raport wygenerowany: {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/reports/list_ga4_properties.py b/scripts/reports/list_ga4_properties.py new file mode 100644 index 0000000..a46c22e --- /dev/null +++ b/scripts/reports/list_ga4_properties.py @@ -0,0 +1,32 @@ +"""List all GA4 properties accessible by this token.""" +import os +from dotenv import load_dotenv +from google.oauth2.credentials import Credentials +from google.analytics.admin_v1beta import AnalyticsAdminServiceClient + +load_dotenv() + +CLIENT_ID = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_ID") +CLIENT_SECRET = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_SECRET") +REFRESH_TOKEN = os.getenv("GA4_REFRESH_TOKEN") + +credentials = Credentials( + token=None, + refresh_token=REFRESH_TOKEN, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + token_uri="https://oauth2.googleapis.com/token", +) + +client = AnalyticsAdminServiceClient(credentials=credentials) + +print("Accounts:") +print("-" * 60) +for account in client.list_accounts(): + print(f" Account: {account.name} | {account.display_name}") + + print(" Properties:") + request = {"filter": f"parent:{account.name}"} + for prop in client.list_properties(request=request): + print(f" Property ID: {prop.name.replace('properties/', '')} | {prop.display_name}") + print() diff --git a/scripts/reports/output/aruba.rzeszow.pl/2026-02/index.html b/scripts/reports/output/aruba.rzeszow.pl/2026-02/index.html new file mode 100644 index 0000000..18d8fdc --- /dev/null +++ b/scripts/reports/output/aruba.rzeszow.pl/2026-02/index.html @@ -0,0 +1,716 @@ + + + + + + Raport Luty 2026 — Aruba Rzeszow + + + + + + +
+
+ +

Raport z działań marketingowych

+
Aruba Rzeszow — Luty 2026
+
+
+ + + +
+

Wnioski i rekomendacje

+
+
Spadek konwersji do obserwacji

Liczba konwersji spadla o 30.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu.

📈
ROAS liczony z Google Ads

ROAS z Google Ads wyniosl 9.50. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu.

+
+
+ + + + + + + + + + + + + + + + + + + + +
+

Google Ads — Podsumowanie

+
+
+
Wyświetlenia
+
181 763
+
+ ▼ -12.6% vs Styczeń +
+
+
+
Kliknięcia
+
4 628
+
+ ▼ -27.0% vs Styczeń +
+
+
+
CTR
+
2.5%
+
+ ▼ -16.4% vs Styczeń +
+
+
+
Konwersje
+
214
+
+ ▼ -30.2% vs Styczeń +
+
+
+
Koszt
+
3788.97 PLN
+
+ ▼ -0.3% vs Styczeń +
+
+
+
CPA
+
17.63 PLN
+
+ ▲ +42.8% vs Styczeń +
+
+
+
ROAS
+
9.50x
+
+ ▼ -30.7% vs Styczeń +
+
+
+ + +
+

Google Ads — Aktywność dzienna

+
+ +
+
+ + +
+

Kampanie

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KampaniaTypWyświetleniaKliknięciaCTRKonwersjeKosztCPA
[Search] brandSEARCH1 57249531.5%24430.53 PLN17.94 PLN
[DSA] produktySEARCH16 6081 2087.3%12445.16 PLN37.41 PLN
[PMax] products (catch-all)PERFORMANCE_MAX158 6612 8861.8%1782828.52 PLN15.89 PLN
[PLA] produkty (bestsellers)SHOPPING4 922390.8%184.76 PLN84.76 PLN
+
+ + +
+

Najpopularniejsze frazy wyszukiwania

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#FrazaWyświetleniaKliknięciaCTRKonwersje
1aruba rzeszów77025132.6%11
2aruba hurtownia1134539.8%2
3onygen krem1 114343.0%1
4aruba rzeszow1172924.8%2
5aruba sklep542953.7%3
6makijaż permanentny brwi217209.2%0
7autoklaw981818.4%0
8brwi permanentne231187.8%0
9aruba kosmetyki301550.0%0
10hurtownia aruba521426.9%0
11radiofrekwencja mikroigłowa342144.1%0
12hurtownia aruba rzeszów481327.1%1
13pielęgnacja brwi po makijażu permanentnym851315.3%0
14gen factor236114.7%0
15aruba hurtownia kosmetyczna221045.5%2
+
+ + + + + + + + + + + + + + +
+

Podsumowanie miesiąca

+
+ Odnotowano 214 konwersji w tym miesiącu. +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/scripts/reports/output/aruba.rzeszow.pl/2026-04/index.html b/scripts/reports/output/aruba.rzeszow.pl/2026-04/index.html new file mode 100644 index 0000000..1d93f22 --- /dev/null +++ b/scripts/reports/output/aruba.rzeszow.pl/2026-04/index.html @@ -0,0 +1,716 @@ + + + + + + Raport Kwiecień 2026 — Aruba Rzeszow + + + + + + +
+
+ +

Raport z działań marketingowych

+
Aruba Rzeszow — Kwiecień 2026
+
+
+ + + +
+

Wnioski i rekomendacje

+
+
Spadek konwersji do obserwacji

Liczba konwersji spadla o 8.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu.

📈
ROAS liczony z Google Ads

ROAS z Google Ads wyniosl 8.47. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu.

🔍
Kontrola wzrostu kosztu

Koszt reklam wzrosl o 12.2% miesiac do miesiaca. Warto porownac wzrost kosztu ze wzrostem konwersji i wartosci konwersji.

+
+
+ + + + + + + + + + + + + + + + + + + + +
+

Google Ads — Podsumowanie

+
+
+
Wyświetlenia
+
172 277
+
+ ▼ -0.6% vs Marzec +
+
+
+
Kliknięcia
+
3 826
+
+ ▲ +2.5% vs Marzec +
+
+
+
CTR
+
2.2%
+
+ ▲ +3.3% vs Marzec +
+
+
+
Konwersje
+
199
+
+ ▼ -8.2% vs Marzec +
+
+
+
Koszt
+
4880.74 PLN
+
+ ▲ +12.2% vs Marzec +
+
+
+
CPA
+
24.46 PLN
+
+ ▲ +22.2% vs Marzec +
+
+
+
ROAS
+
8.47x
+
+ ▼ -1.5% vs Marzec +
+
+
+ + +
+

Google Ads — Aktywność dzienna

+
+ +
+
+ + +
+

Kampanie

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KampaniaTypWyświetleniaKliknięciaCTRKonwersjeKosztCPA
[Search] brandSEARCH1 61448329.9%27337.96 PLN12.52 PLN
[DSA] produktySEARCH9 9846947.0%231098.15 PLN47.75 PLN
[PMax] products (catch-all)PERFORMANCE_MAX138 9212 1641.6%1062762.99 PLN25.94 PLN
[PLA] produkty (bestsellers)SHOPPING21 7584852.2%43681.64 PLN15.85 PLN
+
+ + +
+

Najpopularniejsze frazy wyszukiwania

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#FrazaWyświetleniaKliknięciaCTRKonwersje
1aruba rzeszów83624629.4%16
2gen factor858596.9%1
3aruba hurtownia1224839.3%4
4aruba rzeszow1273930.7%0
5gen factor green2072110.1%2
6gen factor604213.5%1
7verru immuno495193.8%2
8aruba sklep481735.4%1
9aurumaris1131311.5%0
10aruba hurtownia kosmetyczna251248.0%1
11aruba kosmetyki331236.4%1
12gen factor 09471123.4%0
13genfactor111119.9%2
14podopharm verru immuno230114.8%2
15hurtownia aruba321031.2%0
+
+ + + + + + + + + + + + + + +
+

Podsumowanie miesiąca

+
+ Odnotowano 199 konwersji w tym miesiącu. Ruch z reklam wzrósł o 2.5% (3826 kliknięć). +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/scripts/reports/output/aruba.rzeszow.pl_2026-02.json b/scripts/reports/output/aruba.rzeszow.pl_2026-02.json new file mode 100644 index 0000000..bfa8fe2 --- /dev/null +++ b/scripts/reports/output/aruba.rzeszow.pl_2026-02.json @@ -0,0 +1,412 @@ +{ + "client": "aruba.rzeszow.pl", + "month": "2026-02", + "month_name": "Luty", + "year": 2026, + "prev_month": "2026-01", + "prev_month_name": "Styczeń", + "generated_at": "2026-05-14T23:27:24.133206", + "google_ads": { + "campaigns": [ + { + "id": "19591441631", + "name": "[Search] brand", + "status": "ENABLED", + "type": "SEARCH", + "impressions": 1572, + "clicks": 495, + "cost": 430.53, + "conversions": 24.0, + "conversion_value": 5500.26, + "ctr": 31.49, + "cpc": 0.87, + "cpa": 17.94, + "roas": 12.78 + }, + { + "id": "20561423980", + "name": "[DSA] produkty", + "status": "ENABLED", + "type": "SEARCH", + "impressions": 16608, + "clicks": 1208, + "cost": 445.16, + "conversions": 11.9, + "conversion_value": 3113.12, + "ctr": 7.27, + "cpc": 0.37, + "cpa": 37.41, + "roas": 6.99 + }, + { + "id": "21260050298", + "name": "[PMax] products (catch-all)", + "status": "ENABLED", + "type": "PERFORMANCE_MAX", + "impressions": 158661, + "clicks": 2886, + "cost": 2828.52, + "conversions": 178.0, + "conversion_value": 27308.33, + "ctr": 1.82, + "cpc": 0.98, + "cpa": 15.89, + "roas": 9.65 + }, + { + "id": "22926581178", + "name": "[PLA] produkty (bestsellers)", + "status": "ENABLED", + "type": "SHOPPING", + "impressions": 4922, + "clicks": 39, + "cost": 84.76, + "conversions": 1.0, + "conversion_value": 90.0, + "ctr": 0.79, + "cpc": 2.17, + "cpa": 84.76, + "roas": 1.06 + } + ], + "totals": { + "impressions": 181763, + "clicks": 4628, + "cost": 3788.97, + "conversions": 214.9, + "conversion_value": 36011.71, + "ctr": 2.55, + "cpc": 0.82, + "cpa": 17.63, + "roas": 9.5 + }, + "prev_totals": { + "impressions": 208079, + "clicks": 6338, + "cost": 3801.39, + "conversions": 307.7, + "conversion_value": 52085.85, + "ctr": 3.05, + "cpc": 0.6, + "cpa": 12.35, + "roas": 13.7 + }, + "mom_change": { + "impressions_pct": -12.6, + "clicks_pct": -27.0, + "cost_pct": -0.3, + "conversions_pct": -30.2, + "ctr_pct": -16.4, + "cpc_pct": 36.7, + "cpa_pct": 42.8 + }, + "daily": [ + { + "date": "2026-02-01", + "impressions": 7761, + "clicks": 210, + "cost": 132.82 + }, + { + "date": "2026-02-02", + "impressions": 8752, + "clicks": 164, + "cost": 139.71 + }, + { + "date": "2026-02-03", + "impressions": 6894, + "clicks": 188, + "cost": 142.18 + }, + { + "date": "2026-02-04", + "impressions": 6890, + "clicks": 242, + "cost": 162.52 + }, + { + "date": "2026-02-05", + "impressions": 7048, + "clicks": 204, + "cost": 128.61 + }, + { + "date": "2026-02-06", + "impressions": 8251, + "clicks": 198, + "cost": 159.4 + }, + { + "date": "2026-02-07", + "impressions": 6007, + "clicks": 163, + "cost": 106.12 + }, + { + "date": "2026-02-08", + "impressions": 8393, + "clicks": 208, + "cost": 166.05 + }, + { + "date": "2026-02-09", + "impressions": 6761, + "clicks": 202, + "cost": 128.55 + }, + { + "date": "2026-02-10", + "impressions": 8531, + "clicks": 206, + "cost": 155.46 + }, + { + "date": "2026-02-11", + "impressions": 6071, + "clicks": 193, + "cost": 159.92 + }, + { + "date": "2026-02-12", + "impressions": 5122, + "clicks": 169, + "cost": 96.72 + }, + { + "date": "2026-02-13", + "impressions": 6360, + "clicks": 153, + "cost": 158.77 + }, + { + "date": "2026-02-14", + "impressions": 4092, + "clicks": 113, + "cost": 99.1 + }, + { + "date": "2026-02-15", + "impressions": 5897, + "clicks": 139, + "cost": 168.71 + }, + { + "date": "2026-02-16", + "impressions": 6193, + "clicks": 174, + "cost": 155.53 + }, + { + "date": "2026-02-17", + "impressions": 6761, + "clicks": 148, + "cost": 162.05 + }, + { + "date": "2026-02-18", + "impressions": 6894, + "clicks": 137, + "cost": 116.89 + }, + { + "date": "2026-02-19", + "impressions": 5773, + "clicks": 112, + "cost": 161.77 + }, + { + "date": "2026-02-20", + "impressions": 6152, + "clicks": 143, + "cost": 119.62 + }, + { + "date": "2026-02-21", + "impressions": 6529, + "clicks": 139, + "cost": 117.97 + }, + { + "date": "2026-02-22", + "impressions": 5916, + "clicks": 151, + "cost": 150.73 + }, + { + "date": "2026-02-23", + "impressions": 7070, + "clicks": 160, + "cost": 140.72 + }, + { + "date": "2026-02-24", + "impressions": 7262, + "clicks": 184, + "cost": 158.14 + }, + { + "date": "2026-02-25", + "impressions": 6054, + "clicks": 157, + "cost": 121.12 + }, + { + "date": "2026-02-26", + "impressions": 4538, + "clicks": 141, + "cost": 103.38 + }, + { + "date": "2026-02-27", + "impressions": 5064, + "clicks": 141, + "cost": 105.53 + }, + { + "date": "2026-02-28", + "impressions": 4727, + "clicks": 89, + "cost": 70.88 + } + ], + "search_terms": [ + { + "term": "aruba rzeszów", + "impressions": 770, + "clicks": 251, + "cost": 178.38, + "conversions": 11.1, + "ctr": 32.6 + }, + { + "term": "aruba hurtownia", + "impressions": 113, + "clicks": 45, + "cost": 23.81, + "conversions": 2.0, + "ctr": 39.82 + }, + { + "term": "onygen krem", + "impressions": 1114, + "clicks": 34, + "cost": 25.49, + "conversions": 1.0, + "ctr": 3.05 + }, + { + "term": "aruba rzeszow", + "impressions": 117, + "clicks": 29, + "cost": 34.21, + "conversions": 2.0, + "ctr": 24.79 + }, + { + "term": "aruba sklep", + "impressions": 54, + "clicks": 29, + "cost": 15.73, + "conversions": 3.0, + "ctr": 53.7 + }, + { + "term": "makijaż permanentny brwi", + "impressions": 217, + "clicks": 20, + "cost": 4.64, + "conversions": 0.0, + "ctr": 9.22 + }, + { + "term": "autoklaw", + "impressions": 98, + "clicks": 18, + "cost": 4.79, + "conversions": 0.0, + "ctr": 18.37 + }, + { + "term": "brwi permanentne", + "impressions": 231, + "clicks": 18, + "cost": 4.37, + "conversions": 0.0, + "ctr": 7.79 + }, + { + "term": "aruba kosmetyki", + "impressions": 30, + "clicks": 15, + "cost": 14.3, + "conversions": 0.0, + "ctr": 50.0 + }, + { + "term": "hurtownia aruba", + "impressions": 52, + "clicks": 14, + "cost": 11.64, + "conversions": 0.0, + "ctr": 26.92 + }, + { + "term": "radiofrekwencja mikroigłowa", + "impressions": 342, + "clicks": 14, + "cost": 3.33, + "conversions": 0.0, + "ctr": 4.09 + }, + { + "term": "hurtownia aruba rzeszów", + "impressions": 48, + "clicks": 13, + "cost": 11.53, + "conversions": 1.0, + "ctr": 27.08 + }, + { + "term": "pielęgnacja brwi po makijażu permanentnym", + "impressions": 85, + "clicks": 13, + "cost": 1.95, + "conversions": 0.0, + "ctr": 15.29 + }, + { + "term": "gen factor", + "impressions": 236, + "clicks": 11, + "cost": 4.29, + "conversions": 0.0, + "ctr": 4.66 + }, + { + "term": "aruba hurtownia kosmetyczna", + "impressions": 22, + "clicks": 10, + "cost": 5.8, + "conversions": 2.0, + "ctr": 45.45 + } + ] + }, + "ga4": null, + "semstorm": null, + "sales_history": [], + "seo_links": [], + "recommendations": [ + { + "icon": "⚠", + "title": "Spadek konwersji do obserwacji", + "text": "Liczba konwersji spadla o 30.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu." + }, + { + "icon": "📈", + "title": "ROAS liczony z Google Ads", + "text": "ROAS z Google Ads wyniosl 9.50. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu." + } + ] +} \ No newline at end of file diff --git a/scripts/reports/output/aruba.rzeszow.pl_2026-04.json b/scripts/reports/output/aruba.rzeszow.pl_2026-04.json new file mode 100644 index 0000000..c8261ab --- /dev/null +++ b/scripts/reports/output/aruba.rzeszow.pl_2026-04.json @@ -0,0 +1,429 @@ +{ + "client": "aruba.rzeszow.pl", + "month": "2026-04", + "month_name": "Kwiecień", + "year": 2026, + "prev_month": "2026-03", + "prev_month_name": "Marzec", + "generated_at": "2026-05-14T23:23:53.496703", + "google_ads": { + "campaigns": [ + { + "id": "19591441631", + "name": "[Search] brand", + "status": "ENABLED", + "type": "SEARCH", + "impressions": 1614, + "clicks": 483, + "cost": 337.96, + "conversions": 27.0, + "conversion_value": 7967.63, + "ctr": 29.93, + "cpc": 0.7, + "cpa": 12.52, + "roas": 23.58 + }, + { + "id": "20561423980", + "name": "[DSA] produkty", + "status": "ENABLED", + "type": "SEARCH", + "impressions": 9984, + "clicks": 694, + "cost": 1098.15, + "conversions": 23.0, + "conversion_value": 6600.7, + "ctr": 6.95, + "cpc": 1.58, + "cpa": 47.75, + "roas": 6.01 + }, + { + "id": "21260050298", + "name": "[PMax] products (catch-all)", + "status": "ENABLED", + "type": "PERFORMANCE_MAX", + "impressions": 138921, + "clicks": 2164, + "cost": 2762.99, + "conversions": 106.5, + "conversion_value": 19390.88, + "ctr": 1.56, + "cpc": 1.28, + "cpa": 25.94, + "roas": 7.02 + }, + { + "id": "22926581178", + "name": "[PLA] produkty (bestsellers)", + "status": "ENABLED", + "type": "SHOPPING", + "impressions": 21758, + "clicks": 485, + "cost": 681.64, + "conversions": 43.0, + "conversion_value": 7367.07, + "ctr": 2.23, + "cpc": 1.41, + "cpa": 15.85, + "roas": 10.81 + } + ], + "totals": { + "impressions": 172277, + "clicks": 3826, + "cost": 4880.74, + "conversions": 199.5, + "conversion_value": 41326.28, + "ctr": 2.22, + "cpc": 1.28, + "cpa": 24.46, + "roas": 8.47 + }, + "prev_totals": { + "impressions": 173273, + "clicks": 3733, + "cost": 4351.39, + "conversions": 217.4, + "conversion_value": 37429.84, + "ctr": 2.15, + "cpc": 1.17, + "cpa": 20.02, + "roas": 8.6 + }, + "mom_change": { + "impressions_pct": -0.6, + "clicks_pct": 2.5, + "cost_pct": 12.2, + "conversions_pct": -8.2, + "ctr_pct": 3.3, + "cpc_pct": 9.4, + "cpa_pct": 22.2 + }, + "daily": [ + { + "date": "2026-04-01", + "impressions": 6909, + "clicks": 102, + "cost": 120.77 + }, + { + "date": "2026-04-02", + "impressions": 5632, + "clicks": 108, + "cost": 167.66 + }, + { + "date": "2026-04-03", + "impressions": 4210, + "clicks": 72, + "cost": 95.19 + }, + { + "date": "2026-04-04", + "impressions": 3045, + "clicks": 54, + "cost": 101.11 + }, + { + "date": "2026-04-05", + "impressions": 2088, + "clicks": 39, + "cost": 49.2 + }, + { + "date": "2026-04-06", + "impressions": 3976, + "clicks": 96, + "cost": 112.82 + }, + { + "date": "2026-04-07", + "impressions": 5853, + "clicks": 130, + "cost": 138.02 + }, + { + "date": "2026-04-08", + "impressions": 7519, + "clicks": 166, + "cost": 225.05 + }, + { + "date": "2026-04-09", + "impressions": 6605, + "clicks": 137, + "cost": 165.58 + }, + { + "date": "2026-04-10", + "impressions": 4340, + "clicks": 112, + "cost": 130.39 + }, + { + "date": "2026-04-11", + "impressions": 3177, + "clicks": 95, + "cost": 92.74 + }, + { + "date": "2026-04-12", + "impressions": 4104, + "clicks": 114, + "cost": 116.26 + }, + { + "date": "2026-04-13", + "impressions": 7332, + "clicks": 185, + "cost": 201.76 + }, + { + "date": "2026-04-14", + "impressions": 7941, + "clicks": 176, + "cost": 232.59 + }, + { + "date": "2026-04-15", + "impressions": 7296, + "clicks": 164, + "cost": 186.57 + }, + { + "date": "2026-04-16", + "impressions": 6191, + "clicks": 149, + "cost": 165.26 + }, + { + "date": "2026-04-17", + "impressions": 4557, + "clicks": 107, + "cost": 95.56 + }, + { + "date": "2026-04-18", + "impressions": 3621, + "clicks": 101, + "cost": 118.02 + }, + { + "date": "2026-04-19", + "impressions": 5409, + "clicks": 114, + "cost": 175.25 + }, + { + "date": "2026-04-20", + "impressions": 7762, + "clicks": 196, + "cost": 239.2 + }, + { + "date": "2026-04-21", + "impressions": 7615, + "clicks": 163, + "cost": 262.91 + }, + { + "date": "2026-04-22", + "impressions": 9246, + "clicks": 210, + "cost": 265.25 + }, + { + "date": "2026-04-23", + "impressions": 9234, + "clicks": 170, + "cost": 222.45 + }, + { + "date": "2026-04-24", + "impressions": 5931, + "clicks": 116, + "cost": 202.37 + }, + { + "date": "2026-04-25", + "impressions": 5078, + "clicks": 112, + "cost": 174.69 + }, + { + "date": "2026-04-26", + "impressions": 5786, + "clicks": 131, + "cost": 162.94 + }, + { + "date": "2026-04-27", + "impressions": 6014, + "clicks": 144, + "cost": 191.42 + }, + { + "date": "2026-04-28", + "impressions": 6078, + "clicks": 132, + "cost": 181.99 + }, + { + "date": "2026-04-29", + "impressions": 5629, + "clicks": 135, + "cost": 166.02 + }, + { + "date": "2026-04-30", + "impressions": 4099, + "clicks": 96, + "cost": 121.72 + } + ], + "search_terms": [ + { + "term": "aruba rzeszów", + "impressions": 836, + "clicks": 246, + "cost": 131.67, + "conversions": 16.0, + "ctr": 29.43 + }, + { + "term": "gen factor", + "impressions": 858, + "clicks": 59, + "cost": 134.33, + "conversions": 1.0, + "ctr": 6.88 + }, + { + "term": "aruba hurtownia", + "impressions": 122, + "clicks": 48, + "cost": 26.45, + "conversions": 4.0, + "ctr": 39.34 + }, + { + "term": "aruba rzeszow", + "impressions": 127, + "clicks": 39, + "cost": 24.46, + "conversions": 0.0, + "ctr": 30.71 + }, + { + "term": "gen factor green", + "impressions": 207, + "clicks": 21, + "cost": 46.65, + "conversions": 2.0, + "ctr": 10.14 + }, + { + "term": "gen factor", + "impressions": 604, + "clicks": 21, + "cost": 25.05, + "conversions": 1.0, + "ctr": 3.48 + }, + { + "term": "verru immuno", + "impressions": 495, + "clicks": 19, + "cost": 27.24, + "conversions": 2.0, + "ctr": 3.84 + }, + { + "term": "aruba sklep", + "impressions": 48, + "clicks": 17, + "cost": 3.4, + "conversions": 1.0, + "ctr": 35.42 + }, + { + "term": "aurumaris", + "impressions": 113, + "clicks": 13, + "cost": 14.52, + "conversions": 0.0, + "ctr": 11.5 + }, + { + "term": "aruba hurtownia kosmetyczna", + "impressions": 25, + "clicks": 12, + "cost": 7.85, + "conversions": 1.0, + "ctr": 48.0 + }, + { + "term": "aruba kosmetyki", + "impressions": 33, + "clicks": 12, + "cost": 3.27, + "conversions": 1.0, + "ctr": 36.36 + }, + { + "term": "gen factor 09", + "impressions": 47, + "clicks": 11, + "cost": 15.43, + "conversions": 0.0, + "ctr": 23.4 + }, + { + "term": "genfactor", + "impressions": 111, + "clicks": 11, + "cost": 27.84, + "conversions": 2.0, + "ctr": 9.91 + }, + { + "term": "podopharm verru immuno", + "impressions": 230, + "clicks": 11, + "cost": 15.75, + "conversions": 2.0, + "ctr": 4.78 + }, + { + "term": "hurtownia aruba", + "impressions": 32, + "clicks": 10, + "cost": 7.31, + "conversions": 0.0, + "ctr": 31.25 + } + ] + }, + "ga4": null, + "semstorm": null, + "sales_history": [], + "seo_links": [], + "recommendations": [ + { + "icon": "⚠", + "title": "Spadek konwersji do obserwacji", + "text": "Liczba konwersji spadla o 8.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu." + }, + { + "icon": "📈", + "title": "ROAS liczony z Google Ads", + "text": "ROAS z Google Ads wyniosl 8.47. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu." + }, + { + "icon": "🔍", + "title": "Kontrola wzrostu kosztu", + "text": "Koszt reklam wzrosl o 12.2% miesiac do miesiaca. Warto porownac wzrost kosztu ze wzrostem konwersji i wartosci konwersji." + } + ] +} \ No newline at end of file diff --git a/scripts/reports/output/ibra-makeup.pl/2026-04/index.html b/scripts/reports/output/ibra-makeup.pl/2026-04/index.html new file mode 100644 index 0000000..d3189f7 --- /dev/null +++ b/scripts/reports/output/ibra-makeup.pl/2026-04/index.html @@ -0,0 +1,1182 @@ + + + + + + Raport Kwiecień 2026 — Ibra Makeup + + + + + + +
+
+ +

Raport z działań marketingowych

+
Ibra Makeup — Kwiecień 2026
+
+
+ + + +
+

Wnioski i rekomendacje

+
+
Google Ads utrzymuje bardzo dobrą rentowność

Konto wygenerowało 641,8 konwersji przy koszcie 6705,35 PLN i ROAS 10,84. Utrzymujemy aktywne kampanie sprzedażowe, a dalsze zwiększanie budżetu prowadzimy sekwencyjnie, przede wszystkim w kampaniach z ROAS powyżej średniej konta.

📈
Więcej konwersji przy prawie stabilnym koszcie pozyskania

W porównaniu miesiąc do miesiąca konwersje wzrosły o 7,9%, koszt o 8,2%, a CPA tylko o 0,4%. Skala rosła bez widocznego pogorszenia kosztu pozyskania, dlatego nie tniemy budżetu całościowo. Pracujemy na miksie kampanii i przesuwamy uwagę na te segmenty, które utrzymują rentowność.

🔍
Brand i PMax niosą główny wynik

Największą część kosztu i wartości konwersji generują [Search] brand oraz [PMax] products (catch-all). Obie kampanie mają ROAS około 12,9, dlatego zostają główne w strukturze. Zmiany celów ROAS lub budżetów wprowadzamy stopniowo i kontrolujemy wolumen po każdej zmianie.

Kampanie z niskim ROAS wymagają osobnej decyzji

[DG] YouTube Shorts i wybrane kampanie PLA_CL1 mają wyraźnie niższy ROAS niż średnia konta. Nie wyłączamy ich automatycznie tylko na podstawie tego raportu. Rozdzielamy ich role na wsparcie lejka i realną sprzedaż, a przy celu czystej efektywności ograniczamy lub zawężamy je w pierwszej kolejności.

💰
Sprzedaż sklepu jest mocniejsza w danych z arkusza

Arkusz sprzedażowy pokazuje 1711 transakcji i 187795,28 PLN przychodu w kwietniu. Średnia wartość koszyka wynosi 109,76 PLN, dlatego w komunikacji i kampaniach wzmacniamy produkty oraz zestawy, które podnoszą wartość zamówienia, zamiast skupiać się wyłącznie na liczbie transakcji.

Rekomendowany następny krok

Utrzymujemy główny kierunek konta, a optymalizacje prowadzimy punktowo: kontrolujemy kampanie o niskim ROAS, analizujemy udział brandu w wyniku i porównujemy PMax z PLA pod kątem produktów, które można efektywniej skalować. Zmiany budżetów i celów Smart Bidding wdrażamy pojedynczo, z oceną po kolejnej paczce danych.

+
+
+ + + + + + +
+

Dane z Google Analytics

+
+
+
Sesje
+
28 104
+
+ ▼ -13.6% vs Marzec +
+
+
+
Użytkownicy
+
20 698
+
+ ▼ -13.8% vs Marzec +
+
+
+
Nowi użytkownicy
+
18 283
+
+ ▼ -15.1% vs Marzec +
+
+
+
Odsłon
+
90 611
+
+ ▼ -4.6% vs Marzec +
+
+ +
+

Sesje dziennie

+ +
+ +
+
+

Źródła ruchu

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Źródło / MediumSesjeUdział
facebook / paid13 35449.1%
google / cpc4 02014.8%
google / organic3 77913.9%
(direct) / (none)3 01711.1%
ig / social8483.1%
m.facebook.com / referral6362.3%
l.facebook.com / referral5502.0%
l.instagram.com / referral4251.6%
(not set)3141.2%
lm.facebook.com / referral2470.9%
+
+
+

Urządzenia

+ + + + + + + + + + + + + + + + + + +
UrządzenieSesjeUdział
Telefon25 34393.2%
Komputer2 6879.9%
Tablet360.1%
+
+
+
+ + + +
+

E-commerce — Sprzedaż

+
+
+
Transakcje
+
1 711
+
 
+
+
+
Przychód
+
187 795.28 PLN
+
 
+
+
+
Śr. wartość zamówienia
+
109.76 PLN
+
 
+
+ +
+

Przychody i transakcje dziennie

+ +
+ +
+

Historia sprzedaży (miesięcznie)

+ +
+ +

+ Ta tabela pochodzi z GA4 i pokazuje przychód według source / medium. + W sekcji Google Ads pokazujemy wartość konwersji z Google Ads, liczoną według atrybucji i okna konwersji Google Ads. +

+

Przychody wg źródeł ruchu

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Źródło / MediumPrzychódTransakcjeUdział
google / cpc39 344.26 PLN34521.0%
google / organic33 321.73 PLN26317.7%
facebook / paid31 135.90 PLN45616.6%
(direct) / (none)23 268.81 PLN17312.4%
ig / social4 350.16 PLN372.3%
(not set)2 766.38 PLN241.5%
ibra-makeup.pl / referral1 526.83 PLN30.8%
l.facebook.com / referral1 519.87 PLN280.8%
m.facebook.com / referral1 364.05 PLN260.7%
shopify_email / email1 031.58 PLN100.5%
+ +

Top 10 produktów

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#ProduktIlośćPrzychód
1Wygładzający Puder Transparentny No More Pore Pro Makeup Academy IBRA Makeup48618 918.90 PLN
2Kępki rzęs Bride Style MIX IBRA Makeup4685 888.83 PLN
3Zestaw pędzli do makijażu White IBRA Makeup535 035.00 PLN
4Kępki rzęs Bride Style 10mm IBRA Makeup3123 750.33 PLN
5Makeup Blender Sponge marmurkowa gąbka do makijażu IBRA Makeup2303 556.32 PLN
6Nawilżający puder pod oczy Under Eye Hydra Powder IBRA Makeup1063 240.59 PLN
7Kępki rzęs Bride Style 12mm IBRA Makeup2583 146.88 PLN
8Zestaw pędzli do makijażu Fresh IBRA Makeup412 928.58 PLN
9Cień do powiek Brown Sugar IBRA Makeup1022 653.19 PLN
10Makeup Blender sponge różowa gąbka do makijażu IBRA Makeup1392 145.10 PLN
+
+ + + + + + + + + + + +
+

Google Ads — Podsumowanie

+
+
+
Wyświetlenia
+
166 918
+
+ ▼ -5.5% vs Marzec +
+
+
+
Kliknięcia
+
4 339
+
+ ▲ +1.7% vs Marzec +
+
+
+
CTR
+
2.6%
+
+ ▲ +7.9% vs Marzec +
+
+
+
Konwersje
+
641
+
+ ▲ +7.9% vs Marzec +
+
+
+
Wartość konwersji
+
72 679.40 PLN
+
+ ▲ +9.3% vs Marzec +
+
+
+
Koszt
+
6705.35 PLN
+
+ ▲ +8.2% vs Marzec +
+
+
+
ROAS
+
10.84x
+
+ ▲ +1.0% vs Marzec +
+
+
+ + +
+

Google Ads — Aktywność dzienna

+
+ +
+
+ + +
+

Kampanie

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KampaniaTypWyświetleniaKliknięciaCTRKonwersjeWartość konwersjiKosztCPA
[DSA] produktySEARCH1 898613.2%141.72 PLN105.42 PLN105.42 PLN
[Search] brandSEARCH11 7641 92716.4%29834 878.62 PLN2712.17 PLN9.10 PLN
[PMax] products (catch-all)PERFORMANCE_MAX92 0951 7241.9%25529 204.43 PLN2263.02 PLN8.87 PLN
[GDN] porzucone koszykiDISPLAY29 213960.3%6310.57 PLN304.26 PLN52.46 PLN
[PLA] produkty (bestsellers)SHOPPING14 7213482.4%707 193.01 PLN607.74 PLN8.62 PLN
[DG] YouTube ShortsDEMAND_GEN16 3181390.8%7725.68 PLN502.01 PLN68.77 PLN
[PLA_CL1] rzesy | catch_allSHOPPING227114.8%1104.30 PLN67.96 PLN67.96 PLN
[PLA_CL1] rzesySHOPPING682334.8%3221.07 PLN142.77 PLN47.59 PLN
+
+ + +
+

Najpopularniejsze frazy wyszukiwania

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#FrazaWyświetleniaKliknięciaCTRKonwersje
1ibra1 80982245.4%188
2ibra makeup32118958.9%25
3ibra pedzle1955729.2%3
4puder ibra379338.7%1
5ibra897333.7%12
6rzesy ibra1281814.1%4
7gąbeczka ibra167169.6%1
8ibra kosmetyki321237.5%0
9ibra rzesy611219.7%7
10ibra zestaw pędzli751216.0%0
11ibra gąbka259124.6%6
12ibra pedzle zestaw361130.6%2
13pędzle ibra641117.2%0
14ibra cień do powiek751013.3%0
15ibra gąbka187105.3%1
+
+ + + + + + + + + + + + + + +
+

Podsumowanie miesiąca

+
+ Przychód sklepu utrzymał się na poziomie 187,795.28 PLN. Zrealizowano 1711 transakcji przy średniej wartości zamówienia 109.76 PLN. ROAS z Google Ads: 10.84 (każda wydana złotówka przyniosła 10.84 PLN przychodu). Ruch z reklam wzrósł o 1.7% (4339 kliknięć). Strona odnotowała 28,104 sesji i 20,698 użytkowników. +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/scripts/reports/output/ibra-makeup.pl_2026-04.json b/scripts/reports/output/ibra-makeup.pl_2026-04.json new file mode 100644 index 0000000..7bc00b2 --- /dev/null +++ b/scripts/reports/output/ibra-makeup.pl_2026-04.json @@ -0,0 +1,1021 @@ +{ + "client": "ibra-makeup.pl", + "month": "2026-04", + "month_name": "Kwiecień", + "year": 2026, + "prev_month": "2026-03", + "prev_month_name": "Marzec", + "generated_at": "2026-05-14T23:59:27.081205", + "google_ads": { + "campaigns": [ + { + "id": "20553531321", + "name": "[DSA] produkty", + "status": "PAUSED", + "type": "SEARCH", + "impressions": 1898, + "clicks": 61, + "cost": 105.42, + "conversions": 1.0, + "conversion_value": 41.72, + "ctr": 3.21, + "cpc": 1.73, + "cpa": 105.42, + "roas": 0.4 + }, + { + "id": "20776680369", + "name": "[Search] brand", + "status": "ENABLED", + "type": "SEARCH", + "impressions": 11764, + "clicks": 1927, + "cost": 2712.17, + "conversions": 298.2, + "conversion_value": 34878.62, + "ctr": 16.38, + "cpc": 1.41, + "cpa": 9.1, + "roas": 12.86 + }, + { + "id": "21071199249", + "name": "[PMax] products (catch-all)", + "status": "ENABLED", + "type": "PERFORMANCE_MAX", + "impressions": 92095, + "clicks": 1724, + "cost": 2263.02, + "conversions": 255.0, + "conversion_value": 29204.43, + "ctr": 1.87, + "cpc": 1.31, + "cpa": 8.87, + "roas": 12.91 + }, + { + "id": "21397679905", + "name": "[GDN] porzucone koszyki", + "status": "PAUSED", + "type": "DISPLAY", + "impressions": 29213, + "clicks": 96, + "cost": 304.26, + "conversions": 5.8, + "conversion_value": 310.57, + "ctr": 0.33, + "cpc": 3.17, + "cpa": 52.46, + "roas": 1.02 + }, + { + "id": "21630222614", + "name": "[PLA] produkty (bestsellers)", + "status": "PAUSED", + "type": "SHOPPING", + "impressions": 14721, + "clicks": 348, + "cost": 607.74, + "conversions": 70.5, + "conversion_value": 7193.01, + "ctr": 2.36, + "cpc": 1.75, + "cpa": 8.62, + "roas": 11.84 + }, + { + "id": "23731923052", + "name": "[DG] YouTube Shorts", + "status": "ENABLED", + "type": "DEMAND_GEN", + "impressions": 16318, + "clicks": 139, + "cost": 502.01, + "conversions": 7.3, + "conversion_value": 725.68, + "ctr": 0.85, + "cpc": 3.61, + "cpa": 68.77, + "roas": 1.45 + }, + { + "id": "23785538808", + "name": "[PLA_CL1] rzesy | catch_all", + "status": "ENABLED", + "type": "SHOPPING", + "impressions": 227, + "clicks": 11, + "cost": 67.96, + "conversions": 1.0, + "conversion_value": 104.3, + "ctr": 4.85, + "cpc": 6.18, + "cpa": 67.96, + "roas": 1.53 + }, + { + "id": "23790879566", + "name": "[PLA_CL1] rzesy", + "status": "ENABLED", + "type": "SHOPPING", + "impressions": 682, + "clicks": 33, + "cost": 142.77, + "conversions": 3.0, + "conversion_value": 221.07, + "ctr": 4.84, + "cpc": 4.33, + "cpa": 47.59, + "roas": 1.55 + } + ], + "totals": { + "impressions": 166918, + "clicks": 4339, + "cost": 6705.35, + "conversions": 641.8, + "conversion_value": 72679.4, + "ctr": 2.6, + "cpc": 1.55, + "cpa": 10.45, + "roas": 10.84 + }, + "prev_totals": { + "impressions": 176677, + "clicks": 4266, + "cost": 6196.72, + "conversions": 595.0, + "conversion_value": 66503.87, + "ctr": 2.41, + "cpc": 1.45, + "cpa": 10.41, + "roas": 10.73 + }, + "mom_change": { + "impressions_pct": -5.5, + "clicks_pct": 1.7, + "cost_pct": 8.2, + "conversions_pct": 7.9, + "conversion_value_pct": 9.3, + "ctr_pct": 7.9, + "cpc_pct": 6.9, + "cpa_pct": 0.4, + "roas_pct": 1.0 + }, + "daily": [ + { + "date": "2026-04-01", + "impressions": 6515, + "clicks": 115, + "cost": 211.94 + }, + { + "date": "2026-04-02", + "impressions": 5262, + "clicks": 131, + "cost": 235.86 + }, + { + "date": "2026-04-03", + "impressions": 4530, + "clicks": 104, + "cost": 192.75 + }, + { + "date": "2026-04-04", + "impressions": 5132, + "clicks": 103, + "cost": 178.07 + }, + { + "date": "2026-04-05", + "impressions": 5349, + "clicks": 122, + "cost": 249.16 + }, + { + "date": "2026-04-06", + "impressions": 6360, + "clicks": 135, + "cost": 261.55 + }, + { + "date": "2026-04-07", + "impressions": 5258, + "clicks": 143, + "cost": 233.27 + }, + { + "date": "2026-04-08", + "impressions": 4972, + "clicks": 161, + "cost": 223.75 + }, + { + "date": "2026-04-09", + "impressions": 5322, + "clicks": 174, + "cost": 216.71 + }, + { + "date": "2026-04-10", + "impressions": 6110, + "clicks": 150, + "cost": 200.38 + }, + { + "date": "2026-04-11", + "impressions": 5248, + "clicks": 166, + "cost": 220.36 + }, + { + "date": "2026-04-12", + "impressions": 6487, + "clicks": 163, + "cost": 241.91 + }, + { + "date": "2026-04-13", + "impressions": 4774, + "clicks": 154, + "cost": 230.25 + }, + { + "date": "2026-04-14", + "impressions": 5344, + "clicks": 169, + "cost": 218.37 + }, + { + "date": "2026-04-15", + "impressions": 6625, + "clicks": 150, + "cost": 226.53 + }, + { + "date": "2026-04-16", + "impressions": 4466, + "clicks": 141, + "cost": 195.9 + }, + { + "date": "2026-04-17", + "impressions": 5230, + "clicks": 121, + "cost": 174.37 + }, + { + "date": "2026-04-18", + "impressions": 6923, + "clicks": 148, + "cost": 212.03 + }, + { + "date": "2026-04-19", + "impressions": 6957, + "clicks": 168, + "cost": 255.68 + }, + { + "date": "2026-04-20", + "impressions": 4657, + "clicks": 149, + "cost": 201.93 + }, + { + "date": "2026-04-21", + "impressions": 4909, + "clicks": 133, + "cost": 241.3 + }, + { + "date": "2026-04-22", + "impressions": 5150, + "clicks": 120, + "cost": 209.46 + }, + { + "date": "2026-04-23", + "impressions": 5856, + "clicks": 118, + "cost": 192.15 + }, + { + "date": "2026-04-24", + "impressions": 6981, + "clicks": 134, + "cost": 223.38 + }, + { + "date": "2026-04-25", + "impressions": 5184, + "clicks": 152, + "cost": 271.93 + }, + { + "date": "2026-04-26", + "impressions": 5166, + "clicks": 179, + "cost": 279.31 + }, + { + "date": "2026-04-27", + "impressions": 4425, + "clicks": 195, + "cost": 266.36 + }, + { + "date": "2026-04-28", + "impressions": 5492, + "clicks": 188, + "cost": 234.52 + }, + { + "date": "2026-04-29", + "impressions": 6422, + "clicks": 137, + "cost": 212.87 + }, + { + "date": "2026-04-30", + "impressions": 5812, + "clicks": 116, + "cost": 193.27 + } + ], + "search_terms": [ + { + "term": "ibra", + "impressions": 1809, + "clicks": 822, + "cost": 956.39, + "conversions": 188.3, + "ctr": 45.44 + }, + { + "term": "ibra makeup", + "impressions": 321, + "clicks": 189, + "cost": 172.84, + "conversions": 25.4, + "ctr": 58.88 + }, + { + "term": "ibra pedzle", + "impressions": 195, + "clicks": 57, + "cost": 78.59, + "conversions": 3.0, + "ctr": 29.23 + }, + { + "term": "puder ibra", + "impressions": 379, + "clicks": 33, + "cost": 67.0, + "conversions": 1.0, + "ctr": 8.71 + }, + { + "term": "ibra", + "impressions": 897, + "clicks": 33, + "cost": 75.21, + "conversions": 11.5, + "ctr": 3.68 + }, + { + "term": "rzesy ibra", + "impressions": 128, + "clicks": 18, + "cost": 31.62, + "conversions": 4.0, + "ctr": 14.06 + }, + { + "term": "gąbeczka ibra", + "impressions": 167, + "clicks": 16, + "cost": 35.58, + "conversions": 1.0, + "ctr": 9.58 + }, + { + "term": "ibra kosmetyki", + "impressions": 32, + "clicks": 12, + "cost": 0.8, + "conversions": 0.0, + "ctr": 37.5 + }, + { + "term": "ibra rzesy", + "impressions": 61, + "clicks": 12, + "cost": 0.77, + "conversions": 7.0, + "ctr": 19.67 + }, + { + "term": "ibra zestaw pędzli", + "impressions": 75, + "clicks": 12, + "cost": 22.31, + "conversions": 0.0, + "ctr": 16.0 + }, + { + "term": "ibra gąbka", + "impressions": 259, + "clicks": 12, + "cost": 14.84, + "conversions": 6.0, + "ctr": 4.63 + }, + { + "term": "ibra pedzle zestaw", + "impressions": 36, + "clicks": 11, + "cost": 17.23, + "conversions": 2.0, + "ctr": 30.56 + }, + { + "term": "pędzle ibra", + "impressions": 64, + "clicks": 11, + "cost": 16.55, + "conversions": 0.0, + "ctr": 17.19 + }, + { + "term": "ibra cień do powiek", + "impressions": 75, + "clicks": 10, + "cost": 17.29, + "conversions": 0.0, + "ctr": 13.33 + }, + { + "term": "ibra gąbka", + "impressions": 187, + "clicks": 10, + "cost": 19.4, + "conversions": 1.0, + "ctr": 5.35 + } + ] + }, + "ga4": { + "current": { + "sessions": 28104, + "users": 20698, + "new_users": 18283, + "pageviews": 90611, + "avg_duration": 170.7, + "bounce_rate": 51.9 + }, + "previous": { + "sessions": 32519, + "users": 24003, + "new_users": 21542, + "pageviews": 94960, + "avg_duration": 149.4, + "bounce_rate": 58.3 + }, + "mom_change": { + "sessions_pct": -13.6, + "users_pct": -13.8, + "new_users_pct": -15.1, + "pageviews_pct": -4.6, + "avg_duration_pct": 14.3, + "bounce_rate_pct": -11.0 + }, + "sources": [ + { + "source_medium": "facebook / paid", + "sessions": 13354 + }, + { + "source_medium": "google / cpc", + "sessions": 4020 + }, + { + "source_medium": "google / organic", + "sessions": 3779 + }, + { + "source_medium": "(direct) / (none)", + "sessions": 3017 + }, + { + "source_medium": "ig / social", + "sessions": 848 + }, + { + "source_medium": "m.facebook.com / referral", + "sessions": 636 + }, + { + "source_medium": "l.facebook.com / referral", + "sessions": 550 + }, + { + "source_medium": "l.instagram.com / referral", + "sessions": 425 + }, + { + "source_medium": "(not set)", + "sessions": 314 + }, + { + "source_medium": "lm.facebook.com / referral", + "sessions": 247 + } + ], + "devices": [ + { + "device": "mobile", + "sessions": 25343 + }, + { + "device": "desktop", + "sessions": 2687 + }, + { + "device": "tablet", + "sessions": 36 + } + ], + "daily": [ + { + "date": "2026-04-01", + "sessions": 820, + "users": 711 + }, + { + "date": "2026-04-02", + "sessions": 788, + "users": 717 + }, + { + "date": "2026-04-03", + "sessions": 593, + "users": 535 + }, + { + "date": "2026-04-04", + "sessions": 587, + "users": 517 + }, + { + "date": "2026-04-05", + "sessions": 637, + "users": 592 + }, + { + "date": "2026-04-06", + "sessions": 869, + "users": 787 + }, + { + "date": "2026-04-07", + "sessions": 865, + "users": 753 + }, + { + "date": "2026-04-08", + "sessions": 1084, + "users": 938 + }, + { + "date": "2026-04-09", + "sessions": 1176, + "users": 1055 + }, + { + "date": "2026-04-10", + "sessions": 1369, + "users": 1215 + }, + { + "date": "2026-04-11", + "sessions": 747, + "users": 654 + }, + { + "date": "2026-04-12", + "sessions": 1486, + "users": 1341 + }, + { + "date": "2026-04-13", + "sessions": 1324, + "users": 1167 + }, + { + "date": "2026-04-14", + "sessions": 1453, + "users": 1302 + }, + { + "date": "2026-04-15", + "sessions": 1167, + "users": 1049 + }, + { + "date": "2026-04-16", + "sessions": 1142, + "users": 1018 + }, + { + "date": "2026-04-17", + "sessions": 1007, + "users": 916 + }, + { + "date": "2026-04-18", + "sessions": 901, + "users": 813 + }, + { + "date": "2026-04-19", + "sessions": 1117, + "users": 1010 + }, + { + "date": "2026-04-20", + "sessions": 854, + "users": 748 + }, + { + "date": "2026-04-21", + "sessions": 891, + "users": 807 + }, + { + "date": "2026-04-22", + "sessions": 871, + "users": 798 + }, + { + "date": "2026-04-23", + "sessions": 1010, + "users": 926 + }, + { + "date": "2026-04-24", + "sessions": 820, + "users": 731 + }, + { + "date": "2026-04-25", + "sessions": 687, + "users": 638 + }, + { + "date": "2026-04-26", + "sessions": 894, + "users": 806 + }, + { + "date": "2026-04-27", + "sessions": 750, + "users": 662 + }, + { + "date": "2026-04-28", + "sessions": 736, + "users": 644 + }, + { + "date": "2026-04-29", + "sessions": 614, + "users": 523 + }, + { + "date": "2026-04-30", + "sessions": 583, + "users": 521 + } + ], + "ecommerce": { + "current": { + "transactions": 1711, + "revenue": 187795.28, + "aov": 109.76 + }, + "previous": { + "transactions": 0, + "revenue": 0.0, + "aov": 0.0 + }, + "mom_change": { + "transactions_pct": null, + "revenue_pct": null, + "aov_pct": null + }, + "daily": [ + { + "date": "2026-04-01", + "revenue": 4065.97, + "transactions": 46 + }, + { + "date": "2026-04-02", + "revenue": 2505.67, + "transactions": 28 + }, + { + "date": "2026-04-03", + "revenue": 2698.03, + "transactions": 24 + }, + { + "date": "2026-04-04", + "revenue": 2289.84, + "transactions": 26 + }, + { + "date": "2026-04-05", + "revenue": 2280.84, + "transactions": 23 + }, + { + "date": "2026-04-06", + "revenue": 3640.54, + "transactions": 44 + }, + { + "date": "2026-04-07", + "revenue": 4565.17, + "transactions": 51 + }, + { + "date": "2026-04-08", + "revenue": 8814.68, + "transactions": 75 + }, + { + "date": "2026-04-09", + "revenue": 6193.02, + "transactions": 69 + }, + { + "date": "2026-04-10", + "revenue": 8431.74, + "transactions": 99 + }, + { + "date": "2026-04-11", + "revenue": 4676.36, + "transactions": 48 + }, + { + "date": "2026-04-12", + "revenue": 8887.72, + "transactions": 87 + }, + { + "date": "2026-04-13", + "revenue": 9542.56, + "transactions": 61 + }, + { + "date": "2026-04-14", + "revenue": 8131.23, + "transactions": 88 + }, + { + "date": "2026-04-15", + "revenue": 6879.09, + "transactions": 72 + }, + { + "date": "2026-04-16", + "revenue": 6008.1, + "transactions": 70 + }, + { + "date": "2026-04-17", + "revenue": 4270.85, + "transactions": 54 + }, + { + "date": "2026-04-18", + "revenue": 4254.25, + "transactions": 45 + }, + { + "date": "2026-04-19", + "revenue": 5988.66, + "transactions": 58 + }, + { + "date": "2026-04-20", + "revenue": 3671.21, + "transactions": 32 + }, + { + "date": "2026-04-21", + "revenue": 3851.26, + "transactions": 34 + }, + { + "date": "2026-04-22", + "revenue": 3583.34, + "transactions": 30 + }, + { + "date": "2026-04-23", + "revenue": 3873.24, + "transactions": 38 + }, + { + "date": "2026-04-24", + "revenue": 2443.2, + "transactions": 22 + }, + { + "date": "2026-04-25", + "revenue": 3040.02, + "transactions": 29 + }, + { + "date": "2026-04-26", + "revenue": 3272.03, + "transactions": 32 + }, + { + "date": "2026-04-27", + "revenue": 4280.95, + "transactions": 39 + }, + { + "date": "2026-04-28", + "revenue": 3230.63, + "transactions": 34 + }, + { + "date": "2026-04-29", + "revenue": 4324.52, + "transactions": 32 + }, + { + "date": "2026-04-30", + "revenue": 4671.93, + "transactions": 28 + } + ], + "revenue_by_source": [ + { + "source_medium": "google / cpc", + "revenue": 39344.26, + "transactions": 345 + }, + { + "source_medium": "google / organic", + "revenue": 33321.73, + "transactions": 263 + }, + { + "source_medium": "facebook / paid", + "revenue": 31135.9, + "transactions": 456 + }, + { + "source_medium": "(direct) / (none)", + "revenue": 23268.81, + "transactions": 173 + }, + { + "source_medium": "ig / social", + "revenue": 4350.16, + "transactions": 37 + }, + { + "source_medium": "(not set)", + "revenue": 2766.38, + "transactions": 24 + }, + { + "source_medium": "ibra-makeup.pl / referral", + "revenue": 1526.83, + "transactions": 3 + }, + { + "source_medium": "l.facebook.com / referral", + "revenue": 1519.87, + "transactions": 28 + }, + { + "source_medium": "m.facebook.com / referral", + "revenue": 1364.05, + "transactions": 26 + }, + { + "source_medium": "shopify_email / email", + "revenue": 1031.58, + "transactions": 10 + } + ], + "top_products": [ + { + "name": "Wygładzający Puder Transparentny No More Pore Pro Makeup Academy IBRA Makeup", + "revenue": 18918.9, + "quantity": 486 + }, + { + "name": "Kępki rzęs Bride Style MIX IBRA Makeup", + "revenue": 5888.83, + "quantity": 468 + }, + { + "name": "Zestaw pędzli do makijażu White IBRA Makeup", + "revenue": 5035.0, + "quantity": 53 + }, + { + "name": "Kępki rzęs Bride Style 10mm IBRA Makeup", + "revenue": 3750.33, + "quantity": 312 + }, + { + "name": "Makeup Blender Sponge marmurkowa gąbka do makijażu IBRA Makeup", + "revenue": 3556.32, + "quantity": 230 + }, + { + "name": "Nawilżający puder pod oczy Under Eye Hydra Powder IBRA Makeup", + "revenue": 3240.59, + "quantity": 106 + }, + { + "name": "Kępki rzęs Bride Style 12mm IBRA Makeup", + "revenue": 3146.88, + "quantity": 258 + }, + { + "name": "Zestaw pędzli do makijażu Fresh IBRA Makeup", + "revenue": 2928.58, + "quantity": 41 + }, + { + "name": "Cień do powiek Brown Sugar IBRA Makeup", + "revenue": 2653.19, + "quantity": 102 + }, + { + "name": "Makeup Blender sponge różowa gąbka do makijażu IBRA Makeup", + "revenue": 2145.1, + "quantity": 139 + } + ], + "source": "google_sheet" + } + }, + "semstorm": null, + "sales_history": [ + { + "month": "2026-04", + "transactions": 1711, + "revenue": 187795.28, + "aov": 109.76, + "source": "google_sheet" + } + ], + "seo_links": [], + "recommendations": [ + { + "icon": "✅", + "title": "Google Ads utrzymuje bardzo dobrą rentowność", + "text": "Konto wygenerowało 641,8 konwersji przy koszcie 6705,35 PLN i ROAS 10,84. Utrzymujemy aktywne kampanie sprzedażowe, a dalsze zwiększanie budżetu prowadzimy sekwencyjnie, przede wszystkim w kampaniach z ROAS powyżej średniej konta." + }, + { + "icon": "📈", + "title": "Więcej konwersji przy prawie stabilnym koszcie pozyskania", + "text": "W porównaniu miesiąc do miesiąca konwersje wzrosły o 7,9%, koszt o 8,2%, a CPA tylko o 0,4%. Skala rosła bez widocznego pogorszenia kosztu pozyskania, dlatego nie tniemy budżetu całościowo. Pracujemy na miksie kampanii i przesuwamy uwagę na te segmenty, które utrzymują rentowność." + }, + { + "icon": "🔍", + "title": "Brand i PMax niosą główny wynik", + "text": "Największą część kosztu i wartości konwersji generują [Search] brand oraz [PMax] products (catch-all). Obie kampanie mają ROAS około 12,9, dlatego zostają główne w strukturze. Zmiany celów ROAS lub budżetów wprowadzamy stopniowo i kontrolujemy wolumen po każdej zmianie." + }, + { + "icon": "⚠", + "title": "Kampanie z niskim ROAS wymagają osobnej decyzji", + "text": "[DG] YouTube Shorts i wybrane kampanie PLA_CL1 mają wyraźnie niższy ROAS niż średnia konta. Nie wyłączamy ich automatycznie tylko na podstawie tego raportu. Rozdzielamy ich role na wsparcie lejka i realną sprzedaż, a przy celu czystej efektywności ograniczamy lub zawężamy je w pierwszej kolejności." + }, + { + "icon": "💰", + "title": "Sprzedaż sklepu jest mocniejsza w danych z arkusza", + "text": "Arkusz sprzedażowy pokazuje 1711 transakcji i 187795,28 PLN przychodu w kwietniu. Średnia wartość koszyka wynosi 109,76 PLN, dlatego w komunikacji i kampaniach wzmacniamy produkty oraz zestawy, które podnoszą wartość zamówienia, zamiast skupiać się wyłącznie na liczbie transakcji." + }, + { + "icon": "➤", + "title": "Rekomendowany następny krok", + "text": "Utrzymujemy główny kierunek konta, a optymalizacje prowadzimy punktowo: kontrolujemy kampanie o niskim ROAS, analizujemy udział brandu w wyniku i porównujemy PMax z PLA pod kątem produktów, które można efektywniej skalować. Zmiany budżetów i celów Smart Bidding wdrażamy pojedynczo, z oceną po kolejnej paczce danych." + } + ] +} \ No newline at end of file diff --git a/scripts/reports/output/ibra-makeup.pl_2026-04_recommendations.json b/scripts/reports/output/ibra-makeup.pl_2026-04_recommendations.json new file mode 100644 index 0000000..8259031 --- /dev/null +++ b/scripts/reports/output/ibra-makeup.pl_2026-04_recommendations.json @@ -0,0 +1,97 @@ +{ + "source": "agent_ai", + "instruction": "Uzupelnia agent AI po analizie danych raportu. Skrypt nie powinien sam generowac wnioskow ani rekomendacji.", + "context": { + "google_ads_totals": { + "cost": 6705.35, + "clicks": 4339, + "conversions": 641.8, + "conversion_value": 72679.4, + "roas": 10.84, + "cpa": 10.45 + }, + "google_ads_mom_change": { + "cost_pct": 8.2, + "clicks_pct": 1.7, + "conversions_pct": 7.9, + "conversion_value_pct": 0, + "roas_pct": 0, + "cpa_pct": 0.4 + }, + "ga4_ecommerce": { + "transactions": 1711, + "revenue": 187795.28, + "transactions_pct": 0, + "revenue_pct": 0 + }, + "top_campaigns_by_cost": [ + { + "name": "[Search] brand", + "cost": 2712.17, + "conversions": 298.2, + "conversion_value": 34878.62, + "roas": 12.86 + }, + { + "name": "[PMax] products (catch-all)", + "cost": 2263.02, + "conversions": 255.0, + "conversion_value": 29204.43, + "roas": 12.91 + }, + { + "name": "[PLA] produkty (bestsellers)", + "cost": 607.74, + "conversions": 70.5, + "conversion_value": 7193.01, + "roas": 11.84 + }, + { + "name": "[DG] YouTube Shorts", + "cost": 502.01, + "conversions": 7.3, + "conversion_value": 725.68, + "roas": 1.45 + }, + { + "name": "[GDN] porzucone koszyki", + "cost": 304.26, + "conversions": 5.8, + "conversion_value": 310.57, + "roas": 1.02 + } + ] + }, + "recommendations": [ + { + "icon": "✅", + "title": "Google Ads utrzymuje bardzo dobrą rentowność", + "text": "Konto wygenerowało 641,8 konwersji przy koszcie 6705,35 PLN i ROAS 10,84. Utrzymujemy aktywne kampanie sprzedażowe, a dalsze zwiększanie budżetu prowadzimy sekwencyjnie, przede wszystkim w kampaniach z ROAS powyżej średniej konta." + }, + { + "icon": "📈", + "title": "Więcej konwersji przy prawie stabilnym koszcie pozyskania", + "text": "W porównaniu miesiąc do miesiąca konwersje wzrosły o 7,9%, koszt o 8,2%, a CPA tylko o 0,4%. Skala rosła bez widocznego pogorszenia kosztu pozyskania, dlatego nie tniemy budżetu całościowo. Pracujemy na miksie kampanii i przesuwamy uwagę na te segmenty, które utrzymują rentowność." + }, + { + "icon": "🔍", + "title": "Brand i PMax niosą główny wynik", + "text": "Największą część kosztu i wartości konwersji generują [Search] brand oraz [PMax] products (catch-all). Obie kampanie mają ROAS około 12,9, dlatego zostają główne w strukturze. Zmiany celów ROAS lub budżetów wprowadzamy stopniowo i kontrolujemy wolumen po każdej zmianie." + }, + { + "icon": "⚠", + "title": "Kampanie z niskim ROAS wymagają osobnej decyzji", + "text": "[DG] YouTube Shorts i wybrane kampanie PLA_CL1 mają wyraźnie niższy ROAS niż średnia konta. Nie wyłączamy ich automatycznie tylko na podstawie tego raportu. Rozdzielamy ich role na wsparcie lejka i realną sprzedaż, a przy celu czystej efektywności ograniczamy lub zawężamy je w pierwszej kolejności." + }, + { + "icon": "💰", + "title": "Sprzedaż sklepu jest mocniejsza w danych z arkusza", + "text": "Arkusz sprzedażowy pokazuje 1711 transakcji i 187795,28 PLN przychodu w kwietniu. Średnia wartość koszyka wynosi 109,76 PLN, dlatego w komunikacji i kampaniach wzmacniamy produkty oraz zestawy, które podnoszą wartość zamówienia, zamiast skupiać się wyłącznie na liczbie transakcji." + }, + { + "icon": "➤", + "title": "Rekomendowany następny krok", + "text": "Utrzymujemy główny kierunek konta, a optymalizacje prowadzimy punktowo: kontrolujemy kampanie o niskim ROAS, analizujemy udział brandu w wyniku i porównujemy PMax z PLA pod kątem produktów, które można efektywniej skalować. Zmiany budżetów i celów Smart Bidding wdrażamy pojedynczo, z oceną po kolejnej paczce danych." + } + ] +} diff --git a/scripts/reports/test_ga4_access.py b/scripts/reports/test_ga4_access.py new file mode 100644 index 0000000..6a41006 --- /dev/null +++ b/scripts/reports/test_ga4_access.py @@ -0,0 +1,40 @@ +"""Quick test: check if OAuth credentials can access GA4 property.""" +import os +from dotenv import load_dotenv +from google.oauth2.credentials import Credentials +from google.analytics.data_v1beta import BetaAnalyticsDataClient +from google.analytics.data_v1beta.types import RunReportRequest, DateRange, Metric + +load_dotenv() + +PROPERTY_ID = os.getenv("GA4_PROPERTY_ID_studio-zoe.pl") +CLIENT_ID = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_ID") +CLIENT_SECRET = os.getenv("GOOGLE_ADS_OAUTH2_CLIENT_SECRET") +REFRESH_TOKEN = os.getenv("GA4_REFRESH_TOKEN") + +print(f"GA4 Property ID: {PROPERTY_ID}") + +credentials = Credentials( + token=None, + refresh_token=REFRESH_TOKEN, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + token_uri="https://oauth2.googleapis.com/token", +) + +client = BetaAnalyticsDataClient(credentials=credentials) + +try: + request = RunReportRequest( + property=f"properties/{PROPERTY_ID}", + date_ranges=[DateRange(start_date="2026-02-01", end_date="2026-02-28")], + metrics=[Metric(name="sessions"), Metric(name="totalUsers")], + ) + response = client.run_report(request) + + for row in response.rows: + print(f"Sessions: {row.metric_values[0].value}, Users: {row.metric_values[1].value}") + + print("\nGA4 access works!") +except Exception as e: + print(f"\nGA4 access failed: {e}") diff --git a/scripts/reports/upload_report_ftp.py b/scripts/reports/upload_report_ftp.py new file mode 100644 index 0000000..beccc3d --- /dev/null +++ b/scripts/reports/upload_report_ftp.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Upload raportu HTML na serwer FTP (adspro.projectpro.pl). + +Użycie: + python scripts/reports/upload_report_ftp.py --local-dir output/studio-zoe.pl/2026-02/ --remote-path /raporty/studio-zoe/2026-02/ +""" + +import argparse +import ftplib +import os +import sys +import io +from pathlib import Path + +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(ROOT)) +from src.gads_v2.config import load_env + +load_env(ROOT / ".env") + + +def ftp_mkdirs(ftp, path): + """Create nested directories on FTP server.""" + dirs = path.strip("/").split("/") + current = "" + for d in dirs: + current += f"/{d}" + try: + ftp.cwd(current) + except ftplib.error_perm: + try: + ftp.mkd(current) + print(f" Utworzono katalog: {current}") + except ftplib.error_perm: + pass # already exists or no permission + + +def main(): + parser = argparse.ArgumentParser(description="Upload raportu na FTP") + parser.add_argument("--local-dir", required=True, help="Lokalny folder z raportem") + parser.add_argument("--remote-path", required=True, help="Sciezka na serwerze (np. /raporty/studio-zoe/2026-02/)") + args = parser.parse_args() + + host = os.environ["ADSPRO_HOST"] + user = os.environ["ADSPRO_USERNAME"] + password = os.environ["ADSPRO_PASSWORD"] + base_path = os.environ["ADSPRO_REMOTE_PATH"] + + local_dir = Path(args.local_dir) + if not local_dir.is_absolute(): + local_dir = ROOT / "scripts" / "reports" / local_dir + + if not local_dir.exists(): + print(f"Blad: folder {local_dir} nie istnieje") + sys.exit(1) + + # Fix Git Bash path mangling on Windows (e.g. /raporty -> C:/Program Files/Git/raporty) + remote_path = args.remote_path + if "Program Files/Git" in remote_path or ":" in remote_path: + # Extract the intended path after the Git prefix + import re + match = re.search(r'/raporty/.+', remote_path) + if match: + remote_path = match.group(0) + else: + remote_path = "/" + remote_path.split("/")[-3] + "/" + remote_path.split("/")[-2] + "/" + remote_path.split("/")[-1] + + remote_full = base_path.rstrip("/") + "/" + remote_path.strip("/") + + print(f"Laczenie z {host}...") + + # Try FTP_TLS first, fallback to plain FTP + try: + ftp = ftplib.FTP_TLS(host, timeout=30) + ftp.login(user, password) + ftp.prot_p() + print(" Polaczono (FTP TLS)") + except Exception: + ftp = ftplib.FTP(host, timeout=30) + ftp.login(user, password) + print(" Polaczono (FTP)") + + ftp.encoding = "utf-8" + + # Create remote directory structure + print(f"Tworzenie katalogow: {remote_full}") + ftp_mkdirs(ftp, remote_full) + ftp.cwd(remote_full) + + # Upload all files + files_uploaded = 0 + for file_path in local_dir.rglob("*"): + if file_path.is_file(): + relative = file_path.relative_to(local_dir) + remote_file = str(relative).replace("\\", "/") + + # Create subdirectories if needed + if "/" in remote_file: + subdir = "/".join(remote_file.split("/")[:-1]) + ftp_mkdirs(ftp, remote_full + "/" + subdir) + ftp.cwd(remote_full) + + print(f" Uploading: {remote_file} ({file_path.stat().st_size:,} bytes)") + with open(file_path, "rb") as f: + ftp.storbinary(f"STOR {remote_file}", f) + files_uploaded += 1 + + ftp.quit() + + domain_part = args.remote_path.strip("/") + url = f"https://adspro.projectpro.pl/{domain_part}/" + + print(f"\nUpload zakonczony! {files_uploaded} plikow.") + print(f"URL: {url}") + + +if __name__ == "__main__": + main() diff --git a/src/gads_v2/__init__.py b/src/gads_v2/__init__.py new file mode 100644 index 0000000..f38884b --- /dev/null +++ b/src/gads_v2/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.1.0" + diff --git a/src/gads_v2/cleanup.py b/src/gads_v2/cleanup.py new file mode 100644 index 0000000..25388cb --- /dev/null +++ b/src/gads_v2/cleanup.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from pathlib import Path + +from .config import ROOT +from .history import now_local + + +@dataclass(frozen=True) +class CleanupResult: + retention_days: int + scanned_files: int + deleted_files: int + deleted_bytes: int + + +def cleanup_old_plans(retention_days: int = 7) -> CleanupResult: + cutoff = now_local() - timedelta(days=max(retention_days, 0)) + plans_root = ROOT / "clients" + scanned = 0 + deleted = 0 + deleted_bytes = 0 + if not plans_root.exists(): + return CleanupResult(retention_days, scanned, deleted, deleted_bytes) + + for plans_dir in plans_root.glob("*/plans"): + if not plans_dir.is_dir(): + continue + for path in plans_dir.iterdir(): + if path.suffix.lower() not in {".json", ".md"}: + continue + scanned += 1 + try: + modified = now_local().fromtimestamp(path.stat().st_mtime, tz=now_local().tzinfo) + except OSError: + continue + if modified >= cutoff: + continue + try: + size = path.stat().st_size + path.unlink() + except OSError: + continue + deleted += 1 + deleted_bytes += size + return CleanupResult(retention_days, scanned, deleted, deleted_bytes) + + +def plan_retention_days(global_rules: dict, default: int = 7) -> int: + cleanup_rules = global_rules.get("plan_cleanup", {}) + try: + return int(cleanup_rules.get("retention_days", default)) + except (TypeError, ValueError): + return default + + +def print_cleanup_result(result: CleanupResult) -> None: + if result.deleted_files <= 0: + return + mb = result.deleted_bytes / 1024 / 1024 + print( + "\nAutoczyszczenie planow: " + f"usunieto {result.deleted_files} plikow starszych niz {result.retention_days} dni " + f"({mb:.2f} MB)." + ) diff --git a/src/gads_v2/cli.py b/src/gads_v2/cli.py new file mode 100644 index 0000000..c48cbed --- /dev/null +++ b/src/gads_v2/cli.py @@ -0,0 +1,1783 @@ +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from collections import Counter +from datetime import datetime +from textwrap import wrap + +from .cleanup import cleanup_old_plans, plan_retention_days, print_cleanup_result +from .config import load_config, load_env +from .knowledge.importer import import_knowledge_file +from .knowledge.legacy_import import import_legacy_lancedb +from .knowledge.store import ( + approve_task_suggestion, + assign_rule_to_task, + delete_rule, + ensure_knowledge_store, + list_rules, + load_rules, + mark_review_progress, + pending_task_suggestions, + reject_task_suggestion, + reset_review_state, + rule_preview, + rules_for_task, + review_start_index, + search_rules, + store_summary, + unassigned_rules, + update_rule_status, +) +from .knowledge.vector_index import build_vector_index, semantic_search, vector_index_summary +from .reminders import add_reminder, print_client_reminders +from .table import print_table +from .task_catalog import ( + load_groups, + load_tasks, + print_task_list, + task_by_number, + task_by_selection, + tasks_by_group_number, + tasks_by_selection_group, +) +from .tasks.pla_settings_check import run_check_pla_settings +from .tasks.pla_cl1_sync import run_sync_pla_cl1 +from .tasks.shopping_troas_ag_optimization import run_optimize_shopping_troas_ag +from .tasks.account_anomaly_check import run_check_account_anomalies +from .tasks.ad_asset_status_check import run_check_ad_asset_statuses +from .tasks.ad_schedule_check import run_check_ad_schedules +from .tasks.bidding_strategy_check import run_check_bidding_strategies +from .tasks.budget_usage_check import run_check_budget_usage +from .tasks.campaign_language_check import run_check_campaign_languages +from .tasks.campaign_location_check import run_check_campaign_locations +from .tasks.campaign_network_check import run_check_campaign_networks +from .tasks.conversion_tracking_check import run_check_conversion_tracking +from .tasks.device_performance_check import run_check_device_performance +from .tasks.feed_merchant_quality_check import run_check_feed_merchant_quality +from .tasks.auction_insights_check import run_check_auction_insights +from .tasks.impression_share_check import run_check_impression_share +from .tasks.keyword_status_check import run_check_keyword_statuses +from .tasks.pmax_structure_check import run_check_pmax_structure +from .tasks.remarketing_setup_check import run_check_remarketing_setup +from .tasks.rsa_assets_check import run_check_rsa_assets +from .tasks.search_basic_settings_check import run_check_search_basic_settings +from .tasks.search_terms_check import run_check_search_terms +from .tasks.shopping_product_status_check import run_check_shopping_product_statuses +from .tasks.product_feed_optimization import ( + run_fill_product_unit_pricing, + run_optimize_product_categories, + run_optimize_product_feed, + run_optimize_product_titles, +) +from .tasks.additional_audits import ( + run_check_ad_group_performance, + run_check_age_performance, + run_check_conversion_action_performance, + run_check_day_of_week_performance, + run_check_gender_performance, + run_check_hour_of_day_performance, + run_check_keyword_quality_score, + run_check_landing_page_performance, + run_check_network_performance, + run_check_shopping_product_performance, +) + + +def choose_index(label: str, options: list[str]) -> int | None: + print(f"\n{label}") + for i, option in enumerate(options, 1): + print(f"{i}. {option}") + try: + raw = input("Wybierz numer albo Enter aby wyjsc: ").strip() + except EOFError: + print() + return None + if not raw: + return None + try: + idx = int(raw) + except ValueError: + print("Nieprawidlowy numer.") + return None + if idx < 1 or idx > len(options): + print("Nieprawidlowy numer.") + return None + return idx - 1 + + +def main() -> None: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + parser = argparse.ArgumentParser(description="Google Ads ver 2") + parser.add_argument( + "command", + nargs="?", + choices=["analiza-klienta", "analiza-zadania", "wiedza", "notatka-przypomnij", "raport-klienta"], + help="Tryb klient -> zadanie, zadanie -> wszyscy klienci, baza wiedzy albo przypomnienie", + ) + parser.add_argument( + "knowledge_action", + nargs="?", + help="Komenda wiedzy: init, dodaj, propozycje, zatwierdz, odrzuc, szukaj, szukaj-ai, reguly, lista, statystyki", + ) + parser.add_argument( + "knowledge_query", + nargs="*", + help="Zapytanie dla komendy wiedza szukaj", + ) + parser.add_argument("--client", help="Domena klienta z config/clients.toml") + parser.add_argument("--client-number", type=int, help="Numer klienta z listy analiza-klienta") + parser.add_argument("--month", help="Miesiac raportu: YYYY-MM albo MM.YYYY") + parser.add_argument("--client-name", help="Czytelna nazwa klienta na raporcie") + parser.add_argument("--confirm-upload", help="Wymagane do uploadu raportu. Uzyj dokladnie: TAK") + parser.add_argument("--confirm-recommendations", help="Wymagane do wygenerowania HTML z proponowanymi wnioskami. Uzyj dokladnie: TAK") + parser.add_argument("--skip-ga4", action="store_true", help="Pomin GA4 przy raporcie klienta") + parser.add_argument( + "--task", + choices=[ + "sync_pla_cl1", + "optimize_shopping_troas_ag", + "check_pla_settings", + "optimize_product_feed", + "optimize_product_titles", + "optimize_product_categories", + "fill_product_unit_pricing", + "check_conversion_tracking", + "check_search_basic_settings", + "check_budget_usage", + "check_bidding_strategies", + "check_search_terms", + "check_rsa_assets", + "check_feed_merchant_quality", + "check_pmax_structure", + "check_remarketing_setup", + "check_account_anomalies", + "check_campaign_locations", + "check_campaign_networks", + "check_campaign_languages", + "check_ad_schedules", + "check_ad_asset_statuses", + "check_keyword_statuses", + "check_shopping_product_statuses", + "check_impression_share", + "check_auction_insights", + "check_device_performance", + "check_day_of_week_performance", + "check_hour_of_day_performance", + "check_network_performance", + "check_ad_group_performance", + "check_keyword_quality_score", + "check_landing_page_performance", + "check_conversion_action_performance", + "check_shopping_product_performance", + "check_gender_performance", + "check_age_performance", + ], + help="Zadanie do uruchomienia bez menu", + ) + parser.add_argument("--select", help="Wybór z listy zadan, np. 1.1, 1.0 albo ALL") + parser.add_argument("--task-number", type=int, help="Numer zadania z listy analiza-klienta") + parser.add_argument("--group-number", type=int, help="Uruchom wszystkie zadania z grupy o podanym numerze") + parser.add_argument("--group-all-current", action="store_true", help="Uruchom wszystkie zadania z pierwszej widocznej grupy") + parser.add_argument("--all-groups", action="store_true", help="Uruchom wszystkie zadania ze wszystkich grup") + parser.add_argument("--file", help="Plik zrodlowy dla komendy wiedza dodaj") + parser.add_argument("--source", help="Czytelna nazwa zrodla dla komendy wiedza dodaj") + parser.add_argument("--model", help="Model OpenAI dla komendy wiedza dodaj") + parser.add_argument("--rule-id", help="ID reguly dla akceptacji lub odrzucenia propozycji zadania") + parser.add_argument("--topic", help="Filtr tematu dla komend wiedzy") + parser.add_argument("--status", help="Filtr statusu regul: active, draft, archived, duplicate") + parser.add_argument("--duplicate-of", help="ID reguly nadrzednej dla komendy wiedza duplikat") + parser.add_argument("--from", dest="from_path", help="Sciezka zrodlowa, np. stara baza LanceDB") + parser.add_argument("--table", default="fakty", help="Nazwa tabeli przy imporcie starej LanceDB") + parser.add_argument("--import-limit", type=int, help="Opcjonalny limit importowanych rekordow ze starej bazy") + parser.add_argument("--restart", action="store_true", help="Zacznij przeglad nieprzypisanych regul od poczatku") + parser.add_argument("--max-rules", type=int, default=12, help="Maksymalna liczba regul z jednego importu wiedzy") + parser.add_argument("--dry-run", action="store_true", help="Sprawdz import wiedzy bez wywolania API i zapisu regul") + parser.add_argument("--limit", type=int, help="Limit wynikow dla komend wiedzy") + parser.add_argument("--plan-only", action="store_true", help="Tylko przygotuj plan i zapisz go do pliku") + parser.add_argument("--apply-plan", help="Wdroz zapisany plan JSON") + parser.add_argument("--keep-plans-days", type=int, help="Ile dni trzymac stare plany przed autoczyszczeniem") + parser.add_argument( + "--confirm-apply", + help="Wymagane przy --apply-plan. Uzyj dokladnie: TAK", + ) + args = parser.parse_args() + + load_env() + if args.command == "wiedza": + handle_knowledge_command(args) + return + + try: + cfg = load_config() + except Exception as exc: + print(exc) + sys.exit(1) + + domains = sorted(cfg.clients) + if not domains: + print("Brak klientow w config/clients.toml.") + return + + cleanup_days = args.keep_plans_days if args.keep_plans_days is not None else plan_retention_days(cfg.global_rules) + if args.command in {"analiza-klienta", "analiza-zadania"}: + print_cleanup_result(cleanup_old_plans(cleanup_days)) + + tasks = load_tasks() + groups = load_groups() + selected_domain = args.client + if args.client_number: + if args.client_number < 1 or args.client_number > len(domains): + print(f"Nie ma klienta numer {args.client_number}.") + return + selected_domain = domains[args.client_number - 1] + + if args.command == "notatka-przypomnij": + reminder_text = " ".join([part for part in [args.knowledge_action, *args.knowledge_query] if part]).strip() + if not reminder_text: + print("Podaj tresc przypomnienia, np.:") + print('python gads.py notatka-przypomnij --client aruba.rzeszow.pl "Za 4 tygodnie przypomnij mi o porownaniu PLA wzgledem PMax"') + return + if selected_domain and selected_domain not in cfg.clients: + print(f"Nie znaleziono klienta {selected_domain} w config/clients.toml.") + return + try: + reminder = add_reminder(reminder_text, domain=selected_domain) + except Exception as exc: + print(f"Nie udalo sie zapisac przypomnienia: {exc}") + return + print("\nDodano przypomnienie.") + print_table( + ["Pole", "Wartosc"], + [ + ["Termin", reminder.due_date], + ["Zakres", reminder.client or "globalne"], + ["Notatka", reminder.text], + ], + ) + return + + if args.command == "raport-klienta": + handle_client_report_command(args, cfg, domains, selected_domain) + return + + if ( + args.command == "analiza-klienta" + and resolve_report_domain(args.knowledge_action, domains) in cfg.clients + and args.knowledge_query + and looks_like_report_month(args.knowledge_query[0]) + ): + handle_client_report_command(args, cfg, domains, selected_domain) + return + + if args.command == "analiza-klienta": + if not selected_domain: + print("\nWybierz klienta:") + print_table(["Nr", "Domena"], [[str(i), domain] for i, domain in enumerate(domains, 1)]) + print("\nNastepny krok:") + print("python gads.py analiza-klienta --client-number ") + return + + if selected_domain not in cfg.clients: + print(f"Nie znaleziono klienta {selected_domain} w config/clients.toml.") + return + + if not args.select and not args.task_number and not args.group_number and not args.group_all_current and not args.all_groups: + print(f"\nKlient: {selected_domain}") + print_client_reminders(selected_domain) + print_task_list(tasks) + print("\nNastepny krok:") + print( + "python gads.py analiza-klienta " + f"--client-number {domains.index(selected_domain) + 1} " + "--select --plan-only" + ) + return + + if args.select: + selected = args.select.strip() + if selected.upper() == "ALL": + run_task_sequence( + tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + selected_group_tasks = tasks_by_selection_group(tasks, groups, selected) + if selected_group_tasks: + run_task_sequence( + selected_group_tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + selected_task = task_by_selection(tasks, selected) + if not selected_task: + print(f"Nie ma wyboru {selected}.") + return + run_task( + selected_task.id, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + apply_plan_path=args.apply_plan, + confirm_apply=args.confirm_apply, + ) + return + + if args.group_all_current: + first_group_number = groups[0].number if groups else None + selected_tasks = tasks_by_group_number(tasks, groups, first_group_number) if first_group_number else [] + if not selected_tasks: + print("Brak zadan w pierwszej grupie.") + return + run_task_sequence( + selected_tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + + if args.group_number: + selected_tasks = tasks_by_group_number(tasks, groups, args.group_number) + if not selected_tasks: + print(f"Nie ma grupy numer {args.group_number}.") + return + run_task_sequence( + selected_tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + + if args.all_groups: + run_task_sequence( + tasks, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + ) + return + + selected_task = task_by_number(tasks, args.task_number) + if not selected_task: + print(f"Nie ma zadania numer {args.task_number}.") + return + run_task( + selected_task.id, + cfg.clients[selected_domain], + cfg.global_rules, + plan_only=args.plan_only, + apply_plan_path=args.apply_plan, + confirm_apply=args.confirm_apply, + ) + return + + if args.command != "analiza-zadania" and (args.client or args.task): + if not args.client or not args.task: + print("Dla trybu bez menu podaj jednoczesnie --client i --task.") + return + if args.client not in cfg.clients: + print(f"Nie znaleziono klienta {args.client} w config/clients.toml.") + return + run_task( + args.task, + cfg.clients[args.client], + cfg.global_rules, + plan_only=args.plan_only, + apply_plan_path=args.apply_plan, + confirm_apply=args.confirm_apply, + ) + return + + if args.command == "analiza-zadania": + if args.apply_plan: + print("Tryb analiza-zadania przygotowuje plany dla wielu klientow i nie obsluguje --apply-plan.") + print("Wdrozenie wykonuj osobno dla konkretnego klienta i konkretnego pliku planu.") + return + + if not args.select and not args.task and not args.task_number and not args.group_number and not args.all_groups: + print("\nWybierz zadanie do uruchomienia dla wszystkich klientow:") + print_task_list(tasks) + print("\nNastepny krok:") + print("python gads.py analiza-zadania --select ") + print("\nTa komenda przygotuje plany po kolei dla wszystkich klientow z config/clients.toml.") + return + + selected_tasks = [] + if args.select: + selected = args.select.strip() + if selected.upper() == "ALL": + selected_tasks = tasks + else: + selected_tasks = tasks_by_selection_group(tasks, groups, selected) + if not selected_tasks: + selected_task = task_by_selection(tasks, selected) + selected_tasks = [selected_task] if selected_task else [] + if not selected_tasks: + print(f"Nie ma wyboru {selected}.") + return + elif args.group_number: + selected_tasks = tasks_by_group_number(tasks, groups, args.group_number) + if not selected_tasks: + print(f"Nie ma grupy numer {args.group_number}.") + return + elif args.all_groups: + selected_tasks = tasks + elif args.task: + selected_tasks = [task for task in tasks if task.id == args.task] + if not selected_tasks: + print(f"Nie ma zadania {args.task}.") + return + elif args.task_number: + selected_task = task_by_number(tasks, args.task_number) + selected_tasks = [selected_task] if selected_task else [] + if not selected_tasks: + print(f"Nie ma zadania numer {args.task_number}.") + return + + run_tasks_for_all_clients( + selected_tasks, + cfg, + domains, + plan_only=True, + ) + return + + client_idx = choose_index("Klient", domains) + if client_idx is None: + return + client = cfg.clients[domains[client_idx]] + + task_labels = [task.name for task in tasks] + task_idx = choose_index("Zadanie", task_labels) + if task_idx is None: + return + + run_task(tasks[task_idx].id, client, cfg.global_rules) + + +def handle_knowledge_command(args: argparse.Namespace) -> None: + if not args.knowledge_action: + print("\nKomendy bazy wiedzy:") + print("python gads.py wiedza init") + print('python gads.py wiedza dodaj --file "knowledge/sources/artykul.md" --source "Nazwa zrodla"') + print("python gads.py wiedza propozycje") + print("python gads.py wiedza zatwierdz --rule-id --task ") + print("python gads.py wiedza odrzuc --rule-id --task ") + print('python gads.py wiedza szukaj "pmax shopping"') + print("python gads.py wiedza reguly --task check_pla_settings") + print("python gads.py wiedza lista --topic shopping") + print("python gads.py wiedza statystyki") + print("python gads.py wiedza indeksuj") + print('python gads.py wiedza szukaj-ai "czy PMax kanibalizuje Display?"') + print('python gads.py wiedza import-stare --from "D:\\google ads\\lancedb"') + print("python gads.py wiedza przypisz") + return + + if args.knowledge_action == "init": + paths = ensure_knowledge_store() + summary = store_summary() + print("\nLokalna baza wiedzy jest gotowa.") + print_table( + ["Element", "Sciezka"], + [ + ["Katalog wiedzy", str(paths["knowledge_dir"])], + ["Materialy zrodlowe", str(paths["sources_dir"])], + ["Reguly JSONL", str(paths["rules_path"])], + ["Historia importow", str(paths["imports_path"])], + ["LanceDB", str(paths["lancedb_dir"])], + ], + ) + print_table( + ["Metryka", "Liczba"], + [ + ["Reguly", str(summary["rules"])], + ["Reguly aktywne", str(summary["active_rules"])], + ["Reguly robocze", str(summary["draft_rules"])], + ["Reguly zarchiwizowane", str(summary["archived_rules"])], + ["Reguly-duplikaty", str(summary["duplicate_rules"])], + ["Tematy", str(summary["topics"])], + ["Zadania zatwierdzone", str(summary["task_ids"])], + ["Zadania proponowane", str(summary["suggested_task_ids"])], + ["Propozycje do akceptacji", str(summary["suggestions"])], + ["Zrodla", str(summary["sources"])], + ], + ) + index_meta = vector_index_summary() + if index_meta: + print("\nIndeks semantyczny") + print_table( + ["Pole", "Wartosc"], + [ + ["Model", str(index_meta.get("model", ""))], + ["Reguly w indeksie", str(index_meta.get("rules_count", ""))], + ["Utworzono", str(index_meta.get("created_at", ""))], + ["Tabela zapisana w meta", str(index_meta.get("table_path", ""))], + ["Tabela dla tego komputera", str(index_meta.get("current_table_path", ""))], + ["Dostepny lokalnie", "TAK" if index_meta.get("current_available") else "NIE"], + ], + ) + if not index_meta.get("current_available"): + print("Uruchom `python gads.py wiedza indeksuj`, aby odbudowac indeks na tym komputerze.") + return + + if args.knowledge_action == "dodaj": + if not args.file: + print('Podaj plik, np. python gads.py wiedza dodaj --file "knowledge/sources/artykul.md" --source "Nazwa zrodla"') + return + if not args.source: + print('Podaj --source, np. --source "Optmyzr blog"') + return + try: + result = import_knowledge_file( + file_path=Path(args.file), + source_name=args.source, + model=args.model, + max_rules=args.max_rules, + dry_run=args.dry_run, + ) + except Exception as exc: + print(f"Nie udalo sie dodac wiedzy: {exc}") + return + if result.dry_run: + print("\nImport wiedzy sprawdzony w trybie dry-run.") + print("API nie zostalo wywolane i reguly nie zostaly zapisane.") + else: + print("\nDodano wiedze do lokalnej bazy.") + print_table( + ["Pole", "Wartosc"], + [ + ["Plik", str(result.source_path)], + ["Zrodlo", result.source_name], + ["Model", result.model], + ["Nowe reguly", str(result.rules_count)], + ["Uwagi", result.notes], + ], + ) + if not result.dry_run: + ask_about_task_suggestions(rule_ids=set(result.rule_ids), limit=knowledge_limit(args)) + return + + if args.knowledge_action == "import-stare": + source_path = Path(args.from_path) if args.from_path else Path(r"D:\google ads\lancedb") + try: + result = import_legacy_lancedb( + db_path=source_path, + table_name=args.table, + limit=args.import_limit, + ) + except Exception as exc: + print(f"Nie udalo sie zaimportowac starej LanceDB: {exc}") + return + print("\nZaimportowano wiedze ze starej LanceDB bez przypisywania do zadan.") + print_table( + ["Pole", "Wartosc"], + [ + ["Baza", str(result.db_path)], + ["Tabela", result.table_name], + ["Rekordy w zrodle", str(result.total_rows)], + ["Dodano regul", str(result.imported_count)], + ["Pominieto istniejace", str(result.skipped_existing_count)], + ], + ) + print("\nPo imporcie odswiez indeks semantyczny:") + print("python gads.py wiedza indeksuj") + return + + if args.knowledge_action == "propozycje": + ask_about_task_suggestions(limit=knowledge_limit(args)) + return + + if args.knowledge_action == "przypisz": + review_unassigned_rules(limit=knowledge_limit(args, default=1), restart=args.restart) + return + + if args.knowledge_action in {"zatwierdz", "odrzuc"}: + if not args.rule_id: + print("Podaj --rule-id.") + return + if not args.task: + print("Podaj --task z istniejacym task_id.") + return + try: + if args.knowledge_action == "zatwierdz": + rule = approve_task_suggestion(args.rule_id, args.task) + print(f"Zatwierdzono przypisanie reguly {rule.id} do zadania {args.task}.") + else: + rule = reject_task_suggestion(args.rule_id, args.task) + print(f"Odrzucono propozycje przypisania reguly {rule.id} do zadania {args.task}.") + except Exception as exc: + print(f"Nie udalo sie zaktualizowac propozycji: {exc}") + return + return + + if args.knowledge_action == "szukaj": + query = " ".join(args.knowledge_query).strip() + if not query: + print('Podaj zapytanie, np. python gads.py wiedza szukaj "pmax shopping"') + return + results = search_rules(query, limit=knowledge_limit(args)) + if not results: + print("Brak pasujacych regul.") + return + rows = [ + [ + str(index), + str(result.score), + result.rule.id, + result.rule.topic, + ", ".join(result.rule.task_ids), + rule_preview(result.rule), + ] + for index, result in enumerate(results, 1) + ] + print_table(["Nr", "Wynik", "ID", "Temat", "Zadania", "Regula"], rows) + return + + if args.knowledge_action == "szukaj-ai": + query = " ".join(args.knowledge_query).strip() + if not query: + print('Podaj zapytanie, np. python gads.py wiedza szukaj-ai "czy PMax kanibalizuje Display?"') + return + try: + results = semantic_search(query, limit=knowledge_limit(args), model=args.model) + except Exception as exc: + print(f"Nie udalo sie wykonac wyszukiwania semantycznego: {exc}") + return + if not results: + print("Brak pasujacych regul.") + return + rows = [ + [ + str(index), + f"{result.distance:.4f}", + result.id, + result.topic, + result.task_ids, + result.text, + ] + for index, result in enumerate(results, 1) + ] + print_table(["Nr", "Dystans", "ID", "Temat", "Zadania", "Regula"], rows) + return + + if args.knowledge_action == "reguly": + if not args.task: + print("Podaj zadanie przez --task, np. python gads.py wiedza reguly --task check_pla_settings") + return + rules = rules_for_task(args.task) + if not rules: + print(f"Brak regul dla zadania: {args.task}") + return + rows = [ + [str(index), rule.id, rule.topic, rule.rule_type, rule_preview(rule)] + for index, rule in enumerate(rules[: max(knowledge_limit(args), 0)], 1) + ] + print_table(["Nr", "ID", "Temat", "Typ", "Regula"], rows) + limit = knowledge_limit(args) + if len(rules) > limit: + print(f"... oraz {len(rules) - limit} kolejnych regul") + return + + if args.knowledge_action == "indeksuj": + try: + result = build_vector_index(model=args.model) + except Exception as exc: + print(f"Nie udalo sie zbudowac indeksu LanceDB: {exc}") + return + print("\nZbudowano indeks semantyczny LanceDB.") + print_table( + ["Pole", "Wartosc"], + [ + ["Tabela", str(result.table_path)], + ["Model", result.model], + ["Reguly", str(result.rules_count)], + ], + ) + return + + if args.knowledge_action == "lista": + rules = list_rules( + topic=args.topic, + task_id=args.task, + status=args.status, + source=args.source, + ) + if not rules: + print("Brak regul dla podanych filtrow.") + return + shown = rules[: max(knowledge_limit(args), 0)] + rows = [ + [ + str(index), + rule.id, + rule.status, + rule.topic, + ", ".join(rule.task_ids), + rule.source, + rule_preview(rule), + ] + for index, rule in enumerate(shown, 1) + ] + print_table(["Nr", "ID", "Status", "Temat", "Zadania", "Zrodlo", "Regula"], rows) + if len(rules) > len(shown): + print(f"... oraz {len(rules) - len(shown)} kolejnych regul") + return + + if args.knowledge_action == "statystyki": + print_knowledge_statistics() + return + + if args.knowledge_action in {"archiwizuj", "aktywuj", "duplikat"}: + if not args.rule_id: + print("Podaj --rule-id.") + return + try: + if args.knowledge_action == "archiwizuj": + rule = update_rule_status(args.rule_id, "archived") + print(f"Zarchiwizowano regule: {rule.id}") + elif args.knowledge_action == "aktywuj": + rule = update_rule_status(args.rule_id, "active") + print(f"Aktywowano regule: {rule.id}") + else: + if not args.duplicate_of: + print("Podaj --duplicate-of z ID reguly nadrzednej.") + return + rule = update_rule_status(args.rule_id, "duplicate", duplicate_of=args.duplicate_of) + print(f"Oznaczono regule {rule.id} jako duplikat: {args.duplicate_of}") + except Exception as exc: + print(f"Nie udalo sie zmienic statusu reguly: {exc}") + return + return + + print(f"Nieznana komenda wiedzy: {args.knowledge_action}") + + +def knowledge_limit(args: argparse.Namespace, default: int = 10) -> int: + if args.limit is None: + return default + return args.limit + + +def print_knowledge_statistics() -> None: + rules = load_rules() + if not rules: + print("Baza wiedzy nie ma jeszcze regul.") + return + + status_counts = Counter(rule.status for rule in rules) + topic_counts = Counter(rule.topic for rule in rules) + source_counts = Counter(rule.source for rule in rules) + task_counts = Counter(task_id for rule in rules for task_id in rule.task_ids) + pending_count = sum( + 1 + for rule in rules + for task_id in rule.suggested_task_ids + if task_id not in rule.task_ids + ) + + print("\nStatusy") + print_table( + ["Status", "Liczba"], + [[status, str(count)] for status, count in sorted(status_counts.items())], + ) + print(f"\nPropozycje do akceptacji: {pending_count}") + + print("\nTematy") + print_table( + ["Temat", "Liczba"], + [[topic, str(count)] for topic, count in topic_counts.most_common()], + ) + + print("\nZadania") + if task_counts: + print_table( + ["Zadanie", "Liczba regul"], + [[task_id, str(count)] for task_id, count in task_counts.most_common()], + ) + else: + print("Brak zatwierdzonych przypisan do zadan.") + + print("\nZrodla") + print_table( + ["Zrodlo", "Liczba"], + [[source, str(count)] for source, count in source_counts.most_common()], + ) + + +def review_unassigned_rules(limit: int = 10, restart: bool = False) -> None: + if restart: + reset_review_state() + + rules = unassigned_rules() + if not rules: + print("Brak aktywnych regul bez przypisania do zadan.") + return + + start = 0 if restart else review_start_index(rules) + if start >= len(rules): + print("Przeglad doszedl do konca kolejki nieprzypisanych regul.") + print("Aby przejrzec je od poczatku, uruchom:") + print("python gads.py wiedza przypisz --restart") + return + + tasks = load_tasks() + task_rows = [ + [str(index), task.id, task.group_name, task.name] + for index, task in enumerate(tasks, 1) + ] + task_by_index = {str(index): task.id for index, task in enumerate(tasks, 1)} + task_ids = {task.id for task in tasks} + batch = rules[start : start + max(limit, 0)] + + print(f"\nNieprzypisane reguly: {len(rules)}. Start: {start + 1}/{len(rules)}.") + print("Mozesz przerwac wpisujac Q. Wznowienie zacznie od tej samej reguly.") + + for offset, rule in enumerate(batch, start + 1): + print("\n" + "=" * 72) + print(f"Regula {offset}/{len(rules)}") + print("=" * 72) + print_rule_review(rule) + print("\nDostepne zadania") + print_table(["Nr", "Task ID", "Grupa", "Zadanie"], task_rows) + print("\nWpisz numer zadania, kilka numerow po przecinku, task_id, P aby pominac, U aby usunac, Q aby przerwac.") + try: + answer = input(f"Do jakiego zadania dodac regule {rule.id}? ").strip() + except EOFError: + print("\nBrak wejscia terminala. Postep zostaje przed aktualna regula.") + return + + sort_key = [rule.topic, rule.id] + queue_index = offset - 1 + if not answer: + print("Zostawiono bez zmian. Postep zapisany.") + mark_review_progress(rule.id, sort_key=sort_key, queue_index=queue_index) + continue + if answer.upper() == "Q": + print("Przerwano. Wznowienie zacznie od tej reguly.") + return + if answer.upper() in {"P", "POMIN", "SKIP"}: + print("Pominieto. Postep zapisany.") + mark_review_progress(rule.id, sort_key=sort_key, queue_index=queue_index) + continue + if answer.upper() in {"U", "USUN", "DELETE", "D"}: + try: + confirmation = input( + f"Aby trwale usunac regule {rule.id} z rules.jsonl, wpisz USUN: " + ).strip() + except EOFError: + print("\nBrak potwierdzenia. Regula zostaje bez zmian.") + return + if confirmation != "USUN": + print("Nie usunieto reguly. Wznowienie zacznie od tej reguly.") + return + try: + delete_rule(rule.id) + except Exception as exc: + print(f"Nie udalo sie usunac reguly: {exc}") + return + print("Usunieto regule z rules.jsonl. Postep zapisany.") + print("Po zakonczeniu przegladu odswiez indeks: python gads.py wiedza indeksuj") + mark_review_progress(rule.id, sort_key=sort_key, queue_index=queue_index) + continue + + selected_task_ids = parse_task_selection(answer, task_by_index, task_ids) + if not selected_task_ids: + print("Nie rozpoznano zadania. Regula zostaje bez zmian, postep zapisany.") + mark_review_progress(rule.id, sort_key=sort_key, queue_index=queue_index) + continue + + for task_id in selected_task_ids: + try: + assign_rule_to_task(rule.id, task_id) + except Exception as exc: + print(f"Nie udalo sie przypisac do {task_id}: {exc}") + continue + print(f"Dodano do zadania: {task_id}") + mark_review_progress(rule.id, sort_key=sort_key, queue_index=queue_index) + + print("\nKoniec tej porcji przegladu.") + print("Aby kontynuowac:") + print("python gads.py wiedza przypisz") + + +def parse_task_selection(answer: str, task_by_index: dict[str, str], task_ids: set[str]) -> list[str]: + selected: list[str] = [] + for raw_part in answer.replace(";", ",").split(","): + part = raw_part.strip() + if not part: + continue + task_id = task_by_index.get(part) or part + if task_id not in task_ids: + print(f"Nie ma zadania: {part}") + continue + if task_id not in selected: + selected.append(task_id) + return selected + + +def print_rule_review(rule) -> None: + rows = [ + ["ID", rule.id], + ["Status", rule.status], + ["Temat", rule.topic], + ["Typ", rule.rule_type], + ["Zrodlo", rule.source], + ["Plik zrodlowy", rule.source_file], + ["Pewnosc", rule.confidence], + ] + if rule.suggested_task_ids: + rows.append(["Proponowane zadania", ", ".join(rule.suggested_task_ids)]) + print_table(["Pole", "Wartosc"], rows) + + print_wrapped_block("Warunek", rule.condition) + print_wrapped_block("Rekomendacja", rule.recommendation or rule.text) + print_wrapped_block("Ryzyko", rule.risk) + if rule.text and rule.text not in {rule.condition, rule.recommendation, rule.risk}: + print_wrapped_block("Tekst do wyszukiwania", rule.text) + + +def print_wrapped_block(title: str, value: str, width: int = 110) -> None: + print(f"\n{title}:") + raw_value = (value or "").strip() + if not raw_value: + print(" (brak)") + return + + for paragraph in raw_value.splitlines(): + clean_paragraph = " ".join(paragraph.split()) + if not clean_paragraph: + print() + continue + wrapped_lines = wrap( + clean_paragraph, + width=width, + break_long_words=False, + break_on_hyphens=False, + ) + for line in wrapped_lines or [""]: + print(f" {line}") + + +def preview_text(value: str, max_length: int = 220) -> str: + value = " ".join((value or "").split()) + if len(value) <= max_length: + return value + return value[: max_length - 3].rstrip() + "..." + + +def ask_about_task_suggestions(rule_ids: set[str] | None = None, limit: int = 10) -> None: + suggestions = pending_task_suggestions() + if rule_ids is not None: + suggestions = [item for item in suggestions if item["rule"].id in rule_ids] + if not suggestions: + print("\nBrak propozycji przypisania regul do zadan.") + return + + shown = suggestions[: max(limit, 0)] + print("\nPropozycje przypisania regul do zadan") + rows = [] + for index, item in enumerate(shown, 1): + rule = item["rule"] + rows.append( + [ + str(index), + rule.id, + rule.topic, + item["task_id"], + rule_preview(rule), + ] + ) + print_table(["Nr", "ID reguly", "Temat", "Proponowane zadanie", "Regula"], rows) + if len(suggestions) > len(shown): + print(f"... oraz {len(suggestions) - len(shown)} kolejnych propozycji") + + print("\nDla kazdej propozycji wpisz:") + print("- TAK: dopisz zadanie do reguly") + print("- NIE: odrzuc propozycje") + print("- Enter: zostaw propozycje do pozniejszej decyzji") + + for item in shown: + rule = item["rule"] + task_id = item["task_id"] + try: + answer = input(f"Dodac regule {rule.id} do zadania {task_id}? ").strip() + except EOFError: + print("\nBrak wejscia terminala. Propozycje zostaja oczekujace.") + return + if not answer: + print("Zostawiono jako oczekujace.") + continue + if answer == "TAK": + try: + approve_task_suggestion(rule.id, task_id) + except Exception as exc: + print(f"Nie udalo sie zatwierdzic: {exc}") + continue + print("Zatwierdzono.") + continue + if answer.upper() in {"NIE", "NO"}: + try: + reject_task_suggestion(rule.id, task_id) + except Exception as exc: + print(f"Nie udalo sie odrzucic: {exc}") + continue + print("Odrzucono.") + continue + print("Nie rozpoznano odpowiedzi. Propozycja zostaje oczekujaca.") + + +def normalize_report_month(raw: str | None) -> str | None: + if not raw: + return None + value = raw.strip() + if not value: + return None + if "." in value: + month, year = value.split(".", 1) + return f"{int(year):04d}-{int(month):02d}" + if "-" in value: + left, right = value.split("-", 1) + if len(left) == 2 and len(right) == 4: + month, year = left, right + else: + year, month = left, right + return f"{int(year):04d}-{int(month):02d}" + raise ValueError("Miesiac podaj jako YYYY-MM, MM-YYYY albo MM.YYYY.") + + +def looks_like_report_month(raw: str | None) -> bool: + try: + normalize_report_month(raw) + except Exception: + return False + return True + + +def report_slug(domain: str) -> str: + return domain.replace(".pl", "").replace(".", "-") + + +def default_client_report_name(domain: str) -> str: + return domain.replace(".pl", "").replace("-", " ").replace(".", " ").title() + + +def resolve_report_domain(raw: str | None, domains: list[str]) -> str | None: + if not raw: + return None + value = raw.strip() + if value in domains: + return value + matches = [ + domain + for domain in domains + if domain.startswith(f"{value}.") or domain.replace(".pl", "") == value + ] + if len(matches) == 1: + return matches[0] + return value + + +def run_checked(command: list[str], cwd: Path) -> None: + print() + print("Uruchamiam:") + print(" ".join(command)) + completed = subprocess.run(command, cwd=str(cwd), text=True) + if completed.returncode != 0: + raise RuntimeError(f"Komenda zakonczyla sie kodem {completed.returncode}.") + + +def handle_client_report_command(args: argparse.Namespace, cfg, domains: list[str], selected_domain: str | None) -> None: + root = Path(__file__).resolve().parents[2] + positional_domain = args.knowledge_action + positional_month = args.knowledge_query[0] if args.knowledge_query else None + selected_domain = resolve_report_domain(selected_domain, domains) + resolved_positional_domain = resolve_report_domain(positional_domain, domains) + if not selected_domain and resolved_positional_domain in cfg.clients: + selected_domain = resolved_positional_domain + month_raw = args.month or positional_month + if selected_domain and not month_raw and positional_domain and resolved_positional_domain not in cfg.clients: + month_raw = positional_domain + + if not selected_domain: + print("\nWybierz klienta:") + print_table(["Nr", "Domena"], [[str(i), domain] for i, domain in enumerate(domains, 1)]) + print("\nNastepny krok:") + print("python gads.py raport-klienta --client-number --month ") + return + if selected_domain not in cfg.clients: + print(f"Nie znaleziono klienta {selected_domain} w config/clients.toml.") + return + try: + month = normalize_report_month(month_raw) + except ValueError as exc: + print(str(exc)) + return + if not month: + print("Podaj miesiac raportu, np.:") + print(f"python gads.py raport-klienta --client {selected_domain} --month {datetime.now().strftime('%Y-%m')}") + return + + client_name = args.client_name or default_client_report_name(selected_domain) + data_path = root / "scripts" / "reports" / "output" / f"{selected_domain}_{month}.json" + html_path = root / "scripts" / "reports" / "output" / selected_domain / month / "index.html" + recommendations_path = root / "scripts" / "reports" / "output" / f"{selected_domain}_{month}_recommendations.json" + + fetch_cmd = [ + sys.executable, + "scripts/reports/fetch_monthly_report_data.py", + "--customer", + selected_domain, + "--month", + month, + ] + if args.skip_ga4: + fetch_cmd.append("--skip-ga4") + + generate_cmd = [ + sys.executable, + "scripts/reports/generate_html_report.py", + "--data", + str(data_path), + "--client", + client_name, + ] + + print(f"\nRaport klienta: {selected_domain}") + print(f"Miesiac: {month}") + print(f"Nazwa w raporcie: {client_name}") + + try: + run_checked(fetch_cmd, root) + if data_path.exists(): + recommendations = ensure_report_recommendations_file(data_path, recommendations_path) + print_report_recommendations(recommendations, recommendations_path) + if args.confirm_recommendations != "TAK": + print("\nHTML nie zostal jeszcze wygenerowany.") + print("Agent AI powinien przygotowac albo poprawic wnioski w pliku rekomendacji.") + print("Po akceptacji tresci uruchom:") + print( + "python gads.py raport-klienta " + f"--client {selected_domain} --month {month} --client-name \"{client_name}\" --confirm-recommendations TAK" + ) + print("\nSkrypt nie generuje wnioskow samodzielnie. Plik rekomendacji uzupelnia agent AI przed generowaniem HTML.") + return + apply_report_recommendations(data_path, recommendations_path) + run_checked(generate_cmd, root) + except Exception as exc: + print(f"\nNie udalo sie wygenerowac raportu: {exc}") + return + + print("\nRaport HTML jest gotowy do sprawdzenia:") + print(html_path) + print("\nUpload nie zostal wykonany bez potwierdzenia.") + print("Aby wyslac raport na serwer:") + print( + "python gads.py raport-klienta " + f"--client {selected_domain} --month {month} --client-name \"{client_name}\" --confirm-upload TAK" + ) + + if args.confirm_upload != "TAK": + return + + missing = [key for key in ("ADSPRO_HOST", "ADSPRO_USERNAME", "ADSPRO_PASSWORD", "ADSPRO_REMOTE_PATH") if not os.environ.get(key)] + if missing: + print(f"\nBrak danych FTP w .env: {', '.join(missing)}") + return + + remote_path = f"/raporty/{report_slug(selected_domain)}/{month}/" + upload_cmd = [ + sys.executable, + "scripts/reports/upload_report_ftp.py", + "--local-dir", + str(html_path.parent), + "--remote-path", + remote_path, + ] + try: + run_checked(upload_cmd, root) + except Exception as exc: + print(f"\nNie udalo sie wyslac raportu: {exc}") + return + + print("\nRaport wyslany.") + print(f"URL: https://adspro.projectpro.pl{remote_path}") + + +def build_report_recommendation_context(data_path: Path) -> dict: + data = json.loads(data_path.read_text(encoding="utf-8")) + ads = data.get("google_ads", {}) + totals = ads.get("totals", {}) + prev_totals = ads.get("prev_totals", {}) + mom = ads.get("mom_change", {}) + campaigns = sorted(ads.get("campaigns", []), key=lambda row: row.get("cost", 0), reverse=True) + ga4 = data.get("ga4") or {} + ecom = ga4.get("ecommerce") or {} + ecom_current = ecom.get("current") or {} + ecom_mom = ecom.get("mom_change") or {} + return { + "google_ads_totals": { + "cost": totals.get("cost", 0), + "clicks": totals.get("clicks", 0), + "conversions": totals.get("conversions", 0), + "conversion_value": totals.get("conversion_value", 0), + "roas": totals.get("roas", 0), + "cpa": totals.get("cpa", 0), + }, + "google_ads_mom_change": { + "cost_pct": mom.get("cost_pct", 0), + "clicks_pct": mom.get("clicks_pct", 0), + "conversions_pct": mom.get("conversions_pct", 0), + "conversion_value_pct": mom.get("conversion_value_pct", 0), + "roas_pct": mom.get("roas_pct", 0), + "cpa_pct": mom.get("cpa_pct", 0), + }, + "ga4_ecommerce": { + "transactions": ecom_current.get("transactions", 0), + "revenue": ecom_current.get("revenue", 0), + "transactions_pct": ecom_mom.get("transactions_pct", 0), + "revenue_pct": ecom_mom.get("revenue_pct", 0), + }, + "top_campaigns_by_cost": [ + { + "name": campaign.get("name", ""), + "cost": campaign.get("cost", 0), + "conversions": campaign.get("conversions", 0), + "conversion_value": campaign.get("conversion_value", 0), + "roas": campaign.get("roas", 0), + } + for campaign in campaigns[:5] + ], + } + + +def ensure_report_recommendations_file(data_path: Path, path: Path) -> list[dict]: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + content = json.loads(path.read_text(encoding="utf-8")) + if isinstance(content, dict): + return content.get("recommendations", []) + return content + payload = { + "source": "agent_ai", + "instruction": ( + "Uzupelnia agent AI po analizie danych raportu. Skrypt nie powinien sam generowac " + "wnioskow ani rekomendacji." + ), + "context": build_report_recommendation_context(data_path), + "recommendations": [ + { + "icon": "➤", + "title": "DO UZUPELNIENIA PRZEZ AGENTA AI", + "text": "Przygotuj konkretny wniosek i rekomendacje na podstawie danych raportu.", + } + ], + } + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload["recommendations"] + + +def print_report_recommendations(recommendations: list[dict], path: Path) -> None: + print("\nPropozycje wnioskow i rekomendacji do raportu") + print_table( + ["Nr", "Tytul", "Tekst"], + [ + [str(index), item.get("title", ""), item.get("text", "")] + for index, item in enumerate(recommendations, 1) + ], + ) + print(f"\nPlik rekomendacji: {path}") + + +def apply_report_recommendations(data_path: Path, recommendations_path: Path) -> None: + data = json.loads(data_path.read_text(encoding="utf-8")) + content = json.loads(recommendations_path.read_text(encoding="utf-8")) + recommendations = content.get("recommendations", []) if isinstance(content, dict) else content + unfinished = [ + item + for item in recommendations + if "DO UZUPELNIENIA" in item.get("title", "") or "DO UZUPELNIENIA" in item.get("text", "") + ] + if unfinished: + raise RuntimeError("Plik rekomendacji nadal zawiera pozycje do uzupelnienia przez agenta AI.") + data["recommendations"] = recommendations + data_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def run_task( + task_id, + client, + global_rules, + plan_only: bool = False, + apply_plan_path: str | None = None, + confirm_apply: str | None = None, + show_navigation: bool = True, +) -> None: + if task_id == "sync_pla_cl1": + run_sync_pla_cl1( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "optimize_shopping_troas_ag": + run_optimize_shopping_troas_ag( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_pla_settings": + run_check_pla_settings( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "optimize_product_feed": + run_optimize_product_feed( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "optimize_product_titles": + run_optimize_product_titles( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "optimize_product_categories": + run_optimize_product_categories( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "fill_product_unit_pricing": + run_fill_product_unit_pricing( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_conversion_tracking": + run_check_conversion_tracking( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_search_basic_settings": + run_check_search_basic_settings( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_budget_usage": + run_check_budget_usage( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_bidding_strategies": + run_check_bidding_strategies( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_search_terms": + run_check_search_terms( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_rsa_assets": + run_check_rsa_assets( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_feed_merchant_quality": + run_check_feed_merchant_quality( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_pmax_structure": + run_check_pmax_structure( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_remarketing_setup": + run_check_remarketing_setup( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_account_anomalies": + run_check_account_anomalies( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_campaign_locations": + run_check_campaign_locations( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_campaign_networks": + run_check_campaign_networks( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_campaign_languages": + run_check_campaign_languages( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_ad_schedules": + run_check_ad_schedules( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_ad_asset_statuses": + run_check_ad_asset_statuses( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_keyword_statuses": + run_check_keyword_statuses( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_shopping_product_statuses": + run_check_shopping_product_statuses( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_impression_share": + run_check_impression_share( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_auction_insights": + run_check_auction_insights( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_device_performance": + run_check_device_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_day_of_week_performance": + run_check_day_of_week_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_hour_of_day_performance": + run_check_hour_of_day_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_network_performance": + run_check_network_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_ad_group_performance": + run_check_ad_group_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_keyword_quality_score": + run_check_keyword_quality_score( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_landing_page_performance": + run_check_landing_page_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_conversion_action_performance": + run_check_conversion_action_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_shopping_product_performance": + run_check_shopping_product_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_gender_performance": + run_check_gender_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + if task_id == "check_age_performance": + run_check_age_performance( + client, + global_rules, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + return + print(f"Zadanie {task_id} nie ma jeszcze implementacji.") + + +def run_task_sequence(tasks, client, global_rules, plan_only: bool = False) -> None: + total = len(tasks) + if plan_only: + print("Tryb zbiorczy plan-only przygotuje plany po kolei.") + print("Agent powinien analizowac i wdrazac kazdy plan osobno, przed przejsciem do kolejnego zadania.") + for index, task in enumerate(tasks, 1): + print() + print("#" * 72) + print(f"Zadanie {index}/{total}: {task.group_name} / {task.name}") + print("#" * 72) + run_task(task.id, client, global_rules, plan_only=plan_only, show_navigation=False) + print() + print("Zakonczono sekwencje zadan.") + print_sequence_navigation(client.domain) + + +def run_tasks_for_all_clients(tasks, cfg, domains: list[str], plan_only: bool = True) -> None: + total_clients = len(domains) + total_tasks = len(tasks) + print("\nTryb analiza-zadania") + print("Zadania beda uruchamiane po kolei dla wszystkich klientow.") + print("Dla bezpieczenstwa ten tryb przygotowuje tylko plany i nie wdraza zmian.") + print_table( + ["Metryka", "Liczba"], + [ + ["Klienci", str(total_clients)], + ["Zadania", str(total_tasks)], + ["Tryb", "plan-only"], + ], + ) + for task_index, task in enumerate(tasks, 1): + print() + print("#" * 72) + print(f"Zadanie {task_index}/{total_tasks}: {task.group_name} / {task.name}") + print("#" * 72) + for client_index, domain in enumerate(domains, 1): + print() + print("-" * 72) + print(f"Klient {client_index}/{total_clients}: {domain}") + print("-" * 72) + try: + run_task( + task.id, + cfg.clients[domain], + cfg.global_rules, + plan_only=plan_only, + show_navigation=False, + ) + except Exception as exc: + print(f"Nie udalo sie przygotowac planu dla klienta {domain}: {exc}") + print() + print("Zakonczono tryb analiza-zadania.") + print("\nCo dalej:") + print("1. Lista zadan") + print("2. Lista klientow") + print("3. Zakoncz") + print("\nKomendy:") + print("1 -> python gads.py analiza-zadania") + print("2 -> python gads.py analiza-klienta") + + +def print_sequence_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 print_next_navigation(client_number: int | None = None) -> None: + print("\nCo dalej:") + if client_number: + print(f"1. Lista zadan klienta numer {client_number}") + print("2. Lista klientow") + print("3. Zakoncz") + print("\nKomendy:") + print(f"1 -> python gads.py analiza-klienta --client-number {client_number}") + print("2 -> python gads.py analiza-klienta") + else: + print("1. Lista klientow") + print("2. Zakoncz") + print("\nKomendy:") + print("1 -> python gads.py analiza-klienta") diff --git a/src/gads_v2/config.py b/src/gads_v2/config.py new file mode 100644 index 0000000..4b9c7c0 --- /dev/null +++ b/src/gads_v2/config.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import os +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def load_env(path: Path | None = None) -> None: + env_path = path or ROOT / ".env" + if not env_path.exists(): + return + for raw in env_path.read_text(encoding="utf-8-sig").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +@dataclass(frozen=True) +class ClientConfig: + domain: str + google_ads_customer_id: str + adspro_client_id: str | None = None + rules: dict | None = None + + @property + def safe_customer_id(self) -> str: + return re.sub(r"\D", "", self.google_ads_customer_id) + + def effective_rules(self, global_rules: dict, section: str) -> dict: + rules = dict(global_rules.get(section, {})) + rules.update((self.rules or {}).get(section, {})) + return rules + + +@dataclass(frozen=True) +class AppConfig: + clients: dict[str, ClientConfig] + global_rules: dict + + +def load_config(path: Path | None = None) -> AppConfig: + config_path = path or ROOT / "config" / "clients.toml" + if not config_path.exists(): + example = ROOT / "config" / "clients.example.toml" + raise FileNotFoundError( + f"Brak {config_path}. Utworz ten plik na podstawie {example}." + ) + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + clients = {} + for domain, row in data.get("clients", {}).items(): + clients[domain] = ClientConfig( + domain=domain, + google_ads_customer_id=str(row["google_ads_customer_id"]), + adspro_client_id=str(row.get("adspro_client_id")) if row.get("adspro_client_id") else None, + rules={key: value for key, value in row.items() if isinstance(value, dict)}, + ) + return AppConfig(clients=clients, global_rules=data.get("global_rules", {})) + + +def client_dir(domain: str) -> Path: + path = ROOT / "clients" / domain + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/src/gads_v2/google_ads.py b/src/gads_v2/google_ads.py new file mode 100644 index 0000000..9b0c08d --- /dev/null +++ b/src/gads_v2/google_ads.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import os +from typing import Any + +from google.ads.googleads.client import GoogleAdsClient + + +def get_google_ads_client(use_proto_plus: bool = True) -> GoogleAdsClient: + developer_token = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN") or os.environ.get( + "GOOGLE_ADS_DEVELOPER_TOKNE" + ) + if not developer_token: + raise RuntimeError("Brak GOOGLE_ADS_DEVELOPER_TOKEN w .env.") + return GoogleAdsClient.load_from_dict( + { + "developer_token": developer_token, + "client_id": os.environ["GOOGLE_ADS_OAUTH2_CLIENT_ID"], + "client_secret": os.environ["GOOGLE_ADS_OAUTH2_CLIENT_SECRET"], + "refresh_token": os.environ["GOOGLE_ADS_OAUTH2_REFRESH_TOKEN"], + "login_customer_id": os.environ["GOOGLE_ADS_MANAGER_ACCOUNT_ID"], + "use_proto_plus": use_proto_plus, + } + ) + + +def run_query(client: GoogleAdsClient, customer_id: str, query: str, timeout: float = 300.0) -> list[Any]: + service = client.get_service("GoogleAdsService") + rows = [] + for batch in service.search_stream(customer_id=customer_id, query=query, timeout=timeout): + rows.extend(batch.results) + return rows + diff --git a/src/gads_v2/history.py b/src/gads_v2/history.py new file mode 100644 index 0000000..347f404 --- /dev/null +++ b/src/gads_v2/history.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +from .config import client_dir + + +TZ = ZoneInfo("Europe/Warsaw") + + +def now_local() -> datetime: + return datetime.now(TZ) + + +def append_history(domain: str, event: dict) -> Path: + ts = now_local() + event = {"timestamp": ts.isoformat(timespec="seconds"), **event} + base = client_dir(domain) / "history" + base.mkdir(parents=True, exist_ok=True) + path = base / f"{ts.date().isoformat()}.jsonl" + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + return path + + +def append_change_markdown(domain: str, title: str, rows: list[dict]) -> Path: + ts = now_local() + base = client_dir(domain) / "changes" + base.mkdir(parents=True, exist_ok=True) + path = base / f"{ts.date().isoformat()}.md" + exists = path.exists() + with path.open("a", encoding="utf-8") as f: + if not exists: + f.write(f"# Zmiany {ts.date().isoformat()}\n\n") + f.write(f"## {ts.strftime('%H:%M')} - {title}\n\n") + if not rows: + f.write("Brak wdrozonych zmian.\n\n") + return path + keys = list(rows[0].keys()) + f.write("| " + " | ".join(keys) + " |\n") + f.write("| " + " | ".join(["---"] * len(keys)) + " |\n") + for row in rows: + f.write("| " + " | ".join(str(row.get(k, "")) for k in keys) + " |\n") + f.write("\n") + return path + diff --git a/src/gads_v2/knowledge/__init__.py b/src/gads_v2/knowledge/__init__.py new file mode 100644 index 0000000..8080e47 --- /dev/null +++ b/src/gads_v2/knowledge/__init__.py @@ -0,0 +1,2 @@ +"""Local knowledge store helpers.""" + diff --git a/src/gads_v2/knowledge/importer.py b/src/gads_v2/knowledge/importer.py new file mode 100644 index 0000000..bbea731 --- /dev/null +++ b/src/gads_v2/knowledge/importer.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import requests + +from ..task_catalog import load_tasks +from .store import append_import_record, append_rules, ensure_knowledge_store, normalize_rule_row + + +DEFAULT_MODEL = "gpt-4.1-mini" +DEFAULT_MAX_RULES = 12 +MAX_SOURCE_CHARS = 60000 + + +RULE_SCHEMA = { + "type": "object", + "additionalProperties": False, + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "id": {"type": "string"}, + "topic": {"type": "string"}, + "suggested_task_ids": { + "type": "array", + "items": {"type": "string"}, + }, + "rule_type": {"type": "string"}, + "condition": {"type": "string"}, + "recommendation": {"type": "string"}, + "risk": {"type": "string"}, + "source": {"type": "string"}, + "confidence": {"type": "string"}, + "text": {"type": "string"}, + }, + "required": [ + "id", + "topic", + "suggested_task_ids", + "rule_type", + "condition", + "recommendation", + "risk", + "source", + "confidence", + "text", + ], + }, + }, + "notes": {"type": "string"}, + }, + "required": ["rules", "notes"], +} + + +@dataclass(frozen=True) +class ImportResult: + source_path: Path + source_name: str + model: str + rules_count: int + rule_ids: list[str] + notes: str + dry_run: bool + + +def import_knowledge_file( + file_path: Path, + source_name: str, + model: str | None = None, + max_rules: int = DEFAULT_MAX_RULES, + dry_run: bool = False, +) -> ImportResult: + ensure_knowledge_store() + if not file_path.exists(): + raise FileNotFoundError(f"Nie znaleziono pliku wiedzy: {file_path}") + if not file_path.is_file(): + raise ValueError(f"Sciezka nie jest plikiem: {file_path}") + if not source_name.strip(): + raise ValueError("Podaj --source, czyli czytelna nazwe zrodla wiedzy.") + + content = read_source_file(file_path) + selected_model = model or os.environ.get("KNOWLEDGE_OPENAI_MODEL") or os.environ.get("OPENAI_MODEL") or DEFAULT_MODEL + + if dry_run: + return ImportResult( + source_path=file_path, + source_name=source_name, + model=selected_model, + rules_count=0, + rule_ids=[], + notes="Tryb dry-run: plik odczytany, API nie zostalo wywolane, reguly nie zostaly zapisane.", + dry_run=True, + ) + + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + raise RuntimeError( + "Brak OPENAI_API_KEY. Dodaj klucz do .env albo uruchom z --dry-run, " + "zeby tylko sprawdzic plik wejsciowy." + ) + + payload = call_openai_extractor( + api_key=api_key, + model=selected_model, + source_name=source_name, + content=content, + max_rules=max_rules, + ) + raw_rules = payload.get("rules") or [] + normalized_rules = [] + for row in raw_rules: + normalized = normalize_rule_row({**row, "source": source_name, "task_ids": []}) + normalized_rules.append(normalized) + + saved_rules = append_rules(normalized_rules, source_file=str(file_path)) + notes = str(payload.get("notes") or "").strip() + append_import_record( + { + "file": str(file_path), + "source": source_name, + "model": selected_model, + "rules_count": len(saved_rules), + "notes": notes, + } + ) + return ImportResult( + source_path=file_path, + source_name=source_name, + model=selected_model, + rules_count=len(saved_rules), + rule_ids=[rule.id for rule in saved_rules], + notes=notes, + dry_run=False, + ) + + +def read_source_file(path: Path) -> str: + content = path.read_text(encoding="utf-8-sig", errors="replace") + content = content.strip() + if not content: + raise ValueError(f"Plik wiedzy jest pusty: {path}") + if len(content) > MAX_SOURCE_CHARS: + return content[:MAX_SOURCE_CHARS] + return content + + +def call_openai_extractor( + api_key: str, + model: str, + source_name: str, + content: str, + max_rules: int, +) -> dict[str, Any]: + base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/") + endpoint = f"{base_url}/responses" + response = requests.post( + endpoint, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "instructions": build_extractor_instructions(source_name, max_rules), + "input": build_extractor_input(source_name, content), + "text": { + "format": { + "type": "json_schema", + "name": "knowledge_rules", + "strict": True, + "schema": RULE_SCHEMA, + } + }, + "max_output_tokens": 6000, + "store": False, + }, + timeout=120, + ) + if response.status_code >= 400: + raise RuntimeError(f"OpenAI API zwrocilo blad {response.status_code}: {response.text}") + data = response.json() + output_text = extract_output_text(data) + if not output_text: + raise RuntimeError("OpenAI API nie zwrocilo tekstu z regułami.") + try: + payload = json.loads(output_text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"OpenAI API zwrocilo nieprawidlowy JSON: {exc}") from exc + if not isinstance(payload, dict): + raise RuntimeError("OpenAI API powinno zwrocic obiekt JSON.") + return payload + + +def extract_output_text(data: dict[str, Any]) -> str: + if isinstance(data.get("output_text"), str): + return data["output_text"] + parts: list[str] = [] + for item in data.get("output", []) or []: + for content in item.get("content", []) or []: + if content.get("type") == "output_text" and isinstance(content.get("text"), str): + parts.append(content["text"]) + elif isinstance(content.get("text"), str): + parts.append(content["text"]) + return "\n".join(parts).strip() + + +def build_extractor_instructions(source_name: str, max_rules: int) -> str: + task_lines = [] + for task in load_tasks(): + task_lines.append( + f"- {task.id}: {task.group_name} / {task.name}. {task.description}" + ) + tasks_block = "\n".join(task_lines) + return f""" +Jestes parserem wiedzy dla terminalowego narzedzia Google Ads. +Masz zamienic material zrodlowy na maksymalnie {max_rules} atomowych regul. + +Zrodlo ma nazwe: {source_name} + +Aktualnie istniejace task_id w narzedziu: +{tasks_block} + +Wymagania: +- Pisz po polsku. +- Wszystkie pola opisowe (`condition`, `recommendation`, `risk`, `text`, `notes`) musza byc po polsku. +- Zwracaj tylko JSON zgodny ze schematem. +- Jedna regula ma opisywac jeden konkretny wniosek, warunek, ryzyko albo rekomendacje. +- Nie tworz regul ogolnikowych typu "monitoruj wyniki"; regula ma mowic co sprawdzic i dlaczego. +- suggested_task_ids wypelniaj tylko istniejacymi task_id z listy powyzej. +- suggested_task_ids ustaw tylko wtedy, gdy regula bezposrednio dotyczy danego zadania i moze byc uzyta w jego planie. +- Nie proponuj zadania tylko dlatego, ze regula dotyczy podobnego typu kampanii albo ogolnej optymalizacji. +- Jesli regula jest wazna, ale nie pasuje bezposrednio do istniejacego zadania, ustaw suggested_task_ids na []. +- Narzedzie zapisze suggested_task_ids jako propozycje. Zadanie zostanie dopisane do task_ids dopiero po akceptacji uzytkownika. +- source w kazdej regule ustaw dokladnie jako nazwe zrodla podana powyzej. +- confidence ustaw jako low, medium albo high. +- rule_type ustaw jako audit_check, recommendation, warning albo implementation_note. +- topic ustaw krotko, np. search, pmax, shopping, feed-merchant, konwersje, ga4, gtm-tracking, negative-keywords, strategie-stawek, produkty. +- id ma byc krotkim stabilnym identyfikatorem po angielsku albo bez polskich znakow. +- text ma byc samodzielnym, jednozdaniowym opisem reguly. +""".strip() + + +def build_extractor_input(source_name: str, content: str) -> str: + return f""" +Przetworz ponizszy material na atomowe reguly dla narzedzia Google Ads. + +Zrodlo: {source_name} + +MATERIAL: +{content} +""".strip() diff --git a/src/gads_v2/knowledge/legacy_import.py b/src/gads_v2/knowledge/legacy_import.py new file mode 100644 index 0000000..eb154e5 --- /dev/null +++ b/src/gads_v2/knowledge/legacy_import.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from collections import defaultdict + +import lancedb + +from .store import append_import_record, append_rules, load_rules, normalize_rule_row + + +DEFAULT_LEGACY_DB = Path(r"D:\google ads\lancedb") +DEFAULT_LEGACY_TABLE = "fakty" + + +@dataclass(frozen=True) +class LegacyImportResult: + db_path: Path + table_name: str + imported_count: int + skipped_existing_count: int + total_rows: int + + +def import_legacy_lancedb( + db_path: Path = DEFAULT_LEGACY_DB, + table_name: str = DEFAULT_LEGACY_TABLE, + limit: int | None = None, +) -> LegacyImportResult: + if not db_path.exists(): + raise FileNotFoundError(f"Nie znaleziono starej bazy LanceDB: {db_path}") + + db = lancedb.connect(str(db_path)) + table_names = list(db.table_names()) if hasattr(db, "table_names") else list(db.list_tables()) + if table_name not in table_names: + raise ValueError(f"Brak tabeli {table_name} w {db_path}. Dostepne tabele: {', '.join(table_names)}") + + table = db.open_table(table_name) + df = table.to_pandas() + if limit is not None: + df = df.head(max(limit, 0)) + + existing_ids = {rule.id for rule in load_rules()} + rows: list[dict[str, Any]] = [] + skipped = 0 + source_file = str(db_path / f"{table_name}.lance") + + occurrences: dict[str, int] = defaultdict(int) + for _, record in df.iterrows(): + record_dict = record.to_dict() + old_id = str(record_dict.get("id") or "").strip() + occurrences[old_id] += 1 + row = legacy_record_to_rule(record_dict, source_file, occurrences[old_id]) + normalized = normalize_rule_row(row) + if normalized["id"] in existing_ids: + skipped += 1 + continue + existing_ids.add(normalized["id"]) + rows.append(normalized) + + saved = append_rules(rows) + append_import_record( + { + "file": source_file, + "source": f"legacy_lancedb:{table_name}", + "model": "none", + "rules_count": len(saved), + "skipped_existing_count": skipped, + "notes": "Import starej tabeli LanceDB bez API i bez przypisywania regul do zadan.", + } + ) + return LegacyImportResult( + db_path=db_path, + table_name=table_name, + imported_count=len(saved), + skipped_existing_count=skipped, + total_rows=len(df), + ) + + +def legacy_record_to_rule(record: dict[str, Any], source_file: str, occurrence: int = 1) -> dict[str, Any]: + old_id = str(record.get("id") or "").strip() + fact = str(record.get("fakt") or "").strip() + topic = str(record.get("temat") or "legacy").strip() + section = str(record.get("sekcja") or "").strip() + source = str(record.get("zrodlo") or "legacy_lancedb").strip() + target_id = old_id or fact[:80] or "legacy_rule" + if occurrence > 1: + target_id = f"{target_id[:80]}__{occurrence}" + return { + "id": target_id, + "status": "active", + "topic": topic, + "task_ids": [], + "suggested_task_ids": [], + "rule_type": "implementation_note", + "condition": section, + "recommendation": fact, + "risk": "", + "source": source, + "source_file": source_file, + "confidence": "medium", + "duplicate_of": "", + "supersedes": [], + "text": fact, + } diff --git a/src/gads_v2/knowledge/store.py b/src/gads_v2/knowledge/store.py new file mode 100644 index 0000000..980ce5a --- /dev/null +++ b/src/gads_v2/knowledge/store.py @@ -0,0 +1,620 @@ +from __future__ import annotations + +import json +import re +import unicodedata +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +from ..config import ROOT + + +KNOWLEDGE_DIR = ROOT / "knowledge" +SOURCES_DIR = KNOWLEDGE_DIR / "sources" +LANCEDB_DIR = KNOWLEDGE_DIR / "lancedb" +RULES_PATH = KNOWLEDGE_DIR / "rules.jsonl" +IMPORTS_PATH = KNOWLEDGE_DIR / "imports.jsonl" +REVIEW_STATE_PATH = KNOWLEDGE_DIR / "review_state.json" +ALLOWED_STATUSES = {"active", "draft", "archived", "duplicate"} +DEFAULT_STATUS = "active" + + +@dataclass(frozen=True) +class KnowledgeRule: + id: str + status: str + topic: str + task_ids: list[str] + suggested_task_ids: list[str] + rule_type: str + condition: str + recommendation: str + risk: str + source: str + source_file: str + confidence: str + created_at: str + updated_at: str + duplicate_of: str + supersedes: list[str] + text: str + raw: dict[str, Any] + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "KnowledgeRule": + task_ids = normalize_task_id_list(data.get("task_ids") or []) + suggested_task_ids = normalize_task_id_list( + data.get("suggested_task_ids") or data.get("pending_task_ids") or [] + ) + text = str(data.get("text") or "").strip() + if not text: + text = " ".join( + str(data.get(key) or "").strip() + for key in ("condition", "recommendation", "risk") + if str(data.get(key) or "").strip() + ) + return cls( + id=str(data.get("id") or "").strip(), + status=normalize_status(data.get("status")), + topic=str(data.get("topic") or "").strip(), + task_ids=task_ids, + suggested_task_ids=suggested_task_ids, + rule_type=str(data.get("rule_type") or "").strip(), + condition=str(data.get("condition") or "").strip(), + recommendation=str(data.get("recommendation") or "").strip(), + risk=str(data.get("risk") or "").strip(), + source=str(data.get("source") or "").strip(), + source_file=str(data.get("source_file") or "").strip(), + confidence=str(data.get("confidence") or "").strip(), + created_at=str(data.get("created_at") or "").strip(), + updated_at=str(data.get("updated_at") or "").strip(), + duplicate_of=str(data.get("duplicate_of") or "").strip(), + supersedes=normalize_string_list(data.get("supersedes") or []), + text=text, + raw=data, + ) + + +@dataclass(frozen=True) +class SearchResult: + rule: KnowledgeRule + score: int + + +def ensure_knowledge_store() -> dict[str, Path]: + KNOWLEDGE_DIR.mkdir(parents=True, exist_ok=True) + SOURCES_DIR.mkdir(parents=True, exist_ok=True) + LANCEDB_DIR.mkdir(parents=True, exist_ok=True) + for path in (RULES_PATH, IMPORTS_PATH): + if not path.exists(): + path.write_text("", encoding="utf-8") + if not REVIEW_STATE_PATH.exists(): + REVIEW_STATE_PATH.write_text("{}", encoding="utf-8") + return { + "knowledge_dir": KNOWLEDGE_DIR, + "sources_dir": SOURCES_DIR, + "lancedb_dir": LANCEDB_DIR, + "rules_path": RULES_PATH, + "imports_path": IMPORTS_PATH, + "review_state_path": REVIEW_STATE_PATH, + } + + +def load_rules(path: Path = RULES_PATH) -> list[KnowledgeRule]: + ensure_knowledge_store() + rows = read_rule_rows(path) + return [KnowledgeRule.from_dict(row) for row in rows] + + +def read_rule_rows(path: Path = RULES_PATH) -> list[dict[str, Any]]: + ensure_knowledge_store() + rows: list[dict[str, Any]] = [] + for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = raw_line.strip() + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"Nieprawidlowy JSONL w {path}:{line_number}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"Wiersz {path}:{line_number} nie jest obiektem JSON.") + rows.append(data) + return rows + + +def write_rule_rows(rows: list[dict[str, Any]], path: Path = RULES_PATH) -> None: + ensure_knowledge_store() + with path.open("w", encoding="utf-8", newline="\n") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n") + + +def append_rules( + rule_rows: list[dict[str, Any]], + path: Path = RULES_PATH, + source_file: str = "", +) -> list[KnowledgeRule]: + ensure_knowledge_store() + existing_ids = {rule.id for rule in load_rules(path) if rule.id} + normalized_rows = [] + for row in rule_rows: + if source_file and not row.get("source_file"): + row = {**row, "source_file": source_file} + normalized = normalize_rule_row(row) + normalized["id"] = unique_rule_id(normalized["id"], existing_ids) + existing_ids.add(normalized["id"]) + normalized_rows.append(normalized) + + if normalized_rows: + with path.open("a", encoding="utf-8", newline="\n") as f: + for row in normalized_rows: + f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n") + return [KnowledgeRule.from_dict(row) for row in normalized_rows] + + +def append_import_record(record: dict[str, Any], path: Path = IMPORTS_PATH) -> None: + ensure_knowledge_store() + payload = { + "created_at": datetime.now().isoformat(timespec="seconds"), + **record, + } + with path.open("a", encoding="utf-8", newline="\n") as f: + f.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n") + + +def rules_for_task(task_id: str, path: Path = RULES_PATH) -> list[KnowledgeRule]: + task_id = task_id.strip() + return [ + rule + for rule in load_rules(path) + if rule.status == "active" and task_id in rule.task_ids + ] + + +def search_rules(query: str, limit: int = 10, path: Path = RULES_PATH) -> list[SearchResult]: + query_terms = tokenize(query) + if not query_terms: + return [] + + results: list[SearchResult] = [] + for rule in load_rules(path): + if rule.status != "active": + continue + haystack = searchable_text(rule) + score = sum(haystack.count(term) for term in query_terms) + if rule.topic.casefold() in query.casefold(): + score += 3 + for task_id in rule.task_ids: + if task_id.casefold() in query.casefold(): + score += 3 + if score > 0: + results.append(SearchResult(rule=rule, score=score)) + + results.sort(key=lambda item: (-item.score, item.rule.topic, item.rule.id)) + return results[: max(limit, 0)] + + +def store_summary(path: Path = RULES_PATH) -> dict[str, int]: + rules = load_rules(path) + topics = {rule.topic for rule in rules if rule.topic} + task_ids = {task_id for rule in rules for task_id in rule.task_ids} + suggested_task_ids = { + task_id + for rule in rules + for task_id in rule.suggested_task_ids + if task_id not in rule.task_ids + } + sources = {rule.source for rule in rules if rule.source} + by_status = {status: sum(1 for rule in rules if rule.status == status) for status in ALLOWED_STATUSES} + return { + "rules": len(rules), + "active_rules": by_status.get("active", 0), + "draft_rules": by_status.get("draft", 0), + "archived_rules": by_status.get("archived", 0), + "duplicate_rules": by_status.get("duplicate", 0), + "topics": len(topics), + "task_ids": len(task_ids), + "suggested_task_ids": len(suggested_task_ids), + "suggestions": sum( + 1 + for rule in rules + for task_id in rule.suggested_task_ids + if task_id not in rule.task_ids + ), + "sources": len(sources), + } + + +def list_rules( + topic: str | None = None, + task_id: str | None = None, + status: str | None = None, + source: str | None = None, + path: Path = RULES_PATH, +) -> list[KnowledgeRule]: + rules = load_rules(path) + if topic: + normalized_topic = slugify(topic) + rules = [rule for rule in rules if rule.topic == normalized_topic] + if task_id: + normalized_task_id = slugify(task_id) + rules = [rule for rule in rules if normalized_task_id in rule.task_ids] + if status: + normalized_status = normalize_status(status) + rules = [rule for rule in rules if rule.status == normalized_status] + if source: + normalized_source = source.casefold() + rules = [rule for rule in rules if normalized_source in rule.source.casefold()] + return sorted(rules, key=lambda rule: (rule.topic, rule.id)) + + +def update_rule_status( + rule_id: str, + status: str, + duplicate_of: str = "", + path: Path = RULES_PATH, +) -> KnowledgeRule: + normalized_rule_id = rule_id.strip() + normalized_status = normalize_status(status) + if normalized_status not in ALLOWED_STATUSES: + raise ValueError(f"Nieprawidlowy status: {status}") + + rows = read_rule_rows(path) + updated_rule: KnowledgeRule | None = None + now = now_iso() + for row in rows: + if str(row.get("id") or "").strip() != normalized_rule_id: + continue + row["status"] = normalized_status + row["updated_at"] = now + if duplicate_of: + row["duplicate_of"] = duplicate_of.strip() + elif normalized_status != "duplicate": + row["duplicate_of"] = "" + updated_rule = KnowledgeRule.from_dict(row) + break + + if not updated_rule: + raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}") + + write_rule_rows(rows, path) + return updated_rule + + +def tokenize(value: str) -> list[str]: + return [ + token.casefold() + for token in re.findall(r"\w+", value or "", flags=re.UNICODE) + if len(token) >= 2 + ] + + +def searchable_text(rule: KnowledgeRule) -> str: + values = [ + rule.id, + rule.status, + rule.topic, + " ".join(rule.task_ids), + " ".join(rule.suggested_task_ids), + rule.rule_type, + rule.condition, + rule.recommendation, + rule.risk, + rule.source, + rule.source_file, + rule.confidence, + rule.text, + ] + return " ".join(values).casefold() + + +def normalize_rule_row(data: dict[str, Any]) -> dict[str, Any]: + task_ids = normalize_task_id_list(data.get("task_ids") or []) + suggested_task_ids = normalize_task_id_list(data.get("suggested_task_ids") or []) + created_at = clean_field(data.get("created_at")) or now_iso() + updated_at = clean_field(data.get("updated_at")) or created_at + text = clean_field(data.get("text")) + condition = clean_field(data.get("condition")) + recommendation = clean_field(data.get("recommendation")) + risk = clean_field(data.get("risk")) + if not text: + text = " ".join(item for item in [condition, recommendation, risk] if item) + row = { + "id": slugify(clean_field(data.get("id")) or text or recommendation or condition or "knowledge_rule"), + "status": normalize_status(data.get("status")), + "topic": slugify(clean_field(data.get("topic")) or "inne"), + "task_ids": task_ids, + "suggested_task_ids": [item for item in suggested_task_ids if item not in task_ids], + "rule_type": slugify(clean_field(data.get("rule_type")) or "recommendation"), + "condition": condition, + "recommendation": recommendation, + "risk": risk, + "source": clean_field(data.get("source")), + "source_file": clean_field(data.get("source_file")), + "confidence": slugify(clean_field(data.get("confidence")) or "medium"), + "created_at": created_at, + "updated_at": updated_at, + "duplicate_of": clean_field(data.get("duplicate_of")), + "supersedes": normalize_string_list(data.get("supersedes") or []), + "text": text, + } + validate_rule_row(row) + return row + + +def validate_rule_row(row: dict[str, Any]) -> None: + required = [ + "id", + "status", + "topic", + "task_ids", + "suggested_task_ids", + "rule_type", + "condition", + "recommendation", + "risk", + "source", + "source_file", + "confidence", + "created_at", + "updated_at", + "duplicate_of", + "supersedes", + "text", + ] + missing = [key for key in required if key not in row] + if missing: + raise ValueError(f"Regula nie ma wymaganych pol: {', '.join(missing)}") + if not isinstance(row["task_ids"], list): + raise ValueError("Pole task_ids musi byc lista.") + if not isinstance(row["suggested_task_ids"], list): + raise ValueError("Pole suggested_task_ids musi byc lista.") + if not isinstance(row["supersedes"], list): + raise ValueError("Pole supersedes musi byc lista.") + if not row["id"]: + raise ValueError("Regula musi miec id.") + if row["status"] not in ALLOWED_STATUSES: + raise ValueError(f"Nieprawidlowy status reguly: {row['status']}") + if not row["topic"]: + raise ValueError("Regula musi miec topic.") + if not row["source"]: + raise ValueError("Regula musi miec source.") + if not row["text"] and not row["recommendation"]: + raise ValueError("Regula musi miec text albo recommendation.") + + +def clean_field(value: Any) -> str: + return " ".join(str(value or "").replace("\r", "\n").split()) + + +def now_iso() -> str: + return datetime.now().isoformat(timespec="seconds") + + +def normalize_status(value: Any) -> str: + status = slugify(clean_field(value) or DEFAULT_STATUS) + if status not in ALLOWED_STATUSES: + return DEFAULT_STATUS + return status + + +def normalize_task_id_list(value: Any) -> list[str]: + if isinstance(value, str): + value = [value] + if not isinstance(value, list): + return [] + return [slugify(str(item)) for item in value if str(item).strip()] + + +def normalize_string_list(value: Any) -> list[str]: + if isinstance(value, str): + value = [value] + if not isinstance(value, list): + return [] + return [clean_field(item) for item in value if clean_field(item)] + + +def slugify(value: str, max_length: int = 96) -> str: + value = value.casefold() + replacements = {"\u0142": "l"} + for source, target in replacements.items(): + value = value.replace(source, target) + value = unicodedata.normalize("NFKD", value) + value = "".join(char for char in value if not unicodedata.combining(char)) + value = re.sub(r"[^a-z0-9]+", "_", value).strip("_") + value = re.sub(r"_+", "_", value) + return (value or "knowledge_rule")[:max_length].strip("_") or "knowledge_rule" + + +def unique_rule_id(base_id: str, existing_ids: set[str]) -> str: + candidate = base_id + suffix = 2 + while candidate in existing_ids: + candidate = f"{base_id}_{suffix}" + suffix += 1 + return candidate + + +def pending_task_suggestions(path: Path = RULES_PATH) -> list[dict[str, Any]]: + suggestions: list[dict[str, Any]] = [] + for rule in load_rules(path): + if rule.status != "active": + continue + for task_id in rule.suggested_task_ids: + if task_id in rule.task_ids: + continue + suggestions.append({"rule": rule, "task_id": task_id}) + return suggestions + + +def approve_task_suggestion(rule_id: str, task_id: str, path: Path = RULES_PATH) -> KnowledgeRule: + return update_task_suggestion(rule_id, task_id, approve=True, path=path) + + +def reject_task_suggestion(rule_id: str, task_id: str, path: Path = RULES_PATH) -> KnowledgeRule: + return update_task_suggestion(rule_id, task_id, approve=False, path=path) + + +def update_task_suggestion( + rule_id: str, + task_id: str, + approve: bool, + path: Path = RULES_PATH, +) -> KnowledgeRule: + normalized_rule_id = rule_id.strip() + normalized_task_id = slugify(task_id) + rows = read_rule_rows(path) + updated_rule: KnowledgeRule | None = None + + for row in rows: + if str(row.get("id") or "").strip() != normalized_rule_id: + continue + task_ids = normalize_task_id_list(row.get("task_ids") or []) + suggested_task_ids = normalize_task_id_list(row.get("suggested_task_ids") or []) + if normalized_task_id not in suggested_task_ids: + raise ValueError( + f"Regula {normalized_rule_id} nie ma oczekujacej propozycji dla zadania {normalized_task_id}." + ) + if approve and normalized_task_id not in task_ids: + task_ids.append(normalized_task_id) + row["task_ids"] = task_ids + row["suggested_task_ids"] = [item for item in suggested_task_ids if item != normalized_task_id] + row["updated_at"] = now_iso() + updated_rule = KnowledgeRule.from_dict(row) + break + + if not updated_rule: + raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}") + + write_rule_rows(rows, path) + return updated_rule + + +def assign_rule_to_task(rule_id: str, task_id: str, path: Path = RULES_PATH) -> KnowledgeRule: + normalized_rule_id = rule_id.strip() + normalized_task_id = slugify(task_id) + rows = read_rule_rows(path) + updated_rule: KnowledgeRule | None = None + + for row in rows: + if str(row.get("id") or "").strip() != normalized_rule_id: + continue + task_ids = normalize_task_id_list(row.get("task_ids") or []) + suggested_task_ids = normalize_task_id_list(row.get("suggested_task_ids") or []) + if normalized_task_id not in task_ids: + task_ids.append(normalized_task_id) + row["task_ids"] = task_ids + row["suggested_task_ids"] = [item for item in suggested_task_ids if item != normalized_task_id] + row["updated_at"] = now_iso() + updated_rule = KnowledgeRule.from_dict(row) + break + + if not updated_rule: + raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}") + + write_rule_rows(rows, path) + return updated_rule + + +def delete_rule(rule_id: str, path: Path = RULES_PATH) -> KnowledgeRule: + normalized_rule_id = rule_id.strip() + rows = read_rule_rows(path) + remaining_rows: list[dict[str, Any]] = [] + deleted_rule: KnowledgeRule | None = None + + for row in rows: + if str(row.get("id") or "").strip() == normalized_rule_id and deleted_rule is None: + deleted_rule = KnowledgeRule.from_dict(row) + continue + remaining_rows.append(row) + + if not deleted_rule: + raise ValueError(f"Nie znaleziono reguly: {normalized_rule_id}") + + write_rule_rows(remaining_rows, path) + return deleted_rule + + +def unassigned_rules(path: Path = RULES_PATH) -> list[KnowledgeRule]: + return [ + rule + for rule in load_rules(path) + if rule.status == "active" and not rule.task_ids + ] + + +def load_review_state(path: Path = REVIEW_STATE_PATH) -> dict[str, Any]: + ensure_knowledge_store() + if not path.exists(): + return {} + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def save_review_state(state: dict[str, Any], path: Path = REVIEW_STATE_PATH) -> None: + ensure_knowledge_store() + path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + + +def reset_review_state(queue_name: str = "unassigned") -> None: + state = load_review_state() + state.pop(queue_name, None) + save_review_state(state) + + +def review_start_index(rules: list[KnowledgeRule], queue_name: str = "unassigned") -> int: + state = load_review_state() + queue_state = state.get(queue_name) or {} + last_rule_id = queue_state.get("last_rule_id", "") + if not last_rule_id: + return 0 + for index, rule in enumerate(rules): + if rule.id == last_rule_id: + return min(index + 1, len(rules)) + last_queue_index = queue_state.get("last_queue_index") + if isinstance(last_queue_index, int): + return min(max(last_queue_index, 0), len(rules)) + last_sort_key = queue_state.get("last_sort_key") + if isinstance(last_sort_key, list) and len(last_sort_key) == 2: + normalized_last_sort_key = (str(last_sort_key[0]), str(last_sort_key[1])) + for index, rule in enumerate(rules): + if (rule.topic, rule.id) > normalized_last_sort_key: + return index + return len(rules) + return 0 + + +def mark_review_progress( + rule_id: str, + queue_name: str = "unassigned", + sort_key: list[str] | None = None, + queue_index: int | None = None, +) -> None: + state = load_review_state() + queue_state = { + "last_rule_id": rule_id, + "updated_at": now_iso(), + } + if sort_key: + queue_state["last_sort_key"] = [str(sort_key[0]), str(sort_key[1])] + if queue_index is not None: + queue_state["last_queue_index"] = queue_index + state[queue_name] = queue_state + save_review_state(state) + + +def rule_preview(rule: KnowledgeRule, max_length: int = 90) -> str: + value = rule.recommendation or rule.text or rule.condition or rule.risk + value = " ".join(value.split()) + if len(value) <= max_length: + return value + return value[: max_length - 3].rstrip() + "..." diff --git a/src/gads_v2/knowledge/vector_index.py b/src/gads_v2/knowledge/vector_index.py new file mode 100644 index 0000000..b1b48de --- /dev/null +++ b/src/gads_v2/knowledge/vector_index.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import lancedb +import requests + +from .store import LANCEDB_DIR, KnowledgeRule, ensure_knowledge_store, load_rules, now_iso + + +TABLE_NAME = "rules" +INDEX_META_PATH = LANCEDB_DIR / "index_meta.json" +DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" +EMBEDDING_BATCH_SIZE = 64 + + +@dataclass(frozen=True) +class IndexBuildResult: + table_path: Path + model: str + rules_count: int + + +@dataclass(frozen=True) +class SemanticSearchResult: + id: str + topic: str + task_ids: str + source: str + text: str + distance: float + + +def build_vector_index(model: str | None = None) -> IndexBuildResult: + ensure_knowledge_store() + storage_dir = lancedb_storage_dir() + storage_dir.mkdir(parents=True, exist_ok=True) + selected_model = embedding_model(model) + rules = [rule for rule in load_rules() if rule.status == "active"] + if not rules: + raise RuntimeError("Brak aktywnych regul do zaindeksowania.") + + texts = [rule_search_text(rule) for rule in rules] + vectors = embed_texts(texts, selected_model) + rows = [rule_to_lancedb_row(rule, text, vector) for rule, text, vector in zip(rules, texts, vectors)] + + db = lancedb.connect(str(storage_dir)) + db.create_table(TABLE_NAME, data=rows, mode="overwrite") + meta = { + "created_at": now_iso(), + "model": selected_model, + "rules_count": len(rows), + "table": TABLE_NAME, + "storage_dir": str(storage_dir), + "table_path": str(storage_dir / f"{TABLE_NAME}.lance"), + "rule_ids": [rule.id for rule in rules], + } + INDEX_META_PATH.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + return IndexBuildResult( + table_path=storage_dir / f"{TABLE_NAME}.lance", + model=selected_model, + rules_count=len(rows), + ) + + +def semantic_search(query: str, limit: int = 10, model: str | None = None) -> list[SemanticSearchResult]: + ensure_knowledge_store() + query = query.strip() + if not query: + return [] + selected_model = embedding_model(model) + storage_dir = lancedb_storage_dir() + db = lancedb.connect(str(storage_dir)) + if TABLE_NAME not in table_names(db): + raise RuntimeError("Brak indeksu LanceDB. Uruchom: python gads.py wiedza indeksuj") + + query_vector = embed_texts([query], selected_model)[0] + table = db.open_table(TABLE_NAME) + rows = table.search(query_vector).limit(max(limit, 0)).to_list() + return [ + SemanticSearchResult( + id=str(row.get("id") or ""), + topic=str(row.get("topic") or ""), + task_ids=str(row.get("task_ids") or ""), + source=str(row.get("source") or ""), + text=str(row.get("text") or ""), + distance=float(row.get("_distance") or 0.0), + ) + for row in rows + ] + + +def vector_index_summary() -> dict[str, Any]: + ensure_knowledge_store() + if not INDEX_META_PATH.exists(): + return {} + meta = json.loads(INDEX_META_PATH.read_text(encoding="utf-8")) + table_path = Path(str(meta.get("table_path") or "")) + current_table_path = lancedb_storage_dir() / f"{TABLE_NAME}.lance" + meta["available"] = table_path.exists() + meta["current_storage_dir"] = str(lancedb_storage_dir()) + meta["current_table_path"] = str(current_table_path) + meta["current_available"] = current_table_path.exists() + return meta + + +def lancedb_storage_dir() -> Path: + configured = os.environ.get("KNOWLEDGE_LANCEDB_DIR") + if configured: + return Path(configured) + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + return Path(local_app_data) / "google-ads-ver2-knowledge-lancedb" + return LANCEDB_DIR + + +def embedding_model(model: str | None = None) -> str: + return ( + model + or os.environ.get("KNOWLEDGE_EMBEDDING_MODEL") + or DEFAULT_EMBEDDING_MODEL + ) + + +def embed_texts(texts: list[str], model: str) -> list[list[float]]: + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + raise RuntimeError("Brak OPENAI_API_KEY. Dodaj klucz do .env.") + + vectors: list[list[float]] = [] + for start in range(0, len(texts), EMBEDDING_BATCH_SIZE): + batch = texts[start : start + EMBEDDING_BATCH_SIZE] + vectors.extend(call_embeddings_api(api_key, model, batch)) + if len(vectors) != len(texts): + raise RuntimeError("Liczba embeddingow nie zgadza sie z liczba tekstow.") + return vectors + + +def call_embeddings_api(api_key: str, model: str, texts: list[str]) -> list[list[float]]: + base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/") + response = requests.post( + f"{base_url}/embeddings", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "input": texts, + "encoding_format": "float", + }, + timeout=120, + ) + if response.status_code >= 400: + raise RuntimeError(f"OpenAI embeddings API zwrocilo blad {response.status_code}: {response.text}") + payload = response.json() + data = payload.get("data") or [] + data = sorted(data, key=lambda item: int(item.get("index", 0))) + vectors = [item.get("embedding") for item in data] + if not all(isinstance(vector, list) and vector for vector in vectors): + raise RuntimeError("OpenAI embeddings API nie zwrocilo poprawnych wektorow.") + return vectors + + +def rule_to_lancedb_row(rule: KnowledgeRule, search_text: str, vector: list[float]) -> dict[str, Any]: + return { + "id": rule.id, + "status": rule.status, + "topic": rule.topic, + "task_ids": ", ".join(rule.task_ids), + "suggested_task_ids": ", ".join(rule.suggested_task_ids), + "rule_type": rule.rule_type, + "condition": rule.condition, + "recommendation": rule.recommendation, + "risk": rule.risk, + "source": rule.source, + "source_file": rule.source_file, + "confidence": rule.confidence, + "created_at": rule.created_at, + "updated_at": rule.updated_at, + "duplicate_of": rule.duplicate_of, + "supersedes": ", ".join(rule.supersedes), + "text": rule.text, + "search_text": search_text, + "vector": vector, + } + + +def rule_search_text(rule: KnowledgeRule) -> str: + values = [ + f"ID: {rule.id}", + f"Temat: {rule.topic}", + f"Typ: {rule.rule_type}", + f"Warunek: {rule.condition}", + f"Rekomendacja: {rule.recommendation}", + f"Ryzyko: {rule.risk}", + f"Tekst: {rule.text}", + f"Zadania: {', '.join(rule.task_ids)}", + f"Zrodlo: {rule.source}", + ] + return "\n".join(value for value in values if value.strip()) + + +def table_names(db: Any) -> list[str]: + if hasattr(db, "table_names"): + return list(db.table_names()) + return list(db.list_tables()) diff --git a/src/gads_v2/reminders.py b/src/gads_v2/reminders.py new file mode 100644 index 0000000..dd0dcde --- /dev/null +++ b/src/gads_v2/reminders.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import json +import re +import unicodedata +from dataclasses import dataclass +from datetime import date, timedelta +from pathlib import Path + +from .config import ROOT, client_dir +from .history import now_local +from .table import print_table + + +GLOBAL_REMINDERS_PATH = ROOT / "reminders.jsonl" + + +@dataclass(frozen=True) +class Reminder: + id: str + created_at: str + due_date: str + text: str + client: str + status: str + + @classmethod + def from_dict(cls, data: dict) -> "Reminder": + return cls( + id=str(data.get("id", "")), + created_at=str(data.get("created_at", "")), + due_date=str(data.get("due_date", "")), + text=str(data.get("text", "")), + client=str(data.get("client", "")), + status=str(data.get("status", "active")), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "created_at": self.created_at, + "due_date": self.due_date, + "text": self.text, + "client": self.client, + "status": self.status, + } + + +def normalize_text(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + without_marks = "".join(ch for ch in normalized if not unicodedata.combining(ch)) + return without_marks.lower() + + +def reminder_path(domain: str | None = None) -> Path: + if domain: + base = client_dir(domain) + return base / "reminders.jsonl" + return GLOBAL_REMINDERS_PATH + + +def load_reminders(domain: str | None = None, include_global: bool = False) -> list[Reminder]: + paths = [] + if include_global: + paths.append(GLOBAL_REMINDERS_PATH) + paths.append(reminder_path(domain)) + reminders: list[Reminder] = [] + for path in paths: + if not path.exists(): + continue + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + reminders.append(Reminder.from_dict(json.loads(line))) + except json.JSONDecodeError: + continue + reminders.sort(key=lambda item: (item.due_date, item.created_at, item.id)) + return reminders + + +def parse_reminder_text(raw_text: str) -> tuple[date, str]: + text = " ".join(raw_text.split()).strip() + if not text: + raise ValueError("Brak tresci przypomnienia.") + + today = now_local().date() + normalized = normalize_text(text) + due = today + matched_prefix = "" + + patterns = [ + (r"^za\s+(\d+)\s+dni(?:\s+|$)", "days"), + (r"^za\s+(\d+)\s+dzien(?:\s+|$)", "days"), + (r"^za\s+(\d+)\s+tygodnie(?:\s+|$)", "weeks"), + (r"^za\s+(\d+)\s+tygodni(?:\s+|$)", "weeks"), + (r"^za\s+(\d+)\s+tydzien(?:\s+|$)", "weeks"), + (r"^za\s+(\d+)\s+miesiace(?:\s+|$)", "months"), + (r"^za\s+(\d+)\s+miesiecy(?:\s+|$)", "months"), + (r"^za\s+(\d+)\s+miesiac(?:\s+|$)", "months"), + ] + for pattern, unit in patterns: + match = re.search(pattern, normalized) + if not match: + continue + amount = int(match.group(1)) + if unit == "days": + due = today + timedelta(days=amount) + elif unit == "weeks": + due = today + timedelta(weeks=amount) + else: + due = today + timedelta(days=amount * 30) + matched_prefix = text[: match.end()].strip() + break + + if not matched_prefix: + if normalized.startswith("jutro"): + due = today + timedelta(days=1) + matched_prefix = text[:5].strip() + elif normalized.startswith("dzisiaj"): + due = today + matched_prefix = text[:7].strip() + + note = text[len(matched_prefix) :].strip() if matched_prefix else text + note_normalized = normalize_text(note) + for prefix in [ + "przypomnij mi o ", + "przypomnij o ", + "przypomnienie o ", + "o ", + ]: + if note_normalized.startswith(prefix): + note = note[len(prefix) :].strip() + break + if not note: + note = text + return due, note + + +def add_reminder(raw_text: str, domain: str | None = None) -> Reminder: + due, note = parse_reminder_text(raw_text) + ts = now_local() + reminder = Reminder( + id=f"rem_{ts.strftime('%Y%m%d%H%M%S')}", + created_at=ts.isoformat(timespec="seconds"), + due_date=due.isoformat(), + text=note, + client=domain or "", + status="active", + ) + path = reminder_path(domain) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(reminder.to_dict(), ensure_ascii=False) + "\n") + return reminder + + +def active_reminders_for_client(domain: str) -> list[Reminder]: + return [item for item in load_reminders(domain, include_global=True) if item.status == "active"] + + +def reminder_status_label(reminder: Reminder) -> str: + today = now_local().date() + try: + due = date.fromisoformat(reminder.due_date) + except ValueError: + return "brak daty" + days = (due - today).days + if days < 0: + return f"po terminie {abs(days)} dni" + if days == 0: + return "dzisiaj" + if days == 1: + return "jutro" + return f"za {days} dni" + + +def print_client_reminders(domain: str, limit: int = 12) -> None: + reminders = active_reminders_for_client(domain) + if not reminders: + return + print("\nPrzypomnienia") + shown = reminders[:limit] + print_table( + ["Termin", "Status", "Zakres", "Notatka"], + [ + [ + reminder.due_date, + reminder_status_label(reminder), + reminder.client or "globalne", + reminder.text, + ] + for reminder in shown + ], + ) + if len(reminders) > len(shown): + print(f"... oraz {len(reminders) - len(shown)} kolejnych przypomnien") diff --git a/src/gads_v2/table.py b/src/gads_v2/table.py new file mode 100644 index 0000000..5ced8fe --- /dev/null +++ b/src/gads_v2/table.py @@ -0,0 +1,25 @@ +from __future__ import annotations + + +def print_table(headers: list[str], rows: list[list[str]]) -> None: + widths = [len(header) for header in headers] + for row in rows: + for index, value in enumerate(row): + widths[index] = max(widths[index], len(str(value))) + + def border(left: str, middle: str, right: str) -> str: + return left + middle.join("─" * (width + 2) for width in widths) + right + + def line(values: list[str]) -> str: + cells = [f" {str(value):<{widths[index]}} " for index, value in enumerate(values)] + return "│" + "│".join(cells) + "│" + + print(border("┌", "┬", "┐")) + print(line(headers)) + print(border("├", "┼", "┤")) + for index, row in enumerate(rows): + print(line([str(value) for value in row])) + if index != len(rows) - 1: + print(border("├", "┼", "┤")) + print(border("└", "┴", "┘")) + diff --git a/src/gads_v2/task_catalog.py b/src/gads_v2/task_catalog.py new file mode 100644 index 0000000..06b6e33 --- /dev/null +++ b/src/gads_v2/task_catalog.py @@ -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"]], + ) diff --git a/src/gads_v2/tasks/__init__.py b/src/gads_v2/tasks/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/gads_v2/tasks/__init__.py @@ -0,0 +1 @@ + diff --git a/src/gads_v2/tasks/account_anomaly_check.py b/src/gads_v2/tasks/account_anomaly_check.py new file mode 100644 index 0000000..c4ee29d --- /dev/null +++ b/src/gads_v2/tasks/account_anomaly_check.py @@ -0,0 +1,949 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import timedelta +from pathlib import Path +from typing import Any + +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_account_anomalies" +TASK_NAME = "Sprawdzenie anomalii konta" + + +SCOPE = [ + { + "area": "Okres porownania", + "check": "Porownaj ostatnie 7 zakonczonych dni z poprzednimi 7 dniami, bez uzywania niepelnych danych z dzisiaj.", + }, + { + "area": "Metryki kampanii", + "check": "Sprawdz koszt, klikniecia, wyswietlenia, konwersje, wartosc konwersji, CTR, CPC i ROAS na poziomie aktywnych kampanii.", + }, + { + "area": "Nagly spadek", + "check": "Oznacz kampanie, w ktorych spadl ruch, koszt, konwersje, wartosc konwersji albo ROAS.", + }, + { + "area": "Nagly wzrost", + "check": "Oznacz kampanie, w ktorych koszt, CPC albo ruch wzrosly szybciej niz wyniki.", + }, + { + "area": "Priorytet reakcji", + "check": "Nadaj anomaliom poziom waznosci, aby agent mogl szybko zdecydowac, ktore kampanie sprawdzic jako pierwsze.", + }, +] + + +OUT_OF_SCOPE = [ + "zmiany budzetow i ocena pacingu budzetu", + "zmiany strategii stawek oraz celow Docelowy ROAS/Docelowy CPA", + "analiza zapytan uzytkownikow oraz wykluczen", + "analiza reklam RSA, zasobow i kreacji", + "wdrazanie zmian na koncie Google Ads", +] + + +@dataclass +class AccountAnomalyPlan: + currency_code: str + recent_period: dict + previous_period: dict + account_summary: list[dict] + campaigns: list[dict] + anomalies: 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, + "currency_code": self.currency_code, + "recent_period": self.recent_period, + "previous_period": self.previous_period, + "account_summary": self.account_summary, + "campaigns": self.campaigns, + "anomalies": self.anomalies, + "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) -> "AccountAnomalyPlan": + return cls( + currency_code=data.get("currency_code", ""), + recent_period=data.get("recent_period", {}), + previous_period=data.get("previous_period", {}), + account_summary=data.get("account_summary", []), + campaigns=data.get("campaigns", []), + anomalies=data.get("anomalies", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def format_money_micros(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def format_money_amount(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{float(value or 0):.2f}{suffix}" + + +def format_number(value: int | float, decimals: int = 0) -> str: + if decimals <= 0: + return str(int(round(float(value or 0)))) + return f"{float(value or 0):.{decimals}f}" + + +def format_percent_value(value: int | float) -> str: + return f"{float(value or 0):.1f}%" + + +def format_change(value: float | None) -> str: + if value is None: + return "nowe dane" + return f"{value:+.1f}%" + + +def pct_change(previous: float, recent: float) -> float | None: + if previous == 0: + return None + return round(((recent - previous) / previous) * 100, 1) + + +def percent(numerator: int | float, denominator: int | float) -> float: + if not denominator: + return 0.0 + return round((float(numerator) / float(denominator)) * 100, 2) + + +def empty_metrics() -> dict: + return { + "cost_micros": 0, + "clicks": 0, + "impressions": 0, + "conversions": 0.0, + "conversion_value": 0.0, + } + + +def add_metrics(target: dict, metrics: Any) -> None: + target["cost_micros"] += int(metrics.cost_micros or 0) + target["clicks"] += int(metrics.clicks or 0) + target["impressions"] += int(metrics.impressions or 0) + target["conversions"] += float(metrics.conversions or 0) + target["conversion_value"] += float(metrics.conversions_value or 0) + + +def derived_metrics(metrics: dict) -> dict: + cost_amount = micros_to_amount(metrics["cost_micros"]) + clicks = metrics["clicks"] + impressions = metrics["impressions"] + conversions = metrics["conversions"] + conversion_value = metrics["conversion_value"] + return { + **metrics, + "cost_amount": cost_amount, + "ctr_percent": percent(clicks, impressions), + "avg_cpc_micros": int(metrics["cost_micros"] / clicks) if clicks else 0, + "conversion_rate_percent": percent(conversions, clicks), + "roas": round(conversion_value / cost_amount, 2) if cost_amount else 0.0, + } + + +def period_labels() -> tuple[dict, dict]: + today = now_local().date() + recent_end = today - timedelta(days=1) + recent_start = recent_end - timedelta(days=6) + previous_end = recent_start - timedelta(days=1) + previous_start = previous_end - timedelta(days=6) + return ( + { + "label": "ostatnie 7 zakonczonych dni", + "start": recent_start.isoformat(), + "end": recent_end.isoformat(), + }, + { + "label": "poprzednie 7 dni", + "start": previous_start.isoformat(), + "end": previous_end.isoformat(), + }, + ) + + +def fetch_currency_code(google_client, customer_id: str) -> str: + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code + FROM customer + """, + ) + if not rows: + return "" + return str(rows[0].customer.currency_code or "") + + +def fetch_campaign_period_metrics(client_config: ClientConfig) -> tuple[str, dict, dict, list[dict]]: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + currency_code = fetch_currency_code(google_client, customer_id) + recent_period, previous_period = period_labels() + rows = run_query( + google_client, + customer_id, + f""" + SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + segments.date, + metrics.cost_micros, + metrics.clicks, + metrics.impressions, + metrics.conversions, + metrics.conversions_value + FROM campaign + WHERE campaign.status = 'ENABLED' + AND segments.date BETWEEN '{previous_period["start"]}' AND '{recent_period["end"]}' + """, + ) + + campaigns: dict[str, dict] = {} + for row in rows: + campaign = row.campaign + campaign_id = str(campaign.id) + record = campaigns.setdefault( + campaign_id, + { + "campaign_id": campaign_id, + "campaign_name": campaign.name, + "status": enum_name(campaign.status), + "channel_type": enum_name(campaign.advertising_channel_type), + "recent": empty_metrics(), + "previous": empty_metrics(), + }, + ) + row_date = str(row.segments.date) + if recent_period["start"] <= row_date <= recent_period["end"]: + add_metrics(record["recent"], row.metrics) + elif previous_period["start"] <= row_date <= previous_period["end"]: + add_metrics(record["previous"], row.metrics) + + campaign_rows = [] + for record in campaigns.values(): + recent = derived_metrics(record["recent"]) + previous = derived_metrics(record["previous"]) + campaign_rows.append( + { + "campaign_id": record["campaign_id"], + "campaign_name": record["campaign_name"], + "status": record["status"], + "channel_type": record["channel_type"], + "recent": recent, + "previous": previous, + "changes": metric_changes(previous, recent), + } + ) + campaign_rows.sort(key=lambda row: (-row["recent"]["cost_micros"], row["campaign_name"])) + return currency_code, recent_period, previous_period, campaign_rows + + +def metric_changes(previous: dict, recent: dict) -> dict: + return { + "cost": pct_change(previous["cost_micros"], recent["cost_micros"]), + "clicks": pct_change(previous["clicks"], recent["clicks"]), + "impressions": pct_change(previous["impressions"], recent["impressions"]), + "conversions": pct_change(previous["conversions"], recent["conversions"]), + "conversion_value": pct_change(previous["conversion_value"], recent["conversion_value"]), + "ctr": pct_change(previous["ctr_percent"], recent["ctr_percent"]), + "avg_cpc": pct_change(previous["avg_cpc_micros"], recent["avg_cpc_micros"]), + "roas": pct_change(previous["roas"], recent["roas"]), + } + + +def aggregate_account_summary(campaigns: list[dict], currency_code: str) -> list[dict]: + recent = empty_metrics() + previous = empty_metrics() + for campaign in campaigns: + for key in recent: + recent[key] += campaign["recent"][key] + previous[key] += campaign["previous"][key] + recent = derived_metrics(recent) + previous = derived_metrics(previous) + return [ + summary_row("Koszt", previous["cost_micros"], recent["cost_micros"], "money_micros", currency_code), + summary_row("Klikniecia", previous["clicks"], recent["clicks"], "number", currency_code), + summary_row("Wyswietlenia", previous["impressions"], recent["impressions"], "number", currency_code), + summary_row("Konwersje", previous["conversions"], recent["conversions"], "decimal", currency_code), + summary_row( + "Wartosc konwersji", + previous["conversion_value"], + recent["conversion_value"], + "money_amount", + currency_code, + ), + summary_row("CTR", previous["ctr_percent"], recent["ctr_percent"], "percent", currency_code), + summary_row("Sredni CPC", previous["avg_cpc_micros"], recent["avg_cpc_micros"], "money_micros", currency_code), + summary_row("ROAS", previous["roas"], recent["roas"], "decimal", currency_code), + ] + + +def summary_row(label: str, previous: float, recent: float, value_type: str, currency_code: str) -> dict: + return { + "metric": label, + "previous": display_metric(previous, value_type, currency_code), + "recent": display_metric(recent, value_type, currency_code), + "change_percent": format_change(pct_change(previous, recent)), + } + + +def display_metric(value: float, value_type: str, currency_code: str) -> str: + if value_type == "money_micros": + return format_money_micros(value, currency_code) + if value_type == "money_amount": + return format_money_amount(value, currency_code) + if value_type == "percent": + return format_percent_value(value) + if value_type == "decimal": + return format_number(value, 2) + return format_number(value) + + +def anomaly( + campaign: dict, + metric: str, + severity: str, + previous_value: str, + recent_value: str, + change_percent: str, + reason: str, + recommendation: str, +) -> dict: + return { + "campaign_id": campaign["campaign_id"], + "campaign_name": campaign["campaign_name"], + "channel_type": campaign["channel_type"], + "status": campaign["status"], + "metric": metric, + "severity": severity, + "previous_value": previous_value, + "recent_value": recent_value, + "change_percent": change_percent, + "reason": reason, + "recommendation": recommendation, + } + + +def build_campaign_anomalies(campaign: dict, currency_code: str) -> list[dict]: + recent = campaign["recent"] + previous = campaign["previous"] + changes = campaign["changes"] + found = [] + + if previous["cost_micros"] >= 50_000_000 and recent["cost_micros"] == 0: + found.append( + anomaly( + campaign, + "koszt", + "wysokie", + format_money_micros(previous["cost_micros"], currency_code), + format_money_micros(recent["cost_micros"], currency_code), + "-100.0%", + "kampania miala koszt w poprzednim okresie i nie ma kosztu w ostatnich 7 dniach", + "sprawdz status kampanii, budzet, odrzucenia, pomiar i ostatnie zmiany", + ) + ) + elif previous["cost_micros"] == 0 and recent["cost_micros"] >= 50_000_000: + found.append( + anomaly( + campaign, + "koszt", + "niskie", + format_money_micros(previous["cost_micros"], currency_code), + format_money_micros(recent["cost_micros"], currency_code), + "nowe dane", + "kampania zaczela wydawac srodki po okresie bez kosztu", + "sprawdz, czy start kampanii byl planowany i czy wyniki sa akceptowalne", + ) + ) + + if changes["cost"] is not None and previous["cost_micros"] >= 50_000_000: + if changes["cost"] >= 60: + found.append( + anomaly( + campaign, + "koszt", + "srednie", + format_money_micros(previous["cost_micros"], currency_code), + format_money_micros(recent["cost_micros"], currency_code), + format_change(changes["cost"]), + "koszt wzrosl szybciej niz typowy tygodniowy prog alarmowy", + "sprawdz budzet, strategie stawek i zmiany ruchu w osobnych zadaniach", + ) + ) + elif changes["cost"] <= -50: + found.append( + anomaly( + campaign, + "koszt", + "srednie", + format_money_micros(previous["cost_micros"], currency_code), + format_money_micros(recent["cost_micros"], currency_code), + format_change(changes["cost"]), + "koszt spadl o co najmniej polowe tydzien do tygodnia", + "sprawdz, czy spadek wynika z decyzji, limitow, odrzucen albo utraty ruchu", + ) + ) + + if changes["clicks"] is not None and previous["clicks"] >= 20: + if changes["clicks"] <= -45: + found.append( + anomaly( + campaign, + "klikniecia", + "srednie", + format_number(previous["clicks"]), + format_number(recent["clicks"]), + format_change(changes["clicks"]), + "klikniecia spadly mocno przy sensownym wolumenie bazowym", + "sprawdz aukcje, status kampanii, budzet i zapytania w zadaniach szczegolowych", + ) + ) + elif changes["clicks"] >= 80: + found.append( + anomaly( + campaign, + "klikniecia", + "niskie", + format_number(previous["clicks"]), + format_number(recent["clicks"]), + format_change(changes["clicks"]), + "klikniecia wzrosly bardzo mocno tydzien do tygodnia", + "sprawdz, czy wzrost jest jakosciowy i nie wynika z niepasujacego ruchu", + ) + ) + + if changes["conversions"] is not None and previous["conversions"] >= 3: + if recent["conversions"] == 0: + severity = "wysokie" + else: + severity = "srednie" + if changes["conversions"] <= -50: + found.append( + anomaly( + campaign, + "konwersje", + severity, + format_number(previous["conversions"], 2), + format_number(recent["conversions"], 2), + format_change(changes["conversions"]), + "konwersje spadly o co najmniej polowe", + "sprawdz pomiar konwersji, ruch i ostatnie zmiany w kampanii", + ) + ) + elif changes["conversions"] >= 80: + found.append( + anomaly( + campaign, + "konwersje", + "niskie", + format_number(previous["conversions"], 2), + format_number(recent["conversions"], 2), + format_change(changes["conversions"]), + "konwersje wzrosly bardzo mocno tydzien do tygodnia", + "sprawdz, czy wzrost jest rzeczywisty i czy nie zmienil sie pomiar", + ) + ) + + if changes["conversion_value"] is not None and previous["conversion_value"] >= 100: + if changes["conversion_value"] <= -50: + found.append( + anomaly( + campaign, + "wartosc konwersji", + "wysokie", + format_money_amount(previous["conversion_value"], currency_code), + format_money_amount(recent["conversion_value"], currency_code), + format_change(changes["conversion_value"]), + "wartosc konwersji spadla o co najmniej polowe", + "sprawdz pomiar, koszyk, kampanie produktowe i jakosc ruchu", + ) + ) + elif changes["conversion_value"] >= 100: + found.append( + anomaly( + campaign, + "wartosc konwersji", + "niskie", + format_money_amount(previous["conversion_value"], currency_code), + format_money_amount(recent["conversion_value"], currency_code), + format_change(changes["conversion_value"]), + "wartosc konwersji wzrosla ponad dwukrotnie", + "sprawdz, czy wzrost wynika z realnej sprzedazy, a nie zmiany pomiaru", + ) + ) + + if changes["ctr"] is not None and previous["impressions"] >= 500 and previous["ctr_percent"] >= 1: + if changes["ctr"] <= -35: + found.append( + anomaly( + campaign, + "CTR", + "srednie", + format_percent_value(previous["ctr_percent"]), + format_percent_value(recent["ctr_percent"]), + format_change(changes["ctr"]), + "CTR spadl przy wystarczajacej liczbie wyswietlen", + "sprawdz dopasowanie ruchu, reklamy i zmiany konkurencji", + ) + ) + + if changes["avg_cpc"] is not None and previous["clicks"] >= 20: + if changes["avg_cpc"] >= 60: + found.append( + anomaly( + campaign, + "sredni CPC", + "srednie", + format_money_micros(previous["avg_cpc_micros"], currency_code), + format_money_micros(recent["avg_cpc_micros"], currency_code), + format_change(changes["avg_cpc"]), + "sredni CPC wzrosl mocno tydzien do tygodnia", + "sprawdz strategie stawek, aukcje i segmenty ruchu", + ) + ) + + if changes["roas"] is not None and previous["cost_micros"] >= 50_000_000 and previous["roas"] >= 1: + if changes["roas"] <= -40: + found.append( + anomaly( + campaign, + "ROAS", + "wysokie", + format_number(previous["roas"], 2), + format_number(recent["roas"], 2), + format_change(changes["roas"]), + "ROAS spadl przy istotnym koszcie bazowym", + "sprawdz rentownosc, pomiar, produkt/feed i strategie stawek w zadaniach szczegolowych", + ) + ) + + return found + + +def build_anomalies(campaigns: list[dict], currency_code: str) -> list[dict]: + anomalies = [] + for campaign in campaigns: + anomalies.extend(build_campaign_anomalies(campaign, currency_code)) + severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2} + anomalies.sort( + key=lambda row: ( + severity_order.get(row["severity"], 9), + row["campaign_name"], + row["metric"], + ) + ) + return anomalies + + +def compact_campaign_row(campaign: dict, currency_code: str) -> dict: + previous = campaign["previous"] + recent = campaign["recent"] + return { + "campaign_id": campaign["campaign_id"], + "campaign_name": campaign["campaign_name"], + "status": campaign["status"], + "channel_type": campaign["channel_type"], + "previous_cost": format_money_micros(previous["cost_micros"], currency_code), + "recent_cost": format_money_micros(recent["cost_micros"], currency_code), + "cost_change": format_change(campaign["changes"]["cost"]), + "previous_clicks": format_number(previous["clicks"]), + "recent_clicks": format_number(recent["clicks"]), + "clicks_change": format_change(campaign["changes"]["clicks"]), + "previous_conversions": format_number(previous["conversions"], 2), + "recent_conversions": format_number(recent["conversions"], 2), + "conversions_change": format_change(campaign["changes"]["conversions"]), + "previous_roas": format_number(previous["roas"], 2), + "recent_roas": format_number(recent["roas"], 2), + "roas_change": format_change(campaign["changes"]["roas"]), + } + + +def build_account_anomaly_plan(client_config: ClientConfig) -> AccountAnomalyPlan: + warnings = [] + recent_period, previous_period = period_labels() + try: + currency_code, recent_period, previous_period, campaigns_raw = fetch_campaign_period_metrics(client_config) + except Exception as exc: + currency_code = "" + campaigns_raw = [] + warnings.append(f"Nie udalo sie pobrac metryk kampanii z Google Ads API: {exc}") + + if not campaigns_raw: + warnings.append("Nie znaleziono kampanii z danymi w analizowanym okresie albo nie udalo sie ich pobrac.") + + anomalies = build_anomalies(campaigns_raw, currency_code) + if not anomalies and campaigns_raw: + warnings.append("Nie wykryto anomalii wedlug obecnych progow. Wyniki nadal warto porownac z kontekstem klienta.") + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace anomalii i alertow bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + campaigns = [compact_campaign_row(campaign, currency_code) for campaign in campaigns_raw] + return AccountAnomalyPlan( + currency_code=currency_code, + recent_period=recent_period, + previous_period=previous_period, + account_summary=aggregate_account_summary(campaigns_raw, currency_code), + campaigns=campaigns, + anomalies=anomalies, + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_account_anomaly_plan(domain: str, plan: AccountAnomalyPlan) -> 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 anomalii konta", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Okresy", + "", + f"- Ostatnie 7 zakonczonych dni: {plan.recent_period.get('start', '')} - {plan.recent_period.get('end', '')}", + f"- Poprzednie 7 dni: {plan.previous_period.get('start', '')} - {plan.previous_period.get('end', '')}", + "", + "## Podsumowanie", + "", + f"- Kampanie z danymi: {len(plan.campaigns)}", + f"- Wykryte anomalie: {len(plan.anomalies)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.account_summary: + lines.extend( + [ + "## Podsumowanie konta", + "", + "| Metryka | Poprzednie 7 dni | Ostatnie 7 dni | Zmiana |", + "| --- | --- | --- | --- |", + ] + ) + for row in plan.account_summary: + lines.append(f"| {row['metric']} | {row['previous']} | {row['recent']} | {row['change_percent']} |") + lines.append("") + if plan.anomalies: + lines.extend( + [ + "## Wykryte anomalie", + "", + "| Waznosc | Kampania | Metryka | Poprzednio | Teraz | Zmiana | Powod | Rekomendacja |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for item in plan.anomalies: + lines.append( + f"| {item['severity']} | {md_cell(item['campaign_name'])} | {item['metric']} | " + f"{item['previous_value']} | {item['recent_value']} | {item['change_percent']} | " + f"{md_cell(item['reason'])} | {md_cell(item['recommendation'])} |" + ) + lines.append("") + if plan.campaigns: + lines.extend( + [ + "## Porownanie kampanii", + "", + "| Kampania | Typ | Status | Koszt poprzednio | Koszt teraz | Zmiana kosztu | Konwersje poprzednio | Konwersje teraz | Zmiana konwersji | ROAS poprzednio | ROAS teraz | Zmiana ROAS |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for campaign in plan.campaigns: + lines.append( + f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | " + f"{campaign['previous_cost']} | {campaign['recent_cost']} | {campaign['cost_change']} | " + f"{campaign['previous_conversions']} | {campaign['recent_conversions']} | {campaign['conversions_change']} | " + f"{campaign['previous_roas']} | {campaign['recent_roas']} | {campaign['roas_change']} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_account_anomaly_plan(plan: AccountAnomalyPlan) -> None: + print("\nPlan sprawdzenia anomalii konta") + print_table( + ["Metryka", "Liczba"], + [ + ["Kampanie z danymi", str(len(plan.campaigns))], + ["Wykryte anomalie", str(len(plan.anomalies))], + ["Reguly wiedzy", str(len(plan.knowledge_rules))], + ["Zmiany do wdrozenia", "0"], + ], + ) + print("\nOkresy porownania") + print_table( + ["Okres", "Od", "Do"], + [ + [plan.previous_period.get("label", "poprzednie 7 dni"), plan.previous_period.get("start", ""), plan.previous_period.get("end", "")], + [plan.recent_period.get("label", "ostatnie 7 zakonczonych dni"), plan.recent_period.get("start", ""), plan.recent_period.get("end", "")], + ], + ) + 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.account_summary: + print("\nPodsumowanie konta") + print_table( + ["Metryka", "Poprzednie 7 dni", "Ostatnie 7 dni", "Zmiana"], + [[row["metric"], row["previous"], row["recent"], row["change_percent"]] for row in plan.account_summary], + ) + if plan.anomalies: + print("\nWykryte anomalie") + print_table( + ["Nr", "Waznosc", "Kampania", "Metryka", "Poprzednio", "Teraz", "Zmiana", "Powod"], + [ + [ + str(index), + item["severity"], + item["campaign_name"], + item["metric"], + item["previous_value"], + item["recent_value"], + item["change_percent"], + item["reason"], + ] + for index, item in enumerate(plan.anomalies[:30], 1) + ], + ) + if len(plan.anomalies) > 30: + print(f"... oraz {len(plan.anomalies) - 30} kolejnych anomalii w pliku planu") + if plan.campaigns: + print("\nPorownanie kampanii") + print_table( + ["Nr", "Kampania", "Typ", "Koszt poprz.", "Koszt teraz", "Zmiana", "Konw. poprz.", "Konw. teraz", "ROAS teraz"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["channel_type"], + campaign["previous_cost"], + campaign["recent_cost"], + campaign["cost_change"], + campaign["previous_conversions"], + campaign["recent_conversions"], + campaign["recent_roas"], + ] + for index, campaign in enumerate(plan.campaigns[:30], 1) + ], + ) + if len(plan.campaigns) > 30: + print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu") + 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_account_anomaly_plan( + client_config: ClientConfig, + plan: AccountAnomalyPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem anomalii 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(item["campaign_name"] for item in plan.anomalies[:10]), + "summary": { + "campaigns": len(plan.campaigns), + "anomalies": len(plan.anomalies), + "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_account_anomalies( + 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 = AccountAnomalyPlan.from_dict(plan_data) + print_account_anomaly_plan(plan) + apply_account_anomaly_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia anomalii konta...") + plan = build_account_anomaly_plan(client_config) + print_account_anomaly_plan(plan) + json_path, md_path = save_account_anomaly_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(item["campaign_name"] for item in plan.anomalies[:10]), + "summary": { + "campaigns": len(plan.campaigns), + "anomalies": len(plan.anomalies), + "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 anomalii.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/ad_asset_status_check.py b/src/gads_v2/tasks/ad_asset_status_check.py new file mode 100644 index 0000000..1691d33 --- /dev/null +++ b/src/gads_v2/tasks/ad_asset_status_check.py @@ -0,0 +1,627 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_ad_asset_statuses" +TASK_NAME = "Sprawdzenie statusow reklam i zasobow" + + +SCOPE = [ + { + "area": "Reklamy", + "check": "Pokaz status reklamy, typ reklamy i status zatwierdzenia polityk dla aktywnych grup reklam.", + }, + { + "area": "Odrzucenia i ograniczenia", + "check": "Oznacz reklamy odrzucone, ograniczone, oczekujace albo wstrzymane jako elementy do szybkiej reakcji.", + }, + { + "area": "Zasoby PMax", + "check": "Pokaz status zasobow w asset group, jezeli Google Ads API udostepnia te dane dla konta.", + }, + { + "area": "Audyt techniczny", + "check": "Oddziel dostepnosc emisji od oceny jakosci tekstow, naglowkow i kreacji.", + }, +] + + +OUT_OF_SCOPE = [ + "analiza jakosci tekstow reklam i kreacji", + "budzety i wykorzystanie budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "zapytania uzytkownikow oraz wykluczenia", + "wdrazanie zmian reklam albo zasobow na koncie Google Ads", +] + + +PROBLEM_APPROVAL_STATUSES = { + "DISAPPROVED", + "AREA_OF_INTEREST_ONLY", + "APPROVED_LIMITED", + "UNDER_REVIEW", + "UNKNOWN", + "UNSPECIFIED", +} + + +PROBLEM_ENTITY_STATUSES = { + "PAUSED", + "DISABLED", + "UNKNOWN", + "UNSPECIFIED", +} + + +@dataclass +class AdAssetStatusPlan: + ads: list[dict] + assets: list[dict] + ad_status_summary: list[dict] + approval_summary: list[dict] + asset_status_summary: list[dict] + problem_items: 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, + "ads": self.ads, + "assets": self.assets, + "ad_status_summary": self.ad_status_summary, + "approval_summary": self.approval_summary, + "asset_status_summary": self.asset_status_summary, + "problem_items": self.problem_items, + "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) -> "AdAssetStatusPlan": + return cls( + ads=data.get("ads", []), + assets=data.get("assets", []), + ad_status_summary=data.get("ad_status_summary", []), + approval_summary=data.get("approval_summary", []), + asset_status_summary=data.get("asset_status_summary", []), + problem_items=data.get("problem_items", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def ad_label(row: dict) -> str: + if row.get("ad_name"): + return row["ad_name"] + return f"{row['ad_type']} {row['ad_id']}" + + +def item_severity(status: str, approval_status: str) -> str: + if approval_status == "DISAPPROVED": + return "wysokie" + if status in PROBLEM_ENTITY_STATUSES: + return "srednie" + if approval_status in {"APPROVED_LIMITED", "AREA_OF_INTEREST_ONLY"}: + return "srednie" + if approval_status == "UNDER_REVIEW": + return "niskie" + if approval_status in {"UNKNOWN", "UNSPECIFIED"}: + return "niskie" + return "ok" + + +def item_flags(status: str, approval_status: str) -> list[str]: + flags = [] + if status in PROBLEM_ENTITY_STATUSES: + flags.append(f"status: {status}") + if approval_status in PROBLEM_APPROVAL_STATUSES: + flags.append(f"polityka: {approval_status}") + return flags or ["ok"] + + +def fetch_ads(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, + ad_group.id, + ad_group.name, + ad_group.status, + ad_group_ad.ad.id, + ad_group_ad.ad.name, + ad_group_ad.ad.type, + ad_group_ad.status, + ad_group_ad.policy_summary.approval_status + FROM ad_group_ad + WHERE campaign.status != 'REMOVED' + AND ad_group.status != 'REMOVED' + AND ad_group_ad.status != 'REMOVED' + """, + ) + + ads = [] + for row in rows: + ad = row.ad_group_ad.ad + status = enum_name(row.ad_group_ad.status) + approval_status = enum_name(row.ad_group_ad.policy_summary.approval_status) + record = { + "item_type": "reklama", + "campaign_id": str(row.campaign.id), + "campaign_name": row.campaign.name, + "campaign_status": enum_name(row.campaign.status), + "channel_type": enum_name(row.campaign.advertising_channel_type), + "ad_group_id": str(row.ad_group.id), + "ad_group_name": row.ad_group.name, + "ad_group_status": enum_name(row.ad_group.status), + "ad_id": str(ad.id), + "ad_name": str(ad.name or ""), + "ad_type": enum_name(ad.type), + "status": status, + "approval_status": approval_status, + } + record["label"] = ad_label(record) + record["severity"] = item_severity(status, approval_status) + record["flags"] = item_flags(status, approval_status) + ads.append(record) + ads.sort(key=lambda row: (row["severity"], row["campaign_name"], row["ad_group_name"], row["ad_type"])) + return ads + + +def fetch_asset_group_assets(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, + asset_group.id, + asset_group.name, + asset.id, + asset.name, + asset.type, + asset_group_asset.field_type, + asset_group_asset.status, + asset_group_asset.policy_summary.approval_status + FROM asset_group_asset + WHERE campaign.status != 'REMOVED' + AND asset_group_asset.status != 'REMOVED' + """, + ) + + assets = [] + for row in rows: + status = enum_name(row.asset_group_asset.status) + approval_status = enum_name(row.asset_group_asset.policy_summary.approval_status) + record = { + "item_type": "zasob", + "campaign_id": str(row.campaign.id), + "campaign_name": row.campaign.name, + "campaign_status": enum_name(row.campaign.status), + "asset_group_id": str(row.asset_group.id), + "asset_group_name": row.asset_group.name, + "asset_id": str(row.asset.id), + "asset_name": str(row.asset.name or ""), + "asset_type": enum_name(row.asset.type), + "field_type": enum_name(row.asset_group_asset.field_type), + "status": status, + "approval_status": approval_status, + } + record["label"] = record["asset_name"] or f"{record['asset_type']} {record['asset_id']}" + record["severity"] = item_severity(status, approval_status) + record["flags"] = item_flags(status, approval_status) + assets.append(record) + assets.sort(key=lambda row: (row["severity"], row["campaign_name"], row["asset_group_name"], row["field_type"])) + return assets + + +def build_counter_summary(rows: list[dict], field: str, label: str) -> list[dict]: + counter = Counter(row.get(field, "") or "(brak)" for row in rows) + return [{label: key, "count": value} for key, value in counter.most_common()] + + +def build_problem_items(ads: list[dict], assets: list[dict]) -> list[dict]: + items = [] + for ad in ads: + if ad["flags"] == ["ok"]: + continue + items.append( + { + "item_type": "reklama", + "severity": ad["severity"], + "campaign_name": ad["campaign_name"], + "container": ad["ad_group_name"], + "label": ad["label"], + "status": ad["status"], + "approval_status": ad["approval_status"], + "flags": ad["flags"], + "recommendation": "sprawdz przyczyne ograniczenia lub odrzucenia w Google Ads", + } + ) + for asset in assets: + if asset["flags"] == ["ok"]: + continue + items.append( + { + "item_type": "zasob", + "severity": asset["severity"], + "campaign_name": asset["campaign_name"], + "container": asset["asset_group_name"], + "label": asset["label"], + "status": asset["status"], + "approval_status": asset["approval_status"], + "flags": asset["flags"], + "recommendation": "sprawdz status zasobu i polityki w asset group", + } + ) + severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9} + items.sort(key=lambda row: (severity_order.get(row["severity"], 9), row["campaign_name"], row["item_type"])) + return items + + +def build_ad_asset_status_plan(client_config: ClientConfig) -> AdAssetStatusPlan: + warnings = [] + try: + ads = fetch_ads(client_config) + except Exception as exc: + ads = [] + warnings.append(f"Nie udalo sie pobrac reklam z Google Ads API: {exc}") + + try: + assets = fetch_asset_group_assets(client_config) + except Exception as exc: + assets = [] + warnings.append(f"Nie udalo sie pobrac zasobow PMax z Google Ads API: {exc}") + + if not ads: + warnings.append("Nie znaleziono reklam albo nie udalo sie ich pobrac.") + if not assets: + warnings.append("Nie znaleziono zasobow asset group albo API nie udostepnilo ich dla tego konta.") + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace odrzucen reklam i zasobow bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return AdAssetStatusPlan( + ads=ads, + assets=assets, + ad_status_summary=build_counter_summary(ads, "status", "status"), + approval_summary=build_counter_summary(ads + assets, "approval_status", "approval_status"), + asset_status_summary=build_counter_summary(assets, "status", "status"), + problem_items=build_problem_items(ads, assets), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_ad_asset_status_plan(domain: str, plan: AdAssetStatusPlan) -> 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 statusow reklam i zasobow", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Reklamy: {len(plan.ads)}", + f"- Zasoby asset group: {len(plan.assets)}", + f"- Elementy do oceny: {len(plan.problem_items)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.problem_items: + lines.extend( + [ + "## Elementy do oceny", + "", + "| Waznosc | Typ | Kampania | Kontener | Element | Status | Polityka | Flagi | Rekomendacja |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for item in plan.problem_items: + lines.append( + f"| {item['severity']} | {item['item_type']} | {md_cell(item['campaign_name'])} | " + f"{md_cell(item['container'])} | {md_cell(item['label'])} | {item['status']} | " + f"{item['approval_status']} | {md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |" + ) + lines.append("") + if plan.ad_status_summary: + lines.extend(["## Statusy reklam", "", "| Status | Liczba |", "| --- | --- |"]) + for row in plan.ad_status_summary: + lines.append(f"| {row['status']} | {row['count']} |") + lines.append("") + if plan.approval_summary: + lines.extend(["## Statusy zatwierdzenia", "", "| Status polityki | Liczba |", "| --- | --- |"]) + for row in plan.approval_summary: + lines.append(f"| {row['approval_status']} | {row['count']} |") + lines.append("") + if plan.ads: + lines.extend( + [ + "## Reklamy", + "", + "| Kampania | Grupa reklam | Typ | Status | Polityka | Flagi |", + "| --- | --- | --- | --- | --- | --- |", + ] + ) + for ad in plan.ads: + lines.append( + f"| {md_cell(ad['campaign_name'])} | {md_cell(ad['ad_group_name'])} | {ad['ad_type']} | " + f"{ad['status']} | {ad['approval_status']} | {md_cell(', '.join(ad['flags']))} |" + ) + lines.append("") + if plan.assets: + lines.extend( + [ + "## Zasoby asset group", + "", + "| Kampania | Asset group | Typ | Pole | Status | Polityka | Flagi |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for asset in plan.assets: + lines.append( + f"| {md_cell(asset['campaign_name'])} | {md_cell(asset['asset_group_name'])} | {asset['asset_type']} | " + f"{asset['field_type']} | {asset['status']} | {asset['approval_status']} | {md_cell(', '.join(asset['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_ad_asset_status_plan(plan: AdAssetStatusPlan) -> None: + print("\nPlan sprawdzenia statusow reklam i zasobow") + print_table( + ["Metryka", "Liczba"], + [ + ["Reklamy", str(len(plan.ads))], + ["Zasoby asset group", str(len(plan.assets))], + ["Elementy do oceny", str(len(plan.problem_items))], + ["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.problem_items: + print("\nElementy do oceny") + print_table( + ["Nr", "Waznosc", "Typ", "Kampania", "Kontener", "Status", "Polityka", "Flagi"], + [ + [ + str(index), + item["severity"], + item["item_type"], + item["campaign_name"], + item["container"], + item["status"], + item["approval_status"], + ", ".join(item["flags"]), + ] + for index, item in enumerate(plan.problem_items[:30], 1) + ], + ) + if len(plan.problem_items) > 30: + print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu") + if plan.ad_status_summary: + print("\nStatusy reklam") + print_table(["Status", "Liczba"], [[row["status"], str(row["count"])] for row in plan.ad_status_summary]) + if plan.approval_summary: + print("\nStatusy zatwierdzenia") + print_table( + ["Status polityki", "Liczba"], + [[row["approval_status"], str(row["count"])] for row in plan.approval_summary], + ) + 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_ad_asset_status_plan( + client_config: ClientConfig, + plan: AdAssetStatusPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem statusow reklam i zasobow 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(item["campaign_name"] for item in plan.problem_items[:10]), + "summary": { + "ads": len(plan.ads), + "assets": len(plan.assets), + "problem_items": len(plan.problem_items), + "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_ad_asset_statuses( + 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 = AdAssetStatusPlan.from_dict(plan_data) + print_ad_asset_status_plan(plan) + apply_ad_asset_status_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia statusow reklam i zasobow...") + plan = build_ad_asset_status_plan(client_config) + print_ad_asset_status_plan(plan) + json_path, md_path = save_ad_asset_status_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(item["campaign_name"] for item in plan.problem_items[:10]), + "summary": { + "ads": len(plan.ads), + "assets": len(plan.assets), + "problem_items": len(plan.problem_items), + "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 statusow reklam i zasobow.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/ad_schedule_check.py b/src/gads_v2/tasks/ad_schedule_check.py new file mode 100644 index 0000000..b1951d6 --- /dev/null +++ b/src/gads_v2/tasks/ad_schedule_check.py @@ -0,0 +1,564 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_ad_schedules" +TASK_NAME = "Sprawdzenie harmonogramu reklam" + + +SCOPE = [ + { + "area": "Harmonogram 24/7", + "check": "Oznacz kampanie bez jawnego harmonogramu jako dzialajace caly tydzien i caly dzien.", + }, + { + "area": "Niestandardowe godziny", + "check": "Wypisz kampanie z ustawionymi przedzialami dni i godzin emisji.", + }, + { + "area": "Typ kampanii", + "check": "Pokaz harmonogram razem z typem kampanii, zeby osobno oceniac Search, Shopping i PMax.", + }, + { + "area": "Audyt ustawien", + "check": "Przygotuj szybki przeglad harmonogramow, ktory mozna wykonywac rzadziej niz budzety i anomalie.", + }, +] + + +OUT_OF_SCOPE = [ + "budzety i wykorzystanie budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "zapytania uzytkownikow oraz wykluczenia", + "reklamy RSA, assety i kreacje", + "wdrazanie zmian harmonogramu na koncie Google Ads", +] + + +DAY_ORDER = { + "MONDAY": 1, + "TUESDAY": 2, + "WEDNESDAY": 3, + "THURSDAY": 4, + "FRIDAY": 5, + "SATURDAY": 6, + "SUNDAY": 7, +} + + +DAY_LABELS = { + "MONDAY": "poniedzialek", + "TUESDAY": "wtorek", + "WEDNESDAY": "sroda", + "THURSDAY": "czwartek", + "FRIDAY": "piatek", + "SATURDAY": "sobota", + "SUNDAY": "niedziela", +} + + +MINUTE_LABELS = { + "ZERO": "00", + "FIFTEEN": "15", + "THIRTY": "30", + "FORTY_FIVE": "45", +} + + +@dataclass +class AdSchedulePlan: + campaigns: list[dict] + schedule_summary: list[dict] + channel_summary: 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, + "schedule_summary": self.schedule_summary, + "channel_summary": self.channel_summary, + "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) -> "AdSchedulePlan": + return cls( + campaigns=data.get("campaigns", []), + schedule_summary=data.get("schedule_summary", []), + channel_summary=data.get("channel_summary", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def minute_label(value: Any) -> str: + raw = enum_name(value) + return MINUTE_LABELS.get(raw, raw) + + +def hour_label(hour: int, minute: str) -> str: + return f"{int(hour):02d}:{minute}" + + +def schedule_label(schedule: dict) -> str: + day = DAY_LABELS.get(schedule["day_of_week"], schedule["day_of_week"]) + start = hour_label(schedule["start_hour"], schedule["start_minute"]) + end = hour_label(schedule["end_hour"], schedule["end_minute"]) + return f"{day} {start}-{end}" + + +def join_schedule_labels(schedules: list[dict], limit: int = 8) -> str: + if not schedules: + return "24/7 (brak jawnego harmonogramu)" + labels = [schedule["label"] for schedule in schedules] + shown = labels[:limit] + if len(labels) > limit: + shown.append(f"... +{len(labels) - limit}") + return ", ".join(shown) + + +def campaign_flags(campaign: dict) -> list[str]: + schedules = campaign["schedules"] + flags = [] + if not schedules: + flags.append("dziala 24/7") + if len(schedules) == 7 and all( + item["start_hour"] == 0 + and item["start_minute"] == "00" + and item["end_hour"] == 24 + and item["end_minute"] == "00" + for item in schedules + ): + flags.append("harmonogram rowny 24/7") + if schedules and len(schedules) < 5: + flags.append("malo dni emisji") + if any(item["start_hour"] < 6 or item["end_hour"] > 22 for item in schedules): + flags.append("emisja nocna do oceny") + return flags or ["ok"] + + +def fetch_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 + FROM campaign + WHERE campaign.status != 'REMOVED' + """, + ) + + campaigns = [] + for row in rows: + campaign = row.campaign + campaigns.append( + { + "campaign_id": str(campaign.id), + "campaign_name": campaign.name, + "status": enum_name(campaign.status), + "channel_type": enum_name(campaign.advertising_channel_type), + "schedules": [], + } + ) + return campaigns + + +def fetch_ad_schedules(client_config: ClientConfig) -> dict[str, 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_criterion.criterion_id, + campaign_criterion.status, + campaign_criterion.ad_schedule.day_of_week, + campaign_criterion.ad_schedule.start_hour, + campaign_criterion.ad_schedule.start_minute, + campaign_criterion.ad_schedule.end_hour, + campaign_criterion.ad_schedule.end_minute + FROM campaign_criterion + WHERE campaign.status != 'REMOVED' + AND campaign_criterion.status != 'REMOVED' + AND campaign_criterion.type = 'AD_SCHEDULE' + """, + ) + + schedules_by_campaign: dict[str, list[dict]] = {} + for row in rows: + campaign_id = str(row.campaign.id) + criterion = row.campaign_criterion + schedule = criterion.ad_schedule + record = { + "criterion_id": str(criterion.criterion_id), + "status": enum_name(criterion.status), + "day_of_week": enum_name(schedule.day_of_week), + "start_hour": int(schedule.start_hour or 0), + "start_minute": minute_label(schedule.start_minute), + "end_hour": int(schedule.end_hour or 0), + "end_minute": minute_label(schedule.end_minute), + } + record["label"] = schedule_label(record) + schedules_by_campaign.setdefault(campaign_id, []).append(record) + + for schedules in schedules_by_campaign.values(): + schedules.sort(key=lambda item: (DAY_ORDER.get(item["day_of_week"], 99), item["start_hour"], item["start_minute"])) + return schedules_by_campaign + + +def attach_schedules(campaigns: list[dict], schedules_by_campaign: dict[str, list[dict]]) -> list[dict]: + for campaign in campaigns: + campaign["schedules"] = schedules_by_campaign.get(campaign["campaign_id"], []) + campaign["schedule_count"] = len(campaign["schedules"]) + campaign["schedule_label"] = join_schedule_labels(campaign["schedules"]) + campaign["flags"] = campaign_flags(campaign) + campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"])) + return campaigns + + +def build_schedule_summary(campaigns: list[dict]) -> list[dict]: + return [ + { + "metric": "Kampanie", + "count": len(campaigns), + }, + { + "metric": "Kampanie bez jawnego harmonogramu", + "count": sum(1 for campaign in campaigns if campaign["schedule_count"] == 0), + }, + { + "metric": "Kampanie z harmonogramem", + "count": sum(1 for campaign in campaigns if campaign["schedule_count"] > 0), + }, + { + "metric": "Kampanie z emisja nocna do oceny", + "count": sum(1 for campaign in campaigns if "emisja nocna do oceny" in campaign["flags"]), + }, + { + "metric": "Kampanie z malą liczba dni emisji", + "count": sum(1 for campaign in campaigns if "malo dni emisji" in campaign["flags"]), + }, + ] + + +def build_channel_summary(campaigns: list[dict]) -> list[dict]: + counter = Counter(row["channel_type"] for row in campaigns) + return [{"channel_type": key, "count": value} for key, value in counter.most_common()] + + +def build_ad_schedule_plan(client_config: ClientConfig) -> AdSchedulePlan: + warnings = [] + try: + campaigns = fetch_campaigns(client_config) + schedules_by_campaign = fetch_ad_schedules(client_config) + campaigns = attach_schedules(campaigns, schedules_by_campaign) + except Exception as exc: + campaigns = [] + warnings.append(f"Nie udalo sie pobrac harmonogramow reklam z Google Ads API: {exc}") + + if not campaigns: + warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac harmonogramow.") + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace harmonogramow reklam bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return AdSchedulePlan( + campaigns=campaigns, + schedule_summary=build_schedule_summary(campaigns), + channel_summary=build_channel_summary(campaigns), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_ad_schedule_plan(domain: str, plan: AdSchedulePlan) -> 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 harmonogramu reklam", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kampanie: {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"| {md_cell(row.get('area', ''))} | {md_cell(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.schedule_summary: + lines.extend(["## Podsumowanie harmonogramow", "", "| Metryka | Liczba |", "| --- | --- |"]) + for row in plan.schedule_summary: + lines.append(f"| {md_cell(row['metric'])} | {row['count']} |") + lines.append("") + if plan.channel_summary: + lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"]) + for row in plan.channel_summary: + lines.append(f"| {row['channel_type']} | {row['count']} |") + lines.append("") + if plan.campaigns: + lines.extend( + [ + "## Kampanie", + "", + "| Kampania | Typ | Status | Harmonogram | Flagi |", + "| --- | --- | --- | --- | --- |", + ] + ) + for campaign in plan.campaigns: + lines.append( + f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | " + f"{md_cell(campaign['schedule_label'])} | {md_cell(', '.join(campaign['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_ad_schedule_plan(plan: AdSchedulePlan) -> None: + print("\nPlan sprawdzenia harmonogramu reklam") + print_table( + ["Metryka", "Liczba"], + [ + ["Kampanie", 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.schedule_summary: + print("\nPodsumowanie harmonogramow") + print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.schedule_summary]) + if plan.channel_summary: + print("\nPodsumowanie po typach kampanii") + print_table( + ["Typ", "Liczba"], + [[row["channel_type"], str(row["count"])] for row in plan.channel_summary], + ) + if plan.campaigns: + print("\nKampanie") + print_table( + ["Nr", "Kampania", "Typ", "Liczba przedz.", "Harmonogram", "Flagi"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["channel_type"], + str(campaign["schedule_count"]), + campaign["schedule_label"], + ", ".join(campaign["flags"]), + ] + for index, campaign in enumerate(plan.campaigns[:30], 1) + ], + ) + if len(plan.campaigns) > 30: + print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu") + 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_ad_schedule_plan( + client_config: ClientConfig, + plan: AdSchedulePlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem harmonogramu reklam 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_ad_schedules( + 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 = AdSchedulePlan.from_dict(plan_data) + print_ad_schedule_plan(plan) + apply_ad_schedule_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia harmonogramu reklam...") + plan = build_ad_schedule_plan(client_config) + print_ad_schedule_plan(plan) + json_path, md_path = save_ad_schedule_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 harmonogramu reklam.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/additional_audits.py b/src/gads_v2/tasks/additional_audits.py new file mode 100644 index 0000000..662afae --- /dev/null +++ b/src/gads_v2/tasks/additional_audits.py @@ -0,0 +1,994 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +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 + + +OUT_OF_SCOPE_COMMON = [ + "wdrazanie zmian na koncie Google Ads", + "automatyczne zmiany budzetow", + "automatyczne zmiany stawek albo strategii ustalania stawek", + "automatyczne wylaczanie kampanii, grup reklam, slow kluczowych, reklam albo produktow", +] + + +@dataclass(frozen=True) +class AuditDefinition: + task_id: str + task_name: str + intro: str + query: str + row_builder: Callable[[Any], dict] + summary_fields: list[str] + table_fields: list[str] + scope: list[dict] + out_of_scope: list[str] + sort_key: Callable[[dict], tuple] + finding_builder: Callable[[dict], list[str]] + + +@dataclass +class GenericAuditPlan: + task: str + task_name: str + rows: list[dict] + findings: list[dict] + summary: list[dict] + scope: list[dict] + out_of_scope: list[str] + knowledge_rules: list[dict] + warnings: list[str] + + def to_dict(self) -> dict: + return { + "task": self.task, + "task_name": self.task_name, + "rows": self.rows, + "findings": self.findings, + "summary": self.summary, + "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) -> "GenericAuditPlan": + return cls( + task=data.get("task", ""), + task_name=data.get("task_name", ""), + rows=data.get("rows", []), + findings=data.get("findings", []), + summary=data.get("summary", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def money_micros(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def money_amount(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{float(value or 0):.2f}{suffix}" + + +def decimal(value: int | float, places: int = 2) -> str: + return f"{float(value or 0):.{places}f}" + + +def percent(numerator: int | float, denominator: int | float) -> float: + if not denominator: + return 0.0 + return round((float(numerator) / float(denominator)) * 100, 1) + + +def rate(numerator: int | float, denominator: int | float) -> float: + if not denominator: + return 0.0 + return round((float(numerator) / float(denominator)) * 100, 2) + + +def roas(conversion_value: float, cost_micros: int) -> float: + cost = micros_to_amount(cost_micros) + if not cost: + return 0.0 + return round(float(conversion_value or 0) / cost, 2) + + +def cpa(cost_micros: int, conversions: float) -> float: + if not conversions: + return 0.0 + return round(micros_to_amount(cost_micros) / float(conversions), 2) + + +def safe_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def safe_float(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def fetch_currency_code(google_client, customer_id: str) -> str: + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code + FROM customer + """, + ) + if not rows: + return "" + return str(rows[0].customer.currency_code or "") + + +def base_metrics(row: Any, currency_code: str) -> dict: + metrics = row.metrics + cost_micros = safe_int(metrics.cost_micros) + conversions = safe_float(metrics.conversions) + conversion_value = safe_float(metrics.conversions_value) + clicks = safe_int(metrics.clicks) + impressions = safe_int(metrics.impressions) + return { + "impressions": impressions, + "clicks": clicks, + "cost": money_micros(cost_micros, currency_code), + "cost_micros": cost_micros, + "conversions": round(conversions, 2), + "conversion_value": money_amount(conversion_value, currency_code), + "conversion_value_raw": round(conversion_value, 2), + "ctr": f"{rate(clicks, impressions):.2f}%", + "avg_cpc": money_micros(int(cost_micros / clicks) if clicks else 0, currency_code), + "conversion_rate": f"{rate(conversions, clicks):.2f}%", + "roas": roas(conversion_value, cost_micros), + "cpa": money_amount(cpa(cost_micros, conversions), currency_code), + } + + +def row_campaign_day(row: Any, currency_code: str) -> dict: + return { + "campaign": row.campaign.name, + "channel": enum_name(row.campaign.advertising_channel_type), + "day_of_week": enum_name(row.segments.day_of_week), + **base_metrics(row, currency_code), + } + + +def row_campaign_hour(row: Any, currency_code: str) -> dict: + return { + "campaign": row.campaign.name, + "channel": enum_name(row.campaign.advertising_channel_type), + "hour": str(row.segments.hour), + **base_metrics(row, currency_code), + } + + +def row_campaign_network(row: Any, currency_code: str) -> dict: + return { + "campaign": row.campaign.name, + "channel": enum_name(row.campaign.advertising_channel_type), + "network": enum_name(row.segments.ad_network_type), + **base_metrics(row, currency_code), + } + + +def row_ad_group(row: Any, currency_code: str) -> dict: + return { + "campaign": row.campaign.name, + "ad_group": row.ad_group.name, + "status": enum_name(row.ad_group.status), + "type": enum_name(row.ad_group.type_), + **base_metrics(row, currency_code), + } + + +def row_keyword_quality(row: Any, currency_code: str) -> dict: + criterion = row.ad_group_criterion + quality = criterion.quality_info + return { + "campaign": row.campaign.name, + "ad_group": row.ad_group.name, + "keyword": criterion.keyword.text, + "match_type": enum_name(criterion.keyword.match_type), + "status": enum_name(criterion.status), + "quality_score": safe_int(quality.quality_score), + "creative_quality": enum_name(quality.creative_quality_score), + "landing_page_quality": enum_name(quality.post_click_quality_score), + "expected_ctr": enum_name(quality.search_predicted_ctr), + **base_metrics(row, currency_code), + } + + +def row_landing_page(row: Any, currency_code: str) -> dict: + return { + "landing_page": row.landing_page_view.unexpanded_final_url, + **base_metrics(row, currency_code), + } + + +def row_conversion_action(row: Any, currency_code: str) -> dict: + return { + "campaign": row.campaign.name, + "conversion_action": str(row.segments.conversion_action_name or ""), + "conversion_category": enum_name(row.segments.conversion_action_category), + **base_metrics(row, currency_code), + } + + +def row_shopping_product(row: Any, currency_code: str) -> dict: + return { + "campaign": row.campaign.name, + "ad_group": row.ad_group.name, + "product_title": row.segments.product_title, + "item_id": row.segments.product_item_id, + "brand": row.segments.product_brand, + "category_l1": row.segments.product_category_level1, + **base_metrics(row, currency_code), + } + + +def row_gender(row: Any, currency_code: str) -> dict: + return { + "campaign": row.campaign.name, + "ad_group": row.ad_group.name, + "gender": enum_name(row.ad_group_criterion.gender.type_), + **base_metrics(row, currency_code), + } + + +def row_age_range(row: Any, currency_code: str) -> dict: + return { + "campaign": row.campaign.name, + "ad_group": row.ad_group.name, + "age_range": enum_name(row.ad_group_criterion.age_range.type_), + **base_metrics(row, currency_code), + } + + +def generic_findings(row: dict) -> list[str]: + flags = [] + if row.get("cost_micros", 0) >= 100_000_000 and row.get("conversions", 0) == 0: + flags.append("istotny koszt bez konwersji") + if row.get("clicks", 0) >= 50 and row.get("conversions", 0) == 0: + flags.append("wiele klikniec bez konwersji") + if row.get("roas", 0) > 0 and row.get("roas", 0) < 1 and row.get("cost_micros", 0) >= 100_000_000: + flags.append("niski ROAS przy istotnym koszcie") + if row.get("impressions", 0) >= 1000 and row.get("clicks", 0) == 0: + flags.append("wyswietlenia bez klikniec") + return flags + + +def quality_findings(row: dict) -> list[str]: + flags = generic_findings(row) + if 0 < row.get("quality_score", 0) <= 3: + flags.append("niski Wynik Jakosci") + if row.get("landing_page_quality") in {"BELOW_AVERAGE", "BELOW AVERAGE"}: + flags.append("slaba jakosc strony docelowej") + if row.get("expected_ctr") in {"BELOW_AVERAGE", "BELOW AVERAGE"}: + flags.append("niski przewidywany CTR") + return flags + + +def landing_page_findings(row: dict) -> list[str]: + flags = generic_findings(row) + if not row.get("landing_page"): + flags.append("brak widocznego URL strony docelowej") + return flags + + +def build_summary(rows: list[dict], summary_fields: list[str], currency_code: str) -> list[dict]: + summary = [] + for field in summary_fields: + buckets: dict[str, dict] = {} + for row in rows: + key = str(row.get(field, "") or "(brak)") + bucket = buckets.setdefault( + key, + { + "segment": key, + "rows": 0, + "cost_micros": 0, + "clicks": 0, + "impressions": 0, + "conversions": 0.0, + "conversion_value_raw": 0.0, + }, + ) + bucket["rows"] += 1 + bucket["cost_micros"] += safe_int(row.get("cost_micros")) + bucket["clicks"] += safe_int(row.get("clicks")) + bucket["impressions"] += safe_int(row.get("impressions")) + bucket["conversions"] += safe_float(row.get("conversions")) + bucket["conversion_value_raw"] += safe_float(row.get("conversion_value_raw")) + for bucket in buckets.values(): + summary.append( + { + "group_by": field, + "segment": bucket["segment"], + "rows": bucket["rows"], + "cost": money_micros(bucket["cost_micros"], currency_code), + "clicks": bucket["clicks"], + "conversions": round(bucket["conversions"], 2), + "conversion_value": money_amount(bucket["conversion_value_raw"], currency_code), + "roas": roas(bucket["conversion_value_raw"], bucket["cost_micros"]), + } + ) + summary.sort(key=lambda row: (row["group_by"], -safe_float(str(row["cost"]).split()[0].replace(",", ".")))) + return summary + + +def build_plan(definition: AuditDefinition, client_config: ClientConfig) -> GenericAuditPlan: + warnings = [] + currency_code = "" + rows: list[dict] = [] + try: + google_client = get_google_ads_client(use_proto_plus=True) + currency_code = fetch_currency_code(google_client, client_config.safe_customer_id) + api_rows = run_query(google_client, client_config.safe_customer_id, definition.query) + rows = [definition.row_builder(row, currency_code) for row in api_rows] + except Exception as exc: + warnings.append(f"Nie udalo sie pobrac danych dla zadania {definition.task_name}: {compact_error(exc)}") + + rows.sort(key=definition.sort_key) + findings = [] + for row in rows: + flags = definition.finding_builder(row) + if flags: + findings.append( + { + **{key: row.get(key, "") for key in definition.table_fields if key in row}, + "flags": flags, + "recommendation": "sprawdz ten segment przed decyzja optymalizacyjna", + } + ) + + if not rows: + warnings.append("Nie znaleziono danych w analizowanym zakresie albo API nie zwrocilo wynikow.") + + 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_for_task(definition.task_id) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return GenericAuditPlan( + task=definition.task_id, + task_name=definition.task_name, + rows=rows, + findings=findings, + summary=build_summary(rows, definition.summary_fields, currency_code), + scope=definition.scope, + out_of_scope=definition.out_of_scope, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def compact_error(exc: Exception) -> str: + message = str(exc) + if "PROHIBITED" in message: + return "Google Ads API odrzucilo kombinacje pol w zapytaniu. Szczegoly sa w logu requestu." + if "UNRECOGNIZED_FIELD" in message: + return "Google Ads API nie rozpoznalo jednego z pol zapytania." + if "PERMISSION_DENIED" in message: + return "brak uprawnien do pobrania tych danych." + return message.splitlines()[0][:500] + + +def save_plan(domain: str, definition: AuditDefinition, plan: GenericAuditPlan) -> 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')}_{definition.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 = [ + f"# Plan: {definition.task_name}", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Wiersze danych: {len(plan.rows)}", + f"- Elementy do oceny: {len(plan.findings)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.summary: + lines.extend(["## Podsumowanie segmentow", "", "| Grupowanie | Segment | Wiersze | Koszt | Klikniecia | Konwersje | Wartosc | ROAS |", "| --- | --- | --- | --- | --- | --- | --- | --- |"]) + for row in plan.summary: + lines.append( + f"| {md_cell(row['group_by'])} | {md_cell(row['segment'])} | {row['rows']} | {row['cost']} | " + f"{row['clicks']} | {row['conversions']} | {row['conversion_value']} | {row['roas']} |" + ) + lines.append("") + if plan.findings: + fields = [field for field in definition.table_fields if any(field in row for row in plan.findings)] + lines.extend(["## Elementy do oceny", "", "| " + " | ".join(fields + ["Flagi", "Rekomendacja"]) + " |", "| " + " | ".join("---" for _ in fields + ["Flagi", "Rekomendacja"]) + " |"]) + for row in plan.findings: + values = [md_cell(row.get(field, "")) for field in fields] + values.extend([md_cell(", ".join(row.get("flags", []))), md_cell(row.get("recommendation", ""))]) + lines.append("| " + " | ".join(values) + " |") + lines.append("") + if plan.rows: + fields = [field for field in definition.table_fields if any(field in row for row in plan.rows)] + lines.extend(["## Dane", "", "| " + " | ".join(fields) + " |", "| " + " | ".join("---" for _ in fields) + " |"]) + for row in plan.rows: + lines.append("| " + " | ".join(md_cell(row.get(field, "")) for field in fields) + " |") + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_plan(definition: AuditDefinition, plan: GenericAuditPlan) -> None: + print(f"\nPlan: {definition.task_name}") + print_table( + ["Metryka", "Liczba"], + [ + ["Wiersze danych", str(len(plan.rows))], + ["Elementy do oceny", str(len(plan.findings))], + ["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.summary: + print("\nPodsumowanie segmentow") + print_table( + ["Grupowanie", "Segment", "Wiersze", "Koszt", "Klikniecia", "Konw.", "Wartosc", "ROAS"], + [ + [row["group_by"], row["segment"], str(row["rows"]), row["cost"], str(row["clicks"]), str(row["conversions"]), row["conversion_value"], str(row["roas"])] + for row in plan.summary[:30] + ], + ) + if len(plan.summary) > 30: + print(f"... oraz {len(plan.summary) - 30} kolejnych segmentow w pliku planu") + if plan.findings: + fields = [field for field in definition.table_fields if any(field in row for row in plan.findings)] + rows = [] + for index, row in enumerate(plan.findings[:30], 1): + rows.append([str(index)] + [row.get(field, "") for field in fields] + [", ".join(row.get("flags", []))]) + print("\nElementy do oceny") + print_table(["Nr"] + fields + ["Flagi"], rows) + if len(plan.findings) > 30: + print(f"... oraz {len(plan.findings) - 30} kolejnych elementow w pliku planu") + elif plan.rows: + fields = [field for field in definition.table_fields if any(field in row for row in plan.rows)] + print("\nDane") + print_table( + ["Nr"] + fields, + [[str(index)] + [row.get(field, "") for field in fields] for index, row in enumerate(plan.rows[:30], 1)], + ) + if len(plan.rows) > 30: + print(f"... oraz {len(plan.rows) - 30} kolejnych wierszy w pliku planu") + + +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_plan(client_config: ClientConfig, plan: GenericAuditPlan, show_navigation: bool = True) -> None: + print(f"\nTo zadanie jest audytem: {plan.task_name}. Nie wdraza zmian na koncie Google Ads.") + changes_path = append_change_markdown(client_config.domain, plan.task_name, []) + history_path = append_history( + client_config.domain, + { + "task": plan.task_name, + "status": "audyt oznaczony jako wykonany", + "summary": { + "rows": len(plan.rows), + "findings": len(plan.findings), + "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_generic_audit( + definition: AuditDefinition, + 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 = GenericAuditPlan.from_dict(plan_data) + print_plan(definition, plan) + apply_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print(definition.intro) + plan = build_plan(definition, client_config) + print_plan(definition, plan) + json_path, md_path = save_plan(client_config.domain, definition, plan) + print(f"\nPlan JSON: {json_path}") + print(f"Plan Markdown: {md_path}") + append_history( + client_config.domain, + { + "task": definition.task_name, + "status": "plan przygotowany", + "summary": { + "rows": len(plan.rows), + "findings": len(plan.findings), + "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) + + +def scope(*items: tuple[str, str]) -> list[dict]: + return [{"area": area, "check": check} for area, check in items] + + +def query_campaign_segment(segment_field: str) -> str: + return f""" + SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + {segment_field}, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM campaign + WHERE campaign.status != 'REMOVED' + AND segments.date DURING LAST_30_DAYS + """ + + +QUERY_AD_GROUP = """ + SELECT + campaign.name, + ad_group.name, + ad_group.status, + ad_group.type, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM ad_group + WHERE campaign.status != 'REMOVED' + AND ad_group.status != 'REMOVED' + AND segments.date DURING LAST_30_DAYS + """ + + +QUERY_KEYWORD_QUALITY = """ + SELECT + campaign.name, + ad_group.name, + ad_group_criterion.status, + ad_group_criterion.keyword.text, + ad_group_criterion.keyword.match_type, + ad_group_criterion.quality_info.quality_score, + ad_group_criterion.quality_info.creative_quality_score, + ad_group_criterion.quality_info.post_click_quality_score, + ad_group_criterion.quality_info.search_predicted_ctr, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM keyword_view + WHERE campaign.status != 'REMOVED' + AND ad_group.status != 'REMOVED' + AND ad_group_criterion.status != 'REMOVED' + AND segments.date DURING LAST_30_DAYS + """ + + +QUERY_LANDING_PAGE = """ + SELECT + landing_page_view.unexpanded_final_url, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM landing_page_view + WHERE segments.date DURING LAST_30_DAYS + """ + + +QUERY_CONVERSION_ACTION = """ + SELECT + campaign.name, + segments.conversion_action_name, + segments.conversion_action_category, + metrics.conversions, + metrics.conversions_value + FROM campaign + WHERE campaign.status != 'REMOVED' + AND segments.date DURING LAST_30_DAYS + """ + + +QUERY_SHOPPING_PRODUCT = """ + SELECT + campaign.name, + ad_group.name, + segments.product_title, + segments.product_item_id, + segments.product_brand, + segments.product_category_level1, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM shopping_performance_view + WHERE segments.date DURING LAST_30_DAYS + LIMIT 500 + """ + + +QUERY_GENDER = """ + SELECT + campaign.name, + ad_group.name, + ad_group_criterion.gender.type, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM gender_view + WHERE segments.date DURING LAST_30_DAYS + """ + + +QUERY_AGE_RANGE = """ + SELECT + campaign.name, + ad_group.name, + ad_group_criterion.age_range.type, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM age_range_view + WHERE segments.date DURING LAST_30_DAYS + """ + + +DEFINITIONS: dict[str, AuditDefinition] = { + "check_day_of_week_performance": AuditDefinition( + task_id="check_day_of_week_performance", + task_name="Sprawdzenie dni tygodnia", + intro="Przygotowuje plan sprawdzenia wynikow wedlug dni tygodnia...", + query=query_campaign_segment("segments.day_of_week"), + row_builder=row_campaign_day, + summary_fields=["day_of_week"], + table_fields=["campaign", "channel", "day_of_week", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"], + scope=scope( + ("Dni tygodnia", "Porownaj koszt, klikniecia, konwersje, wartosc konwersji, ROAS i CPA wedlug dnia tygodnia."), + ("Kontekst kampanii", "Pokaz dane w podziale na kampanie, aby nie mieszac roznych typow ruchu."), + ("Decyzja reczna", "Zaznacz segmenty do oceny bez zmiany harmonogramu reklam."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany harmonogramu reklam"], + sort_key=lambda row: (row["campaign"], row["day_of_week"]), + finding_builder=generic_findings, + ), + "check_hour_of_day_performance": AuditDefinition( + task_id="check_hour_of_day_performance", + task_name="Sprawdzenie godzin dnia", + intro="Przygotowuje plan sprawdzenia wynikow wedlug godzin dnia...", + query=query_campaign_segment("segments.hour"), + row_builder=row_campaign_hour, + summary_fields=["hour"], + table_fields=["campaign", "channel", "hour", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"], + scope=scope( + ("Godziny dnia", "Porownaj wyniki wedlug godzin z ostatnich 30 dni."), + ("Efektywnosc", "Pokaz ROAS, CPA i konwersje dla godzin, w ktorych realnie wydawany jest budzet."), + ("Granica zadania", "Nie zmieniaj harmonogramu reklam ani stawek godzinowych w tym zadaniu."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany harmonogramu reklam"], + sort_key=lambda row: (safe_int(row["hour"]), row["campaign"]), + finding_builder=generic_findings, + ), + "check_network_performance": AuditDefinition( + task_id="check_network_performance", + task_name="Sprawdzenie efektywnosci sieci", + intro="Przygotowuje plan sprawdzenia wynikow wedlug sieci...", + query=query_campaign_segment("segments.ad_network_type"), + row_builder=row_campaign_network, + summary_fields=["network", "channel"], + table_fields=["campaign", "channel", "network", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"], + scope=scope( + ("Sieci", "Porownaj wyniki Google Search, partnerow, Display, Shopping i innych sieci zwracanych przez API."), + ("Jakosc ruchu", "Oznacz sieci z kosztem bez konwersji albo slabym ROAS."), + ("Granica zadania", "Nie zmieniaj ustawien sieci w tym audycie."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany ustawien sieci kampanii"], + sort_key=lambda row: (row["campaign"], row["network"]), + finding_builder=generic_findings, + ), + "check_ad_group_performance": AuditDefinition( + task_id="check_ad_group_performance", + task_name="Sprawdzenie grup reklam", + intro="Przygotowuje plan sprawdzenia wynikow grup reklam...", + query=QUERY_AD_GROUP, + row_builder=row_ad_group, + summary_fields=["campaign", "type"], + table_fields=["campaign", "ad_group", "status", "type", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"], + scope=scope( + ("Grupy reklam", "Pokaz koszt, konwersje, wartosc, ROAS i CPA na poziomie grupy reklam."), + ("Priorytet oceny", "Oznacz grupy z kosztem bez konwersji, wieloma kliknieciami bez konwersji albo slabym ROAS."), + ("Granica zadania", "Nie wstrzymuj grup reklam automatycznie."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany struktury grup reklam"], + sort_key=lambda row: (-row["cost_micros"], row["campaign"], row["ad_group"]), + finding_builder=generic_findings, + ), + "check_keyword_quality_score": AuditDefinition( + task_id="check_keyword_quality_score", + task_name="Sprawdzenie Wyniku Jakosci slow kluczowych", + intro="Przygotowuje plan sprawdzenia Wyniku Jakosci slow kluczowych...", + query=QUERY_KEYWORD_QUALITY, + row_builder=row_keyword_quality, + summary_fields=["quality_score", "match_type"], + table_fields=["campaign", "ad_group", "keyword", "match_type", "quality_score", "creative_quality", "landing_page_quality", "expected_ctr", "cost", "clicks", "conversions", "roas"], + scope=scope( + ("Wynik Jakosci", "Sprawdz quality score oraz skladowe: reklama, strona docelowa i przewidywany CTR."), + ("Koszt i wynik", "Polacz ocene jakosci z kosztem, kliknieciami, konwersjami i ROAS."), + ("Granica zadania", "Nie pauzuj slow kluczowych i nie zmieniaj stawek."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["dodawanie albo usuwanie slow kluczowych"], + sort_key=lambda row: (row.get("quality_score", 0) or 99, -row["cost_micros"], row["keyword"]), + finding_builder=quality_findings, + ), + "check_landing_page_performance": AuditDefinition( + task_id="check_landing_page_performance", + task_name="Sprawdzenie stron docelowych", + intro="Przygotowuje plan sprawdzenia stron docelowych...", + query=QUERY_LANDING_PAGE, + row_builder=row_landing_page, + summary_fields=[], + table_fields=["landing_page", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr", "conversion_rate"], + scope=scope( + ("Strony docelowe", "Pokaz wyniki URL-i docelowych z ostatnich 30 dni."), + ("Efektywnosc", "Oznacz strony z kosztem, kliknieciami i brakiem konwersji albo niskim ROAS."), + ("Granica zadania", "Nie zmieniaj URL-i ani reklam automatycznie."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany URL-i docelowych"], + sort_key=lambda row: (-row["cost_micros"], row["landing_page"]), + finding_builder=landing_page_findings, + ), + "check_conversion_action_performance": AuditDefinition( + task_id="check_conversion_action_performance", + task_name="Sprawdzenie akcji konwersji", + intro="Przygotowuje plan sprawdzenia akcji konwersji...", + query=QUERY_CONVERSION_ACTION, + row_builder=row_conversion_action, + summary_fields=["conversion_action", "conversion_category"], + table_fields=["campaign", "conversion_action", "conversion_category", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa"], + scope=scope( + ("Akcje konwersji", "Pokaz, ktore akcje konwersji generuja wynik w kampaniach."), + ("Jakosc pomiaru", "Pomoz wychwycic kampanie optymalizowane na niewlasciwy albo podejrzany typ konwersji."), + ("Granica zadania", "Nie zmieniaj ustawien konwersji ani celow kampanii."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany ustawien konwersji"], + sort_key=lambda row: (row["campaign"], row["conversion_action"]), + finding_builder=generic_findings, + ), + "check_shopping_product_performance": AuditDefinition( + task_id="check_shopping_product_performance", + task_name="Sprawdzenie wynikow produktow Shopping", + intro="Przygotowuje plan sprawdzenia wynikow produktow Shopping...", + query=QUERY_SHOPPING_PRODUCT, + row_builder=row_shopping_product, + summary_fields=["brand", "category_l1"], + table_fields=["campaign", "ad_group", "product_title", "item_id", "brand", "category_l1", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa"], + scope=scope( + ("Produkty", "Pokaz produkty z kosztem, kliknieciami, konwersjami, wartoscia i ROAS."), + ("Priorytet feedu", "Wskaz produkty, ktore moga wymagac analizy ceny, tytulu, feedu albo dostepnosci."), + ("Granica zadania", "Nie zmieniaj feedu, stawek ani struktury kampanii."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["optymalizacja tytulow, kategorii Google i unit pricing"], + sort_key=lambda row: (-row["cost_micros"], row["product_title"]), + finding_builder=generic_findings, + ), + "check_gender_performance": AuditDefinition( + task_id="check_gender_performance", + task_name="Sprawdzenie plci odbiorcow", + intro="Przygotowuje plan sprawdzenia wynikow wedlug plci odbiorcow...", + query=QUERY_GENDER, + row_builder=row_gender, + summary_fields=["gender"], + table_fields=["campaign", "ad_group", "gender", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"], + scope=scope( + ("Plec odbiorcow", "Porownaj wyniki segmentow plci z ostatnich 30 dni."), + ("Efektywnosc", "Pokaz koszt, konwersje, wartosc, ROAS i CPA."), + ("Granica zadania", "Nie dodawaj wykluczen demograficznych ani korekt stawek."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany kierowania demograficznego"], + sort_key=lambda row: (row["campaign"], row["gender"]), + finding_builder=generic_findings, + ), + "check_age_performance": AuditDefinition( + task_id="check_age_performance", + task_name="Sprawdzenie wieku odbiorcow", + intro="Przygotowuje plan sprawdzenia wynikow wedlug wieku odbiorcow...", + query=QUERY_AGE_RANGE, + row_builder=row_age_range, + summary_fields=["age_range"], + table_fields=["campaign", "ad_group", "age_range", "cost", "clicks", "conversions", "conversion_value", "roas", "cpa", "ctr"], + scope=scope( + ("Wiek odbiorcow", "Porownaj wyniki przedzialow wieku z ostatnich 30 dni."), + ("Efektywnosc", "Pokaz koszt, konwersje, wartosc, ROAS i CPA."), + ("Granica zadania", "Nie dodawaj wykluczen demograficznych ani korekt stawek."), + ), + out_of_scope=OUT_OF_SCOPE_COMMON + ["zmiany kierowania demograficznego"], + sort_key=lambda row: (row["campaign"], row["age_range"]), + finding_builder=generic_findings, + ), +} + + +def run_check_day_of_week_performance(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_day_of_week_performance"], *args, **kwargs) + + +def run_check_hour_of_day_performance(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_hour_of_day_performance"], *args, **kwargs) + + +def run_check_network_performance(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_network_performance"], *args, **kwargs) + + +def run_check_ad_group_performance(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_ad_group_performance"], *args, **kwargs) + + +def run_check_keyword_quality_score(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_keyword_quality_score"], *args, **kwargs) + + +def run_check_landing_page_performance(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_landing_page_performance"], *args, **kwargs) + + +def run_check_conversion_action_performance(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_conversion_action_performance"], *args, **kwargs) + + +def run_check_shopping_product_performance(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_shopping_product_performance"], *args, **kwargs) + + +def run_check_gender_performance(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_gender_performance"], *args, **kwargs) + + +def run_check_age_performance(*args, **kwargs) -> None: + run_generic_audit(DEFINITIONS["check_age_performance"], *args, **kwargs) diff --git a/src/gads_v2/tasks/auction_insights_check.py b/src/gads_v2/tasks/auction_insights_check.py new file mode 100644 index 0000000..4a1389b --- /dev/null +++ b/src/gads_v2/tasks/auction_insights_check.py @@ -0,0 +1,544 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_auction_insights" +TASK_NAME = "Sprawdzenie Auction Insights" + + +SCOPE = [ + { + "area": "Konkurenci w aukcji", + "check": "Pobierz domeny konkurentow z segmentu Auction Insights, jezeli developer token ma dostep do tych metryk.", + }, + { + "area": "Overlap i outranking", + "check": "Pokaz overlap rate, position above rate i outranking share dla konkurentow.", + }, + { + "area": "Widocznosc top", + "check": "Pokaz top rate i absolute top rate jako sygnal presji konkurencyjnej.", + }, + { + "area": "Granica zadania", + "check": "Nie podejmuj tutaj decyzji o budzecie, stawkach ani strategiach; to tylko diagnoza aukcji.", + }, +] + + +OUT_OF_SCOPE = [ + "zmiany budzetow", + "zmiany stawek i strategii ustalania stawek", + "decyzje o zmianie Docelowego ROAS albo Docelowego CPA", + "analiza zapytan uzytkownikow", + "wdrazanie zmian na koncie Google Ads", +] + + +@dataclass +class AuctionInsightsPlan: + competitors: list[dict] + campaign_summary: list[dict] + domain_summary: 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, + "competitors": self.competitors, + "campaign_summary": self.campaign_summary, + "domain_summary": self.domain_summary, + "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) -> "AuctionInsightsPlan": + return cls( + competitors=data.get("competitors", []), + campaign_summary=data.get("campaign_summary", []), + domain_summary=data.get("domain_summary", []), + scope=data.get("scope", []), + out_of_scope=data.get("out_of_scope", []), + knowledge_rules=data.get("knowledge_rules", []), + warnings=data.get("warnings", []), + ) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def enum_name(value: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def percent(value: float | int | None) -> float: + if value is None: + return 0.0 + return round(float(value or 0) * 100, 1) + + +def percent_label(value: float | int | None) -> str: + return f"{percent(value):.1f}%" + + +def compact_api_error(exc: Exception) -> str: + message = str(exc) + request_id = "" + if "request_id:" in message: + request_id = message.split("request_id:", 1)[1].splitlines()[0].strip().strip('"') + if "METRIC_ACCESS_DENIED" in message: + base = "METRIC_ACCESS_DENIED: developer token nie ma dostepu do metryk Auction Insights" + elif "PERMISSION_DENIED" in message: + base = "PERMISSION_DENIED: brak uprawnien do pobrania danych Auction Insights" + else: + base = "Nie udalo sie pobrac danych Auction Insights z Google Ads API" + if request_id: + return f"{base}. Request ID: {request_id}." + return f"{base}." + + +def auction_severity(row: dict) -> str: + if row["position_above_rate"] >= 0.5 or row["outranking_share"] <= 0.2: + return "wysokie" + if row["overlap_rate"] >= 0.5 or row["position_above_rate"] >= 0.3: + return "srednie" + return "niskie" + + +def auction_flags(row: dict) -> list[str]: + flags = [] + if row["overlap_rate"] >= 0.5: + flags.append("wysoki overlap") + if row["position_above_rate"] >= 0.5: + flags.append("konkurent czesto wyzej") + elif row["position_above_rate"] >= 0.3: + flags.append("pozycja konkurenta do oceny") + if row["outranking_share"] <= 0.2: + flags.append("niski outranking share") + return flags or ["ok"] + + +def fetch_auction_insights(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, + segments.auction_insight_domain, + metrics.auction_insight_search_impression_share, + metrics.auction_insight_search_overlap_rate, + metrics.auction_insight_search_position_above_rate, + metrics.auction_insight_search_outranking_share, + metrics.auction_insight_search_top_impression_percentage, + metrics.auction_insight_search_absolute_top_impression_percentage + FROM campaign + WHERE campaign.status != 'REMOVED' + AND segments.date DURING LAST_30_DAYS + LIMIT 1000 + """, + ) + + competitors = [] + for row in rows: + domain = str(row.segments.auction_insight_domain or "").strip() + if not domain: + continue + metrics = row.metrics + record = { + "campaign_id": str(row.campaign.id), + "campaign_name": row.campaign.name, + "status": enum_name(row.campaign.status), + "channel_type": enum_name(row.campaign.advertising_channel_type), + "domain": domain, + "impression_share": float(metrics.auction_insight_search_impression_share or 0), + "overlap_rate": float(metrics.auction_insight_search_overlap_rate or 0), + "position_above_rate": float(metrics.auction_insight_search_position_above_rate or 0), + "outranking_share": float(metrics.auction_insight_search_outranking_share or 0), + "top_rate": float(metrics.auction_insight_search_top_impression_percentage or 0), + "absolute_top_rate": float(metrics.auction_insight_search_absolute_top_impression_percentage or 0), + } + record["severity"] = auction_severity(record) + record["flags"] = auction_flags(record) + competitors.append(record) + severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9} + competitors.sort( + key=lambda row: ( + severity_order.get(row["severity"], 9), + row["campaign_name"], + -row["overlap_rate"], + row["domain"], + ) + ) + return competitors + + +def build_campaign_summary(competitors: list[dict]) -> list[dict]: + buckets: dict[str, dict] = {} + for item in competitors: + row = buckets.setdefault( + item["campaign_name"], + { + "campaign_name": item["campaign_name"], + "channel_type": item["channel_type"], + "competitors": 0, + "high_risk": 0, + "max_overlap_rate": 0.0, + "max_position_above_rate": 0.0, + }, + ) + row["competitors"] += 1 + if item["severity"] == "wysokie": + row["high_risk"] += 1 + row["max_overlap_rate"] = max(row["max_overlap_rate"], item["overlap_rate"]) + row["max_position_above_rate"] = max(row["max_position_above_rate"], item["position_above_rate"]) + return sorted(buckets.values(), key=lambda row: (-row["high_risk"], -row["competitors"], row["campaign_name"])) + + +def build_domain_summary(competitors: list[dict]) -> list[dict]: + counter = Counter(item["domain"] for item in competitors) + return [{"domain": key, "campaigns": value} for key, value in counter.most_common()] + + +def build_auction_insights_plan(client_config: ClientConfig) -> AuctionInsightsPlan: + warnings = [] + try: + competitors = fetch_auction_insights(client_config) + except Exception as exc: + competitors = [] + message = str(exc) + if "METRIC_ACCESS_DENIED" in message or "auction_insight" in message: + warnings.append( + "Google Ads API zwrocilo brak dostepu do metryk Auction Insights dla obecnego developer tokena. " + "Zadanie jest gotowe, ale zacznie zwracac konkurentow dopiero po odblokowaniu tych metryk." + ) + warnings.append(compact_api_error(exc)) + + if not competitors: + warnings.append("Nie znaleziono danych Auction Insights albo nie udalo sie ich pobrac.") + warnings.append( + "To zadanie tylko diagnozuje presje konkurencji w aukcji. Decyzje o budzetach, stawkach i strategiach pozostaja w osobnych zadaniach." + ) + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace Auction Insights bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return AuctionInsightsPlan( + competitors=competitors, + campaign_summary=build_campaign_summary(competitors), + domain_summary=build_domain_summary(competitors), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_auction_insights_plan(domain: str, plan: AuctionInsightsPlan) -> 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 Auction Insights", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Rekordy konkurentow: {len(plan.competitors)}", + f"- Kampanie z konkurentami: {len(plan.campaign_summary)}", + f"- Domeny konkurentow: {len(plan.domain_summary)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.campaign_summary: + lines.extend( + [ + "## Podsumowanie po kampaniach", + "", + "| Kampania | Typ | Konkurenci | Wysokie ryzyko | Max overlap | Max position above |", + "| --- | --- | --- | --- | --- | --- |", + ] + ) + for row in plan.campaign_summary: + lines.append( + f"| {md_cell(row['campaign_name'])} | {row['channel_type']} | {row['competitors']} | " + f"{row['high_risk']} | {percent_label(row['max_overlap_rate'])} | {percent_label(row['max_position_above_rate'])} |" + ) + lines.append("") + if plan.domain_summary: + lines.extend(["## Domeny konkurentow", "", "| Domena | Kampanie |", "| --- | --- |"]) + for row in plan.domain_summary: + lines.append(f"| {md_cell(row['domain'])} | {row['campaigns']} |") + lines.append("") + if plan.competitors: + lines.extend( + [ + "## Auction Insights", + "", + "| Waznosc | Kampania | Konkurent | Impression share | Overlap | Position above | Outranking | Top | Abs. top | Flagi |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for item in plan.competitors: + lines.append( + f"| {item['severity']} | {md_cell(item['campaign_name'])} | {md_cell(item['domain'])} | " + f"{percent_label(item['impression_share'])} | {percent_label(item['overlap_rate'])} | " + f"{percent_label(item['position_above_rate'])} | {percent_label(item['outranking_share'])} | " + f"{percent_label(item['top_rate'])} | {percent_label(item['absolute_top_rate'])} | " + f"{md_cell(', '.join(item['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_auction_insights_plan(plan: AuctionInsightsPlan) -> None: + print("\nPlan sprawdzenia Auction Insights") + print_table( + ["Metryka", "Liczba"], + [ + ["Rekordy konkurentow", str(len(plan.competitors))], + ["Kampanie z konkurentami", str(len(plan.campaign_summary))], + ["Domeny konkurentow", str(len(plan.domain_summary))], + ["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.campaign_summary: + print("\nPodsumowanie po kampaniach") + print_table( + ["Kampania", "Typ", "Konkurenci", "Wysokie", "Max overlap", "Max above"], + [ + [ + row["campaign_name"], + row["channel_type"], + str(row["competitors"]), + str(row["high_risk"]), + percent_label(row["max_overlap_rate"]), + percent_label(row["max_position_above_rate"]), + ] + for row in plan.campaign_summary + ], + ) + if plan.domain_summary: + print("\nDomeny konkurentow") + print_table(["Domena", "Kampanie"], [[row["domain"], str(row["campaigns"])] for row in plan.domain_summary[:30]]) + if plan.competitors: + print("\nAuction Insights") + print_table( + ["Nr", "Waznosc", "Kampania", "Konkurent", "Overlap", "Above", "Outranking", "Flagi"], + [ + [ + str(index), + item["severity"], + item["campaign_name"], + item["domain"], + percent_label(item["overlap_rate"]), + percent_label(item["position_above_rate"]), + percent_label(item["outranking_share"]), + ", ".join(item["flags"]), + ] + for index, item in enumerate(plan.competitors[:30], 1) + ], + ) + if len(plan.competitors) > 30: + print(f"... oraz {len(plan.competitors) - 30} kolejnych rekordow w pliku planu") + 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_auction_insights_plan( + client_config: ClientConfig, + plan: AuctionInsightsPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem Auction Insights 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(row["campaign_name"] for row in plan.campaign_summary[:10]), + "summary": { + "competitors": len(plan.competitors), + "campaigns": len(plan.campaign_summary), + "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_auction_insights( + 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 = AuctionInsightsPlan.from_dict(plan_data) + print_auction_insights_plan(plan) + apply_auction_insights_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia Auction Insights...") + plan = build_auction_insights_plan(client_config) + print_auction_insights_plan(plan) + json_path, md_path = save_auction_insights_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(row["campaign_name"] for row in plan.campaign_summary[:10]), + "summary": { + "competitors": len(plan.competitors), + "campaigns": len(plan.campaign_summary), + "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 Auction Insights.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/bidding_strategy_check.py b/src/gads_v2/tasks/bidding_strategy_check.py new file mode 100644 index 0000000..92956e5 --- /dev/null +++ b/src/gads_v2/tasks/bidding_strategy_check.py @@ -0,0 +1,929 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +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 +from ..knowledge.store import rules_for_task +from ..table import print_table + + +TASK_ID = "check_bidding_strategies" +TASK_NAME = "Sprawdzenie strategii stawek" + + +SCOPE = [ + { + "area": "Typ strategii", + "check": "Pokaz typ strategii ustalania stawek dla aktywnych kampanii.", + }, + { + "area": "Cele strategii", + "check": "Pokaz aktualny Docelowy ROAS albo Docelowy CPA, jezeli kampania go uzywa.", + }, + { + "area": "Wolumen konwersji", + "check": "Sprawdz liczbe konwersji z ostatnich 30 dni jako kontekst dla automatycznych strategii.", + }, + { + "area": "Stabilnosc decyzji", + "check": "Oznacz kampanie, gdzie malo danych zwieksza ryzyko pochopnej zmiany strategii albo celu.", + }, + { + "area": "Ocena celu", + "check": "Porownaj rzeczywisty ROAS/CPA z aktualnym celem i oznacz cele zbyt niskie albo zbyt wysokie.", + }, + { + "area": "Kontekst budzetu", + "check": "Uwzglednij wykorzystanie budzetu i utrate wyswietlania przez budzet przed rekomendacja zmiany strategii.", + }, + { + "area": "Zmiany po budzecie", + "check": "Jesli budzet byl niedawno zmieniany, rekomenduj odczekanie przed zmiana strategii albo celu.", + }, + { + "area": "Dopasowanie strategii", + "check": "Sprawdz, czy strategia pasuje do typu kampanii i dostepnego wolumenu danych.", + }, + { + "area": "Rekomendacja", + "check": "Pokaz konkretna rekomendacje decyzyjna bez automatycznego wdrazania zmian strategii.", + }, +] + + +OUT_OF_SCOPE = [ + "budzety i pacing budzetu", + "podstawowe ustawienia kampanii, np. lokalizacje i sieci", + "zapytania uzytkownikow oraz wykluczenia", + "reklamy, zasoby i kreacje", + "automatyczne wdrazanie zmian strategii stawek", +] + + +LOW_CONVERSION_THRESHOLD = 15 +STABLE_CONVERSION_THRESHOLD = 30 +TARGET_ROAS_TOO_LOW_RATIO = 1.5 +TARGET_ROAS_TOO_HIGH_RATIO = 0.75 +BUDGET_LOST_THRESHOLD = 0.15 +HIGH_BUDGET_LOST_THRESHOLD = 0.3 +RECENT_BUDGET_CHANGE_DAYS = 7 + + +@dataclass +class BiddingStrategyPlan: + currency_code: str + campaigns: list[dict] + scope: list[dict] + out_of_scope: list[str] + knowledge_rules: list[dict] + warnings: list[str] + target_changes: list[dict] | None = None + + def to_dict(self) -> dict: + return { + "task": TASK_ID, + "task_name": TASK_NAME, + "currency_code": self.currency_code, + "campaigns": self.campaigns, + "scope": self.scope, + "out_of_scope": self.out_of_scope, + "knowledge_rules": self.knowledge_rules, + "warnings": self.warnings, + "target_changes": self.target_changes or [], + "changes": self.target_changes or [], + } + + @classmethod + def from_dict(cls, data: dict) -> "BiddingStrategyPlan": + return cls( + currency_code=data.get("currency_code", ""), + 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", []), + target_changes=data.get("target_changes", data.get("changes", [])), + ) + + +def enum_name(value: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def format_money(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def format_decimal(value: int | float) -> str: + return f"{float(value or 0):.2f}" + + +def percent_label(value: int | float | None) -> str: + if value is None: + return "-" + return f"{float(value) * 100:.2f}%" + + +def safe_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def safe_float(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def target_roas_label(value: float) -> str: + if value <= 0: + return "" + return f"Docelowy ROAS {value * 100:.0f}%" + + +def target_cpa_label(value_micros: int, currency_code: str) -> str: + if value_micros <= 0: + return "" + return f"Docelowy CPA {format_money(value_micros, currency_code)}" + + +def strategy_target_label(row: dict, currency_code: str) -> str: + labels = [ + target_roas_label(row.get("target_roas", 0)), + target_roas_label(row.get("maximize_conversion_value_target_roas", 0)), + target_cpa_label(row.get("target_cpa_micros", 0), currency_code), + target_cpa_label(row.get("maximize_conversions_target_cpa_micros", 0), currency_code), + ] + return ", ".join(label for label in labels if label) or "brak jawnego celu" + + +def risk_label(strategy_type: str, conversions_30d: float) -> str: + strategy = strategy_type.upper() + automated = { + "MAXIMIZE_CONVERSIONS", + "MAXIMIZE_CONVERSION_VALUE", + "TARGET_CPA", + "TARGET_ROAS", + } + if conversions_30d <= 0: + return "brak konwersji w 30 dni" + if strategy in automated and conversions_30d < LOW_CONVERSION_THRESHOLD: + return "malo konwersji dla automatyzacji" + return "dane do oceny" + + +def actual_roas(cost_micros: int, conversion_value: float) -> float: + cost = micros_to_amount(cost_micros) + if cost <= 0: + return 0.0 + return round(float(conversion_value or 0) / cost, 2) + + +def primary_target_roas(row: dict) -> float: + return safe_float(row.get("target_roas")) or safe_float(row.get("maximize_conversion_value_target_roas")) + + +def primary_target_cpa_micros(row: dict) -> int: + return safe_int(row.get("target_cpa_micros")) or safe_int(row.get("maximize_conversions_target_cpa_micros")) + + +def target_assessment(row: dict) -> str: + target_roas = primary_target_roas(row) + roas = safe_float(row.get("actual_roas")) + conversions = safe_float(row.get("conversions_30d")) + if target_roas > 0 and roas > 0: + if conversions < LOW_CONVERSION_THRESHOLD: + return "za malo danych do oceny celu ROAS" + if roas >= target_roas * TARGET_ROAS_TOO_LOW_RATIO: + return "Docelowy ROAS prawdopodobnie za niski" + if roas <= target_roas * TARGET_ROAS_TOO_HIGH_RATIO: + return "Docelowy ROAS prawdopodobnie za wysoki" + return "Docelowy ROAS zgodny z wynikiem" + if primary_target_cpa_micros(row) > 0: + if conversions < LOW_CONVERSION_THRESHOLD: + return "za malo danych do oceny celu CPA" + return "Docelowy CPA do oceny po koszcie na konwersje" + return "brak jawnego celu do oceny" + + +def stability_label(row: dict) -> str: + conversions = safe_float(row.get("conversions_30d")) + conversion_value = safe_float(row.get("conversion_value_30d")) + cost = safe_int(row.get("cost_30d_micros")) + if conversions <= 0: + return "niestabilne: brak konwersji" + if conversions < LOW_CONVERSION_THRESHOLD: + return "niestabilne: malo konwersji" + if conversions < STABLE_CONVERSION_THRESHOLD: + return "umiarkowane: dane do ostroznej decyzji" + if conversion_value <= 0 or cost <= 0: + return "niestabilne: brak wartosci albo kosztu" + return "stabilne" + + +def budget_context(row: dict, recent_budget_changes: set[str]) -> str: + if row["campaign_id"] in recent_budget_changes or row["campaign_name"] in recent_budget_changes: + return f"budzet zmieniony w ostatnich {RECENT_BUDGET_CHANGE_DAYS} dniach" + lost_budget = safe_float(row.get("search_budget_lost_impression_share")) + usage = safe_float(row.get("budget_usage_percent")) + if lost_budget >= HIGH_BUDGET_LOST_THRESHOLD: + return "mocne ograniczenie budzetem" + if lost_budget >= BUDGET_LOST_THRESHOLD: + return "ograniczenie budzetem do oceny" + if usage >= 95: + return "budzet blisko limitu" + if usage and usage < 30: + return "niskie wykorzystanie budzetu" + return "brak silnego sygnalu budzetowego" + + +def strategy_fit(row: dict) -> str: + strategy = row.get("bidding_strategy_type", "").upper() + channel = row.get("channel_type", "").upper() + conversions = safe_float(row.get("conversions_30d")) + if strategy == "TARGET_IMPRESSION_SHARE" and channel != "SEARCH": + return "do sprawdzenia: udzial w wyswietleniach poza Search" + if strategy == "TARGET_IMPRESSION_SHARE": + return "pasuje do kampanii brand/search, nie optymalizuje bezposrednio wartosci konwersji" + if strategy in {"TARGET_ROAS", "MAXIMIZE_CONVERSION_VALUE"}: + if conversions < LOW_CONVERSION_THRESHOLD: + return "ryzykowne: za malo konwersji dla strategii wartosci" + return "pasuje do kampanii e-commerce z wartoscia konwersji" + if strategy in {"TARGET_CPA", "MAXIMIZE_CONVERSIONS"}: + if conversions < LOW_CONVERSION_THRESHOLD: + return "ryzykowne: za malo konwersji dla automatyzacji" + return "pasuje do celu pozyskiwania konwersji" + return "do oceny recznej" + + +def bidding_recommendation(row: dict) -> dict: + if "budzet zmieniony" in row.get("budget_context", ""): + return { + "level": "czekaj", + "action": "odczekaj przed zmiana strategii", + "reason": "budzet byl niedawno zmieniony; najpierw zbierz nowe dane po zmianie", + } + if row.get("budget_context") in {"mocne ograniczenie budzetem", "ograniczenie budzetem do oceny"}: + return { + "level": "czekaj", + "action": "najpierw rozwiaz ograniczenie budzetem", + "reason": "zmiana celu strategii przy ograniczeniu budzetem moze zaciemnic efekt decyzji", + } + if row.get("stability_label", "").startswith("niestabilne"): + return { + "level": "ostroznie", + "action": "nie zmieniaj strategii", + "reason": row["stability_label"], + } + assessment = row.get("target_assessment", "") + if "za niski" in assessment: + return { + "level": "do decyzji", + "action": "rozwaz stopniowe podniesienie Docelowego ROAS", + "reason": "rzeczywisty ROAS jest wyraznie wyzszy niz cel i dane sa wystarczajace", + } + if "za wysoki" in assessment: + return { + "level": "do decyzji", + "action": "rozwaz obnizenie Docelowego ROAS albo analize rentownosci", + "reason": "rzeczywisty ROAS jest wyraznie ponizej celu", + } + return { + "level": "ok", + "action": "bez zmiany strategii", + "reason": "brak mocnego sygnalu do zmiany celu albo strategii", + } + + +def recent_budget_change_campaigns(domain: str) -> set[str]: + history_dir = client_dir(domain) / "history" + changed: set[str] = set() + if not history_dir.exists(): + return changed + cutoff = now_local() - timedelta(days=RECENT_BUDGET_CHANGE_DAYS) + for path in history_dir.glob("*.jsonl"): + try: + for line in path.read_text(encoding="utf-8-sig").splitlines(): + if not line.strip(): + continue + row = json.loads(line) + timestamp = row.get("timestamp") + if timestamp: + try: + if datetime.fromisoformat(timestamp) < cutoff: + continue + except ValueError: + pass + summary = row.get("summary", {}) + if not summary.get("budget_changes"): + continue + campaign_text = str(row.get("campaign", "")) + for name in campaign_text.split(","): + name = name.strip() + if name: + changed.add(name) + except Exception: + continue + return changed + + +def bidding_recommendations(campaigns: list[dict]) -> list[dict]: + return [ + campaign + for campaign in campaigns + if campaign.get("bidding_recommendation", {}).get("level") not in {"ok", None} + ] + + +def roas_target_field(row: dict) -> str: + strategy = row.get("bidding_strategy_type", "").upper() + if strategy == "TARGET_ROAS" and safe_float(row.get("target_roas")) > 0: + return "target_roas.target_roas" + if strategy == "MAXIMIZE_CONVERSION_VALUE" and safe_float(row.get("maximize_conversion_value_target_roas")) > 0: + return "maximize_conversion_value.target_roas" + return "" + + +def suggested_target_roas_change(row: dict) -> dict | None: + field = roas_target_field(row) + if not field: + return None + recommendation = row.get("bidding_recommendation", {}) + if recommendation.get("level") != "do decyzji": + return None + + current = primary_target_roas(row) + actual = safe_float(row.get("actual_roas")) + if current <= 0 or actual <= 0: + return None + + assessment = row.get("target_assessment", "") + if "za niski" in assessment: + target = min(current * 1.10, actual * 0.80) + direction = "podniesienie" + elif "za wysoki" in assessment: + target = max(current * 0.90, actual * 1.20) + direction = "obnizenie" + else: + return None + + target = round(target, 2) + if abs(target - current) < 0.01: + return None + + return { + "campaign_id": row["campaign_id"], + "campaign_name": row["campaign_name"], + "strategy": row["bidding_strategy_type"], + "field": field, + "current_target_roas": round(current, 2), + "target_roas": target, + "current_label": f"{current * 100:.0f}%", + "target_label": f"{target * 100:.0f}%", + "actual_roas": actual, + "direction": direction, + "reason": recommendation.get("reason", ""), + } + + +def target_change_candidates(campaigns: list[dict]) -> list[dict]: + changes = [] + for campaign in campaigns: + change = suggested_target_roas_change(campaign) + if change: + changes.append(change) + return changes + + +def fetch_bidding_targets(google_client, customer_id: str, warnings: list[str]) -> dict[str, dict]: + try: + rows = run_query( + google_client, + customer_id, + """ + SELECT + campaign.id, + campaign.target_cpa.target_cpa_micros, + campaign.target_roas.target_roas, + campaign.maximize_conversions.target_cpa_micros, + campaign.maximize_conversion_value.target_roas + FROM campaign + WHERE campaign.status = 'ENABLED' + """, + ) + except Exception as exc: + warnings.append(f"Nie udalo sie pobrac celow Docelowy ROAS/CPA: {exc}") + return {} + + targets: dict[str, dict] = {} + for row in rows: + campaign = row.campaign + targets[str(campaign.id)] = { + "target_cpa_micros": safe_int(campaign.target_cpa.target_cpa_micros), + "target_roas": safe_float(campaign.target_roas.target_roas), + "maximize_conversions_target_cpa_micros": safe_int( + campaign.maximize_conversions.target_cpa_micros + ), + "maximize_conversion_value_target_roas": safe_float( + campaign.maximize_conversion_value.target_roas + ), + } + return targets + + +def fetch_bidding_campaigns(client_config: ClientConfig) -> tuple[str, list[dict], list[str]]: + warnings: list[str] = [] + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + targets_by_campaign = fetch_bidding_targets(google_client, customer_id, warnings) + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code, + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + campaign.bidding_strategy_type, + campaign.bidding_strategy, + campaign_budget.id, + campaign_budget.name, + campaign_budget.amount_micros, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value, + metrics.search_impression_share, + metrics.search_budget_lost_impression_share, + metrics.search_rank_lost_impression_share + FROM campaign + WHERE campaign.status = 'ENABLED' + AND segments.date DURING LAST_30_DAYS + """, + ) + + currency_code = "" + campaigns = [] + for row in rows: + currency_code = currency_code or str(row.customer.currency_code or "") + campaign = row.campaign + campaign_id = str(campaign.id) + record = { + "campaign_id": campaign_id, + "campaign_name": campaign.name, + "status": enum_name(campaign.status), + "channel_type": enum_name(campaign.advertising_channel_type), + "bidding_strategy_type": enum_name(campaign.bidding_strategy_type), + "bidding_strategy_resource": str(campaign.bidding_strategy or ""), + "budget_id": str(row.campaign_budget.id), + "budget_name": row.campaign_budget.name, + "daily_budget_micros": safe_int(row.campaign_budget.amount_micros), + "cost_30d_micros": safe_int(row.metrics.cost_micros), + "conversions_30d": safe_float(row.metrics.conversions), + "conversion_value_30d": safe_float(row.metrics.conversions_value), + "search_impression_share": safe_float(row.metrics.search_impression_share), + "search_budget_lost_impression_share": safe_float(row.metrics.search_budget_lost_impression_share), + "search_rank_lost_impression_share": safe_float(row.metrics.search_rank_lost_impression_share), + } + expected_30d_micros = record["daily_budget_micros"] * 30 + record["budget_usage_percent"] = ( + round((record["cost_30d_micros"] / expected_30d_micros) * 100, 1) + if expected_30d_micros + else 0.0 + ) + record["actual_roas"] = actual_roas(record["cost_30d_micros"], record["conversion_value_30d"]) + record.update(targets_by_campaign.get(campaign_id, {})) + record["target_label"] = strategy_target_label(record, currency_code) + record["risk_label"] = risk_label(record["bidding_strategy_type"], record["conversions_30d"]) + campaigns.append(record) + return currency_code, campaigns, warnings + + +def build_bidding_strategy_plan(client_config: ClientConfig) -> BiddingStrategyPlan: + warnings = [] + try: + currency_code, campaigns, fetch_warnings = fetch_bidding_campaigns(client_config) + warnings.extend(fetch_warnings) + except Exception as exc: + currency_code = "" + campaigns = [] + warnings.append(f"Nie udalo sie pobrac strategii stawek z Google Ads API: {exc}") + + if not campaigns: + warnings.append("Nie znaleziono aktywnych kampanii z danymi z ostatnich 30 dni albo nie udalo sie ich pobrac.") + + recent_budget_changes = recent_budget_change_campaigns(client_config.domain) + for campaign in campaigns: + campaign["target_assessment"] = target_assessment(campaign) + campaign["stability_label"] = stability_label(campaign) + campaign["budget_context"] = budget_context(campaign, recent_budget_changes) + campaign["strategy_fit"] = strategy_fit(campaign) + campaign["bidding_recommendation"] = bidding_recommendation(campaign) + + 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. " + "Reguly dotyczace strategii stawek bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + recommendation_order = {"czekaj": 0, "do decyzji": 1, "ostroznie": 2, "ok": 9} + campaigns.sort( + key=lambda row: ( + recommendation_order.get(row.get("bidding_recommendation", {}).get("level"), 9), + row["risk_label"], + row["campaign_name"], + ) + ) + target_changes = target_change_candidates(campaigns) + return BiddingStrategyPlan( + currency_code=currency_code, + campaigns=campaigns, + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + target_changes=target_changes, + ) + + +def save_bidding_strategy_plan(domain: str, plan: BiddingStrategyPlan) -> 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 strategii stawek", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kampanie aktywne z danymi 30 dni: {len(plan.campaigns)}", + f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}", + f"- Rekomendacje strategii do decyzji: {len(bidding_recommendations(plan.campaigns))}", + f"- Zmiany celu do wdrozenia po akceptacji: {len(plan.target_changes or [])}", + "", + ] + 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( + [ + "## Strategie stawek z ostatnich 30 dni", + "", + "| Kampania | Typ | Strategia | Cel | Koszt | Konwersje | Wartosc konwersji | ROAS | Utrata budzet | Ocena celu | Stabilnosc | Budzet |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for campaign in plan.campaigns: + lines.append( + f"| {campaign['campaign_name']} | {campaign['channel_type']} | " + f"{campaign['bidding_strategy_type']} | {campaign['target_label']} | " + f"{format_money(campaign['cost_30d_micros'], plan.currency_code)} | " + f"{format_decimal(campaign['conversions_30d'])} | " + f"{format_decimal(campaign['conversion_value_30d'])} | " + f"{format_decimal(campaign.get('actual_roas', 0))} | " + f"{percent_label(campaign.get('search_budget_lost_impression_share'))} | " + f"{campaign.get('target_assessment', '')} | " + f"{campaign.get('stability_label', '')} | " + f"{campaign.get('budget_context', '')} |" + ) + lines.append("") + recommendations = bidding_recommendations(plan.campaigns) + if recommendations: + lines.extend( + [ + "## Rekomendacje strategii do decyzji", + "", + "| Kampania | Waznosc | Rekomendacja | Powod | Dopasowanie strategii |", + "| --- | --- | --- | --- | --- |", + ] + ) + for campaign in recommendations: + recommendation = campaign["bidding_recommendation"] + lines.append( + f"| {campaign['campaign_name']} | {recommendation['level']} | " + f"{recommendation['action']} | {recommendation['reason']} | " + f"{campaign.get('strategy_fit', '')} |" + ) + lines.append("") + if plan.target_changes: + lines.extend( + [ + "## Zmiany celu strategii do akceptacji", + "", + "| Kampania | Strategia | Kierunek | Obecnie | Docelowo | Rzeczywisty ROAS | Powod |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for change in plan.target_changes: + lines.append( + f"| {change['campaign_name']} | {change['strategy']} | {change['direction']} | " + f"{change['current_label']} | {change['target_label']} | " + f"{format_decimal(change['actual_roas'])} | {change['reason']} |" + ) + 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_bidding_strategy_plan(plan: BiddingStrategyPlan) -> None: + print("\nPlan sprawdzenia strategii stawek") + print_table( + ["Metryka", "Liczba"], + [ + ["Kampanie z danymi 30 dni", str(len(plan.campaigns))], + ["Reguly wiedzy", str(len(plan.knowledge_rules))], + ["Rekomendacje strategii", str(len(bidding_recommendations(plan.campaigns)))], + ["Zmiany celu do wdrozenia", str(len(plan.target_changes or []))], + ], + ) + 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("\nStrategie stawek z ostatnich 30 dni") + print_table( + ["Nr", "Kampania", "Typ", "Strategia", "Cel", "Konw.", "ROAS", "Utrata budz.", "Ocena celu", "Budzet"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["channel_type"], + campaign["bidding_strategy_type"], + campaign["target_label"], + format_decimal(campaign["conversions_30d"]), + format_decimal(campaign.get("actual_roas", 0)), + percent_label(campaign.get("search_budget_lost_impression_share")), + campaign.get("target_assessment", ""), + campaign.get("budget_context", ""), + ] + for index, campaign in enumerate(plan.campaigns, 1) + ], + ) + if plan.target_changes: + print("\nZmiany celu strategii do akceptacji") + print_table( + ["Nr", "Kampania", "Strategia", "Kierunek", "Obecnie", "Docelowo", "ROAS", "Powod"], + [ + [ + str(index), + change["campaign_name"], + change["strategy"], + change["direction"], + change["current_label"], + change["target_label"], + format_decimal(change["actual_roas"]), + change["reason"], + ] + for index, change in enumerate(plan.target_changes, 1) + ], + ) + recommendations = bidding_recommendations(plan.campaigns) + if recommendations: + print("\nRekomendacje strategii do decyzji") + print_table( + ["Nr", "Kampania", "Waznosc", "Rekomendacja", "Powod", "Dopasowanie"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["bidding_recommendation"]["level"], + campaign["bidding_recommendation"]["action"], + campaign["bidding_recommendation"]["reason"], + campaign.get("strategy_fit", ""), + ] + for index, campaign in enumerate(recommendations, 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_bidding_strategy_plan( + client_config: ClientConfig, + plan: BiddingStrategyPlan, + show_navigation: bool = True, +) -> None: + target_changes = plan.target_changes or [] + changed = 0 + errors = [] + if target_changes: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + service = google_client.get_service("CampaignService") + operations = [] + for change in target_changes: + op = google_client.get_type("CampaignOperation") + campaign = op.update + campaign.resource_name = service.campaign_path(customer_id, change["campaign_id"]) + field = change["field"] + if field == "target_roas.target_roas": + campaign.target_roas.target_roas = float(change["target_roas"]) + elif field == "maximize_conversion_value.target_roas": + campaign.maximize_conversion_value.target_roas = float(change["target_roas"]) + else: + errors.append(f"Nieobslugiwane pole celu: {field}") + continue + op.update_mask = field_mask_pb2.FieldMask(paths=[field]) + operations.append(op) + + if operations: + try: + response = service.mutate_campaigns(customer_id=customer_id, operations=operations) + changed = len(response.results) + except Exception as exc: + errors.append(str(exc)) + + if target_changes: + print("\nWynik wdrozenia zmian celu strategii") + print(f"Zmieniono kampanii: {changed}") + print(f"Bledy: {len(errors)}") + for error in errors: + print(f"Blad: {error}") + else: + print("\nTo zadanie jest audytem strategii stawek i nie ma zmian celu do wdrozenia.") + + rows = [ + { + "klient": client_config.domain, + "kampania": change.get("campaign_name", ""), + "czynnosc": f"Zmien Docelowy ROAS: {change.get('current_label', '')} -> {change.get('target_label', '')}", + "grupa reklam": "", + "produkt": change.get("reason", ""), + } + for change in target_changes + ] + changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows) + history_path = append_history( + client_config.domain, + { + "task": TASK_NAME, + "status": "wdrozono zmiany celu strategii" if target_changes and not errors else "audyt oznaczony jako wykonany", + "campaign": ", ".join(change.get("campaign_name", "") for change in target_changes) + or ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]), + "summary": { + "campaigns": len(plan.campaigns), + "knowledge_rules": len(plan.knowledge_rules), + "target_changes": len(target_changes), + "changed": changed, + "errors": len(errors), + }, + }, + ) + print(f"Historia JSONL: {history_path}") + print(f"Historia Markdown: {changes_path}") + if show_navigation: + print_next_navigation(client_config.domain) + + +def run_check_bidding_strategies( + 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 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')}, a wybrano {client_config.domain}.") + if show_navigation: + print_next_navigation(client_config.domain) + return + plan = BiddingStrategyPlan.from_dict(plan_data) + print_bidding_strategy_plan(plan) + apply_bidding_strategy_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia strategii stawek...") + plan = build_bidding_strategy_plan(client_config) + print_bidding_strategy_plan(plan) + json_path, md_path = save_bidding_strategy_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), + "target_changes": len(plan.target_changes or []), + }, + }, + ) + + if plan_only: + print("\nTryb plan-only: zmiany nie zostaly wdrozone.") + if show_navigation: + print_next_navigation(client_config.domain) + return + + print("\nBrak automatycznego wdrozenia. Uzyj zapisanego planu i potwierdzenia, aby wdrozyc zmiany celu.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/budget_usage_check.py b/src/gads_v2/tasks/budget_usage_check.py new file mode 100644 index 0000000..95212a3 --- /dev/null +++ b/src/gads_v2/tasks/budget_usage_check.py @@ -0,0 +1,804 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +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 +from ..knowledge.store import rules_for_task +from ..table import print_table + + +TASK_ID = "check_budget_usage" +TASK_NAME = "Sprawdzenie wykorzystania budzetu" + + +SCOPE = [ + { + "area": "Wydatki 7 dni", + "check": "Porownaj koszt z ostatnich 7 dni z oczekiwanym wydatkiem wynikajacym z budzetu dziennego.", + }, + { + "area": "Pacing", + "check": "Oznacz kampanie, ktore wydaja bardzo malo albo prawie caly tygodniowy limit budzetu.", + }, + { + "area": "Utrata wyswietlania przez budzet", + "check": "Polacz wykorzystanie budzetu z utrata udzialu w wyswietleniach przez budzet i rentownoscia kampanii.", + }, + { + "area": "Brak wydatkow", + "check": "Wskaz aktywne kampanie z budzetem, ktore nie wydaly srodkow w ostatnich 7 dniach.", + }, + { + "area": "Budzet wspoldzielony", + "check": "Pokaz nazwe budzetu, zeby latwiej wychwycic kampanie korzystajace z tego samego budzetu.", + }, +] + + +OUT_OF_SCOPE = [ + "zmiany stawek i strategii ustalania stawek", + "ocena Docelowego ROAS albo Docelowego CPA", + "analiza zapytan, wykluczen i jakosci ruchu", + "wdrazanie zmian budzetowych na koncie", +] + + +@dataclass +class BudgetUsagePlan: + currency_code: str + campaigns: list[dict] + scope: list[dict] + out_of_scope: list[str] + knowledge_rules: list[dict] + warnings: list[str] + budget_changes: list[dict] | None = None + + def to_dict(self) -> dict: + return { + "task": TASK_ID, + "task_name": TASK_NAME, + "currency_code": self.currency_code, + "campaigns": self.campaigns, + "scope": self.scope, + "out_of_scope": self.out_of_scope, + "knowledge_rules": self.knowledge_rules, + "warnings": self.warnings, + "budget_changes": self.budget_changes or [], + "changes": self.budget_changes or [], + } + + @classmethod + def from_dict(cls, data: dict) -> "BudgetUsagePlan": + return cls( + currency_code=data.get("currency_code", ""), + 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", []), + budget_changes=data.get("budget_changes", data.get("changes", [])), + ) + + +def enum_name(value) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def percent(value: int | float, total: int | float) -> float: + if not total: + return 0.0 + return round((float(value) / float(total)) * 100, 1) + + +def format_money(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def percent_label(value: int | float | None) -> str: + if value is None: + return "-" + return f"{float(value) * 100:.2f}%" + + +def roas_label(value: int | float) -> str: + if not value: + return "-" + return f"{float(value):.2f}" + + +def days_since_label(value) -> str: + if value is None: + return "brak danych" + if value == 0: + return "dzis" + if value == 1: + return "1 dzien temu" + return f"{value} dni temu" + + +def budget_recommendations(campaigns: list[dict]) -> list[dict]: + return [ + campaign + for campaign in campaigns + if campaign.get("budget_recommendation", {}).get("level") not in {"ok", None} + ] + + +def pacing_label(cost_7d_micros: int, expected_7d_micros: int) -> str: + if expected_7d_micros <= 0: + return "brak budzetu" + usage = percent(cost_7d_micros, expected_7d_micros) + if cost_7d_micros <= 0: + return "brak wydatkow" + if usage < 30: + return "niskie wykorzystanie" + if usage > 95: + return "blisko limitu" + return "w normie" + + +def last_budget_change_date(domain: str, campaign_id: str, campaign_name: str) -> datetime | None: + """Najnowsza data wdrozonej zmiany budzetu dla kampanii, z plikow historii.""" + base = client_dir(domain) / "history" + if not base.exists(): + return None + latest: datetime | None = None + for path in sorted(base.glob("*.jsonl")): + try: + content = path.read_text(encoding="utf-8-sig") + except OSError: + continue + for raw in content.splitlines(): + raw = raw.strip() + if not raw: + continue + try: + event = json.loads(raw) + except json.JSONDecodeError: + continue + if event.get("status") != "wdrozono zmiany budzetu": + continue + changes = event.get("budget_changes") or [] + if changes: + matched = any(str(item.get("campaign_id")) == str(campaign_id) for item in changes) + else: + # starsze wpisy bez szczegolow - dopasowanie po nazwie kampanii + matched = ( + event.get("task") == TASK_NAME + and bool(campaign_name) + and campaign_name in (event.get("campaign") or "") + ) + if not matched: + continue + try: + ts = datetime.fromisoformat(event["timestamp"]) + except (KeyError, ValueError): + continue + if latest is None or ts > latest: + latest = ts + return latest + + +def build_budget_recommendation(campaign: dict, min_days_between_budget_changes: int = 0) -> dict: + usage = float(campaign.get("usage_percent") or 0) + lost_budget = float(campaign.get("search_budget_lost_impression_share") or 0) + cost_micros = int(campaign.get("cost_7d_micros") or 0) + daily_budget_micros = int(campaign.get("daily_budget_micros") or 0) + conversion_value = float(campaign.get("conversions_value") or 0) + roas = float(campaign.get("roas") or 0) + days_since = campaign.get("days_since_budget_change") + + increase_percent = 0 + if lost_budget >= 0.5: + increase_percent = 30 + elif lost_budget >= 0.3: + increase_percent = 25 + elif lost_budget >= 0.15: + increase_percent = 15 + + recommended_budget_micros = daily_budget_micros + if increase_percent: + recommended_budget_micros = int(round(daily_budget_micros * (1 + increase_percent / 100))) + budget_delta_micros = recommended_budget_micros - daily_budget_micros + + if usage >= 90 and lost_budget >= 0.3 and conversion_value > 0 and roas >= 2: + recommendation = { + "level": "wysokie", + "action": "rozważ podniesienie budżetu", + "reason": "kampania prawie wykorzystuje budzet, traci duzo wyswietlen przez budzet i ma dodatnia rentownosc", + "suggested_budget_change_percent": increase_percent, + "current_daily_budget_micros": daily_budget_micros, + "recommended_daily_budget_micros": recommended_budget_micros, + "budget_delta_micros": budget_delta_micros, + } + elif usage >= 90 and lost_budget >= 0.15 and conversion_value > 0 and roas >= 1: + recommendation = { + "level": "srednie", + "action": "sprawdz mozliwosc podniesienia budżetu", + "reason": "kampania wykorzystuje budzet i traci czesc wyswietlen przez budzet", + "suggested_budget_change_percent": increase_percent, + "current_daily_budget_micros": daily_budget_micros, + "recommended_daily_budget_micros": recommended_budget_micros, + "budget_delta_micros": budget_delta_micros, + } + elif usage >= 90 and lost_budget >= 0.15: + recommendation = { + "level": "ostroznie", + "action": "nie podnoś budżetu bez oceny rentowności", + "reason": "widac utrate przez budzet, ale brakuje wystarczajacej wartosci konwersji", + "suggested_budget_change_percent": 0, + "current_daily_budget_micros": daily_budget_micros, + "recommended_daily_budget_micros": daily_budget_micros, + "budget_delta_micros": 0, + } + elif cost_micros <= 0: + recommendation = { + "level": "do sprawdzenia", + "action": "sprawdz brak wydatkow", + "reason": "aktywna kampania nie wydala srodkow w ostatnich 7 dniach", + "suggested_budget_change_percent": 0, + "current_daily_budget_micros": daily_budget_micros, + "recommended_daily_budget_micros": daily_budget_micros, + "budget_delta_micros": 0, + } + else: + recommendation = { + "level": "ok", + "action": "bez zmiany budzetu", + "reason": "brak jednoczesnego sygnalu wysokiego wykorzystania i utraty przez budzet", + "suggested_budget_change_percent": 0, + "current_daily_budget_micros": daily_budget_micros, + "recommended_daily_budget_micros": daily_budget_micros, + "budget_delta_micros": 0, + } + + recommendation["days_since_budget_change"] = days_since + recommendation["min_days_between_budget_changes"] = min_days_between_budget_changes + + # Wstrzymaj rekomendacje podniesienia budzetu, jesli budzet zmieniono zbyt niedawno. + if ( + recommendation["level"] in {"wysokie", "srednie"} + and min_days_between_budget_changes > 0 + and days_since is not None + and days_since < min_days_between_budget_changes + ): + recommendation = { + "level": "wstrzymane", + "action": "nie zmieniaj budzetu jeszcze", + "reason": ( + f"budzet zmieniony {days_since} dni temu, minimum {min_days_between_budget_changes}; " + f"pierwotna rekomendacja: {recommendation['level']}" + ), + "suggested_budget_change_percent": 0, + "current_daily_budget_micros": daily_budget_micros, + "recommended_daily_budget_micros": daily_budget_micros, + "budget_delta_micros": 0, + "days_since_budget_change": days_since, + "min_days_between_budget_changes": min_days_between_budget_changes, + } + + return recommendation + + +def fetch_currency_code(google_client, customer_id: str) -> str: + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code + FROM customer + """, + ) + if not rows: + return "" + return str(rows[0].customer.currency_code or "") + + +def fetch_budget_campaigns( + client_config: ClientConfig, + min_days_between_budget_changes: int = 0, +) -> tuple[str, list[dict]]: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + currency_code = fetch_currency_code(google_client, customer_id) + rows = run_query( + google_client, + customer_id, + """ + SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + campaign_budget.id, + campaign_budget.name, + campaign_budget.amount_micros, + campaign_budget.delivery_method, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value, + metrics.search_impression_share, + metrics.search_budget_lost_impression_share, + metrics.search_rank_lost_impression_share + FROM campaign + WHERE campaign.status = 'ENABLED' + AND segments.date DURING LAST_7_DAYS + """, + ) + + campaigns = [] + for row in rows: + campaign = row.campaign + budget = row.campaign_budget + daily_budget_micros = int(budget.amount_micros or 0) + metrics = row.metrics + cost_7d_micros = int(metrics.cost_micros or 0) + expected_7d_micros = daily_budget_micros * 7 + conversions_value = float(metrics.conversions_value or 0) + cost = micros_to_amount(cost_7d_micros) + record = { + "campaign_id": str(campaign.id), + "campaign_name": campaign.name, + "status": enum_name(campaign.status), + "channel_type": enum_name(campaign.advertising_channel_type), + "budget_id": str(budget.id), + "budget_name": budget.name, + "budget_delivery_method": enum_name(budget.delivery_method), + "daily_budget_micros": daily_budget_micros, + "expected_7d_micros": expected_7d_micros, + "cost_7d_micros": cost_7d_micros, + "avg_daily_cost_micros": int(cost_7d_micros / 7), + "usage_percent": percent(cost_7d_micros, expected_7d_micros), + "pacing_label": pacing_label(cost_7d_micros, expected_7d_micros), + "conversions": round(float(metrics.conversions or 0), 2), + "conversions_value": round(conversions_value, 2), + "roas": round(conversions_value / cost, 2) if cost else 0, + "search_impression_share": float(metrics.search_impression_share or 0), + "search_budget_lost_impression_share": float(metrics.search_budget_lost_impression_share or 0), + "search_rank_lost_impression_share": float(metrics.search_rank_lost_impression_share or 0), + } + last_change = last_budget_change_date( + client_config.domain, record["campaign_id"], record["campaign_name"] + ) + record["days_since_budget_change"] = ( + (now_local() - last_change).days if last_change else None + ) + record["budget_recommendation"] = build_budget_recommendation( + record, min_days_between_budget_changes + ) + campaigns.append(record) + return currency_code, campaigns + + +def build_budget_usage_plan( + client_config: ClientConfig, + global_rules: dict | None = None, +) -> BudgetUsagePlan: + warnings = [] + budget_rules = client_config.effective_rules(global_rules or {}, "budget_usage") + min_days_between_budget_changes = int( + budget_rules.get("min_days_between_budget_changes", 0) or 0 + ) + try: + currency_code, campaigns = fetch_budget_campaigns( + client_config, min_days_between_budget_changes + ) + except Exception as exc: + currency_code = "" + campaigns = [] + warnings.append(f"Nie udalo sie pobrac budzetow z Google Ads API: {exc}") + + if not campaigns: + warnings.append("Nie znaleziono aktywnych kampanii z danymi kosztow z ostatnich 7 dni 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. " + "Reguly budzetowe bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + campaigns.sort(key=lambda row: (-row["usage_percent"], row["campaign_name"])) + return BudgetUsagePlan( + currency_code=currency_code, + campaigns=campaigns, + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + budget_changes=[], + ) + + +def save_budget_usage_plan(domain: str, plan: BudgetUsagePlan) -> 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 wykorzystania budzetu", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kampanie aktywne z danymi 7 dni: {len(plan.campaigns)}", + f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}", + f"- Rekomendacje budzetowe do decyzji: {len(budget_recommendations(plan.campaigns))}", + f"- Zmiany budzetu do wdrozenia: {len(plan.budget_changes or [])}", + "", + ] + 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( + [ + "## Wykorzystanie budzetu z ostatnich 7 dni", + "", + "| Kampania | Typ | Budzet dzienny | Koszt 7 dni | Uzycie 7 dni | Utrata przez budzet | ROAS | Status | Ost. zmiana budzetu | Budzet |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for campaign in plan.campaigns: + lines.append( + f"| {campaign['campaign_name']} | {campaign['channel_type']} | " + f"{format_money(campaign['daily_budget_micros'], plan.currency_code)} | " + f"{format_money(campaign['cost_7d_micros'], plan.currency_code)} | " + f"{campaign['usage_percent']:.1f}% | " + f"{percent_label(campaign.get('search_budget_lost_impression_share'))} | " + f"{roas_label(campaign.get('roas', 0))} | " + f"{campaign['pacing_label']} | " + f"{days_since_label(campaign.get('days_since_budget_change'))} | " + f"{campaign['budget_name']} |" + ) + lines.append("") + recommendations = budget_recommendations(plan.campaigns) + if recommendations: + lines.extend( + [ + "## Rekomendacje budzetowe do decyzji", + "", + "| Kampania | Waznosc | Obecnie | Propozycja | Zmiana | Powod |", + "| --- | --- | --- | --- | --- | --- |", + ] + ) + for campaign in recommendations: + recommendation = campaign["budget_recommendation"] + lines.append( + f"| {campaign['campaign_name']} | {recommendation['level']} | " + f"{format_money(recommendation['current_daily_budget_micros'], plan.currency_code)} | " + f"{format_money(recommendation['recommended_daily_budget_micros'], plan.currency_code)} | " + f"+{recommendation['suggested_budget_change_percent']}% " + f"({format_money(recommendation['budget_delta_micros'], plan.currency_code)}) | " + f"{recommendation['reason']} |" + ) + lines.append("") + if plan.budget_changes: + lines.extend( + [ + "## Zmiany budzetu do wdrozenia", + "", + "| Kampania | Budzet | Obecnie | Docelowo | Zmiana | Powod |", + "| --- | --- | --- | --- | --- | --- |", + ] + ) + for change in plan.budget_changes: + lines.append( + f"| {change.get('campaign_name', '')} | {change.get('budget_name', '')} | " + f"{format_money(change.get('current_daily_budget_micros', 0), plan.currency_code)} | " + f"{format_money(change.get('target_daily_budget_micros', 0), plan.currency_code)} | " + f"{format_money(change.get('delta_micros', 0), plan.currency_code)} | " + f"{change.get('reason', '')} |" + ) + 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_budget_usage_plan(plan: BudgetUsagePlan) -> None: + print("\nPlan sprawdzenia wykorzystania budzetu") + print_table( + ["Metryka", "Liczba"], + [ + ["Kampanie z danymi 7 dni", str(len(plan.campaigns))], + ["Reguly wiedzy", str(len(plan.knowledge_rules))], + ["Rekomendacje budzetowe", str(len(budget_recommendations(plan.campaigns)))], + ["Zmiany budzetu do wdrozenia", str(len(plan.budget_changes or []))], + ], + ) + 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("\nWykorzystanie budzetu z ostatnich 7 dni") + print_table( + ["Nr", "Kampania", "Typ", "Budzet dzienny", "Koszt 7 dni", "Uzycie", "Utrata budz.", "ROAS", "Status", "Ost. zm. budz."], + [ + [ + str(index), + campaign["campaign_name"], + campaign["channel_type"], + format_money(campaign["daily_budget_micros"], plan.currency_code), + format_money(campaign["cost_7d_micros"], plan.currency_code), + f"{campaign['usage_percent']:.1f}%", + percent_label(campaign.get("search_budget_lost_impression_share")), + roas_label(campaign.get("roas", 0)), + campaign["pacing_label"], + days_since_label(campaign.get("days_since_budget_change")), + ] + for index, campaign in enumerate(plan.campaigns, 1) + ], + ) + if plan.budget_changes: + print("\nZmiany budzetu do wdrozenia") + print_table( + ["Nr", "Kampania", "Budzet", "Obecnie", "Docelowo", "Zmiana", "Powod"], + [ + [ + str(index), + change.get("campaign_name", ""), + change.get("budget_name", ""), + format_money(change.get("current_daily_budget_micros", 0), plan.currency_code), + format_money(change.get("target_daily_budget_micros", 0), plan.currency_code), + format_money(change.get("delta_micros", 0), plan.currency_code), + change.get("reason", ""), + ] + for index, change in enumerate(plan.budget_changes, 1) + ], + ) + recommendations = budget_recommendations(plan.campaigns) + if recommendations: + print("\nRekomendacje budzetowe do decyzji") + print_table( + ["Nr", "Kampania", "Waznosc", "Obecnie", "Propozycja", "Zmiana", "Powod"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["budget_recommendation"]["level"], + format_money(campaign["budget_recommendation"]["current_daily_budget_micros"], plan.currency_code), + format_money( + campaign["budget_recommendation"]["recommended_daily_budget_micros"], + plan.currency_code, + ), + ( + f"+{campaign['budget_recommendation']['suggested_budget_change_percent']}% " + f"({format_money(campaign['budget_recommendation']['budget_delta_micros'], plan.currency_code)})" + ), + campaign["budget_recommendation"]["reason"], + ] + for index, campaign in enumerate(recommendations, 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_budget_usage_plan( + client_config: ClientConfig, + plan: BudgetUsagePlan, + show_navigation: bool = True, +) -> None: + budget_changes = plan.budget_changes or [] + changed = 0 + errors = [] + if budget_changes: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + service = google_client.get_service("CampaignBudgetService") + operations = [] + for change in budget_changes: + op = google_client.get_type("CampaignBudgetOperation") + budget = op.update + budget.resource_name = service.campaign_budget_path(customer_id, change["budget_id"]) + budget.amount_micros = int(change["target_daily_budget_micros"]) + op.update_mask = field_mask_pb2.FieldMask(paths=["amount_micros"]) + operations.append(op) + + if operations: + try: + response = service.mutate_campaign_budgets(customer_id=customer_id, operations=operations) + changed = len(response.results) + except Exception as exc: + errors.append(str(exc)) + + if budget_changes: + print("\nWynik wdrozenia zmian budzetu") + print(f"Zmieniono budzetow: {changed}") + print(f"Bledy: {len(errors)}") + for error in errors: + print(f"Blad: {error}") + else: + print("\nTo zadanie jest audytem budzetow i nie ma zmian budzetu do wdrozenia.") + + rows = [ + { + "klient": client_config.domain, + "kampania": change.get("campaign_name", ""), + "czynnosc": "Zmien budzet dzienny", + "grupa reklam": "", + "produkt": ( + f"{format_money(change.get('current_daily_budget_micros', 0), plan.currency_code)} -> " + f"{format_money(change.get('target_daily_budget_micros', 0), plan.currency_code)}" + ), + } + for change in budget_changes + ] + changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows) + history_path = append_history( + client_config.domain, + { + "task": TASK_NAME, + "status": "wdrozono zmiany budzetu" if budget_changes and not errors else "audyt oznaczony jako wykonany", + "campaign": ", ".join( + change.get("campaign_name", "") for change in budget_changes + ) + or ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]), + "budget_changes": [ + { + "campaign_id": change.get("campaign_id", ""), + "campaign_name": change.get("campaign_name", ""), + "budget_id": change.get("budget_id", ""), + "target_daily_budget_micros": change.get("target_daily_budget_micros", 0), + } + for change in budget_changes + ], + "summary": { + "campaigns": len(plan.campaigns), + "knowledge_rules": len(plan.knowledge_rules), + "budget_changes": len(budget_changes), + "changed": changed, + "errors": len(errors), + }, + }, + ) + print(f"Historia JSONL: {history_path}") + print(f"Historia Markdown: {changes_path}") + if show_navigation: + print_next_navigation(client_config.domain) + + +def run_check_budget_usage( + 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 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 = BudgetUsagePlan.from_dict(plan_data) + print_budget_usage_plan(plan) + apply_budget_usage_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia wykorzystania budzetu...") + plan = build_budget_usage_plan(client_config, global_rules) + print_budget_usage_plan(plan) + json_path, md_path = save_budget_usage_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 budzetow.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/campaign_language_check.py b/src/gads_v2/tasks/campaign_language_check.py new file mode 100644 index 0000000..10e4998 --- /dev/null +++ b/src/gads_v2/tasks/campaign_language_check.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_campaign_languages" +TASK_NAME = "Sprawdzenie jezykow kampanii" + + +SCOPE = [ + { + "area": "Jezyki kampanii", + "check": "Wypisz jezyki przypisane do kampanii i oznacz kampanie bez jawnych kryteriow jezykowych.", + }, + { + "area": "Rynek klienta", + "check": "Oznacz ustawienia wymagajace recznej oceny zgodnosci z rynkiem klienta.", + }, + { + "area": "Typ kampanii", + "check": "Pokaz jezyki razem z typem kampanii, zeby osobno oceniac Search, Shopping i PMax.", + }, + { + "area": "Audyt miesieczny", + "check": "Przygotuj szybki przeglad ustawien jezykowych, ktory mozna wykonywac rzadziej niz budzety i anomalie.", + }, +] + + +OUT_OF_SCOPE = [ + "budzety i wykorzystanie budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "zapytania uzytkownikow oraz wykluczenia", + "reklamy RSA, assety i kreacje", + "wdrazanie zmian jezykow na koncie Google Ads", +] + + +@dataclass +class CampaignLanguagePlan: + campaigns: list[dict] + language_summary: list[dict] + channel_summary: 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, + "language_summary": self.language_summary, + "channel_summary": self.channel_summary, + "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) -> "CampaignLanguagePlan": + return cls( + campaigns=data.get("campaigns", []), + language_summary=data.get("language_summary", []), + channel_summary=data.get("channel_summary", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def language_label(resource_name: str, language_constants: dict[str, dict]) -> str: + language = language_constants.get(resource_name) + if not language: + return resource_name + code = language.get("code", "") + name = language.get("name", "") + if code: + return f"{name} ({code})" if name else code + return name or resource_name + + +def campaign_flags(campaign: dict) -> list[str]: + flags = [] + language_count = campaign["languages_count"] + labels = " ".join(language["label"].casefold() for language in campaign["languages"]) + if language_count == 0: + flags.append("brak jawnych jezykow") + if language_count > 3: + flags.append("wiele jezykow do oceny") + if "polish" not in labels and "polski" not in labels and "pl" not in labels: + flags.append("brak oczywistego jezyka polskiego") + if campaign["channel_type"] in {"PERFORMANCE_MAX", "SHOPPING"}: + flags.append("sprawdz razem z feedem i rynkiem") + return flags or ["ok"] + + +def fetch_campaign_language_settings(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 + FROM campaign + WHERE campaign.status != 'REMOVED' + """, + ) + + campaigns = [] + for row in rows: + campaign = row.campaign + campaigns.append( + { + "campaign_id": str(campaign.id), + "campaign_name": campaign.name, + "status": enum_name(campaign.status), + "channel_type": enum_name(campaign.advertising_channel_type), + "languages": [], + } + ) + return campaigns + + +def fetch_language_criteria(client_config: ClientConfig) -> tuple[dict[str, list[dict]], dict[str, dict], list[str]]: + google_client = get_google_ads_client(use_proto_plus=True) + warnings = [] + rows = run_query( + google_client, + client_config.safe_customer_id, + """ + SELECT + campaign.id, + campaign_criterion.criterion_id, + campaign_criterion.status, + campaign_criterion.language.language_constant + FROM campaign_criterion + WHERE campaign.status != 'REMOVED' + AND campaign_criterion.status != 'REMOVED' + AND campaign_criterion.type = 'LANGUAGE' + """, + ) + + languages_by_campaign: dict[str, list[dict]] = {} + resource_names = set() + for row in rows: + campaign_id = str(row.campaign.id) + criterion = row.campaign_criterion + resource_name = str(criterion.language.language_constant or "") + if resource_name: + resource_names.add(resource_name) + languages_by_campaign.setdefault(campaign_id, []).append( + { + "criterion_id": str(criterion.criterion_id), + "resource_name": resource_name, + "status": enum_name(criterion.status), + } + ) + + language_constants = fetch_language_constant_names( + google_client, + client_config.safe_customer_id, + sorted(resource_names), + warnings, + ) + return languages_by_campaign, language_constants, warnings + + +def fetch_language_constant_names( + google_client, + customer_id: str, + resource_names: list[str], + warnings: list[str], +) -> dict[str, dict]: + if not resource_names: + return {} + language_constants: dict[str, dict] = {} + chunk_size = 200 + for start in range(0, len(resource_names), chunk_size): + chunk = resource_names[start : start + chunk_size] + quoted = ", ".join(f"'{name}'" for name in chunk) + try: + rows = run_query( + google_client, + customer_id, + f""" + SELECT + language_constant.resource_name, + language_constant.name, + language_constant.code, + language_constant.targetable + FROM language_constant + WHERE language_constant.resource_name IN ({quoted}) + """, + ) + except Exception as exc: + warnings.append(f"Nie udalo sie pobrac nazw language_constant: {exc}") + return language_constants + for row in rows: + language = row.language_constant + language_constants[str(language.resource_name)] = { + "resource_name": str(language.resource_name), + "name": str(language.name or ""), + "code": str(language.code or ""), + "targetable": bool(language.targetable), + } + return language_constants + + +def attach_languages( + campaigns: list[dict], + languages_by_campaign: dict[str, list[dict]], + language_constants: dict[str, dict], +) -> list[dict]: + for campaign in campaigns: + campaign_id = campaign["campaign_id"] + campaign["languages"] = [ + { + **item, + "label": language_label(item["resource_name"], language_constants), + "code": language_constants.get(item["resource_name"], {}).get("code", ""), + "name": language_constants.get(item["resource_name"], {}).get("name", ""), + } + for item in languages_by_campaign.get(campaign_id, []) + ] + campaign["languages_count"] = len(campaign["languages"]) + campaign["flags"] = campaign_flags(campaign) + campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"])) + return campaigns + + +def build_language_summary(campaigns: list[dict]) -> list[dict]: + counter: Counter[str] = Counter() + for campaign in campaigns: + if not campaign["languages"]: + counter["(brak jawnych jezykow)"] += 1 + continue + for language in campaign["languages"]: + counter[language.get("label") or language.get("resource_name") or "(brak nazwy)"] += 1 + return [{"language": key, "campaigns": value} for key, value in counter.most_common()] + + +def build_channel_summary(campaigns: list[dict]) -> list[dict]: + counter = Counter(row["channel_type"] for row in campaigns) + return [{"channel_type": key, "count": value} for key, value in counter.most_common()] + + +def join_language_labels(languages: list[dict], limit: int = 8) -> str: + if not languages: + return "(brak)" + labels = [item.get("label") or item.get("resource_name") or "" for item in languages] + shown = labels[:limit] + if len(labels) > limit: + shown.append(f"... +{len(labels) - limit}") + return ", ".join(shown) + + +def build_campaign_language_plan(client_config: ClientConfig) -> CampaignLanguagePlan: + warnings = [] + try: + campaigns = fetch_campaign_language_settings(client_config) + languages_by_campaign, language_constants, language_warnings = fetch_language_criteria(client_config) + warnings.extend(language_warnings) + campaigns = attach_languages(campaigns, languages_by_campaign, language_constants) + except Exception as exc: + campaigns = [] + warnings.append(f"Nie udalo sie pobrac ustawien jezykow z Google Ads API: {exc}") + + if not campaigns: + warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac ustawien jezykow.") + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace jezykow kampanii bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return CampaignLanguagePlan( + campaigns=campaigns, + language_summary=build_language_summary(campaigns), + channel_summary=build_channel_summary(campaigns), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_campaign_language_plan(domain: str, plan: CampaignLanguagePlan) -> 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 jezykow kampanii", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kampanie: {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"| {md_cell(row.get('area', ''))} | {md_cell(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.language_summary: + lines.extend(["## Podsumowanie jezykow", "", "| Jezyk | Kampanie |", "| --- | --- |"]) + for row in plan.language_summary: + lines.append(f"| {md_cell(row['language'])} | {row['campaigns']} |") + lines.append("") + if plan.channel_summary: + lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"]) + for row in plan.channel_summary: + lines.append(f"| {row['channel_type']} | {row['count']} |") + lines.append("") + if plan.campaigns: + lines.extend( + [ + "## Kampanie", + "", + "| Kampania | Typ | Status | Jezyki | Flagi |", + "| --- | --- | --- | --- | --- |", + ] + ) + for campaign in plan.campaigns: + lines.append( + f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | " + f"{md_cell(join_language_labels(campaign['languages']))} | " + f"{md_cell(', '.join(campaign['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_campaign_language_plan(plan: CampaignLanguagePlan) -> None: + print("\nPlan sprawdzenia jezykow kampanii") + print_table( + ["Metryka", "Liczba"], + [ + ["Kampanie", 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.language_summary: + print("\nPodsumowanie jezykow") + print_table( + ["Jezyk", "Kampanie"], + [[row["language"], str(row["campaigns"])] for row in plan.language_summary], + ) + if plan.channel_summary: + print("\nPodsumowanie po typach kampanii") + print_table( + ["Typ", "Liczba"], + [[row["channel_type"], str(row["count"])] for row in plan.channel_summary], + ) + if plan.campaigns: + print("\nKampanie") + print_table( + ["Nr", "Kampania", "Typ", "Jezyki", "Flagi"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["channel_type"], + join_language_labels(campaign["languages"]), + ", ".join(campaign["flags"]), + ] + for index, campaign in enumerate(plan.campaigns[:30], 1) + ], + ) + if len(plan.campaigns) > 30: + print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu") + 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_campaign_language_plan( + client_config: ClientConfig, + plan: CampaignLanguagePlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem jezykow kampanii 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_campaign_languages( + 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 = CampaignLanguagePlan.from_dict(plan_data) + print_campaign_language_plan(plan) + apply_campaign_language_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia jezykow kampanii...") + plan = build_campaign_language_plan(client_config) + print_campaign_language_plan(plan) + json_path, md_path = save_campaign_language_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 jezykow kampanii.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/campaign_location_check.py b/src/gads_v2/tasks/campaign_location_check.py new file mode 100644 index 0000000..b26c664 --- /dev/null +++ b/src/gads_v2/tasks/campaign_location_check.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_campaign_locations" +TASK_NAME = "Sprawdzenie lokalizacji kampanii" + + +SCOPE = [ + { + "area": "Tryb kierowania", + "check": "Pokaz pozytywny i negatywny tryb kierowania lokalizacja dla kazdej kampanii.", + }, + { + "area": "Lokalizacje docelowe", + "check": "Wypisz lokalizacje dodane do kampanii i oznacz kampanie bez jawnych lokalizacji.", + }, + { + "area": "Wykluczone lokalizacje", + "check": "Wypisz lokalizacje wykluczone i oznacz kampanie, w ktorych brakuje wykluczen do recznej oceny.", + }, + { + "area": "Sprawnosc miesieczna", + "check": "Przygotuj szybki przeglad ustawien, ktory mozna wykonywac rzadziej niz budzety i anomalie.", + }, +] + + +OUT_OF_SCOPE = [ + "budzety i wykorzystanie budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "zapytania uzytkownikow i wykluczenia slow kluczowych", + "reklamy, assety i kreacje", + "wdrazanie zmian lokalizacji na koncie Google Ads", +] + + +@dataclass +class CampaignLocationPlan: + campaigns: list[dict] + geo_type_summary: list[dict] + location_summary: 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, + "geo_type_summary": self.geo_type_summary, + "location_summary": self.location_summary, + "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) -> "CampaignLocationPlan": + return cls( + campaigns=data.get("campaigns", []), + geo_type_summary=data.get("geo_type_summary", []), + location_summary=data.get("location_summary", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def human_geo(value: str) -> str: + return { + "PRESENCE": "Obecnosc", + "PRESENCE_OR_INTEREST": "Obecnosc lub zainteresowanie", + "SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem", + "UNKNOWN": "Nieznane", + "UNSPECIFIED": "Nieokreslone", + }.get(value, value) + + +def location_label(resource_name: str, geo_targets: dict[str, dict]) -> str: + target = geo_targets.get(resource_name) + if not target: + return resource_name + parts = [target.get("name", "")] + country_code = target.get("country_code", "") + target_type = target.get("target_type", "") + suffix = ", ".join(part for part in [country_code, target_type] if part) + if suffix: + parts.append(f"({suffix})") + return " ".join(part for part in parts if part).strip() or resource_name + + +def campaign_flags(campaign: dict) -> list[str]: + flags = [] + if campaign["positive_geo_target_type"] != "PRESENCE": + flags.append("kierowanie nie tylko na obecnosc") + if campaign["positive_locations_count"] == 0: + flags.append("brak jawnych lokalizacji") + if campaign["negative_locations_count"] == 0: + flags.append("brak wykluczen lokalizacji") + if campaign["negative_geo_target_type"] in {"UNKNOWN", "UNSPECIFIED", ""}: + flags.append("nieznany tryb wykluczen") + return flags or ["ok"] + + +def fetch_campaign_geo_settings(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.geo_target_type_setting.negative_geo_target_type + FROM campaign + WHERE campaign.status != 'REMOVED' + """, + ) + + campaigns = [] + for row in rows: + campaign = row.campaign + positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type) + negative_geo = enum_name(campaign.geo_target_type_setting.negative_geo_target_type) + campaigns.append( + { + "campaign_id": str(campaign.id), + "campaign_name": campaign.name, + "status": enum_name(campaign.status), + "channel_type": enum_name(campaign.advertising_channel_type), + "positive_geo_target_type": positive_geo, + "positive_geo_target_type_label": human_geo(positive_geo), + "negative_geo_target_type": negative_geo, + "negative_geo_target_type_label": human_geo(negative_geo), + "positive_locations": [], + "negative_locations": [], + } + ) + return campaigns + + +def fetch_location_criteria(client_config: ClientConfig) -> tuple[dict[str, list[dict]], dict[str, list[dict]], dict[str, dict], list[str]]: + google_client = get_google_ads_client(use_proto_plus=True) + warnings = [] + rows = run_query( + google_client, + client_config.safe_customer_id, + """ + SELECT + campaign.id, + campaign_criterion.criterion_id, + campaign_criterion.negative, + campaign_criterion.status, + campaign_criterion.location.geo_target_constant + FROM campaign_criterion + WHERE campaign.status != 'REMOVED' + AND campaign_criterion.status != 'REMOVED' + AND campaign_criterion.type = 'LOCATION' + """, + ) + + positive: dict[str, list[dict]] = {} + negative: dict[str, list[dict]] = {} + resource_names = set() + for row in rows: + campaign_id = str(row.campaign.id) + criterion = row.campaign_criterion + resource_name = str(criterion.location.geo_target_constant or "") + if resource_name: + resource_names.add(resource_name) + item = { + "criterion_id": str(criterion.criterion_id), + "resource_name": resource_name, + "status": enum_name(criterion.status), + "negative": bool(criterion.negative), + } + target = negative if criterion.negative else positive + target.setdefault(campaign_id, []).append(item) + + geo_targets = fetch_geo_target_names(google_client, client_config.safe_customer_id, sorted(resource_names), warnings) + return positive, negative, geo_targets, warnings + + +def fetch_geo_target_names(google_client, customer_id: str, resource_names: list[str], warnings: list[str]) -> dict[str, dict]: + if not resource_names: + return {} + geo_targets: dict[str, dict] = {} + chunk_size = 200 + for start in range(0, len(resource_names), chunk_size): + chunk = resource_names[start : start + chunk_size] + quoted = ", ".join(f"'{name}'" for name in chunk) + try: + rows = run_query( + google_client, + customer_id, + f""" + SELECT + geo_target_constant.resource_name, + geo_target_constant.name, + geo_target_constant.country_code, + geo_target_constant.target_type, + geo_target_constant.status + FROM geo_target_constant + WHERE geo_target_constant.resource_name IN ({quoted}) + """, + ) + except Exception as exc: + warnings.append(f"Nie udalo sie pobrac nazw lokalizacji geo_target_constant: {exc}") + return geo_targets + for row in rows: + target = row.geo_target_constant + geo_targets[str(target.resource_name)] = { + "resource_name": str(target.resource_name), + "name": str(target.name or ""), + "country_code": str(target.country_code or ""), + "target_type": str(target.target_type or ""), + "status": enum_name(target.status), + } + return geo_targets + + +def attach_locations( + campaigns: list[dict], + positive: dict[str, list[dict]], + negative: dict[str, list[dict]], + geo_targets: dict[str, dict], +) -> list[dict]: + for campaign in campaigns: + campaign_id = campaign["campaign_id"] + campaign["positive_locations"] = [ + { + **item, + "label": location_label(item["resource_name"], geo_targets), + } + for item in positive.get(campaign_id, []) + ] + campaign["negative_locations"] = [ + { + **item, + "label": location_label(item["resource_name"], geo_targets), + } + for item in negative.get(campaign_id, []) + ] + campaign["positive_locations_count"] = len(campaign["positive_locations"]) + campaign["negative_locations_count"] = len(campaign["negative_locations"]) + campaign["flags"] = campaign_flags(campaign) + campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"])) + return campaigns + + +def build_geo_type_summary(campaigns: list[dict]) -> list[dict]: + counter = Counter(campaign["positive_geo_target_type_label"] for campaign in campaigns) + return [{"positive_geo_target_type": key, "count": value} for key, value in counter.most_common()] + + +def build_location_summary(campaigns: list[dict]) -> list[dict]: + return [ + { + "metric": "Kampanie", + "count": len(campaigns), + }, + { + "metric": "Kampanie bez jawnych lokalizacji", + "count": sum(1 for campaign in campaigns if campaign["positive_locations_count"] == 0), + }, + { + "metric": "Kampanie bez wykluczen lokalizacji", + "count": sum(1 for campaign in campaigns if campaign["negative_locations_count"] == 0), + }, + { + "metric": "Kampanie z kierowaniem innym niz Obecnosc", + "count": sum(1 for campaign in campaigns if campaign["positive_geo_target_type"] != "PRESENCE"), + }, + ] + + +def build_campaign_location_plan(client_config: ClientConfig) -> CampaignLocationPlan: + warnings = [] + try: + campaigns = fetch_campaign_geo_settings(client_config) + positive, negative, geo_targets, location_warnings = fetch_location_criteria(client_config) + warnings.extend(location_warnings) + campaigns = attach_locations(campaigns, positive, negative, geo_targets) + except Exception as exc: + campaigns = [] + warnings.append(f"Nie udalo sie pobrac ustawien lokalizacji z Google Ads API: {exc}") + + if not campaigns: + warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac ustawien lokalizacji.") + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace lokalizacji bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return CampaignLocationPlan( + campaigns=campaigns, + geo_type_summary=build_geo_type_summary(campaigns), + location_summary=build_location_summary(campaigns), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def join_location_labels(locations: list[dict], limit: int = 6) -> str: + if not locations: + return "(brak)" + labels = [item.get("label") or item.get("resource_name") or "" for item in locations] + shown = labels[:limit] + if len(labels) > limit: + shown.append(f"... +{len(labels) - limit}") + return ", ".join(shown) + + +def save_campaign_location_plan(domain: str, plan: CampaignLocationPlan) -> 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 lokalizacji kampanii", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kampanie: {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"| {md_cell(row.get('area', ''))} | {md_cell(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.location_summary: + lines.extend(["## Podsumowanie lokalizacji", "", "| Metryka | Liczba |", "| --- | --- |"]) + for row in plan.location_summary: + lines.append(f"| {row['metric']} | {row['count']} |") + lines.append("") + if plan.geo_type_summary: + lines.extend(["## Tryby kierowania lokalizacja", "", "| Tryb | Liczba kampanii |", "| --- | --- |"]) + for row in plan.geo_type_summary: + lines.append(f"| {row['positive_geo_target_type']} | {row['count']} |") + lines.append("") + if plan.campaigns: + lines.extend( + [ + "## Kampanie", + "", + "| Kampania | Typ | Status | Tryb lokalizacji | Tryb wykluczen | Lokalizacje | Wykluczenia | Flagi |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for campaign in plan.campaigns: + lines.append( + f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | " + f"{campaign['positive_geo_target_type_label']} | {campaign['negative_geo_target_type_label']} | " + f"{md_cell(join_location_labels(campaign['positive_locations']))} | " + f"{md_cell(join_location_labels(campaign['negative_locations']))} | " + f"{md_cell(', '.join(campaign['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_campaign_location_plan(plan: CampaignLocationPlan) -> None: + print("\nPlan sprawdzenia lokalizacji kampanii") + print_table( + ["Metryka", "Liczba"], + [ + ["Kampanie", 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.location_summary: + print("\nPodsumowanie lokalizacji") + print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.location_summary]) + if plan.geo_type_summary: + print("\nTryby kierowania lokalizacja") + print_table( + ["Tryb", "Kampanie"], + [[row["positive_geo_target_type"], str(row["count"])] for row in plan.geo_type_summary], + ) + if plan.campaigns: + print("\nKampanie") + print_table( + ["Nr", "Kampania", "Typ", "Tryb", "Lok.", "Wykl.", "Flagi"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["channel_type"], + campaign["positive_geo_target_type_label"], + str(campaign["positive_locations_count"]), + str(campaign["negative_locations_count"]), + ", ".join(campaign["flags"]), + ] + for index, campaign in enumerate(plan.campaigns[:30], 1) + ], + ) + if len(plan.campaigns) > 30: + print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu") + 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_campaign_location_plan( + client_config: ClientConfig, + plan: CampaignLocationPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem lokalizacji 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_campaign_locations( + 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 = CampaignLocationPlan.from_dict(plan_data) + print_campaign_location_plan(plan) + apply_campaign_location_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia lokalizacji kampanii...") + plan = build_campaign_location_plan(client_config) + print_campaign_location_plan(plan) + json_path, md_path = save_campaign_location_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 lokalizacji.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/campaign_network_check.py b/src/gads_v2/tasks/campaign_network_check.py new file mode 100644 index 0000000..2e95a6d --- /dev/null +++ b/src/gads_v2/tasks/campaign_network_check.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_campaign_networks" +TASK_NAME = "Sprawdzenie sieci kampanii" + + +SCOPE = [ + { + "area": "Search", + "check": "Sprawdz, czy kampanie Search maja wlaczony Google Search i czy partnerzy wyszukiwania sa ustawieni swiadomie.", + }, + { + "area": "Siec reklamowa", + "check": "Oznacz kampanie Search z wlaczona siecia reklamowa jako ustawienie wymagajace recznej oceny.", + }, + { + "area": "Typ kampanii", + "check": "Pokaz ustawienia sieci razem z typem kampanii, zeby nie mieszac Search, Shopping i PMax.", + }, + { + "area": "Audyt ustawien", + "check": "Przygotuj szybki przeglad ustawien sieci, ktory mozna wykonywac rzadziej niz budzety i anomalie.", + }, +] + + +OUT_OF_SCOPE = [ + "budzety i wykorzystanie budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "zapytania uzytkownikow oraz wykluczenia", + "reklamy RSA, assety i kreacje", + "wdrazanie zmian ustawien sieci na koncie Google Ads", +] + + +@dataclass +class CampaignNetworkPlan: + campaigns: list[dict] + channel_summary: list[dict] + network_summary: 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, + "channel_summary": self.channel_summary, + "network_summary": self.network_summary, + "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) -> "CampaignNetworkPlan": + return cls( + campaigns=data.get("campaigns", []), + channel_summary=data.get("channel_summary", []), + network_summary=data.get("network_summary", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def yes_no(value: bool) -> str: + return "TAK" if value else "NIE" + + +def campaign_flags(campaign: dict) -> list[str]: + flags = [] + if campaign["channel_type"] == "SEARCH": + if not campaign["target_google_search"]: + flags.append("Search bez Google Search") + if campaign["target_content_network"]: + flags.append("Search z wlaczona siecia reklamowa") + if campaign["target_partner_search_network"]: + flags.append("partnerzy wyszukiwania do oceny") + if campaign["channel_type"] == "SHOPPING" and campaign["target_content_network"]: + flags.append("Shopping z siecia reklamowa do oceny") + if campaign["channel_type"] in {"PERFORMANCE_MAX", "DISPLAY", "VIDEO", "DEMAND_GEN"}: + flags.append("sieci sterowane przez typ kampanii") + return flags or ["ok"] + + +def fetch_campaign_networks(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.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.status != 'REMOVED' + """, + ) + + campaigns = [] + for row in rows: + campaign = row.campaign + record = { + "campaign_id": str(campaign.id), + "campaign_name": campaign.name, + "status": enum_name(campaign.status), + "channel_type": enum_name(campaign.advertising_channel_type), + "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), + } + record["flags"] = campaign_flags(record) + campaigns.append(record) + campaigns.sort(key=lambda row: (row["channel_type"], row["campaign_name"])) + return campaigns + + +def build_channel_summary(campaigns: list[dict]) -> list[dict]: + counter = Counter(row["channel_type"] for row in campaigns) + return [{"channel_type": key, "count": value} for key, value in counter.most_common()] + + +def build_network_summary(campaigns: list[dict]) -> list[dict]: + return [ + { + "metric": "Kampanie", + "count": len(campaigns), + }, + { + "metric": "Google Search wlaczony", + "count": sum(1 for campaign in campaigns if campaign["target_google_search"]), + }, + { + "metric": "Search Network wlaczony", + "count": sum(1 for campaign in campaigns if campaign["target_search_network"]), + }, + { + "metric": "Partnerzy wyszukiwania wlaczeni", + "count": sum(1 for campaign in campaigns if campaign["target_partner_search_network"]), + }, + { + "metric": "Siec reklamowa wlaczona", + "count": sum(1 for campaign in campaigns if campaign["target_content_network"]), + }, + { + "metric": "Kampanie z flagami do oceny", + "count": sum(1 for campaign in campaigns if campaign["flags"] != ["ok"]), + }, + ] + + +def build_campaign_network_plan(client_config: ClientConfig) -> CampaignNetworkPlan: + warnings = [] + try: + campaigns = fetch_campaign_networks(client_config) + except Exception as exc: + campaigns = [] + warnings.append(f"Nie udalo sie pobrac ustawien sieci z Google Ads API: {exc}") + + if not campaigns: + warnings.append("Nie znaleziono kampanii albo nie udalo sie pobrac ustawien sieci.") + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace sieci kampanii bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return CampaignNetworkPlan( + campaigns=campaigns, + channel_summary=build_channel_summary(campaigns), + network_summary=build_network_summary(campaigns), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_campaign_network_plan(domain: str, plan: CampaignNetworkPlan) -> 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 sieci kampanii", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kampanie: {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"| {md_cell(row.get('area', ''))} | {md_cell(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.network_summary: + lines.extend(["## Podsumowanie sieci", "", "| Metryka | Liczba |", "| --- | --- |"]) + for row in plan.network_summary: + lines.append(f"| {row['metric']} | {row['count']} |") + lines.append("") + if plan.channel_summary: + lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"]) + for row in plan.channel_summary: + lines.append(f"| {row['channel_type']} | {row['count']} |") + lines.append("") + if plan.campaigns: + lines.extend( + [ + "## Kampanie", + "", + "| Kampania | Typ | Status | Google Search | Search Network | Partnerzy | Siec reklamowa | Flagi |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for campaign in plan.campaigns: + lines.append( + f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['status']} | " + 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'])} | " + f"{md_cell(', '.join(campaign['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_campaign_network_plan(plan: CampaignNetworkPlan) -> None: + print("\nPlan sprawdzenia sieci kampanii") + print_table( + ["Metryka", "Liczba"], + [ + ["Kampanie", 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.network_summary: + print("\nPodsumowanie sieci") + print_table(["Metryka", "Liczba"], [[row["metric"], str(row["count"])] for row in plan.network_summary]) + if plan.channel_summary: + print("\nPodsumowanie po typach kampanii") + print_table( + ["Typ", "Liczba"], + [[row["channel_type"], str(row["count"])] for row in plan.channel_summary], + ) + if plan.campaigns: + print("\nKampanie") + print_table( + ["Nr", "Kampania", "Typ", "Google", "Search net.", "Partnerzy", "Display", "Flagi"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["channel_type"], + yes_no(campaign["target_google_search"]), + yes_no(campaign["target_search_network"]), + yes_no(campaign["target_partner_search_network"]), + yes_no(campaign["target_content_network"]), + ", ".join(campaign["flags"]), + ] + for index, campaign in enumerate(plan.campaigns[:30], 1) + ], + ) + if len(plan.campaigns) > 30: + print(f"... oraz {len(plan.campaigns) - 30} kolejnych kampanii w pliku planu") + 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_campaign_network_plan( + client_config: ClientConfig, + plan: CampaignNetworkPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem sieci kampanii 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_campaign_networks( + 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 = CampaignNetworkPlan.from_dict(plan_data) + print_campaign_network_plan(plan) + apply_campaign_network_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia sieci kampanii...") + plan = build_campaign_network_plan(client_config) + print_campaign_network_plan(plan) + json_path, md_path = save_campaign_network_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 sieci kampanii.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/conversion_tracking_check.py b/src/gads_v2/tasks/conversion_tracking_check.py new file mode 100644 index 0000000..cb7a8d5 --- /dev/null +++ b/src/gads_v2/tasks/conversion_tracking_check.py @@ -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) diff --git a/src/gads_v2/tasks/device_performance_check.py b/src/gads_v2/tasks/device_performance_check.py new file mode 100644 index 0000000..baff191 --- /dev/null +++ b/src/gads_v2/tasks/device_performance_check.py @@ -0,0 +1,667 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_device_performance" +TASK_NAME = "Sprawdzenie urzadzen" + + +SCOPE = [ + { + "area": "Segment urzadzenia", + "check": "Porownaj wyniki kampanii z ostatnich 30 dni dla komputerow, telefonow i tabletow.", + }, + { + "area": "Rentownosc", + "check": "Policz koszt, konwersje, wartosc konwersji, ROAS i CPA dla kazdego urzadzenia.", + }, + { + "area": "Udzial kosztu", + "check": "Pokaz, jaka czesc kosztu kampanii przypada na dane urzadzenie.", + }, + { + "area": "Sygnaly do oceny", + "check": "Oznacz urzadzenia z istotnym kosztem i slabymi wynikami albo wyraznie odmienna efektywnoscia.", + }, +] + + +OUT_OF_SCOPE = [ + "zmiany korekt stawek dla urzadzen", + "zmiany strategii ustalania stawek", + "zmiany budzetow kampanii", + "analiza zapytan uzytkownikow", + "wdrazanie zmian na koncie Google Ads", +] + + +@dataclass +class DevicePerformancePlan: + currency_code: str + device_summary: list[dict] + campaign_device_rows: list[dict] + findings: 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, + "currency_code": self.currency_code, + "device_summary": self.device_summary, + "campaign_device_rows": self.campaign_device_rows, + "findings": self.findings, + "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) -> "DevicePerformancePlan": + return cls( + currency_code=data.get("currency_code", ""), + device_summary=data.get("device_summary", []), + campaign_device_rows=data.get("campaign_device_rows", []), + findings=data.get("findings", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def format_money_micros(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def format_money_amount(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{float(value or 0):.2f}{suffix}" + + +def format_number(value: int | float, decimals: int = 2) -> str: + return f"{float(value or 0):.{decimals}f}" + + +def percent(numerator: int | float, denominator: int | float) -> float: + if not denominator: + return 0.0 + return round((float(numerator) / float(denominator)) * 100, 1) + + +def roas(conversion_value: float, cost_micros: int) -> float: + cost = micros_to_amount(cost_micros) + if not cost: + return 0.0 + return round(float(conversion_value or 0) / cost, 2) + + +def cpa(cost_micros: int, conversions: float) -> float: + if not conversions: + return 0.0 + return round(micros_to_amount(cost_micros) / float(conversions), 2) + + +def empty_metrics() -> dict: + return { + "cost_micros": 0, + "clicks": 0, + "impressions": 0, + "conversions": 0.0, + "conversion_value": 0.0, + } + + +def add_metrics(target: dict, metrics: Any) -> None: + target["cost_micros"] += int(metrics.cost_micros or 0) + target["clicks"] += int(metrics.clicks or 0) + target["impressions"] += int(metrics.impressions or 0) + target["conversions"] += float(metrics.conversions or 0) + target["conversion_value"] += float(metrics.conversions_value or 0) + + +def fetch_currency_code(google_client, customer_id: str) -> str: + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code + FROM customer + """, + ) + if not rows: + return "" + return str(rows[0].customer.currency_code or "") + + +def fetch_device_rows(client_config: ClientConfig) -> tuple[str, list[dict]]: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + currency_code = fetch_currency_code(google_client, customer_id) + rows = run_query( + google_client, + customer_id, + """ + SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + segments.device, + metrics.cost_micros, + metrics.clicks, + metrics.impressions, + metrics.conversions, + metrics.conversions_value + FROM campaign + WHERE campaign.status != 'REMOVED' + AND segments.date DURING LAST_30_DAYS + """, + ) + + campaigns: dict[str, dict] = {} + for row in rows: + campaign_id = str(row.campaign.id) + campaign = campaigns.setdefault( + campaign_id, + { + "campaign_id": campaign_id, + "campaign_name": row.campaign.name, + "status": enum_name(row.campaign.status), + "channel_type": enum_name(row.campaign.advertising_channel_type), + "total": empty_metrics(), + "devices": defaultdict(empty_metrics), + }, + ) + add_metrics(campaign["total"], row.metrics) + add_metrics(campaign["devices"][enum_name(row.segments.device)], row.metrics) + + result = [] + for campaign in campaigns.values(): + total_cost = campaign["total"]["cost_micros"] + for device, metrics in campaign["devices"].items(): + record = { + "campaign_id": campaign["campaign_id"], + "campaign_name": campaign["campaign_name"], + "status": campaign["status"], + "channel_type": campaign["channel_type"], + "device": device, + "cost_micros": metrics["cost_micros"], + "clicks": metrics["clicks"], + "impressions": metrics["impressions"], + "conversions": round(metrics["conversions"], 2), + "conversion_value": round(metrics["conversion_value"], 2), + "roas": roas(metrics["conversion_value"], metrics["cost_micros"]), + "cpa": cpa(metrics["cost_micros"], metrics["conversions"]), + "cost_share_percent": percent(metrics["cost_micros"], total_cost), + } + record["flags"] = device_flags(record) + record["severity"] = device_severity(record) + result.append(record) + + result.sort( + key=lambda row: ( + {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9}.get(row["severity"], 9), + row["campaign_name"], + -row["cost_micros"], + row["device"], + ) + ) + return currency_code, result + + +def device_flags(row: dict) -> list[str]: + flags = [] + if row["cost_micros"] > 0 and row["conversions"] == 0 and row["cost_share_percent"] >= 20: + flags.append("koszt bez konwersji") + if row["cost_share_percent"] >= 50 and row["roas"] == 0: + flags.append("duzy udzial kosztu bez wartosci") + if row["clicks"] >= 30 and row["conversions"] == 0: + flags.append("wiele klikniec bez konwersji") + if row["roas"] > 0 and row["roas"] < 1 and row["cost_share_percent"] >= 20: + flags.append("niski ROAS przy istotnym koszcie") + if row["impressions"] == 0: + flags.append("brak wyswietlen") + return flags or ["ok"] + + +def device_severity(row: dict) -> str: + flags = set(row["flags"]) + if "duzy udzial kosztu bez wartosci" in flags or "wiele klikniec bez konwersji" in flags: + return "wysokie" + if "koszt bez konwersji" in flags or "niski ROAS przy istotnym koszcie" in flags: + return "srednie" + if flags == {"ok"}: + return "ok" + return "niskie" + + +def build_device_summary(rows: list[dict]) -> list[dict]: + buckets: dict[str, dict] = defaultdict(empty_metrics) + total_cost = 0 + for row in rows: + bucket = buckets[row["device"]] + bucket["cost_micros"] += row["cost_micros"] + bucket["clicks"] += row["clicks"] + bucket["impressions"] += row["impressions"] + bucket["conversions"] += row["conversions"] + bucket["conversion_value"] += row["conversion_value"] + total_cost += row["cost_micros"] + + summary = [] + for device, metrics in buckets.items(): + summary.append( + { + "device": device, + "cost_micros": metrics["cost_micros"], + "clicks": metrics["clicks"], + "impressions": metrics["impressions"], + "conversions": round(metrics["conversions"], 2), + "conversion_value": round(metrics["conversion_value"], 2), + "roas": roas(metrics["conversion_value"], metrics["cost_micros"]), + "cpa": cpa(metrics["cost_micros"], metrics["conversions"]), + "cost_share_percent": percent(metrics["cost_micros"], total_cost), + } + ) + summary.sort(key=lambda row: -row["cost_micros"]) + return summary + + +def build_findings(rows: list[dict]) -> list[dict]: + findings = [] + for row in rows: + if row["flags"] == ["ok"]: + continue + findings.append( + { + "severity": row["severity"], + "campaign_name": row["campaign_name"], + "channel_type": row["channel_type"], + "device": row["device"], + "cost_share_percent": row["cost_share_percent"], + "cost_micros": row["cost_micros"], + "conversions": row["conversions"], + "conversion_value": row["conversion_value"], + "roas": row["roas"], + "cpa": row["cpa"], + "flags": row["flags"], + "recommendation": "sprawdz urzadzenie w kontekscie kampanii przed decyzja o korektach stawek albo strukturze", + } + ) + return findings + + +def build_device_performance_plan(client_config: ClientConfig) -> DevicePerformancePlan: + warnings = [] + try: + currency_code, campaign_device_rows = fetch_device_rows(client_config) + except Exception as exc: + currency_code = "" + campaign_device_rows = [] + warnings.append(f"Nie udalo sie pobrac segmentu urzadzen z Google Ads API: {exc}") + + if not campaign_device_rows: + warnings.append("Nie znaleziono danych wedlug urzadzen z ostatnich 30 dni albo nie udalo sie ich pobrac.") + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace segmentow urzadzen bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return DevicePerformancePlan( + currency_code=currency_code, + device_summary=build_device_summary(campaign_device_rows), + campaign_device_rows=campaign_device_rows, + findings=build_findings(campaign_device_rows), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_device_performance_plan(domain: str, plan: DevicePerformancePlan) -> 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 urzadzen", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Segmenty urzadzen w kampaniach: {len(plan.campaign_device_rows)}", + f"- Elementy do oceny: {len(plan.findings)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.device_summary: + lines.extend( + [ + "## Podsumowanie po urzadzeniach", + "", + "| Urzadzenie | Koszt | Udzial kosztu | Klikniecia | Konwersje | Wartosc konwersji | ROAS | CPA |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for row in plan.device_summary: + lines.append( + f"| {row['device']} | {format_money_micros(row['cost_micros'], plan.currency_code)} | " + f"{row['cost_share_percent']:.1f}% | {row['clicks']} | {format_number(row['conversions'])} | " + f"{format_money_amount(row['conversion_value'], plan.currency_code)} | {format_number(row['roas'])} | " + f"{format_money_amount(row['cpa'], plan.currency_code)} |" + ) + lines.append("") + if plan.findings: + lines.extend( + [ + "## Elementy do oceny", + "", + "| Waznosc | Kampania | Typ | Urzadzenie | Koszt | Udzial kosztu | Konwersje | ROAS | Flagi | Rekomendacja |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for item in plan.findings: + lines.append( + f"| {item['severity']} | {md_cell(item['campaign_name'])} | {item['channel_type']} | {item['device']} | " + f"{format_money_micros(item['cost_micros'], plan.currency_code)} | {item['cost_share_percent']:.1f}% | " + f"{format_number(item['conversions'])} | {format_number(item['roas'])} | " + f"{md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |" + ) + lines.append("") + if plan.campaign_device_rows: + lines.extend( + [ + "## Kampanie i urzadzenia", + "", + "| Kampania | Typ | Urzadzenie | Koszt | Udzial kosztu | Klikniecia | Konwersje | Wartosc konwersji | ROAS | CPA | Flagi |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for row in plan.campaign_device_rows: + lines.append( + f"| {md_cell(row['campaign_name'])} | {row['channel_type']} | {row['device']} | " + f"{format_money_micros(row['cost_micros'], plan.currency_code)} | {row['cost_share_percent']:.1f}% | " + f"{row['clicks']} | {format_number(row['conversions'])} | " + f"{format_money_amount(row['conversion_value'], plan.currency_code)} | {format_number(row['roas'])} | " + f"{format_money_amount(row['cpa'], plan.currency_code)} | {md_cell(', '.join(row['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_device_performance_plan(plan: DevicePerformancePlan) -> None: + print("\nPlan sprawdzenia urzadzen") + print_table( + ["Metryka", "Liczba"], + [ + ["Segmenty urzadzen w kampaniach", str(len(plan.campaign_device_rows))], + ["Elementy do oceny", str(len(plan.findings))], + ["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.device_summary: + print("\nPodsumowanie po urzadzeniach") + print_table( + ["Urzadzenie", "Koszt", "Udzial", "Klikniecia", "Konw.", "Wartosc", "ROAS", "CPA"], + [ + [ + row["device"], + format_money_micros(row["cost_micros"], plan.currency_code), + f"{row['cost_share_percent']:.1f}%", + str(row["clicks"]), + format_number(row["conversions"]), + format_money_amount(row["conversion_value"], plan.currency_code), + format_number(row["roas"]), + format_money_amount(row["cpa"], plan.currency_code), + ] + for row in plan.device_summary + ], + ) + if plan.findings: + print("\nElementy do oceny") + print_table( + ["Nr", "Waznosc", "Kampania", "Urzadzenie", "Koszt", "Udzial", "Konw.", "ROAS", "Flagi"], + [ + [ + str(index), + item["severity"], + item["campaign_name"], + item["device"], + format_money_micros(item["cost_micros"], plan.currency_code), + f"{item['cost_share_percent']:.1f}%", + format_number(item["conversions"]), + format_number(item["roas"]), + ", ".join(item["flags"]), + ] + for index, item in enumerate(plan.findings[:30], 1) + ], + ) + if len(plan.findings) > 30: + print(f"... oraz {len(plan.findings) - 30} kolejnych elementow w pliku planu") + if plan.campaign_device_rows: + print("\nKampanie i urzadzenia") + print_table( + ["Nr", "Kampania", "Typ", "Urzadzenie", "Koszt", "Udzial", "Konw.", "ROAS", "Flagi"], + [ + [ + str(index), + row["campaign_name"], + row["channel_type"], + row["device"], + format_money_micros(row["cost_micros"], plan.currency_code), + f"{row['cost_share_percent']:.1f}%", + format_number(row["conversions"]), + format_number(row["roas"]), + ", ".join(row["flags"]), + ] + for index, row in enumerate(plan.campaign_device_rows[:30], 1) + ], + ) + if len(plan.campaign_device_rows) > 30: + print(f"... oraz {len(plan.campaign_device_rows) - 30} kolejnych wierszy w pliku planu") + 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_device_performance_plan( + client_config: ClientConfig, + plan: DevicePerformancePlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem urzadzen 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(item["campaign_name"] for item in plan.findings[:10]), + "summary": { + "device_rows": len(plan.campaign_device_rows), + "findings": len(plan.findings), + "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_device_performance( + 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 = DevicePerformancePlan.from_dict(plan_data) + print_device_performance_plan(plan) + apply_device_performance_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia urzadzen...") + plan = build_device_performance_plan(client_config) + print_device_performance_plan(plan) + json_path, md_path = save_device_performance_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(item["campaign_name"] for item in plan.findings[:10]), + "summary": { + "device_rows": len(plan.campaign_device_rows), + "findings": len(plan.findings), + "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 urzadzen.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/feed_merchant_quality_check.py b/src/gads_v2/tasks/feed_merchant_quality_check.py new file mode 100644 index 0000000..528655c --- /dev/null +++ b/src/gads_v2/tasks/feed_merchant_quality_check.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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 +from .product_feed_optimization import ( + fetch_missing_category_products, + fetch_missing_title_products, + fetch_missing_unit_pricing_products, +) + + +TASK_ID = "check_feed_merchant_quality" +TASK_NAME = "Sprawdzenie feedu i Merchant Center" +DEFAULT_LIMIT = 50 + + +SCOPE = [ + { + "area": "Atrybuty feedu", + "check": "Sprawdz braki w danych produktowych widoczne w adsPRO, bez przygotowywania zmian produktow.", + }, + { + "area": "Ryzyka Merchant Center", + "check": "Wypisz kontrole, ktore wymagaja pozniejszej integracji Merchant Center API.", + }, + { + "area": "Routing problemow", + "check": "Rozdziel problemy feedu od zadan optymalizacji tytulow, kategorii Google i unit pricing.", + }, + { + "area": "Przygotowanie do Shopping/PMax", + "check": "Zapisz plan audytu, ktory pozniej moze byc uzyty przez zadania Shopping i PMax.", + }, +] + + +MERCHANT_CENTER_CHECKS = [ + "swiezosc ostatniego przetworzenia feedu", + "liczba produktow active, warning i disapproved", + "procent aktywnych produktow", + "produkty odrzucone wedlug kodow problemow", + "landing page errors", + "broken images", + "agregacja problemow Merchant Center po issue code", +] + + +OUT_OF_SCOPE = [ + "optymalizacja tytulow produktow", + "wybor kategorii Google", + "uzupelnianie unit pricing", + "synchronizacja grup reklam PLA_CL1", + "wdrazanie zmian w adsPRO albo Merchant Center", +] + + +@dataclass +class FeedMerchantQualityPlan: + attribute_checks: list[dict] + merchant_center_checks: list[str] + 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, + "attribute_checks": self.attribute_checks, + "merchant_center_checks": self.merchant_center_checks, + "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) -> "FeedMerchantQualityPlan": + return cls( + attribute_checks=data.get("attribute_checks", []), + merchant_center_checks=data.get("merchant_center_checks", []), + scope=data.get("scope", []), + out_of_scope=data.get("out_of_scope", []), + knowledge_rules=data.get("knowledge_rules", []), + warnings=data.get("warnings", []), + ) + + +def product_label(product: dict[str, Any]) -> str: + return str( + product.get("title") + or product.get("default_name") + or product.get("custom_title") + or product.get("name") + or "" + ).strip() + + +def sample_products(products: list[dict], max_items: int = 8) -> list[dict]: + sample = [] + for product in products[:max_items]: + sample.append( + { + "offer_id": str(product.get("offer_id") or product.get("id") or "").strip(), + "title": product_label(product), + "brand": str(product.get("brand") or "").strip(), + "google_product_category": str( + product.get("google_product_category") or product.get("google_category") or "" + ).strip(), + "custom_label_1": str(product.get("custom_label_1") or "").strip(), + } + ) + return sample + + +def attribute_check(name: str, issue: str, products: list[dict], target_task: str, limit: int) -> dict: + return { + "name": name, + "issue": issue, + "count": len(products), + "limit": limit, + "is_limited": len(products) >= limit, + "target_task": target_task, + "sample": sample_products(products), + } + + +def fetch_attribute_checks(client_config: ClientConfig, limit: int) -> list[dict]: + title_products = fetch_missing_title_products(client_config, limit) + category_products = fetch_missing_category_products(client_config, limit) + unit_pricing_products = fetch_missing_unit_pricing_products(client_config, limit) + return [ + attribute_check( + "Tytuly produktow", + "Produkty bez zoptymalizowanego tytulu", + title_products, + "optimize_product_titles", + limit, + ), + attribute_check( + "Kategorie Google", + "Produkty bez kategorii Google", + category_products, + "optimize_product_categories", + limit, + ), + attribute_check( + "Unit pricing", + "Produkty bez unit pricing", + unit_pricing_products, + "fill_product_unit_pricing", + limit, + ), + ] + + +def build_feed_merchant_quality_plan( + client_config: ClientConfig, + global_rules: dict, +) -> FeedMerchantQualityPlan: + rules = client_config.effective_rules(global_rules, "feed_merchant_quality") + limit = int(rules.get("limit", DEFAULT_LIMIT)) + warnings = [] + + try: + attribute_checks = fetch_attribute_checks(client_config, limit) + except Exception as exc: + attribute_checks = [] + warnings.append(f"Nie udalo sie pobrac danych feedu z adsPRO: {exc}") + + warnings.append( + "Statusy active/warning/disapproved, issue codes, landing page errors i broken images wymagaja pozniejszej integracji Merchant Center API." + ) + warnings.append( + "To zadanie tylko wskazuje problemy feedu. Naprawy tytulow, kategorii Google i unit pricing pozostaja w osobnych zadaniach." + ) + warnings.append( + f"Kontrole atrybutow z adsPRO sa pobierane do limitu {limit}; wartosc z plusem oznacza, ze problemow moze byc wiecej." + ) + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace feedu i Merchant Center bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return FeedMerchantQualityPlan( + attribute_checks=attribute_checks, + merchant_center_checks=MERCHANT_CENTER_CHECKS, + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def count_label(check: dict) -> str: + value = str(check.get("count", 0)) + if check.get("is_limited"): + return f"{value}+" + return value + + +def save_feed_merchant_quality_plan(domain: str, plan: FeedMerchantQualityPlan) -> 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 feedu i Merchant Center", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kontrole atrybutow z adsPRO: {len(plan.attribute_checks)}", + f"- Kontrole Merchant Center do pozniejszej integracji: {len(plan.merchant_center_checks)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.attribute_checks: + lines.extend( + [ + "## Kontrole atrybutow z adsPRO", + "", + "| Obszar | Problem | Liczba | Zadanie naprawcze |", + "| --- | --- | --- | --- |", + ] + ) + for check in plan.attribute_checks: + lines.append( + f"| {md_cell(check['name'])} | {md_cell(check['issue'])} | " + f"{count_label(check)} | {md_cell(check['target_task'])} |" + ) + lines.append("") + lines.extend(["## Kontrole Merchant Center do integracji", ""]) + lines.extend(f"- {item}" for item in plan.merchant_center_checks) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_feed_merchant_quality_plan(plan: FeedMerchantQualityPlan) -> None: + print("\nPlan sprawdzenia feedu i Merchant Center") + print_table( + ["Metryka", "Liczba"], + [ + ["Kontrole atrybutow", str(len(plan.attribute_checks))], + ["Kontrole Merchant Center", str(len(plan.merchant_center_checks))], + ["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.attribute_checks: + print("\nKontrole atrybutow z adsPRO") + print_table( + ["Nr", "Obszar", "Problem", "Liczba", "Zadanie naprawcze"], + [ + [str(index), check["name"], check["issue"], count_label(check), check["target_task"]] + for index, check in enumerate(plan.attribute_checks, 1) + ], + ) + print("\nKontrole Merchant Center do pozniejszej integracji") + print_table( + ["Nr", "Kontrola"], + [[str(index), item] for index, item in enumerate(plan.merchant_center_checks, 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_feed_merchant_quality_plan( + client_config: ClientConfig, + plan: FeedMerchantQualityPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem feedu i nie wdraza zmian w adsPRO ani Merchant Center.") + 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": { + "attribute_checks": len(plan.attribute_checks), + "merchant_center_checks": len(plan.merchant_center_checks), + "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_feed_merchant_quality( + 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 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 = FeedMerchantQualityPlan.from_dict(plan_data) + print_feed_merchant_quality_plan(plan) + apply_feed_merchant_quality_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia feedu i Merchant Center...") + plan = build_feed_merchant_quality_plan(client_config, global_rules) + print_feed_merchant_quality_plan(plan) + json_path, md_path = save_feed_merchant_quality_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": { + "attribute_checks": len(plan.attribute_checks), + "merchant_center_checks": len(plan.merchant_center_checks), + "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 feedu.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/impression_share_check.py b/src/gads_v2/tasks/impression_share_check.py new file mode 100644 index 0000000..c2aba4a --- /dev/null +++ b/src/gads_v2/tasks/impression_share_check.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_impression_share" +TASK_NAME = "Sprawdzenie udzialu w wyswietleniach" + + +SCOPE = [ + { + "area": "Udzial w wyswietleniach", + "check": "Sprawdz search impression share dla kampanii z ostatnich 30 dni.", + }, + { + "area": "Utrata przez budzet", + "check": "Oznacz kampanie z istotna utrata udzialu w wyswietleniach przez budzet.", + }, + { + "area": "Utrata przez ranking", + "check": "Oznacz kampanie z istotna utrata udzialu w wyswietleniach przez ranking.", + }, + { + "area": "Widocznosc top", + "check": "Pokaz top impression share i absolute top impression share jako sygnal konkurencyjnosci.", + }, +] + + +OUT_OF_SCOPE = [ + "zmiany budzetow", + "zmiany stawek i strategii ustalania stawek", + "decyzje o zmianie Docelowego ROAS albo Docelowego CPA", + "analiza zapytan uzytkownikow", + "wdrazanie zmian na koncie Google Ads", +] + + +@dataclass +class ImpressionSharePlan: + campaigns: list[dict] + channel_summary: list[dict] + problem_items: 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, + "channel_summary": self.channel_summary, + "problem_items": self.problem_items, + "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) -> "ImpressionSharePlan": + return cls( + campaigns=data.get("campaigns", []), + channel_summary=data.get("channel_summary", []), + problem_items=data.get("problem_items", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def format_money(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def percent(value: float | int | None) -> float: + if value is None: + return 0.0 + return round(float(value or 0) * 100, 1) + + +def percent_label(value: float | int | None) -> str: + return f"{percent(value):.1f}%" + + +def fetch_currency_code(google_client, customer_id: str) -> str: + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code + FROM customer + """, + ) + if not rows: + return "" + return str(rows[0].customer.currency_code or "") + + +def issue_severity(row: dict) -> str: + if row["search_budget_lost_impression_share"] >= 0.3: + return "wysokie" + if row["search_rank_lost_impression_share"] >= 0.5: + return "wysokie" + if row["search_budget_lost_impression_share"] >= 0.15: + return "srednie" + if row["search_rank_lost_impression_share"] >= 0.3: + return "srednie" + if row["search_impression_share"] and row["search_impression_share"] < 0.2: + return "niskie" + return "ok" + + +def campaign_flags(row: dict) -> list[str]: + flags = [] + if row["search_budget_lost_impression_share"] >= 0.3: + flags.append("duza utrata przez budzet") + elif row["search_budget_lost_impression_share"] >= 0.15: + flags.append("utrata przez budzet do oceny") + if row["search_rank_lost_impression_share"] >= 0.5: + flags.append("duza utrata przez ranking") + elif row["search_rank_lost_impression_share"] >= 0.3: + flags.append("utrata przez ranking do oceny") + if row["search_impression_share"] and row["search_impression_share"] < 0.2: + flags.append("niski udzial w wyswietleniach") + if row["impressions"] == 0: + flags.append("brak wyswietlen 30 dni") + return flags or ["ok"] + + +def fetch_impression_share_campaigns(client_config: ClientConfig) -> tuple[str, list[dict]]: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + currency_code = fetch_currency_code(google_client, customer_id) + rows = run_query( + google_client, + customer_id, + """ + SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value, + metrics.search_impression_share, + metrics.search_budget_lost_impression_share, + metrics.search_rank_lost_impression_share, + metrics.search_top_impression_share, + metrics.search_absolute_top_impression_share + FROM campaign + WHERE campaign.status != 'REMOVED' + AND segments.date DURING LAST_30_DAYS + """, + ) + + campaigns = [] + for row in rows: + metrics = row.metrics + record = { + "campaign_id": str(row.campaign.id), + "campaign_name": row.campaign.name, + "status": enum_name(row.campaign.status), + "channel_type": enum_name(row.campaign.advertising_channel_type), + "impressions": int(metrics.impressions or 0), + "clicks": int(metrics.clicks or 0), + "cost_micros": int(metrics.cost_micros or 0), + "cost": format_money(metrics.cost_micros, currency_code), + "conversions": round(float(metrics.conversions or 0), 2), + "conversion_value": round(float(metrics.conversions_value or 0), 2), + "search_impression_share": float(metrics.search_impression_share or 0), + "search_budget_lost_impression_share": float(metrics.search_budget_lost_impression_share or 0), + "search_rank_lost_impression_share": float(metrics.search_rank_lost_impression_share or 0), + "search_top_impression_share": float(metrics.search_top_impression_share or 0), + "search_absolute_top_impression_share": float(metrics.search_absolute_top_impression_share or 0), + } + record["severity"] = issue_severity(record) + record["flags"] = campaign_flags(record) + campaigns.append(record) + + severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9} + campaigns.sort( + key=lambda row: ( + severity_order.get(row["severity"], 9), + -row["search_budget_lost_impression_share"], + -row["search_rank_lost_impression_share"], + row["campaign_name"], + ) + ) + return currency_code, campaigns + + +def build_channel_summary(campaigns: list[dict]) -> list[dict]: + counter = Counter(row["channel_type"] for row in campaigns) + return [{"channel_type": key, "count": value} for key, value in counter.most_common()] + + +def build_problem_items(campaigns: list[dict]) -> list[dict]: + items = [] + for campaign in campaigns: + if campaign["flags"] == ["ok"]: + continue + recommendation = "sprawdz osobno budzet, ranking, strategie stawek i jakosc ruchu przed decyzja o zmianach" + items.append( + { + "severity": campaign["severity"], + "campaign_name": campaign["campaign_name"], + "channel_type": campaign["channel_type"], + "impression_share": percent_label(campaign["search_impression_share"]), + "lost_budget": percent_label(campaign["search_budget_lost_impression_share"]), + "lost_rank": percent_label(campaign["search_rank_lost_impression_share"]), + "top_share": percent_label(campaign["search_top_impression_share"]), + "flags": campaign["flags"], + "recommendation": recommendation, + } + ) + return items + + +def build_impression_share_plan(client_config: ClientConfig) -> ImpressionSharePlan: + warnings = [] + try: + _currency_code, campaigns = fetch_impression_share_campaigns(client_config) + except Exception as exc: + campaigns = [] + warnings.append(f"Nie udalo sie pobrac udzialu w wyswietleniach z Google Ads API: {exc}") + + if not campaigns: + warnings.append("Nie znaleziono kampanii z danymi udzialu w wyswietleniach albo nie udalo sie ich pobrac.") + + warnings.append( + "To zadanie tylko wskazuje utrate udzialu w wyswietleniach. Decyzje o budzetach i stawkach pozostaja w osobnych zadaniach." + ) + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace aukcji i udzialu w wyswietleniach bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return ImpressionSharePlan( + campaigns=campaigns, + channel_summary=build_channel_summary(campaigns), + problem_items=build_problem_items(campaigns), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_impression_share_plan(domain: str, plan: ImpressionSharePlan) -> 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 udzialu w wyswietleniach", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kampanie: {len(plan.campaigns)}", + f"- Elementy do oceny: {len(plan.problem_items)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.channel_summary: + lines.extend(["## Podsumowanie po typach kampanii", "", "| Typ | Liczba |", "| --- | --- |"]) + for row in plan.channel_summary: + lines.append(f"| {row['channel_type']} | {row['count']} |") + lines.append("") + if plan.problem_items: + lines.extend( + [ + "## Elementy do oceny", + "", + "| Waznosc | Kampania | Typ | Udzial | Utrata budzet | Utrata ranking | Top | Flagi | Rekomendacja |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for item in plan.problem_items: + lines.append( + f"| {item['severity']} | {md_cell(item['campaign_name'])} | {item['channel_type']} | " + f"{item['impression_share']} | {item['lost_budget']} | {item['lost_rank']} | {item['top_share']} | " + f"{md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |" + ) + lines.append("") + if plan.campaigns: + lines.extend( + [ + "## Kampanie", + "", + "| Kampania | Typ | Wyswietlenia | Koszt | Udzial | Utrata budzet | Utrata ranking | Top | Abs. top | Flagi |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for campaign in plan.campaigns: + lines.append( + f"| {md_cell(campaign['campaign_name'])} | {campaign['channel_type']} | {campaign['impressions']} | " + f"{campaign['cost']} | {percent_label(campaign['search_impression_share'])} | " + f"{percent_label(campaign['search_budget_lost_impression_share'])} | " + f"{percent_label(campaign['search_rank_lost_impression_share'])} | " + f"{percent_label(campaign['search_top_impression_share'])} | " + f"{percent_label(campaign['search_absolute_top_impression_share'])} | " + f"{md_cell(', '.join(campaign['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_impression_share_plan(plan: ImpressionSharePlan) -> None: + print("\nPlan sprawdzenia udzialu w wyswietleniach") + print_table( + ["Metryka", "Liczba"], + [ + ["Kampanie", str(len(plan.campaigns))], + ["Elementy do oceny", str(len(plan.problem_items))], + ["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.channel_summary: + print("\nPodsumowanie po typach kampanii") + print_table(["Typ", "Liczba"], [[row["channel_type"], str(row["count"])] for row in plan.channel_summary]) + if plan.problem_items: + print("\nElementy do oceny") + print_table( + ["Nr", "Waznosc", "Kampania", "Typ", "Udzial", "Utrata budzet", "Utrata ranking", "Flagi"], + [ + [ + str(index), + item["severity"], + item["campaign_name"], + item["channel_type"], + item["impression_share"], + item["lost_budget"], + item["lost_rank"], + ", ".join(item["flags"]), + ] + for index, item in enumerate(plan.problem_items[:30], 1) + ], + ) + if len(plan.problem_items) > 30: + print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu") + if plan.campaigns: + print("\nKampanie") + print_table( + ["Nr", "Kampania", "Typ", "Wysw.", "Udzial", "Budzet lost", "Rank lost", "Top", "Flagi"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["channel_type"], + str(campaign["impressions"]), + percent_label(campaign["search_impression_share"]), + percent_label(campaign["search_budget_lost_impression_share"]), + percent_label(campaign["search_rank_lost_impression_share"]), + percent_label(campaign["search_top_impression_share"]), + ", ".join(campaign["flags"]), + ] + for index, campaign in enumerate(plan.campaigns[:30], 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_impression_share_plan( + client_config: ClientConfig, + plan: ImpressionSharePlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem aukcji 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(item["campaign_name"] for item in plan.problem_items[:10]), + "summary": { + "campaigns": len(plan.campaigns), + "problem_items": len(plan.problem_items), + "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_impression_share( + 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 = ImpressionSharePlan.from_dict(plan_data) + print_impression_share_plan(plan) + apply_impression_share_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia udzialu w wyswietleniach...") + plan = build_impression_share_plan(client_config) + print_impression_share_plan(plan) + json_path, md_path = save_impression_share_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(item["campaign_name"] for item in plan.problem_items[:10]), + "summary": { + "campaigns": len(plan.campaigns), + "problem_items": len(plan.problem_items), + "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 udzialu w wyswietleniach.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/keyword_status_check.py b/src/gads_v2/tasks/keyword_status_check.py new file mode 100644 index 0000000..2ec6116 --- /dev/null +++ b/src/gads_v2/tasks/keyword_status_check.py @@ -0,0 +1,576 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_keyword_statuses" +TASK_NAME = "Sprawdzenie statusow slow kluczowych" + + +SCOPE = [ + { + "area": "Status slow kluczowych", + "check": "Pokaz status kampanii, grupy reklam i slowa kluczowego dla aktywnych kampanii Search.", + }, + { + "area": "Polityki", + "check": "Oznacz slowa z problemami polityk, jezeli Google Ads API udostepnia status zatwierdzenia.", + }, + { + "area": "Jakosc techniczna", + "check": "Pokaz quality score i oznacz bardzo niskie wyniki jako techniczny sygnal do sprawdzenia.", + }, + { + "area": "Oddzielenie od zapytan", + "check": "Nie analizuj tutaj search terms, wykluczen ani decyzji o dodawaniu nowych fraz.", + }, +] + + +OUT_OF_SCOPE = [ + "analiza zapytan uzytkownikow", + "dodawanie, usuwanie albo wykluczanie slow kluczowych", + "budzety i wykorzystanie budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "wdrazanie zmian slow kluczowych na koncie Google Ads", +] + + +PROBLEM_APPROVAL_STATUSES = { + "DISAPPROVED", + "APPROVED_LIMITED", + "AREA_OF_INTEREST_ONLY", + "UNDER_REVIEW", + "UNKNOWN", + "UNSPECIFIED", +} + + +PROBLEM_STATUS_FIELDS = { + "PAUSED", + "REMOVED", + "UNKNOWN", + "UNSPECIFIED", +} + + +@dataclass +class KeywordStatusPlan: + keywords: list[dict] + status_summary: list[dict] + approval_summary: list[dict] + quality_summary: list[dict] + problem_items: 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, + "keywords": self.keywords, + "status_summary": self.status_summary, + "approval_summary": self.approval_summary, + "quality_summary": self.quality_summary, + "problem_items": self.problem_items, + "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) -> "KeywordStatusPlan": + return cls( + keywords=data.get("keywords", []), + status_summary=data.get("status_summary", []), + approval_summary=data.get("approval_summary", []), + quality_summary=data.get("quality_summary", []), + problem_items=data.get("problem_items", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def safe_quality_score(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def quality_bucket(score: int) -> str: + if score <= 0: + return "brak danych" + if score <= 3: + return "niski" + if score <= 6: + return "sredni" + return "dobry" + + +def keyword_severity(row: dict) -> str: + if row["approval_status"] == "DISAPPROVED": + return "wysokie" + if row["keyword_status"] in PROBLEM_STATUS_FIELDS or row["ad_group_status"] in PROBLEM_STATUS_FIELDS: + return "srednie" + if row["approval_status"] in {"APPROVED_LIMITED", "AREA_OF_INTEREST_ONLY"}: + return "srednie" + if row["quality_score"] and row["quality_score"] <= 3: + return "srednie" + if row["approval_status"] in {"UNDER_REVIEW", "UNKNOWN", "UNSPECIFIED"}: + return "niskie" + return "ok" + + +def keyword_flags(row: dict) -> list[str]: + flags = [] + if row["campaign_status"] in PROBLEM_STATUS_FIELDS: + flags.append(f"kampania: {row['campaign_status']}") + if row["ad_group_status"] in PROBLEM_STATUS_FIELDS: + flags.append(f"grupa reklam: {row['ad_group_status']}") + if row["keyword_status"] in PROBLEM_STATUS_FIELDS: + flags.append(f"slowo: {row['keyword_status']}") + if row["approval_status"] in PROBLEM_APPROVAL_STATUSES: + flags.append(f"polityka: {row['approval_status']}") + if row["quality_score"] and row["quality_score"] <= 3: + flags.append(f"niski quality score: {row['quality_score']}") + return flags or ["ok"] + + +def fetch_keywords(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, + ad_group.id, + ad_group.name, + ad_group.status, + ad_group_criterion.criterion_id, + ad_group_criterion.status, + ad_group_criterion.keyword.text, + ad_group_criterion.keyword.match_type, + ad_group_criterion.quality_info.quality_score + FROM keyword_view + WHERE campaign.status != 'REMOVED' + AND ad_group.status != 'REMOVED' + AND ad_group_criterion.status != 'REMOVED' + AND ad_group_criterion.type = 'KEYWORD' + """, + ) + + keywords = [] + for row in rows: + criterion = row.ad_group_criterion + keyword = criterion.keyword + quality_score = safe_quality_score(criterion.quality_info.quality_score) + record = { + "campaign_id": str(row.campaign.id), + "campaign_name": row.campaign.name, + "campaign_status": enum_name(row.campaign.status), + "channel_type": enum_name(row.campaign.advertising_channel_type), + "ad_group_id": str(row.ad_group.id), + "ad_group_name": row.ad_group.name, + "ad_group_status": enum_name(row.ad_group.status), + "criterion_id": str(criterion.criterion_id), + "keyword_text": keyword.text, + "match_type": enum_name(keyword.match_type), + "keyword_status": enum_name(criterion.status), + "approval_status": "niedostepne w API", + "quality_score": quality_score, + "quality_bucket": quality_bucket(quality_score), + } + record["severity"] = keyword_severity(record) + record["flags"] = keyword_flags(record) + keywords.append(record) + + severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9} + keywords.sort( + key=lambda row: ( + severity_order.get(row["severity"], 9), + row["campaign_name"], + row["ad_group_name"], + row["keyword_text"], + ) + ) + return keywords + + +def build_counter_summary(rows: list[dict], field: str, label: str) -> list[dict]: + counter = Counter(row.get(field, "") or "(brak)" for row in rows) + return [{label: key, "count": value} for key, value in counter.most_common()] + + +def build_problem_items(keywords: list[dict]) -> list[dict]: + problem_items = [] + for keyword in keywords: + if keyword["flags"] == ["ok"]: + continue + problem_items.append( + { + "severity": keyword["severity"], + "campaign_name": keyword["campaign_name"], + "ad_group_name": keyword["ad_group_name"], + "keyword_text": keyword["keyword_text"], + "match_type": keyword["match_type"], + "keyword_status": keyword["keyword_status"], + "approval_status": keyword["approval_status"], + "quality_score": keyword["quality_score"], + "flags": keyword["flags"], + "recommendation": "sprawdz status i przyczyne ograniczenia slowa kluczowego w Google Ads", + } + ) + severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9} + problem_items.sort( + key=lambda row: ( + severity_order.get(row["severity"], 9), + row["campaign_name"], + row["ad_group_name"], + row["keyword_text"], + ) + ) + return problem_items + + +def build_keyword_status_plan(client_config: ClientConfig) -> KeywordStatusPlan: + warnings = [] + try: + keywords = fetch_keywords(client_config) + except Exception as exc: + keywords = [] + warnings.append(f"Nie udalo sie pobrac slow kluczowych z Google Ads API: {exc}") + + if not keywords: + warnings.append("Nie znaleziono slow kluczowych albo nie udalo sie ich pobrac.") + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace statusow slow kluczowych bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return KeywordStatusPlan( + keywords=keywords, + status_summary=build_counter_summary(keywords, "keyword_status", "status"), + approval_summary=build_counter_summary(keywords, "approval_status", "approval_status"), + quality_summary=build_counter_summary(keywords, "quality_bucket", "quality_bucket"), + problem_items=build_problem_items(keywords), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_keyword_status_plan(domain: str, plan: KeywordStatusPlan) -> 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 statusow slow kluczowych", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Slowa kluczowe: {len(plan.keywords)}", + f"- Elementy do oceny: {len(plan.problem_items)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.problem_items: + lines.extend( + [ + "## Elementy do oceny", + "", + "| Waznosc | Kampania | Grupa reklam | Slowo | Dopasowanie | Status | Polityka | QS | Flagi | Rekomendacja |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for item in plan.problem_items: + lines.append( + f"| {item['severity']} | {md_cell(item['campaign_name'])} | {md_cell(item['ad_group_name'])} | " + f"{md_cell(item['keyword_text'])} | {item['match_type']} | {item['keyword_status']} | " + f"{item['approval_status']} | {item['quality_score']} | {md_cell(', '.join(item['flags']))} | " + f"{md_cell(item['recommendation'])} |" + ) + lines.append("") + if plan.status_summary: + lines.extend(["## Statusy slow kluczowych", "", "| Status | Liczba |", "| --- | --- |"]) + for row in plan.status_summary: + lines.append(f"| {row['status']} | {row['count']} |") + lines.append("") + if plan.approval_summary: + lines.extend(["## Statusy zatwierdzenia", "", "| Status polityki | Liczba |", "| --- | --- |"]) + for row in plan.approval_summary: + lines.append(f"| {row['approval_status']} | {row['count']} |") + lines.append("") + if plan.quality_summary: + lines.extend(["## Quality score", "", "| Koszyk | Liczba |", "| --- | --- |"]) + for row in plan.quality_summary: + lines.append(f"| {row['quality_bucket']} | {row['count']} |") + lines.append("") + if plan.keywords: + lines.extend( + [ + "## Slowa kluczowe", + "", + "| Kampania | Grupa reklam | Slowo | Dopasowanie | Status | Polityka | QS | Flagi |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for keyword in plan.keywords: + lines.append( + f"| {md_cell(keyword['campaign_name'])} | {md_cell(keyword['ad_group_name'])} | " + f"{md_cell(keyword['keyword_text'])} | {keyword['match_type']} | {keyword['keyword_status']} | " + f"{keyword['approval_status']} | {keyword['quality_score']} | {md_cell(', '.join(keyword['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_keyword_status_plan(plan: KeywordStatusPlan) -> None: + print("\nPlan sprawdzenia statusow slow kluczowych") + print_table( + ["Metryka", "Liczba"], + [ + ["Slowa kluczowe", str(len(plan.keywords))], + ["Elementy do oceny", str(len(plan.problem_items))], + ["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.problem_items: + print("\nElementy do oceny") + print_table( + ["Nr", "Waznosc", "Kampania", "Grupa reklam", "Slowo", "Status", "Polityka", "QS", "Flagi"], + [ + [ + str(index), + item["severity"], + item["campaign_name"], + item["ad_group_name"], + item["keyword_text"], + item["keyword_status"], + item["approval_status"], + str(item["quality_score"]), + ", ".join(item["flags"]), + ] + for index, item in enumerate(plan.problem_items[:30], 1) + ], + ) + if len(plan.problem_items) > 30: + print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu") + if plan.status_summary: + print("\nStatusy slow kluczowych") + print_table(["Status", "Liczba"], [[row["status"], str(row["count"])] for row in plan.status_summary]) + if plan.approval_summary: + print("\nStatusy zatwierdzenia") + print_table( + ["Status polityki", "Liczba"], + [[row["approval_status"], str(row["count"])] for row in plan.approval_summary], + ) + if plan.quality_summary: + print("\nQuality score") + print_table(["Koszyk", "Liczba"], [[row["quality_bucket"], str(row["count"])] for row in plan.quality_summary]) + 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_keyword_status_plan( + client_config: ClientConfig, + plan: KeywordStatusPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem statusow slow kluczowych 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(item["campaign_name"] for item in plan.problem_items[:10]), + "summary": { + "keywords": len(plan.keywords), + "problem_items": len(plan.problem_items), + "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_keyword_statuses( + 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 = KeywordStatusPlan.from_dict(plan_data) + print_keyword_status_plan(plan) + apply_keyword_status_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia statusow slow kluczowych...") + plan = build_keyword_status_plan(client_config) + print_keyword_status_plan(plan) + json_path, md_path = save_keyword_status_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(item["campaign_name"] for item in plan.problem_items[:10]), + "summary": { + "keywords": len(plan.keywords), + "problem_items": len(plan.problem_items), + "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 statusow slow kluczowych.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/pla_cl1_sync.py b/src/gads_v2/tasks/pla_cl1_sync.py new file mode 100644 index 0000000..5d460e0 --- /dev/null +++ b/src/gads_v2/tasks/pla_cl1_sync.py @@ -0,0 +1,1044 @@ +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 normalize_variant(value: str) -> str: + return (value or "").strip().lower() + + +def parse_campaign_variant(campaign_name: str) -> str: + """Wariant kampanii z czesci po znaku '|' w nazwie, np. 'catch_all'. + + Kampania bez '|' ma wariant pusty i odpowiada produktom z pustym CL4. + """ + match = re.search(r"\]\s*(.+)$", campaign_name) + raw = match.group(1).strip() if match else campaign_name + if "|" in raw: + return normalize_variant(raw.split("|", 1)[1]) + return "" + + +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), + "variant": parse_campaign_variant(row.campaign.name), + } + for row in campaign_rows + ] + if not campaigns: + return SyncPlan([], 0, 0, [], [], [], [], ["Nie znaleziono kampanii [PLA_CL1]."], []) + + # Kazda kampania celuje w pare (CL1, CL4): CL1 z czesci przed '|', CL4 z czesci po '|'. + campaign_by_target = {} + variant_warnings = [] + for campaign in campaigns: + for label in campaign["allowed"]: + key = (label, campaign["variant"]) + if key in campaign_by_target: + variant_warnings.append( + f"Wiele kampanii dla tej samej pary CL1/CL4: " + f"{label} | {campaign['variant'] or '(bez wariantu)'}." + ) + campaign_by_target[key] = campaign + + def resolve_target_campaign(product: dict): + """Zwraca (kampania, czy_uzyto_fallbacku) dla produktu wg jego CL1 i CL4.""" + label = (product.get("custom_label_1") or "").strip() + variant = normalize_variant(product.get("custom_label_4")) + if not label: + return None, False + exact = campaign_by_target.get((label, variant)) + if exact: + return exact, False + if variant and (label, "") in campaign_by_target: + return campaign_by_target[(label, "")], True + return None, False + + by_offer_id = {} + by_title_norm = defaultdict(list) + target_by_offer = {} + products_by_campaign = defaultdict(list) + fallback_offers = [] + orphan_offers = [] + 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 not (label and title and offer_id): + continue + target, used_fallback = resolve_target_campaign(product) + if target is None: + orphan_offers.append(offer_id) + continue + target_by_offer[offer_id] = target + products_by_campaign[target["id"]].append(product) + if used_fallback: + fallback_offers.append(offer_id) + + 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), + "variant": parse_campaign_variant(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 + target = target_by_offer.get((product.get("offer_id") or "").strip()) + if target is None: + # Produkt istnieje w adsPRO, ale nie ma dla niego pasujacej kampanii (CL1/CL4). + if group["ad_group_status"] == "ENABLED": + active_groups_without_match.append(group) + continue + if group["campaign_id"] != target["id"]: + # Zla kampania albo zly wariant (np. CL4 puste, a grupa w kampanii | catch_all). + 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 + + # Najpierw szukamy grupy reklam po produkcie (offer_id), a dopiero + # potem po nazwie. Dzieki temu istniejaca grupa z tym produktem, ale + # pod inna nazwa, zostanie wlaczona i przemianowana, a nie zduplikowana. + matched_via = None + existing_candidates = existing_groups_by_campaign_offer.get((campaign["id"], offer_id), []) + if existing_candidates: + matched_via = "offer_id" + else: + existing_candidates = existing_groups_by_campaign_name.get((campaign["id"], normalize_text(title)), []) + if existing_candidates: + matched_via = "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) + if matched_via == "offer_id" and paused_candidate["ad_group_name"] != title: + rename_plan.append( + { + "ad_group_id": paused_candidate["ad_group_id"], + "campaign_id": paused_candidate["campaign_id"], + "campaign_name": paused_candidate["campaign_name"], + "old_name": paused_candidate["ad_group_name"], + "new_name": title, + } + ) + 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) + + wrong_variant_count = 0 + wrong_segment_count = 0 + for group, product in wrong_groups: + offer_id = (product.get("offer_id") or "").strip() + target = target_by_offer.get(offer_id) + label = (product.get("custom_label_1") or "").strip() + if target and label in group["allowed"] and group["variant"] != target["variant"]: + wrong_variant_count += 1 + else: + wrong_segment_count += 1 + 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 product in products_by_campaign.get(campaign["id"], []): + 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] + + # Google Ads wymaga unikalnych nazw grup reklam w obrebie kampanii. + # Odrzucamy zmiany nazwy, ktore koliduja z inna grupa w tej samej kampanii + # (np. kilka produktow adsPRO ma identyczny tytul). + rename_by_id = {row["ad_group_id"]: row["new_name"] for row in rename_plan} + final_names = defaultdict(Counter) + for group in all_groups: + name = rename_by_id.get(group["ad_group_id"], group["ad_group_name"]) + final_names[group["campaign_id"]][name] += 1 + safe_rename_plan = [] + rename_conflicts = 0 + for row in rename_plan: + if final_names[row["campaign_id"]][row["new_name"]] > 1: + rename_conflicts += 1 + continue + safe_rename_plan.append(row) + rename_plan = safe_rename_plan + + warnings = [] + if groups_without_match: + warnings.append(f"Grupy reklam bez dopasowania w adsPRO: {len(groups_without_match)}.") + warnings.extend(sorted(set(variant_warnings))) + if fallback_offers: + warnings.append( + f"Produkty z CL4, ale bez kampanii-wariantu, przypisane do kampanii bazowej: " + f"{len(fallback_offers)}." + ) + if orphan_offers: + warnings.append( + f"Produkty bez pasujacej kampanii PLA_CL1 (CL1/CL4): {len(orphan_offers)}." + ) + if wrong_variant_count: + warnings.append( + f"Grupy reklam w zlym wariancie kampanii (CL4 nie pasuje): {wrong_variant_count}." + ) + if wrong_segment_count: + warnings.append( + f"Grupy reklam w zlej kampanii (CL1 nie pasuje): {wrong_segment_count}." + ) + if rename_conflicts: + warnings.append( + f"Zmiany nazwy pominiete z powodu kolizji nazw w kampanii " + f"(zduplikowane tytuly w adsPRO): {rename_conflicts}." + ) + + 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: + request = client.get_type("MutateAdGroupsRequest") + request.customer_id = customer_id + request.operations = operations + request.partial_failure = True + response = service.mutate_ad_groups(request=request) + changed += sum(1 for result in response.results if result.resource_name) + 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: + request = client.get_type("MutateAdGroupsRequest") + request.customer_id = customer_id + request.operations = operations + request.partial_failure = True + response = service.mutate_ad_groups(request=request) + changed += sum(1 for result in response.results if result.resource_name) + 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: + request = client.get_type("MutateAdGroupsRequest") + request.customer_id = customer_id + request.operations = operations + request.partial_failure = True + response = service.mutate_ad_groups(request=request) + changed += sum(1 for result in response.results if result.resource_name) + 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) diff --git a/src/gads_v2/tasks/pla_settings_check.py b/src/gads_v2/tasks/pla_settings_check.py new file mode 100644 index 0000000..52a3184 --- /dev/null +++ b/src/gads_v2/tasks/pla_settings_check.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +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 + + +@dataclass +class SettingsPlan: + campaigns: list[dict] + changes: list[dict] + skipped_rules: list[str] + warnings: list[str] + + def to_dict(self) -> dict: + return { + "task": "check_pla_settings", + "campaigns": self.campaigns, + "changes": self.changes, + "skipped_rules": self.skipped_rules, + "warnings": self.warnings, + } + + @classmethod + def from_dict(cls, data: dict) -> "SettingsPlan": + return cls( + campaigns=data.get("campaigns", []), + changes=data.get("changes", []), + skipped_rules=data.get("skipped_rules", []), + warnings=data.get("warnings", []), + ) + + +def enum_name(value) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def priority_name(value) -> str: + raw = enum_name(value) + return {"0": "LOW", "1": "MEDIUM", "2": "HIGH"}.get(raw, raw) + + +def human_geo(value: str) -> str: + return { + "PRESENCE": "Obecność", + "PRESENCE_OR_INTEREST": "Obecność lub zainteresowanie", + "SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem", + }.get(value, value) + + +def human_priority(value: str) -> str: + return { + "LOW": "Niski", + "MEDIUM": "Średni", + "HIGH": "Wysoki", + }.get(value, value) + + +def build_settings_plan(client_config: ClientConfig, global_rules: dict) -> SettingsPlan: + rules = client_config.effective_rules(global_rules, "pla_settings") + require_presence_only = bool(rules.get("require_presence_only", True)) + require_high_priority = bool(rules.get("require_high_priority", True)) + + skipped_rules = [] + if not require_presence_only: + skipped_rules.append("Regula lokalizacji Obecnosc jest wylaczona dla tego klienta.") + if not require_high_priority: + skipped_rules.append("Regula priorytetu wysokiego jest wylaczona dla tego klienta.") + + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + rows = run_query( + google_client, + customer_id, + """ + SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + campaign.geo_target_type_setting.positive_geo_target_type, + campaign.shopping_setting.campaign_priority + FROM campaign + WHERE campaign.advertising_channel_type = 'SHOPPING' + AND campaign.status = 'ENABLED' + """, + ) + + campaigns = [] + changes = [] + for row in rows: + campaign = row.campaign + positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type) + priority = priority_name(campaign.shopping_setting.campaign_priority) + record = { + "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), + "campaign_priority": priority, + "campaign_priority_label": human_priority(priority), + } + campaigns.append(record) + + if require_presence_only and positive_geo != "PRESENCE": + changes.append( + { + "campaign_id": str(campaign.id), + "campaign_name": campaign.name, + "setting": "lokalizacje", + "current_value": positive_geo, + "target_value": "PRESENCE", + "current_label": human_geo(positive_geo), + "target_label": human_geo("PRESENCE"), + "description": "Ustaw lokalizacje na Obecnosc.", + } + ) + if require_high_priority and priority != "HIGH": + changes.append( + { + "campaign_id": str(campaign.id), + "campaign_name": campaign.name, + "setting": "priorytet kampanii", + "current_value": priority, + "target_value": "HIGH", + "current_label": human_priority(priority), + "target_label": human_priority("HIGH"), + "description": "Ustaw priorytet kampanii na wysoki.", + } + ) + + warnings = [] + if not campaigns: + warnings.append("Nie znaleziono kampanii PLA.") + return SettingsPlan(campaigns=campaigns, changes=changes, skipped_rules=skipped_rules, warnings=warnings) + + +def save_settings_plan(domain: str, plan: SettingsPlan) -> 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')}_check_pla_settings" + 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 ustawien kampanii PLA", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kampanie PLA: {len(plan.campaigns)}", + f"- Korekty do wdrozenia: {len(plan.changes)}", + "", + ] + if plan.skipped_rules: + lines.extend(["## Wylaczone reguly", ""]) + lines.extend(f"- {item}" for item in plan.skipped_rules) + lines.append("") + if plan.warnings: + lines.extend(["## Uwagi", ""]) + lines.extend(f"- {item}" for item in plan.warnings) + lines.append("") + lines.extend(["## Kampanie PLA", "", "| Kampania | Status | Lokalizacje | Priorytet |", "| --- | --- | --- | --- |"]) + for row in plan.campaigns: + lines.append( + f"| {row['campaign_name']} | {row['status']} | " + f"{row.get('positive_geo_target_type_label', row['positive_geo_target_type'])} | " + f"{row.get('campaign_priority_label', row['campaign_priority'])} |" + ) + lines.append("") + if plan.changes: + lines.extend(["## Planowane korekty", "", "| Kampania | Ustawienie | Obecnie | Docelowo |", "| --- | --- | --- | --- |"]) + for row in plan.changes: + lines.append( + f"| {row['campaign_name']} | {row['setting']} | " + f"{row.get('current_label', row['current_value'])} | " + f"{row.get('target_label', row['target_value'])} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_settings_plan(plan: SettingsPlan) -> None: + print("\nPlan sprawdzenia ustawien kampanii PLA") + print(f"Kampanie PLA: {len(plan.campaigns)}") + print(f"Korekty do wdrozenia: {len(plan.changes)}") + for item in plan.skipped_rules: + print(f"Pominieto: {item}") + for item in plan.warnings: + print(f"Uwaga: {item}") + for change in plan.changes[:30]: + print( + f" {change['campaign_name']} | {change['setting']} | " + f"{change.get('current_label', change['current_value'])} -> " + f"{change.get('target_label', change['target_value'])}" + ) + if len(plan.changes) > 30: + print(f" ... oraz {len(plan.changes) - 30} kolejnych korekt") + + +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_settings_plan(client_config: ClientConfig, plan: SettingsPlan, show_navigation: bool = True) -> None: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + service = google_client.get_service("CampaignService") + + changes_by_campaign: dict[str, dict] = {} + for change in plan.changes: + changes_by_campaign.setdefault( + change["campaign_id"], + {"campaign_name": change["campaign_name"], "settings": set()}, + ) + changes_by_campaign[change["campaign_id"]]["settings"].add(change["setting"]) + + operations = [] + for campaign_id, row in changes_by_campaign.items(): + op = google_client.get_type("CampaignOperation") + campaign = op.update + campaign.resource_name = service.campaign_path(customer_id, campaign_id) + paths = [] + if "lokalizacje" in row["settings"]: + campaign.geo_target_type_setting.positive_geo_target_type = ( + google_client.enums.PositiveGeoTargetTypeEnum.PRESENCE + ) + paths.append("geo_target_type_setting.positive_geo_target_type") + if "priorytet kampanii" in row["settings"]: + campaign.shopping_setting.campaign_priority = 2 + paths.append("shopping_setting.campaign_priority") + op.update_mask = field_mask_pb2.FieldMask(paths=paths) + operations.append(op) + + changed = 0 + if operations: + response = service.mutate_campaigns(customer_id=customer_id, operations=operations) + changed = len(response.results) + + print("\nWynik wdrozenia zmian") + print(f"Zmieniono kampanii: {changed}") + print(f"Korekty ustawien: {len(plan.changes)}") + + rows = [ + { + "klient": client_config.domain, + "kampania": change["campaign_name"], + "czynnosc": change["description"], + "grupa reklam": "", + "produkt": f"{change.get('current_label', change['current_value'])} -> {change.get('target_label', change['target_value'])}", + } + for change in plan.changes + ] + changes_path = append_change_markdown(client_config.domain, "Sprawdzenie ustawien kampanii PLA", rows) + history_path = append_history( + client_config.domain, + { + "task": "Sprawdzenie ustawien", + "status": "wdrozono zmiany", + "campaign": ", ".join(sorted({change["campaign_name"] for change in plan.changes})[:10]), + "summary": {"campaigns_changed": changed, "settings_changes": len(plan.changes)}, + }, + ) + print(f"Historia JSONL: {history_path}") + print(f"Historia Markdown: {changes_path}") + if show_navigation: + print_next_navigation(client_config.domain) + + +def run_check_pla_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: + 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')}, a wybrano {client_config.domain}.") + if show_navigation: + print_next_navigation(client_config.domain) + return + plan = SettingsPlan.from_dict(plan_data) + print_settings_plan(plan) + apply_settings_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Sprawdzam ustawienia kampanii PLA...") + plan = build_settings_plan(client_config, global_rules) + print_settings_plan(plan) + json_path, md_path = save_settings_plan(client_config.domain, plan) + print(f"\nPlan JSON: {json_path}") + print(f"Plan Markdown: {md_path}") + + append_history( + client_config.domain, + { + "task": "Sprawdzenie ustawien", + "status": "plan przygotowany", + "campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]), + "summary": {"campaigns": len(plan.campaigns), "changes": len(plan.changes)}, + }, + ) + + if plan_only: + print("\nTryb plan-only: zmiany nie zostaly wdrozone.") + if show_navigation: + print_next_navigation(client_config.domain) + return + + if not plan.changes: + print("\nBrak zmian do wdrozenia.") + append_change_markdown(client_config.domain, "Sprawdzenie ustawien kampanii PLA", []) + 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": "Sprawdzenie ustawien", + "status": "odrzucono wdrozenie", + "campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]), + }, + ) + if show_navigation: + print_next_navigation(client_config.domain) + return + + apply_settings_plan(client_config, plan, show_navigation=show_navigation) diff --git a/src/gads_v2/tasks/pmax_structure_check.py b/src/gads_v2/tasks/pmax_structure_check.py new file mode 100644 index 0000000..8cc8ad7 --- /dev/null +++ b/src/gads_v2/tasks/pmax_structure_check.py @@ -0,0 +1,543 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_pmax_structure" +TASK_NAME = "Sprawdzenie struktury PMax" + + +SCOPE = [ + { + "area": "Kampanie PMax", + "check": "Pokaz aktywne i wstrzymane kampanie Performance Max oraz ich podstawowe wyniki z ostatnich 30 dni.", + }, + { + "area": "Asset groups", + "check": "Policz asset groups w kazdej kampanii i wskaz kampanie wymagajace recznej oceny struktury.", + }, + { + "area": "Feed produktowy", + "check": "Oznacz, ze ocena feedu produktowego jest polaczona z osobnym zadaniem Feed i Merchant Center.", + }, + { + "area": "Brand / non-brand", + "check": "Wskaz kampanie, ktore po nazwie moga wymagac recznej oceny podzialu brand/non-brand.", + }, + { + "area": "Kanibalizacja", + "check": "Wypisz ryzyko kanibalizacji Search, Shopping i remarketingu jako punkt do recznej oceny.", + }, +] + + +OUT_OF_SCOPE = [ + "budzety i pacing budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "zapytania uzytkownikow oraz wykluczenia", + "reklamy RSA i zasoby Search", + "wdrazanie zmian w kampaniach Performance Max", +] + + +@dataclass +class PmaxStructurePlan: + currency_code: str + campaigns: list[dict] + asset_groups: 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, + "currency_code": self.currency_code, + "campaigns": self.campaigns, + "asset_groups": self.asset_groups, + "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) -> "PmaxStructurePlan": + return cls( + currency_code=data.get("currency_code", ""), + campaigns=data.get("campaigns", []), + asset_groups=data.get("asset_groups", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def safe_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def safe_float(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def format_money(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def format_decimal(value: int | float) -> str: + return f"{float(value or 0):.2f}" + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def pmax_risks(campaign_name: str, asset_group_count: int, conversions_30d: float) -> list[str]: + name = campaign_name.casefold() + risks = [] + if asset_group_count == 0: + risks.append("brak asset groups") + if asset_group_count == 1: + risks.append("jedna asset group") + if any(token in name for token in ["brand", "branded", "marka"]): + risks.append("sprawdz brand/non-brand") + if conversions_30d <= 0: + risks.append("brak konwersji w 30 dni") + risks.append("sprawdz kanibalizacje Search/Shopping") + return risks + + +def fetch_currency_code(google_client, customer_id: str) -> str: + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code + FROM customer + """, + ) + if not rows: + return "" + return str(rows[0].customer.currency_code or "") + + +def fetch_pmax_asset_groups(google_client, customer_id: str, warnings: list[str]) -> list[dict]: + try: + rows = run_query( + google_client, + customer_id, + """ + SELECT + campaign.id, + campaign.name, + asset_group.id, + asset_group.name, + asset_group.status + FROM asset_group + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND campaign.status != 'REMOVED' + AND asset_group.status != 'REMOVED' + """, + ) + except Exception as exc: + warnings.append(f"Nie udalo sie pobrac asset groups PMax: {exc}") + return [] + + asset_groups = [] + for row in rows: + asset_groups.append( + { + "campaign_id": str(row.campaign.id), + "campaign_name": row.campaign.name, + "asset_group_id": str(row.asset_group.id), + "asset_group_name": row.asset_group.name, + "status": enum_name(row.asset_group.status), + } + ) + return asset_groups + + +def fetch_pmax_campaigns(client_config: ClientConfig) -> tuple[str, list[dict], list[dict], list[str]]: + warnings: list[str] = [] + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + currency_code = fetch_currency_code(google_client, customer_id) + asset_groups = fetch_pmax_asset_groups(google_client, customer_id, warnings) + asset_groups_by_campaign: dict[str, list[dict]] = defaultdict(list) + for asset_group in asset_groups: + asset_groups_by_campaign[asset_group["campaign_id"]].append(asset_group) + + rows = run_query( + google_client, + customer_id, + """ + SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + campaign.bidding_strategy_type, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM campaign + WHERE campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND campaign.status != 'REMOVED' + AND segments.date DURING LAST_30_DAYS + """, + ) + + campaigns = [] + for row in rows: + campaign_id = str(row.campaign.id) + asset_group_count = len(asset_groups_by_campaign.get(campaign_id, [])) + conversions_30d = safe_float(row.metrics.conversions) + campaigns.append( + { + "campaign_id": campaign_id, + "campaign_name": row.campaign.name, + "status": enum_name(row.campaign.status), + "channel_type": enum_name(row.campaign.advertising_channel_type), + "bidding_strategy_type": enum_name(row.campaign.bidding_strategy_type), + "cost_30d_micros": safe_int(row.metrics.cost_micros), + "conversions_30d": conversions_30d, + "conversion_value_30d": safe_float(row.metrics.conversions_value), + "asset_group_count": asset_group_count, + "risk_labels": pmax_risks(row.campaign.name, asset_group_count, conversions_30d), + } + ) + return currency_code, campaigns, asset_groups, warnings + + +def build_pmax_structure_plan(client_config: ClientConfig) -> PmaxStructurePlan: + warnings = [] + try: + currency_code, campaigns, asset_groups, fetch_warnings = fetch_pmax_campaigns(client_config) + warnings.extend(fetch_warnings) + except Exception as exc: + currency_code = "" + campaigns = [] + asset_groups = [] + warnings.append(f"Nie udalo sie pobrac kampanii Performance Max z Google Ads API: {exc}") + + if not campaigns: + warnings.append("Nie znaleziono kampanii Performance Max z danymi z ostatnich 30 dni albo nie udalo sie ich pobrac.") + + warnings.append( + "Informacje o feedzie produktowym i problemach produktow sprawdzaj w osobnym zadaniu Feed i Merchant Center." + ) + warnings.append( + "Kanibalizacja Search/Shopping/remarketing wymaga recznej oceny z innymi zadaniami, a nie automatycznej decyzji." + ) + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace Performance Max bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + campaigns.sort(key=lambda row: (row["status"], row["campaign_name"])) + return PmaxStructurePlan( + currency_code=currency_code, + campaigns=campaigns, + asset_groups=asset_groups, + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_pmax_structure_plan(domain: str, plan: PmaxStructurePlan) -> 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 struktury PMax", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Kampanie PMax: {len(plan.campaigns)}", + f"- Asset groups: {len(plan.asset_groups)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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 Performance Max", + "", + "| Kampania | Status | Asset groups | Koszt 30 dni | Konwersje | Wartosc konwersji | Ryzyka |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for campaign in plan.campaigns: + lines.append( + f"| {md_cell(campaign['campaign_name'])} | {campaign['status']} | " + f"{campaign['asset_group_count']} | {format_money(campaign['cost_30d_micros'], plan.currency_code)} | " + f"{format_decimal(campaign['conversions_30d'])} | {format_decimal(campaign['conversion_value_30d'])} | " + f"{md_cell(', '.join(campaign['risk_labels']))} |" + ) + lines.append("") + if plan.asset_groups: + lines.extend( + [ + "## Asset groups", + "", + "| Kampania | Asset group | Status |", + "| --- | --- | --- |", + ] + ) + for asset_group in plan.asset_groups: + lines.append( + f"| {md_cell(asset_group['campaign_name'])} | " + f"{md_cell(asset_group['asset_group_name'])} | {asset_group['status']} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_pmax_structure_plan(plan: PmaxStructurePlan) -> None: + print("\nPlan sprawdzenia struktury PMax") + print_table( + ["Metryka", "Liczba"], + [ + ["Kampanie PMax", str(len(plan.campaigns))], + ["Asset groups", str(len(plan.asset_groups))], + ["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 Performance Max") + print_table( + ["Nr", "Kampania", "Status", "Asset groups", "Koszt 30 dni", "Konw.", "Ryzyka"], + [ + [ + str(index), + campaign["campaign_name"], + campaign["status"], + str(campaign["asset_group_count"]), + format_money(campaign["cost_30d_micros"], plan.currency_code), + format_decimal(campaign["conversions_30d"]), + ", ".join(campaign["risk_labels"]), + ] + for index, campaign in enumerate(plan.campaigns, 1) + ], + ) + if plan.asset_groups: + print("\nAsset groups") + print_table( + ["Nr", "Kampania", "Asset group", "Status"], + [ + [str(index), row["campaign_name"], row["asset_group_name"], row["status"]] + for index, row in enumerate(plan.asset_groups[:30], 1) + ], + ) + if len(plan.asset_groups) > 30: + print(f"... oraz {len(plan.asset_groups) - 30} kolejnych asset groups w pliku planu") + 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_pmax_structure_plan( + client_config: ClientConfig, + plan: PmaxStructurePlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem struktury PMax 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), + "asset_groups": len(plan.asset_groups), + "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_pmax_structure( + 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 = PmaxStructurePlan.from_dict(plan_data) + print_pmax_structure_plan(plan) + apply_pmax_structure_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia struktury PMax...") + plan = build_pmax_structure_plan(client_config) + print_pmax_structure_plan(plan) + json_path, md_path = save_pmax_structure_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), + "asset_groups": len(plan.asset_groups), + "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 struktury PMax.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/product_feed_optimization.py b/src/gads_v2/tasks/product_feed_optimization.py new file mode 100644 index 0000000..48880dc --- /dev/null +++ b/src/gads_v2/tasks/product_feed_optimization.py @@ -0,0 +1,731 @@ +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +import requests + +from ..config import ClientConfig, client_dir +from ..history import append_change_markdown, append_history, now_local +from ..table import print_table + + +TASK_ID = "optimize_product_feed" +TASK_NAME = "Optymalizacja feed produktow" +TASK_TITLES_ID = "optimize_product_titles" +TASK_TITLES_NAME = "Optymalizacja tytulow produktow" +TASK_CATEGORIES_ID = "optimize_product_categories" +TASK_CATEGORIES_NAME = "Optymalizacja kategorii Google" +TASK_UNIT_PRICING_ID = "fill_product_unit_pricing" +TASK_UNIT_PRICING_NAME = "Uzupelnienie unit pricing" + + +@dataclass +class ProductFeedPlan: + products: list[dict] + title_changes: list[dict] + category_changes: list[dict] + unit_pricing_changes: list[dict] + skipped: list[dict] + warnings: list[str] + task_id: str = TASK_ID + task_name: str = TASK_NAME + + def to_dict(self) -> dict: + return { + "task": self.task_id, + "task_name": self.task_name, + "products": self.products, + "title_changes": self.title_changes, + "category_changes": self.category_changes, + "unit_pricing_changes": self.unit_pricing_changes, + "skipped": self.skipped, + "warnings": self.warnings, + } + + @classmethod + def from_dict(cls, data: dict) -> "ProductFeedPlan": + return cls( + products=data.get("products", []), + title_changes=data.get("title_changes", []), + category_changes=data.get("category_changes", []), + unit_pricing_changes=data.get("unit_pricing_changes", data.get("unit_pricing_previews", [])), + skipped=data.get("skipped", []), + warnings=data.get("warnings", []), + task_id=data.get("task", TASK_ID), + task_name=data.get("task_name", TASK_NAME), + ) + + +def adspro_credentials(client_config: ClientConfig) -> tuple[str, str, str]: + 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_config.adspro_client_id: + raise RuntimeError(f"Brak adspro_client_id dla {client_config.domain} w config/clients.toml.") + return api_url, api_key, client_config.adspro_client_id + + +def adspro_request(api_url: str, payload: dict, timeout: int = 30) -> dict: + response = requests.post(api_url, data=payload, timeout=timeout) + response.raise_for_status() + response.encoding = "utf-8" + data = response.json() + if data.get("result") == "error": + raise RuntimeError(data.get("message") or "adsPRO zwrocil blad.") + return data + + +def fetch_products_by_action(client_config: ClientConfig, action: str, limit: int) -> list[dict]: + api_url, api_key, adspro_client_id = adspro_credentials(client_config) + data = adspro_request( + api_url, + { + "action": action, + "api_key": api_key, + "client_id": adspro_client_id, + "limit": str(limit), + }, + timeout=60, + ) + return data.get("products", []) + + +def fetch_missing_title_products(client_config: ClientConfig, limit: int) -> list[dict]: + return fetch_products_by_action(client_config, "products_get_missing_title", limit) + + +def fetch_missing_category_products(client_config: ClientConfig, limit: int) -> list[dict]: + return fetch_products_by_action(client_config, "products_get_missing_google_category", limit) + + +def fetch_missing_unit_pricing_products(client_config: ClientConfig, limit: int) -> list[dict]: + api_url, api_key, adspro_client_id = adspro_credentials(client_config) + data = adspro_request( + api_url, + { + "action": "products_get_missing_unit_pricing", + "api_key": api_key, + "client_id": adspro_client_id, + "top": str(limit), + }, + timeout=60, + ) + return data.get("products", []) + + +def merge_products(*groups: list[dict]) -> list[dict]: + merged: dict[str, dict] = {} + for products in groups: + for product in products: + offer_id = str(product.get("offer_id") or product.get("id") or "").strip() + if not offer_id: + continue + merged.setdefault(offer_id, {}).update(product) + return list(merged.values()) + + +def changelog_path(domain: str) -> Path: + return client_dir(domain) / "produkty_changelog.jsonl" + + +def read_product_changelog(domain: str) -> list[dict]: + path = changelog_path(domain) + if not path.exists(): + return [] + entries = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + return entries + + +def latest_title_change_dates(domain: str) -> dict[str, date]: + latest: dict[str, date] = {} + for entry in read_product_changelog(domain): + if entry.get("field") != "title": + continue + product_id = str(entry.get("product_id") or entry.get("offer_id") or "") + if not product_id: + continue + try: + changed_at = date.fromisoformat(str(entry.get("date"))) + except ValueError: + continue + if product_id not in latest or changed_at > latest[product_id]: + latest[product_id] = changed_at + return latest + + +def append_product_changelog(domain: str, rows: list[dict]) -> Path: + path = changelog_path(domain) + path.parent.mkdir(parents=True, exist_ok=True) + today = now_local().date().isoformat() + with path.open("a", encoding="utf-8") as f: + for row in rows: + f.write( + json.dumps( + { + "product_id": row["offer_id"], + "date": today, + "field": row["field"], + "old": row.get("current_value", ""), + "new": row.get("target_value", ""), + }, + ensure_ascii=False, + ) + + "\n" + ) + return path + + +def clean_title(value: str) -> str: + value = re.sub(r"\s+", " ", value or "").strip() + value = value.replace(" - - ", " - ") + if value.isupper() and len(value) > 12: + value = value.title() + return value[:150].strip() + + +def suggest_title(product: dict) -> str: + source = product.get("default_name") or product.get("title") or product.get("custom_title") or "" + title = clean_title(source) + brand = clean_title(product.get("brand") or "") + if brand and title and not title.lower().startswith(brand.lower()): + title = clean_title(f"{brand} {title}") + return title + + +def unit_pricing_preview(title: str) -> dict | None: + match = re.search(r"(\d+(?:[,.]\d+)?)\s*(ml|l|g|kg|szt|sztuk|caps|kaps)\b", title or "", re.IGNORECASE) + if not match: + return None + amount = match.group(1).replace(",", ".") + unit = match.group(2).lower() + unit = {"sztuk": "szt", "caps": "szt", "kaps": "szt"}.get(unit, unit) + if unit in {"ml", "l"}: + base = "100 ml" if unit == "ml" else "1 l" + elif unit in {"g", "kg"}: + base = "100 g" if unit == "g" else "1 kg" + else: + base = "1 szt" + return { + "unit_pricing_measure": f"{amount} {unit}", + "unit_pricing_base_measure": base, + } + + +def build_product_feed_plan( + client_config: ClientConfig, + global_rules: dict, + scope: str = "all", + task_id: str = TASK_ID, + task_name: str = TASK_NAME, +) -> ProductFeedPlan: + rules = client_config.effective_rules(global_rules, "product_feed_optimization") + limit = int(rules.get("limit", 10)) + min_days_between_title_changes = int(rules.get("min_days_between_title_changes", 30)) + + title_products = fetch_missing_title_products(client_config, limit) if scope in {"all", "titles"} else [] + category_products = fetch_missing_category_products(client_config, limit) if scope in {"all", "categories"} else [] + unit_products = fetch_missing_unit_pricing_products(client_config, limit) if scope in {"all", "unit_pricing"} else [] + products = merge_products(title_products, category_products, unit_products) + latest_changes = latest_title_change_dates(client_config.domain) + today = now_local().date() + + title_changes = [] + category_changes = [] + unit_changes = [] + skipped = [] + warnings = [] + + for product in title_products: + offer_id = str(product.get("offer_id") or product.get("id") or "").strip() + default_title = product.get("default_name") or product.get("title") or "" + if not offer_id: + skipped.append({"offer_id": "", "reason": "produkt bez offer_id"}) + continue + + changed_at = latest_changes.get(offer_id) + if changed_at and (today - changed_at).days < min_days_between_title_changes: + skipped.append( + { + "offer_id": offer_id, + "reason": f"tytul zmieniony {(today - changed_at).days} dni temu, minimum {min_days_between_title_changes}", + } + ) + continue + + needs_title = bool(product.get("needs_title")) or not product.get("title_changed") + proposed_title = suggest_title(product) + current_title = product.get("custom_title") or default_title + if needs_title and proposed_title and proposed_title != current_title: + title_changes.append( + { + "offer_id": offer_id, + "field": "title", + "current_value": current_title, + "target_value": proposed_title, + "reason": "brak zoptymalizowanego tytulu lub tytul wymaga normalizacji", + } + ) + elif needs_title: + title_changes.append( + { + "offer_id": offer_id, + "field": "title", + "current_value": current_title, + "target_value": "", + "reason": "brak zoptymalizowanego tytulu; tytul wybiera agent AI po analizie produktu", + "requires_agent_decision": True, + } + ) + + for product in category_products: + offer_id = str(product.get("offer_id") or product.get("id") or "").strip() + if not offer_id: + skipped.append({"offer_id": "", "reason": "produkt bez offer_id"}) + continue + current_category = product.get("google_product_category") or product.get("google_category") or "" + category_changes.append( + { + "offer_id": offer_id, + "field": "google_product_category", + "current_value": current_category, + "target_value": "", + "reason": "brak kategorii Google; kategorie wybiera agent AI po analizie produktu", + "requires_agent_decision": True, + } + ) + + seen_unit_offer_ids = set() + for product in unit_products: + offer_id = str(product.get("offer_id") or product.get("id") or "").strip() + default_title = product.get("default_name") or product.get("title") or product.get("custom_title") or "" + if not offer_id or offer_id in seen_unit_offer_ids: + continue + seen_unit_offer_ids.add(offer_id) + preview = unit_pricing_preview(default_title) + if not preview: + skipped.append({"offer_id": offer_id, "reason": "brak jednoznacznego unit pricing w nazwie produktu"}) + continue + unit_changes.append( + { + "offer_id": offer_id, + "field": "unit_pricing", + "title": default_title, + "current_unit_pricing_measure": product.get("unit_pricing_measure") or "", + "current_unit_pricing_base_measure": product.get("unit_pricing_base_measure") or "", + "unit_pricing_measure": preview["unit_pricing_measure"], + "unit_pricing_base_measure": preview["unit_pricing_base_measure"], + "reason": "brak unit pricing; wartosc wyliczona z nazwy produktu", + } + ) + + if scope == "titles" and not title_products: + warnings.append("adsPRO nie zwrocil produktow bez zoptymalizowanego tytulu.") + if scope == "categories" and not category_products: + warnings.append("adsPRO nie zwrocil produktow bez kategorii Google.") + if scope == "all" and not products: + warnings.append("adsPRO nie zwrocil produktow do optymalizacji.") + if scope == "unit_pricing" and not unit_products: + warnings.append("adsPRO nie zwrocil produktow bez unit pricing.") + if category_changes: + warnings.append("Kategorie Google wybiera agent AI; skrypt nie zgaduje ich automatycznie.") + if title_changes and any(row.get("requires_agent_decision") for row in title_changes): + warnings.append("Czesc tytulow wymaga decyzji agenta AI; skrypt nie przepisuje tytulu bazowego jako optymalizacji.") + if unit_changes: + warnings.append("Unit pricing zostanie zapisany w adsPRO dopiero po akceptacji planu.") + + return ProductFeedPlan( + products=products, + title_changes=title_changes, + category_changes=category_changes, + unit_pricing_changes=unit_changes, + skipped=skipped, + warnings=warnings, + task_id=task_id, + task_name=task_name, + ) + + +def save_product_feed_plan(domain: str, plan: ProductFeedPlan) -> 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')}_{plan.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 = [ + f"# Plan: {plan.task_name}", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Produkty z adsPRO: {len(plan.products)}", + f"- Tytuly do zmiany: {len(plan.title_changes)}", + f"- Kategorie do uzupelnienia: {len(plan.category_changes)}", + f"- Unit pricing do zmiany: {len(plan.unit_pricing_changes)}", + f"- Pominiete: {len(plan.skipped)}", + "", + ] + if plan.warnings: + lines.extend(["## Uwagi", ""]) + lines.extend(f"- {item}" for item in plan.warnings) + lines.append("") + if plan.title_changes: + lines.extend(["## Tytuly do decyzji lub zmiany", "", "| Produkt | Obecnie | Docelowo | Powod |", "| --- | --- | --- | --- |"]) + for row in plan.title_changes: + lines.append(f"| {row['offer_id']} | {row['current_value']} | {row['target_value']} | {row['reason']} |") + lines.append("") + if plan.category_changes: + lines.extend(["## Kategorie Google do decyzji agenta AI", "", "| Produkt | Obecnie | Decyzja agenta AI | Powod |", "| --- | --- | --- | --- |"]) + for row in plan.category_changes: + lines.append(f"| {row['offer_id']} | {row['current_value']} | {row['target_value']} | {row['reason']} |") + lines.append("") + if plan.unit_pricing_changes: + lines.extend(["## Unit pricing do zmiany", "", "| Produkt | Measure | Base measure | Powod |", "| --- | --- | --- | --- |"]) + for row in plan.unit_pricing_changes: + lines.append( + f"| {row['offer_id']} | {row['unit_pricing_measure']} | {row['unit_pricing_base_measure']} | {row['reason']} |" + ) + lines.append("") + if plan.skipped: + lines.extend(["## Pominiete", "", "| Produkt | Powod |", "| --- | --- |"]) + for row in plan.skipped: + lines.append(f"| {row.get('offer_id', '')} | {row.get('reason', '')} |") + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_product_feed_plan(plan: ProductFeedPlan) -> None: + print(f"\nPlan: {plan.task_name}") + print_table( + ["Zakres", "Liczba"], + [ + ["Produkty z adsPRO", str(len(plan.products))], + ["Tytuly do zmiany", str(len(plan.title_changes))], + ["Kategorie do uzupelnienia", str(len(plan.category_changes))], + ["Unit pricing do zmiany", str(len(plan.unit_pricing_changes))], + ["Pominiete", str(len(plan.skipped))], + ], + ) + if plan.title_changes: + print("\nNajwazniejsze dzialania") + rows = [ + [str(i), row["offer_id"], "Zmien tytul", row["target_value"]] + for i, row in enumerate(plan.title_changes[:10], 1) + ] + print_table(["Nr", "Produkt", "Dzialanie", "Docelowo"], rows) + if len(plan.title_changes) > 10: + print(f"... oraz {len(plan.title_changes) - 10} kolejnych zmian tytulow") + for warning in plan.warnings: + print(f"Uwaga: {warning}") + + +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 set_product_title(api_url: str, api_key: str, client_id: str, row: dict) -> None: + adspro_request( + api_url, + { + "action": "product_title_set", + "api_key": api_key, + "client_id": client_id, + "offer_id": row["offer_id"], + "title": row["target_value"], + }, + ) + + +def set_product_category(api_url: str, api_key: str, client_id: str, row: dict) -> None: + adspro_request( + api_url, + { + "action": "product_google_category_set", + "api_key": api_key, + "client_id": client_id, + "offer_id": row["offer_id"], + "google_product_category": row["target_value"], + }, + ) + + +def set_product_unit_pricing(api_url: str, api_key: str, client_id: str, row: dict) -> None: + adspro_request( + api_url, + { + "action": "product_unit_pricing_set", + "api_key": api_key, + "client_id": client_id, + "offer_id": row["offer_id"], + "unit_pricing_measure": row["unit_pricing_measure"], + "unit_pricing_base_measure": row["unit_pricing_base_measure"], + }, + ) + + +def apply_product_feed_plan(client_config: ClientConfig, plan: ProductFeedPlan, show_navigation: bool = True) -> None: + api_url, api_key, adspro_client_id = adspro_credentials(client_config) + applied = [] + skipped = [] + + for row in plan.title_changes: + if not row.get("target_value"): + skipped.append({**row, "skip_reason": "brak target_value"}) + continue + set_product_title(api_url, api_key, adspro_client_id, row) + applied.append(row) + + for row in plan.category_changes: + if not row.get("target_value"): + skipped.append({**row, "skip_reason": "brak target_value"}) + continue + set_product_category(api_url, api_key, adspro_client_id, row) + applied.append(row) + + for row in plan.unit_pricing_changes: + if not row.get("unit_pricing_measure") or not row.get("unit_pricing_base_measure"): + skipped.append({**row, "skip_reason": "brak unit_pricing_measure lub unit_pricing_base_measure"}) + continue + set_product_unit_pricing(api_url, api_key, adspro_client_id, row) + applied.append( + { + "offer_id": row["offer_id"], + "field": "unit_pricing", + "current_value": ( + f"{row.get('current_unit_pricing_measure', '')} / " + f"{row.get('current_unit_pricing_base_measure', '')}" + ).strip(" /"), + "target_value": f"{row['unit_pricing_measure']} / {row['unit_pricing_base_measure']}", + } + ) + + print("\nWynik wdrozenia zmian") + print(f"Wdrozono zmian: {len(applied)}") + print(f"Pominieto: {len(skipped)}") + + change_rows = [ + { + "klient": client_config.domain, + "produkt": row["offer_id"], + "pole": row["field"], + "obecnie": row.get("current_value", ""), + "docelowo": row.get("target_value", ""), + } + for row in applied + ] + changes_path = append_change_markdown(client_config.domain, plan.task_name, change_rows) + history_path = append_history( + client_config.domain, + { + "task": plan.task_name, + "status": "wdrozono zmiany", + "product": ", ".join(row["offer_id"] for row in applied[:10]), + "summary": {"applied": len(applied), "skipped": len(skipped)}, + }, + ) + changelog = append_product_changelog(client_config.domain, applied) if applied else changelog_path(client_config.domain) + print(f"Historia JSONL: {history_path}") + print(f"Historia Markdown: {changes_path}") + print(f"Changelog produktow: {changelog}") + if show_navigation: + print_next_navigation(client_config.domain) + + +def run_product_feed_task( + client_config: ClientConfig, + global_rules: dict, + scope: str, + task_id: str, + task_name: str, + 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')}, a wybrano {client_config.domain}.") + if show_navigation: + print_next_navigation(client_config.domain) + return + plan = ProductFeedPlan.from_dict(plan_data) + print_product_feed_plan(plan) + apply_product_feed_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print(f"Pobieram produkty z adsPRO i przygotowuje plan: {task_name}...") + plan = build_product_feed_plan(client_config, global_rules, scope=scope, task_id=task_id, task_name=task_name) + print_product_feed_plan(plan) + json_path, md_path = save_product_feed_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", + "product": ", ".join(str(product.get("offer_id") or product.get("id") or "") for product in plan.products[:10]), + "summary": { + "products": len(plan.products), + "title_changes": len(plan.title_changes), + "category_changes": len(plan.category_changes), + "unit_pricing_changes": len(plan.unit_pricing_changes), + }, + }, + ) + + if plan_only: + print("\nTryb plan-only: zmiany nie zostaly wdrozone.") + if show_navigation: + print_next_navigation(client_config.domain) + return + + if ( + not plan.title_changes + and not any(row.get("target_value") for row in plan.category_changes) + and not plan.unit_pricing_changes + ): + print("\nBrak gotowych zmian do wdrozenia.") + append_change_markdown(client_config.domain, task_name, []) + 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": task_name, + "status": "odrzucono wdrozenie", + "product": ", ".join(row["offer_id"] for row in plan.title_changes[:10]), + }, + ) + if show_navigation: + print_next_navigation(client_config.domain) + return + + apply_product_feed_plan(client_config, plan, show_navigation=show_navigation) + + +def run_optimize_product_feed( + 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: + run_product_feed_task( + client_config, + global_rules, + "all", + TASK_ID, + TASK_NAME, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + + +def run_optimize_product_titles( + 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: + run_product_feed_task( + client_config, + global_rules, + "titles", + TASK_TITLES_ID, + TASK_TITLES_NAME, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + + +def run_optimize_product_categories( + 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: + run_product_feed_task( + client_config, + global_rules, + "categories", + TASK_CATEGORIES_ID, + TASK_CATEGORIES_NAME, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) + + +def run_fill_product_unit_pricing( + 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: + run_product_feed_task( + client_config, + global_rules, + "unit_pricing", + TASK_UNIT_PRICING_ID, + TASK_UNIT_PRICING_NAME, + plan_only=plan_only, + apply_plan_path=apply_plan_path, + confirm_apply=confirm_apply, + show_navigation=show_navigation, + ) diff --git a/src/gads_v2/tasks/remarketing_setup_check.py b/src/gads_v2/tasks/remarketing_setup_check.py new file mode 100644 index 0000000..6cd9fa8 --- /dev/null +++ b/src/gads_v2/tasks/remarketing_setup_check.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_remarketing_setup" +TASK_NAME = "Sprawdzenie remarketingu" + + +SCOPE = [ + { + "area": "Listy odbiorcow", + "check": "Pokaz listy odbiorcow z Google Ads, ich typ, status czlonkostwa i rozmiary dla Search oraz Display.", + }, + { + "area": "Dynamiczny remarketing", + "check": "Wypisz kontrole tagowania produktowego i identyfikatorow produktow do recznej oceny.", + }, + { + "area": "Powiazanie z PMax", + "check": "Oznacz ryzyko nakladania remarketingu z PMax jako punkt do recznej oceny.", + }, + { + "area": "Gotowosc list", + "check": "Oznacz listy zbyt male albo zamkniete jako potencjalny problem wykorzystania w kampaniach.", + }, +] + + +DYNAMIC_REMARKETING_CHECKS = [ + "czy tag Google Ads / GTM przekazuje identyfikatory produktow zgodne z feedem", + "czy zdarzenia e-commerce rozrozniaja view_item, add_to_cart i purchase", + "czy remarketing dynamiczny ma dostep do feedu produktowego", + "czy listy odbiorcow sa wystarczajaco duze dla Search i Display", + "czy PMax nie przechwytuje calego remarketingu bez osobnej kontroli", +] + + +OUT_OF_SCOPE = [ + "budzety i pacing budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "tworzenie reklam i kreacji remarketingowych", + "problemy feedu i Merchant Center poza kontrola identyfikatorow produktow", + "wdrazanie zmian w tagowaniu albo listach odbiorcow", +] + + +@dataclass +class RemarketingSetupPlan: + user_lists: list[dict] + list_type_summary: list[dict] + dynamic_remarketing_checks: list[str] + 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, + "user_lists": self.user_lists, + "list_type_summary": self.list_type_summary, + "dynamic_remarketing_checks": self.dynamic_remarketing_checks, + "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) -> "RemarketingSetupPlan": + return cls( + user_lists=data.get("user_lists", []), + list_type_summary=data.get("list_type_summary", []), + dynamic_remarketing_checks=data.get("dynamic_remarketing_checks", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def safe_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def size_label(search_size: int, display_size: int) -> str: + max_size = max(search_size, display_size) + if max_size <= 0: + return "brak rozmiaru" + if max_size < 100: + return "bardzo mala lista" + if max_size < 1000: + return "mala lista" + return "rozmiar ok" + + +def user_list_flags(row: dict) -> list[str]: + flags = [] + if row["membership_status"] and row["membership_status"] != "OPEN": + flags.append("lista zamknieta") + if row["size_status"] != "rozmiar ok": + flags.append(row["size_status"]) + if row["type"] in {"UNKNOWN", "UNSPECIFIED"}: + flags.append("nieznany typ") + return flags or ["ok"] + + +def fetch_user_lists(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 + user_list.id, + user_list.name, + user_list.type, + user_list.membership_status, + user_list.size_for_search, + user_list.size_for_display + FROM user_list + """, + ) + + user_lists = [] + for row in rows: + user_list = row.user_list + search_size = safe_int(user_list.size_for_search) + display_size = safe_int(user_list.size_for_display) + record = { + "user_list_id": str(user_list.id), + "name": user_list.name, + "type": enum_name(user_list.type), + "membership_status": enum_name(user_list.membership_status), + "size_for_search": search_size, + "size_for_display": display_size, + "size_status": size_label(search_size, display_size), + } + record["flags"] = user_list_flags(record) + user_lists.append(record) + user_lists.sort(key=lambda row: (row["size_status"], row["name"])) + return user_lists + + +def build_list_type_summary(user_lists: list[dict]) -> list[dict]: + counter = Counter(row["type"] for row in user_lists) + return [{"type": key, "count": value} for key, value in counter.most_common()] + + +def build_remarketing_setup_plan(client_config: ClientConfig) -> RemarketingSetupPlan: + warnings = [] + try: + user_lists = fetch_user_lists(client_config) + except Exception as exc: + user_lists = [] + warnings.append(f"Nie udalo sie pobrac list odbiorcow z Google Ads API: {exc}") + + if not user_lists: + warnings.append("Nie znaleziono list odbiorcow albo nie udalo sie ich pobrac.") + + warnings.append( + "Dynamiczny remarketing wymaga potwierdzenia tagowania produktowego w zadaniu Pomiar i konwersje oraz zgodnosci feedu w zadaniu Feed i Merchant Center." + ) + warnings.append( + "Konflikt remarketingu z PMax wymaga recznej oceny razem z zadaniem Sprawdzenie struktury PMax." + ) + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace remarketingu bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return RemarketingSetupPlan( + user_lists=user_lists, + list_type_summary=build_list_type_summary(user_lists), + dynamic_remarketing_checks=DYNAMIC_REMARKETING_CHECKS, + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_remarketing_setup_plan(domain: str, plan: RemarketingSetupPlan) -> 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 remarketingu", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Listy odbiorcow: {len(plan.user_lists)}", + f"- Typy list: {len(plan.list_type_summary)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.list_type_summary: + lines.extend(["## Podsumowanie typow list", "", "| Typ | Liczba |", "| --- | --- |"]) + for row in plan.list_type_summary: + lines.append(f"| {row['type']} | {row['count']} |") + lines.append("") + if plan.user_lists: + lines.extend( + [ + "## Listy odbiorcow", + "", + "| Lista | Typ | Status | Search size | Display size | Flagi |", + "| --- | --- | --- | --- | --- | --- |", + ] + ) + for row in plan.user_lists: + lines.append( + f"| {md_cell(row['name'])} | {row['type']} | {row['membership_status']} | " + f"{row['size_for_search']} | {row['size_for_display']} | {md_cell(', '.join(row['flags']))} |" + ) + lines.append("") + lines.extend(["## Kontrole dynamicznego remarketingu", ""]) + lines.extend(f"- {item}" for item in plan.dynamic_remarketing_checks) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_remarketing_setup_plan(plan: RemarketingSetupPlan) -> None: + print("\nPlan sprawdzenia remarketingu") + print_table( + ["Metryka", "Liczba"], + [ + ["Listy odbiorcow", str(len(plan.user_lists))], + ["Typy list", str(len(plan.list_type_summary))], + ["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.list_type_summary: + print("\nPodsumowanie typow list") + print_table( + ["Typ", "Liczba"], + [[row["type"], str(row["count"])] for row in plan.list_type_summary], + ) + if plan.user_lists: + print("\nListy odbiorcow") + print_table( + ["Nr", "Lista", "Typ", "Status", "Search", "Display", "Flagi"], + [ + [ + str(index), + row["name"], + row["type"], + row["membership_status"], + str(row["size_for_search"]), + str(row["size_for_display"]), + ", ".join(row["flags"]), + ] + for index, row in enumerate(plan.user_lists[:30], 1) + ], + ) + if len(plan.user_lists) > 30: + print(f"... oraz {len(plan.user_lists) - 30} kolejnych list w pliku planu") + print("\nKontrole dynamicznego remarketingu") + print_table( + ["Nr", "Kontrola"], + [[str(index), item] for index, item in enumerate(plan.dynamic_remarketing_checks, 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_remarketing_setup_plan( + client_config: ClientConfig, + plan: RemarketingSetupPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem remarketingu 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": { + "user_lists": len(plan.user_lists), + "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_remarketing_setup( + 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 = RemarketingSetupPlan.from_dict(plan_data) + print_remarketing_setup_plan(plan) + apply_remarketing_setup_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia remarketingu...") + plan = build_remarketing_setup_plan(client_config) + print_remarketing_setup_plan(plan) + json_path, md_path = save_remarketing_setup_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": { + "user_lists": len(plan.user_lists), + "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 remarketingu.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/rsa_assets_check.py b/src/gads_v2/tasks/rsa_assets_check.py new file mode 100644 index 0000000..6711df4 --- /dev/null +++ b/src/gads_v2/tasks/rsa_assets_check.py @@ -0,0 +1,438 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_rsa_assets" +TASK_NAME = "Sprawdzenie reklam RSA i zasobow" +MAX_RSA_ADS = 100 + + +SCOPE = [ + { + "area": "Reklamy RSA", + "check": "Pokaz aktywne reklamy RSA w aktywnych kampaniach Search i grupach reklam.", + }, + { + "area": "Naglowki", + "check": "Policz naglowki i oznacz reklamy z mala liczba wariantow albo duplikatami.", + }, + { + "area": "Teksty reklam", + "check": "Policz opisy i oznacz reklamy z mala liczba wariantow.", + }, + { + "area": "DKI", + "check": "Oznacz uzycie Dynamic Keyword Insertion jako element wymagajacy recznej oceny.", + }, + { + "area": "Final URL", + "check": "Sprawdz, czy reklama ma finalny adres URL.", + }, +] + + +OUT_OF_SCOPE = [ + "zapytania uzytkownikow i wykluczenia", + "budzety i pacing budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "podstawowe ustawienia kampanii, np. lokalizacje i sieci", + "automatyczne tworzenie albo edycja reklam", +] + + +@dataclass +class RsaAssetsPlan: + ads: 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, + "ads": self.ads, + "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) -> "RsaAssetsPlan": + return cls( + ads=data.get("ads", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def text_asset_values(assets: Any) -> list[str]: + values = [] + for asset in assets or []: + text = str(getattr(asset, "text", "") or "").strip() + if text: + values.append(text) + return values + + +def has_dki(values: list[str]) -> bool: + return any("{keyword:" in value.casefold() for value in values) + + +def duplicate_count(values: list[str]) -> int: + normalized = [value.casefold().strip() for value in values if value.strip()] + return len(normalized) - len(set(normalized)) + + +def risk_labels(headlines: list[str], descriptions: list[str], final_urls: list[str]) -> list[str]: + risks = [] + if len(headlines) < 8: + risks.append("malo naglowkow") + if len(descriptions) < 3: + risks.append("malo opisow") + if duplicate_count(headlines) > 0: + risks.append("duplikaty naglowkow") + if has_dki(headlines + descriptions): + risks.append("sprawdz DKI") + if not final_urls: + risks.append("brak final URL") + return risks or ["do oceny"] + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def fetch_rsa_ads(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, + f""" + SELECT + campaign.id, + campaign.name, + ad_group.id, + ad_group.name, + ad_group_ad.ad.id, + ad_group_ad.status, + ad_group_ad.ad.final_urls, + ad_group_ad.ad.responsive_search_ad.headlines, + ad_group_ad.ad.responsive_search_ad.descriptions + FROM ad_group_ad + WHERE campaign.status = 'ENABLED' + AND ad_group.status = 'ENABLED' + AND ad_group_ad.status != 'REMOVED' + AND ad_group_ad.ad.type = 'RESPONSIVE_SEARCH_AD' + LIMIT {MAX_RSA_ADS} + """, + ) + + ads = [] + for row in rows: + ad = row.ad_group_ad.ad + rsa = ad.responsive_search_ad + headlines = text_asset_values(rsa.headlines) + descriptions = text_asset_values(rsa.descriptions) + final_urls = [str(url) for url in ad.final_urls] + risks = risk_labels(headlines, descriptions, final_urls) + ads.append( + { + "campaign_id": str(row.campaign.id), + "campaign_name": row.campaign.name, + "ad_group_id": str(row.ad_group.id), + "ad_group_name": row.ad_group.name, + "ad_id": str(ad.id), + "status": enum_name(row.ad_group_ad.status), + "headline_count": len(headlines), + "description_count": len(descriptions), + "headlines": headlines, + "descriptions": descriptions, + "final_urls": final_urls, + "has_dki": has_dki(headlines + descriptions), + "duplicate_headlines": duplicate_count(headlines), + "risk_labels": risks, + } + ) + return ads + + +def build_rsa_assets_plan(client_config: ClientConfig) -> RsaAssetsPlan: + warnings = [] + try: + ads = fetch_rsa_ads(client_config) + except Exception as exc: + ads = [] + warnings.append(f"Nie udalo sie pobrac reklam RSA z Google Ads API: {exc}") + + if not ads: + warnings.append("Nie znaleziono aktywnych reklam RSA 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. " + "Reguly dotyczace RSA i zasobow bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + ads.sort(key=lambda row: (len(row["risk_labels"]), row["campaign_name"], row["ad_group_name"]), reverse=True) + return RsaAssetsPlan( + ads=ads, + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_rsa_assets_plan(domain: str, plan: RsaAssetsPlan) -> 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 reklam RSA i zasobow", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Reklamy RSA: {len(plan.ads)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.ads: + lines.extend( + [ + "## Reklamy RSA", + "", + "| Kampania | Grupa reklam | Reklama | Naglowki | Opisy | Ryzyka |", + "| --- | --- | --- | --- | --- | --- |", + ] + ) + for ad in plan.ads: + lines.append( + f"| {md_cell(ad['campaign_name'])} | {md_cell(ad['ad_group_name'])} | " + f"{md_cell(ad['ad_id'])} | {ad['headline_count']} | {ad['description_count']} | " + f"{md_cell(', '.join(ad['risk_labels']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_rsa_assets_plan(plan: RsaAssetsPlan) -> None: + print("\nPlan sprawdzenia reklam RSA i zasobow") + print_table( + ["Metryka", "Liczba"], + [ + ["Reklamy RSA", str(len(plan.ads))], + ["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.ads: + print("\nReklamy RSA") + print_table( + ["Nr", "Kampania", "Grupa reklam", "Naglowki", "Opisy", "Ryzyka"], + [ + [ + str(index), + ad["campaign_name"], + ad["ad_group_name"], + str(ad["headline_count"]), + str(ad["description_count"]), + ", ".join(ad["risk_labels"]), + ] + for index, ad in enumerate(plan.ads[:30], 1) + ], + ) + if len(plan.ads) > 30: + print(f"... oraz {len(plan.ads) - 30} kolejnych reklam w pliku planu") + 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_rsa_assets_plan( + client_config: ClientConfig, + plan: RsaAssetsPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem reklam RSA i nie edytuje reklam 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(sorted({ad["campaign_name"] for ad in plan.ads})[:10]), + "summary": { + "ads": len(plan.ads), + "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_rsa_assets( + 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 = RsaAssetsPlan.from_dict(plan_data) + print_rsa_assets_plan(plan) + apply_rsa_assets_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia reklam RSA i zasobow...") + plan = build_rsa_assets_plan(client_config) + print_rsa_assets_plan(plan) + json_path, md_path = save_rsa_assets_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(sorted({ad["campaign_name"] for ad in plan.ads})[:10]), + "summary": { + "ads": len(plan.ads), + "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 reklam RSA.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/search_basic_settings_check.py b/src/gads_v2/tasks/search_basic_settings_check.py new file mode 100644 index 0000000..2c5384d --- /dev/null +++ b/src/gads_v2/tasks/search_basic_settings_check.py @@ -0,0 +1,482 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +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 +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] + changes: 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, + "changes": self.changes, + "scope": self.scope, + "out_of_scope": self.out_of_scope, + "knowledge_rules": self.knowledge_rules, + "warnings": self.warnings, + } + + @classmethod + def from_dict(cls, data: dict) -> "SearchBasicSettingsPlan": + return cls( + campaigns=data.get("campaigns", []), + changes=data.get("changes", []), + 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 md_cell(value) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def build_required_changes(campaign: dict) -> list[dict]: + changes = [] + if campaign["positive_geo_target_type"] != "PRESENCE": + changes.append( + { + "campaign_id": campaign["campaign_id"], + "campaign_name": campaign["campaign_name"], + "setting": "lokalizacje", + "current_value": campaign["positive_geo_target_type"], + "target_value": "PRESENCE", + "current_label": campaign.get("positive_geo_target_type_label", campaign["positive_geo_target_type"]), + "target_label": human_geo("PRESENCE"), + "description": "Ustaw lokalizacje na Obecnosc.", + } + ) + if campaign["target_content_network"]: + changes.append( + { + "campaign_id": campaign["campaign_id"], + "campaign_name": campaign["campaign_name"], + "setting": "siec reklamowa", + "current_value": "true", + "target_value": "false", + "current_label": "Wlaczona", + "target_label": "Wylaczona", + "description": "Wylacz siec reklamowa w kampanii Search.", + } + ) + if campaign["target_partner_search_network"]: + changes.append( + { + "campaign_id": campaign["campaign_id"], + "campaign_name": campaign["campaign_name"], + "setting": "partnerzy wyszukiwania", + "current_value": "true", + "target_value": "false", + "current_label": "Wlaczeni", + "target_label": "Wylaczeni", + "description": "Wylacz partnerow wyszukiwania w kampanii Search.", + } + ) + return changes + + +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.") + + changes = [] + for campaign in campaigns: + changes.extend(build_required_changes(campaign)) + + 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, + changes=changes, + 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)}", + f"- Zmiany do wdrozenia: {len(plan.changes)}", + "", + ] + 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"| {md_cell(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.changes: + lines.extend( + [ + "## Planowane korekty", + "", + "| Kampania | Ustawienie | Obecnie | Docelowo |", + "| --- | --- | --- | --- |", + ] + ) + for change in plan.changes: + lines.append( + f"| {md_cell(change['campaign_name'])} | {md_cell(change['setting'])} | " + f"{md_cell(change.get('current_label', change['current_value']))} | " + f"{md_cell(change.get('target_label', change['target_value']))} |" + ) + 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", str(len(plan.changes))], + ], + ) + 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.changes: + print("\nPlanowane korekty") + print_table( + ["Nr", "Kampania", "Ustawienie", "Obecnie", "Docelowo"], + [ + [ + str(index), + change["campaign_name"], + change["setting"], + change.get("current_label", change["current_value"]), + change.get("target_label", change["target_value"]), + ] + for index, change in enumerate(plan.changes[:30], 1) + ], + ) + if len(plan.changes) > 30: + print(f"... oraz {len(plan.changes) - 30} kolejnych korekt") + 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: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + service = google_client.get_service("CampaignService") + + changes_by_campaign: dict[str, dict] = {} + for change in plan.changes: + changes_by_campaign.setdefault( + change["campaign_id"], + {"campaign_name": change["campaign_name"], "settings": set()}, + ) + changes_by_campaign[change["campaign_id"]]["settings"].add(change["setting"]) + + operations = [] + for campaign_id, row in changes_by_campaign.items(): + op = google_client.get_type("CampaignOperation") + campaign = op.update + campaign.resource_name = service.campaign_path(customer_id, campaign_id) + paths = [] + if "lokalizacje" in row["settings"]: + campaign.geo_target_type_setting.positive_geo_target_type = ( + google_client.enums.PositiveGeoTargetTypeEnum.PRESENCE + ) + paths.append("geo_target_type_setting.positive_geo_target_type") + if "siec reklamowa" in row["settings"]: + campaign.network_settings.target_content_network = False + paths.append("network_settings.target_content_network") + if "partnerzy wyszukiwania" in row["settings"]: + campaign.network_settings.target_partner_search_network = False + paths.append("network_settings.target_partner_search_network") + op.update_mask = field_mask_pb2.FieldMask(paths=paths) + operations.append(op) + + changed = 0 + if operations: + response = service.mutate_campaigns(customer_id=customer_id, operations=operations) + changed = len(response.results) + + print("\nWynik wdrozenia zmian") + print(f"Zmieniono kampanii: {changed}") + print(f"Korekty ustawien: {len(plan.changes)}") + + rows = [ + { + "klient": client_config.domain, + "kampania": change["campaign_name"], + "czynnosc": change["description"], + "grupa reklam": "", + "produkt": f"{change.get('current_label', change['current_value'])} -> {change.get('target_label', change['target_value'])}", + } + for change in plan.changes + ] + changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows) + history_path = append_history( + client_config.domain, + { + "task": TASK_NAME, + "status": "wdrozono zmiany", + "campaign": ", ".join(sorted({change["campaign_name"] for change in plan.changes})[:10]), + "summary": { + "campaigns_changed": changed, + "knowledge_rules": len(plan.knowledge_rules), + "changes": len(plan.changes), + }, + }, + ) + 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 | \ No newline at end of file diff --git a/src/gads_v2/tasks/search_terms_check.py b/src/gads_v2/tasks/search_terms_check.py new file mode 100644 index 0000000..8576695 --- /dev/null +++ b/src/gads_v2/tasks/search_terms_check.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_terms" +TASK_NAME = "Analiza zapytan i wykluczen" +MAX_SEARCH_TERMS = 100 + + +SCOPE = [ + { + "area": "Zapytania 7 dni", + "check": "Pokaz zapytania uzytkownikow z ostatnich 7 dni posortowane po koszcie.", + }, + { + "area": "Kandydaci do wykluczen", + "check": "Oznacz zapytania z kliknieciami i kosztem bez konwersji jako material do recznej oceny.", + }, + { + "area": "Broad match i jakosc dopasowan", + "check": "Zbierz kontekst kampanii i grup reklam, aby latwiej ocenic, czy ruch pasuje do intencji.", + }, + { + "area": "Brand / non-brand", + "check": "Nie rozstrzygaj automatycznie brandu; pokaz zapytania tak, aby agent mogl ocenic intencje.", + }, +] + + +OUT_OF_SCOPE = [ + "budzety i pacing budzetu", + "strategie stawek oraz cele Docelowy ROAS/Docelowy CPA", + "podstawowe ustawienia kampanii, np. lokalizacje i sieci", + "automatyczne dodawanie wykluczen do konta", + "ocena reklam RSA i zasobow reklam", +] + + +@dataclass +class SearchTermsPlan: + currency_code: str + search_terms: 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, + "currency_code": self.currency_code, + "search_terms": self.search_terms, + "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) -> "SearchTermsPlan": + return cls( + currency_code=data.get("currency_code", ""), + search_terms=data.get("search_terms", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def format_money(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def format_decimal(value: int | float) -> str: + return f"{float(value or 0):.2f}" + + +def safe_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def safe_float(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def action_label(clicks: int, cost_micros: int, conversions: float) -> str: + if clicks <= 0: + return "brak klikniec" + if conversions > 0: + return "zostaw do oceny pozytywnej" + if clicks >= 10: + return "pilny kandydat do oceny" + if clicks >= 5: + return "kandydat do oceny" + if cost_micros > 0: + return "obserwuj" + return "brak kosztu" + + +def fetch_currency_code(google_client, customer_id: str) -> str: + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code + FROM customer + """, + ) + if not rows: + return "" + return str(rows[0].customer.currency_code or "") + + +def fetch_search_terms(client_config: ClientConfig) -> tuple[str, list[dict]]: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + currency_code = fetch_currency_code(google_client, customer_id) + rows = run_query( + google_client, + customer_id, + f""" + SELECT + campaign.id, + campaign.name, + campaign.advertising_channel_type, + ad_group.id, + ad_group.name, + search_term_view.search_term, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM search_term_view + WHERE campaign.status = 'ENABLED' + AND ad_group.status = 'ENABLED' + AND campaign.advertising_channel_type = 'SEARCH' + AND segments.date DURING LAST_7_DAYS + ORDER BY metrics.cost_micros DESC + LIMIT {MAX_SEARCH_TERMS} + """, + ) + + search_terms = [] + for row in rows: + clicks = safe_int(row.metrics.clicks) + cost_micros = safe_int(row.metrics.cost_micros) + conversions = safe_float(row.metrics.conversions) + search_terms.append( + { + "campaign_id": str(row.campaign.id), + "campaign_name": row.campaign.name, + "channel_type": enum_name(row.campaign.advertising_channel_type), + "ad_group_id": str(row.ad_group.id), + "ad_group_name": row.ad_group.name, + "search_term": str(row.search_term_view.search_term or ""), + "impressions": safe_int(row.metrics.impressions), + "clicks": clicks, + "cost_micros": cost_micros, + "conversions": conversions, + "conversion_value": safe_float(row.metrics.conversions_value), + "action_label": action_label(clicks, cost_micros, conversions), + } + ) + return currency_code, search_terms + + +def build_search_terms_plan(client_config: ClientConfig) -> SearchTermsPlan: + warnings = [] + try: + currency_code, search_terms = fetch_search_terms(client_config) + except Exception as exc: + currency_code = "" + search_terms = [] + warnings.append(f"Nie udalo sie pobrac zapytan z Google Ads API: {exc}") + + if not search_terms: + warnings.append("Nie znaleziono zapytan Search z ostatnich 7 dni 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. " + "Reguly dotyczace zapytan i wykluczen bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return SearchTermsPlan( + currency_code=currency_code, + search_terms=search_terms, + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_search_terms_plan(domain: str, plan: SearchTermsPlan) -> 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: Analiza zapytan i wykluczen", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Zapytania z ostatnich 7 dni: {len(plan.search_terms)}", + 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.search_terms: + lines.extend( + [ + "## Zapytania z ostatnich 7 dni", + "", + "| Zapytanie | Kampania | Grupa reklam | Klikniecia | Koszt | Konwersje | Ocena |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for term in plan.search_terms: + lines.append( + f"| {term['search_term']} | {term['campaign_name']} | {term['ad_group_name']} | " + f"{term['clicks']} | {format_money(term['cost_micros'], plan.currency_code)} | " + f"{format_decimal(term['conversions'])} | {term['action_label']} |" + ) + 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_terms_plan(plan: SearchTermsPlan) -> None: + print("\nPlan analizy zapytan i wykluczen") + print_table( + ["Metryka", "Liczba"], + [ + ["Zapytania z 7 dni", str(len(plan.search_terms))], + ["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.search_terms: + print("\nZapytania z ostatnich 7 dni") + print_table( + ["Nr", "Zapytanie", "Kampania", "Klik.", "Koszt", "Konw.", "Ocena"], + [ + [ + str(index), + term["search_term"], + term["campaign_name"], + str(term["clicks"]), + format_money(term["cost_micros"], plan.currency_code), + format_decimal(term["conversions"]), + term["action_label"], + ] + for index, term in enumerate(plan.search_terms[:30], 1) + ], + ) + if len(plan.search_terms) > 30: + print(f"... oraz {len(plan.search_terms) - 30} kolejnych zapytan w pliku planu") + 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_terms_plan( + client_config: ClientConfig, + plan: SearchTermsPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem zapytan i nie dodaje wykluczen 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(sorted({term["campaign_name"] for term in plan.search_terms})[:10]), + "summary": { + "search_terms": len(plan.search_terms), + "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_terms( + 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 = SearchTermsPlan.from_dict(plan_data) + print_search_terms_plan(plan) + apply_search_terms_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan analizy zapytan i wykluczen...") + plan = build_search_terms_plan(client_config) + print_search_terms_plan(plan) + json_path, md_path = save_search_terms_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(sorted({term["campaign_name"] for term in plan.search_terms})[:10]), + "summary": { + "search_terms": len(plan.search_terms), + "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 zapytan.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/shopping_product_status_check.py b/src/gads_v2/tasks/shopping_product_status_check.py new file mode 100644 index 0000000..2436312 --- /dev/null +++ b/src/gads_v2/tasks/shopping_product_status_check.py @@ -0,0 +1,637 @@ +from __future__ import annotations + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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_shopping_product_statuses" +TASK_NAME = "Sprawdzenie statusow produktow Shopping" +DEFAULT_PRODUCT_LIMIT = 500 + + +SCOPE = [ + { + "area": "Status produktu", + "check": "Pobierz statusy produktow z zasobu shopping_product w Google Ads API.", + }, + { + "area": "Emisja 30 dni", + "check": "Porownaj produkty ze statusem z danymi emisji z shopping_performance_view z ostatnich 30 dni.", + }, + { + "area": "Produkty do oceny", + "check": "Oznacz produkty niekwalifikujace sie albo kwalifikujace sie, ale bez wyswietlen w ostatnich 30 dniach.", + }, + { + "area": "Granica zadania", + "check": "Oddziel status i emisje produktu od optymalizacji tytulow, kategorii Google, unit pricing i napraw feedu.", + }, +] + + +OUT_OF_SCOPE = [ + "optymalizacja tytulow produktow", + "wybor kategorii Google", + "uzupelnianie unit pricing", + "naprawa feedu w adsPRO albo Merchant Center", + "zmiany stawek, budzetow i struktury kampanii Shopping/PMax", +] + + +PROBLEM_PRODUCT_STATUSES = { + "DISAPPROVED", + "NOT_ELIGIBLE", + "LIMITED", + "UNKNOWN", + "UNSPECIFIED", +} + + +@dataclass +class ShoppingProductStatusPlan: + product_limit: int + products: list[dict] + status_summary: list[dict] + issue_summary: list[dict] + problem_items: 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, + "product_limit": self.product_limit, + "products": self.products, + "status_summary": self.status_summary, + "issue_summary": self.issue_summary, + "problem_items": self.problem_items, + "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) -> "ShoppingProductStatusPlan": + return cls( + product_limit=int(data.get("product_limit") or DEFAULT_PRODUCT_LIMIT), + products=data.get("products", []), + status_summary=data.get("status_summary", []), + issue_summary=data.get("issue_summary", []), + problem_items=data.get("problem_items", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def md_cell(value: Any) -> str: + return str(value or "").replace("|", "\\|").replace("\n", " ").strip() + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def format_money(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def fetch_currency_code(google_client, customer_id: str) -> str: + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code + FROM customer + """, + ) + if not rows: + return "" + return str(rows[0].customer.currency_code or "") + + +def issue_to_dict(issue: Any) -> dict: + code = getattr(issue, "code", "") + severity = getattr(issue, "severity", "") + resolution = getattr(issue, "resolution", "") + attribute = getattr(issue, "attribute", "") + description = getattr(issue, "description", "") + detail = getattr(issue, "detail", "") + return { + "code": enum_name(code), + "severity": enum_name(severity), + "resolution": enum_name(resolution), + "attribute": str(attribute or ""), + "description": str(description or ""), + "detail": str(detail or ""), + } + + +def fetch_shopping_products(client_config: ClientConfig, limit: int) -> tuple[str, list[dict]]: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + currency_code = fetch_currency_code(google_client, customer_id) + rows = run_query( + google_client, + customer_id, + f""" + SELECT + shopping_product.merchant_center_id, + shopping_product.channel, + shopping_product.language_code, + shopping_product.feed_label, + shopping_product.item_id, + shopping_product.title, + shopping_product.status, + shopping_product.issues + FROM shopping_product + LIMIT {int(limit)} + """, + ) + + products = [] + for row in rows: + product = row.shopping_product + issues = [issue_to_dict(issue) for issue in getattr(product, "issues", [])] + products.append( + { + "merchant_center_id": str(product.merchant_center_id or ""), + "channel": enum_name(product.channel), + "language_code": str(product.language_code or ""), + "feed_label": str(product.feed_label or ""), + "item_id": str(product.item_id or ""), + "title": str(product.title or ""), + "status": enum_name(product.status), + "issues": issues, + "issue_count": len(issues), + "impressions_30d": 0, + "clicks_30d": 0, + "cost_30d_micros": 0, + "conversions_30d": 0.0, + "conversion_value_30d": 0.0, + } + ) + return currency_code, products + + +def fetch_product_performance_30d(client_config: ClientConfig) -> dict[str, dict]: + google_client = get_google_ads_client(use_proto_plus=True) + rows = run_query( + google_client, + client_config.safe_customer_id, + """ + SELECT + segments.product_item_id, + segments.product_title, + metrics.impressions, + metrics.clicks, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value + FROM shopping_performance_view + WHERE segments.date DURING LAST_30_DAYS + LIMIT 10000 + """, + ) + + performance: dict[str, dict] = {} + for row in rows: + item_id = str(row.segments.product_item_id or "") + if not item_id: + continue + record = performance.setdefault( + item_id, + { + "item_id": item_id, + "title": str(row.segments.product_title or ""), + "impressions_30d": 0, + "clicks_30d": 0, + "cost_30d_micros": 0, + "conversions_30d": 0.0, + "conversion_value_30d": 0.0, + }, + ) + record["impressions_30d"] += int(row.metrics.impressions or 0) + record["clicks_30d"] += int(row.metrics.clicks or 0) + record["cost_30d_micros"] += int(row.metrics.cost_micros or 0) + record["conversions_30d"] += float(row.metrics.conversions or 0) + record["conversion_value_30d"] += float(row.metrics.conversions_value or 0) + return performance + + +def product_severity(product: dict) -> str: + if product["status"] in {"DISAPPROVED", "NOT_ELIGIBLE"}: + return "wysokie" + if product["status"] in {"LIMITED", "UNKNOWN", "UNSPECIFIED"}: + return "srednie" + if product["issue_count"] > 0: + return "srednie" + if product["status"] == "ELIGIBLE" and product["impressions_30d"] == 0: + return "niskie" + return "ok" + + +def product_flags(product: dict) -> list[str]: + flags = [] + if product["status"] in PROBLEM_PRODUCT_STATUSES: + flags.append(f"status: {product['status']}") + if product["issue_count"]: + flags.append(f"problemy MC/API: {product['issue_count']}") + if product["status"] == "ELIGIBLE" and product["impressions_30d"] == 0: + flags.append("brak wyswietlen 30 dni") + return flags or ["ok"] + + +def attach_performance(products: list[dict], performance: dict[str, dict]) -> list[dict]: + for product in products: + perf = performance.get(product["item_id"], {}) + product["impressions_30d"] = int(perf.get("impressions_30d", 0)) + product["clicks_30d"] = int(perf.get("clicks_30d", 0)) + product["cost_30d_micros"] = int(perf.get("cost_30d_micros", 0)) + product["conversions_30d"] = float(perf.get("conversions_30d", 0.0)) + product["conversion_value_30d"] = float(perf.get("conversion_value_30d", 0.0)) + product["severity"] = product_severity(product) + product["flags"] = product_flags(product) + severity_order = {"wysokie": 0, "srednie": 1, "niskie": 2, "ok": 9} + products.sort( + key=lambda row: ( + severity_order.get(row["severity"], 9), + -row["impressions_30d"], + row["title"], + ) + ) + return products + + +def build_status_summary(products: list[dict]) -> list[dict]: + counter = Counter(product["status"] for product in products) + return [{"status": key, "count": value} for key, value in counter.most_common()] + + +def build_issue_summary(products: list[dict]) -> list[dict]: + counter: Counter[str] = Counter() + for product in products: + for issue in product.get("issues", []): + code = issue.get("code") or "(brak kodu)" + counter[code] += 1 + return [{"issue_code": key, "count": value} for key, value in counter.most_common()] + + +def build_problem_items(products: list[dict], currency_code: str) -> list[dict]: + items = [] + for product in products: + if product["flags"] == ["ok"]: + continue + items.append( + { + "severity": product["severity"], + "item_id": product["item_id"], + "title": product["title"], + "status": product["status"], + "impressions_30d": product["impressions_30d"], + "clicks_30d": product["clicks_30d"], + "cost_30d": format_money(product["cost_30d_micros"], currency_code), + "flags": product["flags"], + "recommendation": "sprawdz status produktu w Merchant Center albo w diagnostyce produktow Google Ads", + } + ) + return items + + +def build_shopping_product_status_plan( + client_config: ClientConfig, + global_rules: dict, +) -> ShoppingProductStatusPlan: + rules = client_config.effective_rules(global_rules, "shopping_product_statuses") + limit = int(rules.get("limit", DEFAULT_PRODUCT_LIMIT)) + warnings = [] + currency_code = "" + try: + currency_code, products = fetch_shopping_products(client_config, limit) + except Exception as exc: + products = [] + warnings.append(f"Nie udalo sie pobrac statusow shopping_product z Google Ads API: {exc}") + + try: + performance = fetch_product_performance_30d(client_config) + except Exception as exc: + performance = {} + warnings.append(f"Nie udalo sie pobrac emisji produktow z shopping_performance_view: {exc}") + + products = attach_performance(products, performance) + if not products: + warnings.append("Nie znaleziono produktow Shopping albo nie udalo sie ich pobrac.") + if len(products) >= limit: + warnings.append(f"Pobrano pierwsze {limit} produktow z Google Ads API; zwieksz limit w regulach klienta, jezeli trzeba szerszego audytu.") + warnings.append( + "To zadanie sprawdza status i emisje produktu. Naprawy feedu, tytulow, kategorii Google i unit pricing pozostaja w osobnych zadaniach." + ) + + 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_for_task(TASK_ID) + ] + if not knowledge_rules: + warnings.append( + "Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. " + "Reguly dotyczace statusow produktow Shopping bedziemy dopisywac osobno po akceptacji uzytkownika." + ) + + return ShoppingProductStatusPlan( + product_limit=limit, + products=products, + status_summary=build_status_summary(products), + issue_summary=build_issue_summary(products), + problem_items=build_problem_items(products, currency_code), + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_shopping_product_status_plan(domain: str, plan: ShoppingProductStatusPlan) -> 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 statusow produktow Shopping", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Produkty sprawdzone: {len(plan.products)}", + f"- Elementy do oceny: {len(plan.problem_items)}", + 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"| {md_cell(row.get('area', ''))} | {md_cell(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.status_summary: + lines.extend(["## Statusy produktow", "", "| Status | Liczba |", "| --- | --- |"]) + for row in plan.status_summary: + lines.append(f"| {row['status']} | {row['count']} |") + lines.append("") + if plan.issue_summary: + lines.extend(["## Problemy produktow", "", "| Kod problemu | Liczba |", "| --- | --- |"]) + for row in plan.issue_summary: + lines.append(f"| {md_cell(row['issue_code'])} | {row['count']} |") + lines.append("") + if plan.problem_items: + lines.extend( + [ + "## Elementy do oceny", + "", + "| Waznosc | Produkt | Tytul | Status | Wyswietlenia 30d | Klikniecia 30d | Koszt 30d | Flagi | Rekomendacja |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for item in plan.problem_items: + lines.append( + f"| {item['severity']} | {md_cell(item['item_id'])} | {md_cell(item['title'])} | {item['status']} | " + f"{item['impressions_30d']} | {item['clicks_30d']} | {item['cost_30d']} | " + f"{md_cell(', '.join(item['flags']))} | {md_cell(item['recommendation'])} |" + ) + lines.append("") + if plan.products: + lines.extend( + [ + "## Produkty", + "", + "| Produkt | Tytul | Status | Jezyk | Feed label | Wyswietlenia 30d | Klikniecia 30d | Flagi |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for product in plan.products: + lines.append( + f"| {md_cell(product['item_id'])} | {md_cell(product['title'])} | {product['status']} | " + f"{product['language_code']} | {md_cell(product['feed_label'])} | {product['impressions_30d']} | " + f"{product['clicks_30d']} | {md_cell(', '.join(product['flags']))} |" + ) + 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"| {md_cell(rule.get('id', ''))} | {md_cell(rule.get('topic', ''))} | " + f"{md_cell(rule.get('recommendation', ''))} | {md_cell(rule.get('risk', ''))} |" + ) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_shopping_product_status_plan(plan: ShoppingProductStatusPlan) -> None: + print("\nPlan sprawdzenia statusow produktow Shopping") + print_table( + ["Metryka", "Liczba"], + [ + ["Produkty sprawdzone", str(len(plan.products))], + ["Elementy do oceny", str(len(plan.problem_items))], + ["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.status_summary: + print("\nStatusy produktow") + print_table(["Status", "Liczba"], [[row["status"], str(row["count"])] for row in plan.status_summary]) + if plan.issue_summary: + print("\nProblemy produktow") + print_table(["Kod problemu", "Liczba"], [[row["issue_code"], str(row["count"])] for row in plan.issue_summary]) + if plan.problem_items: + print("\nElementy do oceny") + print_table( + ["Nr", "Waznosc", "Produkt", "Status", "Wysw. 30d", "Klik. 30d", "Flagi"], + [ + [ + str(index), + item["severity"], + item["item_id"], + item["status"], + str(item["impressions_30d"]), + str(item["clicks_30d"]), + ", ".join(item["flags"]), + ] + for index, item in enumerate(plan.problem_items[:30], 1) + ], + ) + if len(plan.problem_items) > 30: + print(f"... oraz {len(plan.problem_items) - 30} kolejnych elementow w pliku planu") + 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_shopping_product_status_plan( + client_config: ClientConfig, + plan: ShoppingProductStatusPlan, + show_navigation: bool = True, +) -> None: + print("\nTo zadanie jest audytem statusow produktow i nie wdraza zmian w Google Ads, adsPRO ani Merchant Center.") + 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", + "product": ", ".join(item["item_id"] for item in plan.problem_items[:10]), + "summary": { + "products": len(plan.products), + "problem_items": len(plan.problem_items), + "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_shopping_product_statuses( + 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 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 = ShoppingProductStatusPlan.from_dict(plan_data) + print_shopping_product_status_plan(plan) + apply_shopping_product_status_plan(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan sprawdzenia statusow produktow Shopping...") + plan = build_shopping_product_status_plan(client_config, global_rules) + print_shopping_product_status_plan(plan) + json_path, md_path = save_shopping_product_status_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", + "product": ", ".join(item["item_id"] for item in plan.problem_items[:10]), + "summary": { + "products": len(plan.products), + "problem_items": len(plan.problem_items), + "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 statusow produktow Shopping.") + if show_navigation: + print_next_navigation(client_config.domain) diff --git a/src/gads_v2/tasks/shopping_troas_ag_optimization.py b/src/gads_v2/tasks/shopping_troas_ag_optimization.py new file mode 100644 index 0000000..3c29821 --- /dev/null +++ b/src/gads_v2/tasks/shopping_troas_ag_optimization.py @@ -0,0 +1,788 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +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 +from ..knowledge.store import rules_for_task +from ..table import print_table + + +TASK_ID = "optimize_shopping_troas_ag" +TASK_NAME = "Automatyzacja tROAS per grupa reklam PLA" + +MIN_CLICKS_ALL_TIME = 100 +CONVERSIONS_TRIGGER = 10 +MIN_ROAS_DELTA = 1.0 +MAX_TROAS_STEP = 0.5 + + +SCOPE = [ + { + "area": "Zakres", + "check": "Analizuje tylko aktywne grupy reklam w aktywnych kampaniach Standard Shopping.", + }, + { + "area": "Dane 30 dni", + "check": "Liczy realny ROAS grupy reklam z kosztu i wartosci konwersji z ostatnich 30 dni.", + }, + { + "area": "100 klikow", + "check": "Grupy reklam z mniej niz 100 klikami od poczatku trafiaja tylko na watchliste.", + }, + { + "area": "Trigger 10 konwersji", + "check": "Podbicie tROAS wymaga co najmniej 10 nowych konwersji wzgledem lokalnego baseline.", + }, + { + "area": "Stopniowanie", + "check": "Jedna analiza moze podniesc tROAS grupy reklam maksymalnie o 0.5.", + }, + { + "area": "Rollback", + "check": "Jesli po zmianie realny ROAS z 30 dni spada ponizej ustawionego tROAS, plan proponuje przywrocenie poprzedniej wartosci.", + }, +] + + +OUT_OF_SCOPE = [ + "Performance Max", + "Search", + "kampanie Shopping bez grup reklam", + "automatyczne wdrozenie bez akceptacji uzytkownika", + "pauzowanie grup reklam z niskim albo zerowym ROAS", +] + + +@dataclass +class ShoppingTroasAgPlan: + currency_code: str + ad_groups: list[dict] + watchlist: list[dict] + target_changes: list[dict] + rollback_changes: list[dict] + scope: list[dict] + out_of_scope: list[str] + knowledge_rules: list[dict] + warnings: list[str] + + def to_dict(self) -> dict: + changes = self.rollback_changes + self.target_changes + return { + "task": TASK_ID, + "task_name": TASK_NAME, + "currency_code": self.currency_code, + "ad_groups": self.ad_groups, + "watchlist": self.watchlist, + "target_changes": self.target_changes, + "rollback_changes": self.rollback_changes, + "changes": changes, + "scope": self.scope, + "out_of_scope": self.out_of_scope, + "knowledge_rules": self.knowledge_rules, + "warnings": self.warnings, + } + + @classmethod + def from_dict(cls, data: dict) -> "ShoppingTroasAgPlan": + return cls( + currency_code=data.get("currency_code", ""), + ad_groups=data.get("ad_groups", []), + watchlist=data.get("watchlist", []), + target_changes=data.get("target_changes", []), + rollback_changes=data.get("rollback_changes", []), + 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: Any) -> str: + name = getattr(value, "name", None) + if name: + return name + return str(value) + + +def safe_float(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def safe_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def micros_to_amount(value: int | float) -> float: + return round(float(value or 0) / 1_000_000, 2) + + +def format_money(value: int | float, currency_code: str) -> str: + suffix = f" {currency_code}" if currency_code else "" + return f"{micros_to_amount(value):.2f}{suffix}" + + +def format_decimal(value: int | float) -> str: + return f"{float(value or 0):.2f}" + + +def troas_label(value: int | float) -> str: + if not value: + return "brak override" + return f"{float(value):.2f}" + + +def baseline_path(domain: str) -> Path: + return client_dir(domain) / "troas_ag_baseline.json" + + +def load_baseline(domain: str) -> dict: + path = baseline_path(domain) + if not path.exists(): + return {"ad_groups": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {"ad_groups": {}} + if not isinstance(data, dict): + return {"ad_groups": {}} + data.setdefault("ad_groups", {}) + return data + + +def save_baseline(domain: str, data: dict) -> Path: + path = baseline_path(domain) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + return path + + +def real_roas(cost_micros: int, conversion_value: float) -> float: + cost = micros_to_amount(cost_micros) + if cost <= 0: + return 0.0 + return round(float(conversion_value or 0) / cost, 2) + + +def effective_troas(row: dict) -> float: + ad_group_target = safe_float(row.get("ad_group_target_roas")) + if ad_group_target > 0: + return ad_group_target + return safe_float(row.get("campaign_target_roas")) or safe_float(row.get("campaign_maximize_target_roas")) + + +def current_target_source(row: dict) -> str: + if safe_float(row.get("ad_group_target_roas")) > 0: + return "grupa reklam" + if safe_float(row.get("campaign_target_roas")) > 0 or safe_float(row.get("campaign_maximize_target_roas")) > 0: + return "kampania" + return "brak celu" + + +def suggested_raise(row: dict) -> dict | None: + if safe_int(row.get("clicks_all_time")) < MIN_CLICKS_ALL_TIME: + return None + conversions_delta = safe_float(row.get("new_conversions_since_baseline")) + if conversions_delta < CONVERSIONS_TRIGGER: + return None + current = safe_float(row.get("troas_pre")) + actual = safe_float(row.get("real_roas_30d")) + if current <= 0: + return None + if actual - current <= MIN_ROAS_DELTA: + return None + target = min(round(actual - 0.5, 2), round(current + MAX_TROAS_STEP, 2)) + if target <= current: + return None + return { + "change_type": "raise", + "campaign_id": row["campaign_id"], + "campaign_name": row["campaign_name"], + "ad_group_id": row["ad_group_id"], + "ad_group_name": row["ad_group_name"], + "current_troas": current, + "target_troas": target, + "previous_troas": current, + "real_roas_30d": actual, + "delta": round(actual - current, 2), + "new_conversions_since_baseline": conversions_delta, + "clicks_all_time": row.get("clicks_all_time", 0), + "reason": "realny ROAS z 30 dni jest wyzszy od tROAS o ponad 1.0 i minelo co najmniej 10 nowych konwersji od baseline", + } + + +def suggested_rollback(row: dict) -> dict | None: + baseline = row.get("baseline") or {} + last_target = safe_float(baseline.get("last_target_troas")) + previous = safe_float(baseline.get("previous_troas")) + actual = safe_float(row.get("real_roas_30d")) + if last_target <= 0 or previous <= 0: + return None + if actual >= last_target: + return None + return { + "change_type": "rollback", + "campaign_id": row["campaign_id"], + "campaign_name": row["campaign_name"], + "ad_group_id": row["ad_group_id"], + "ad_group_name": row["ad_group_name"], + "current_troas": row["troas_pre"], + "target_troas": previous, + "previous_troas": previous, + "real_roas_30d": actual, + "delta": round(actual - last_target, 2), + "new_conversions_since_baseline": row.get("new_conversions_since_baseline", 0), + "clicks_all_time": row.get("clicks_all_time", 0), + "reason": "realny ROAS po zmianie jest nizszy niz ustawiony tROAS; plan proponuje przywrocenie poprzedniej wartosci", + } + + +def watchlist_reason(row: dict) -> str: + clicks = safe_int(row.get("clicks_all_time")) + actual = safe_float(row.get("real_roas_30d")) + conversions = safe_float(row.get("conversions_30d")) + if clicks < MIN_CLICKS_ALL_TIME and (actual <= 0 or conversions <= 0): + return "mniej niz 100 klikow od poczatku i niski albo zerowy ROAS - obserwuj, bez akcji" + if clicks < MIN_CLICKS_ALL_TIME: + return "mniej niz 100 klikow od poczatku - obserwuj, bez akcji" + return "" + + +def fetch_currency_code(google_client, customer_id: str) -> str: + rows = run_query( + google_client, + customer_id, + """ + SELECT + customer.currency_code + FROM customer + """, + ) + if not rows: + return "" + return str(rows[0].customer.currency_code or "") + + +def fetch_all_time_clicks(google_client, customer_id: str) -> dict[str, int]: + rows = run_query( + google_client, + customer_id, + """ + SELECT + ad_group.id, + metrics.clicks + FROM ad_group + WHERE campaign.status = 'ENABLED' + AND ad_group.status = 'ENABLED' + AND campaign.advertising_channel_type = 'SHOPPING' + """, + ) + clicks: dict[str, int] = {} + for row in rows: + clicks[str(row.ad_group.id)] = safe_int(row.metrics.clicks) + return clicks + + +def fetch_shopping_ad_groups(client_config: ClientConfig) -> tuple[str, list[dict], list[str]]: + warnings: list[str] = [] + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + currency_code = fetch_currency_code(google_client, customer_id) + all_time_clicks = fetch_all_time_clicks(google_client, customer_id) + rows = run_query( + google_client, + customer_id, + """ + SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + campaign.target_roas.target_roas, + campaign.maximize_conversion_value.target_roas, + ad_group.id, + ad_group.name, + ad_group.status, + ad_group.type, + ad_group.target_roas, + ad_group.effective_target_roas, + ad_group.effective_target_roas_source, + metrics.cost_micros, + metrics.clicks, + metrics.conversions, + metrics.conversions_value + FROM ad_group + WHERE campaign.status = 'ENABLED' + AND ad_group.status = 'ENABLED' + AND campaign.advertising_channel_type = 'SHOPPING' + AND segments.date DURING LAST_30_DAYS + """, + ) + + records = [] + for row in rows: + ad_group = row.ad_group + campaign = row.campaign + ad_group_id = str(ad_group.id) + cost_micros = safe_int(row.metrics.cost_micros) + conversion_value = safe_float(row.metrics.conversions_value) + record = { + "campaign_id": str(campaign.id), + "campaign_name": campaign.name, + "campaign_status": enum_name(campaign.status), + "channel_type": enum_name(campaign.advertising_channel_type), + "campaign_target_roas": safe_float(campaign.target_roas.target_roas), + "campaign_maximize_target_roas": safe_float(campaign.maximize_conversion_value.target_roas), + "ad_group_id": ad_group_id, + "ad_group_name": ad_group.name, + "ad_group_status": enum_name(ad_group.status), + "ad_group_type": enum_name(ad_group.type), + "ad_group_target_roas": safe_float(ad_group.target_roas), + "ad_group_effective_target_roas": safe_float(ad_group.effective_target_roas), + "ad_group_effective_target_roas_source": enum_name(ad_group.effective_target_roas_source), + "cost_30d_micros": cost_micros, + "clicks_30d": safe_int(row.metrics.clicks), + "clicks_all_time": all_time_clicks.get(ad_group_id, 0), + "conversions_30d": round(safe_float(row.metrics.conversions), 2), + "conversion_value_30d": round(conversion_value, 2), + "real_roas_30d": real_roas(cost_micros, conversion_value), + } + record["troas_pre"] = effective_troas(record) + record["troas_source"] = current_target_source(record) + records.append(record) + if not records: + warnings.append("Nie znaleziono aktywnych grup reklam w aktywnych kampaniach Standard Shopping z danymi z ostatnich 30 dni.") + return currency_code, records, warnings + + +def build_shopping_troas_ag_plan(client_config: ClientConfig) -> ShoppingTroasAgPlan: + warnings: list[str] = [] + try: + currency_code, ad_groups, fetch_warnings = fetch_shopping_ad_groups(client_config) + warnings.extend(fetch_warnings) + except Exception as exc: + currency_code = "" + ad_groups = [] + warnings.append(f"Nie udalo sie pobrac grup reklam Shopping z Google Ads API: {exc}") + + baseline = load_baseline(client_config.domain) + baseline_rows = baseline.get("ad_groups", {}) + watchlist: list[dict] = [] + target_changes: list[dict] = [] + rollback_changes: list[dict] = [] + + for row in ad_groups: + stored = baseline_rows.get(row["ad_group_id"], {}) + row["baseline"] = stored + baseline_conversions = safe_float(stored.get("baseline_conversions_30d")) + row["baseline_conversions_30d"] = baseline_conversions + row["baseline_known"] = bool(stored) + row["new_conversions_since_baseline"] = round(max(0.0, row["conversions_30d"] - baseline_conversions), 2) + row["watchlist_reason"] = watchlist_reason(row) + if row["watchlist_reason"]: + watchlist.append(row) + continue + rollback = suggested_rollback(row) + if rollback: + rollback_changes.append(rollback) + continue + change = suggested_raise(row) + if change: + target_changes.append(change) + + ad_groups.sort(key=lambda item: (item["campaign_name"], item["ad_group_name"])) + watchlist.sort( + key=lambda item: ( + -safe_int(item["clicks_all_time"]), + -safe_float(item["conversions_30d"]), + item["campaign_name"], + item["ad_group_name"], + ) + ) + target_changes.sort(key=lambda item: (-item["delta"], item["campaign_name"], item["ad_group_name"])) + rollback_changes.sort(key=lambda item: (item["campaign_name"], item["ad_group_name"])) + + 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.") + + return ShoppingTroasAgPlan( + currency_code=currency_code, + ad_groups=ad_groups, + watchlist=watchlist, + target_changes=target_changes, + rollback_changes=rollback_changes, + scope=SCOPE, + out_of_scope=OUT_OF_SCOPE, + knowledge_rules=knowledge_rules, + warnings=warnings, + ) + + +def save_shopping_troas_ag_plan(domain: str, plan: ShoppingTroasAgPlan) -> 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: Automatyzacja tROAS per grupa reklam PLA", + "", + f"Klient: {domain}", + f"Utworzono: {ts.isoformat(timespec='seconds')}", + "", + "## Podsumowanie", + "", + f"- Grupy reklam Shopping z danymi 30 dni: {len(plan.ad_groups)}", + f"- Kandydaci do podniesienia tROAS: {len(plan.target_changes)}", + f"- Kandydaci do rollbacku: {len(plan.rollback_changes)}", + f"- Watchlista bez akcji: {len(plan.watchlist)}", + f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}", + "", + ] + 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("") + if plan.target_changes: + lines.extend( + [ + "## Kandydaci do podniesienia tROAS", + "", + "| Kampania | Grupa reklam | tROAS pre | Real ROAS | Delta | Nowy tROAS | Nowe konwersje | Klikniecia all-time |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for change in plan.target_changes: + lines.append( + f"| {change['campaign_name']} | {change['ad_group_name']} | " + f"{format_decimal(change['current_troas'])} | {format_decimal(change['real_roas_30d'])} | " + f"{format_decimal(change['delta'])} | {format_decimal(change['target_troas'])} | " + f"{format_decimal(change['new_conversions_since_baseline'])} | {change['clicks_all_time']} |" + ) + lines.append("") + if plan.rollback_changes: + lines.extend( + [ + "## Kandydaci do rollbacku", + "", + "| Kampania | Grupa reklam | Obecny tROAS | Real ROAS | Przywroc tROAS | Powod |", + "| --- | --- | --- | --- | --- | --- |", + ] + ) + for change in plan.rollback_changes: + lines.append( + f"| {change['campaign_name']} | {change['ad_group_name']} | " + f"{format_decimal(change['current_troas'])} | {format_decimal(change['real_roas_30d'])} | " + f"{format_decimal(change['target_troas'])} | {change['reason']} |" + ) + lines.append("") + if plan.watchlist: + lines.extend( + [ + "## Watchlista bez akcji", + "", + "| Kampania | Grupa reklam | Klikniecia all-time | Konwersje 30 dni | Real ROAS | Powod |", + "| --- | --- | --- | --- | --- | --- |", + ] + ) + for row in plan.watchlist: + lines.append( + f"| {row['campaign_name']} | {row['ad_group_name']} | {row['clicks_all_time']} | " + f"{format_decimal(row['conversions_30d'])} | {format_decimal(row['real_roas_30d'])} | " + f"{row['watchlist_reason']} |" + ) + 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("") + lines.extend(["## Poza zakresem tego zadania", ""]) + lines.extend(f"- {item}" for item in plan.out_of_scope) + lines.append("") + md_path.write_text("\n".join(lines), encoding="utf-8") + return json_path, md_path + + +def print_shopping_troas_ag_plan(plan: ShoppingTroasAgPlan) -> None: + print("\nPlan automatyzacji tROAS per grupa reklam PLA") + print_table( + ["Metryka", "Liczba"], + [ + ["Grupy reklam Shopping z danymi 30 dni", str(len(plan.ad_groups))], + ["Kandydaci do podniesienia tROAS", str(len(plan.target_changes))], + ["Kandydaci do rollbacku", str(len(plan.rollback_changes))], + ["Watchlista bez akcji", str(len(plan.watchlist))], + ["Reguly wiedzy", str(len(plan.knowledge_rules))], + ], + ) + 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)], + ) + if plan.target_changes: + print("\nKandydaci do podniesienia tROAS") + print_table( + ["Nr", "Kampania", "Grupa reklam", "tROAS pre", "Real ROAS", "Delta", "Nowy tROAS", "Nowe konw."], + [ + [ + str(index), + change["campaign_name"], + change["ad_group_name"], + format_decimal(change["current_troas"]), + format_decimal(change["real_roas_30d"]), + format_decimal(change["delta"]), + format_decimal(change["target_troas"]), + format_decimal(change["new_conversions_since_baseline"]), + ] + for index, change in enumerate(plan.target_changes, 1) + ], + ) + if plan.rollback_changes: + print("\nKandydaci do rollbacku") + print_table( + ["Nr", "Kampania", "Grupa reklam", "Obecny tROAS", "Real ROAS", "Przywroc", "Powod"], + [ + [ + str(index), + change["campaign_name"], + change["ad_group_name"], + format_decimal(change["current_troas"]), + format_decimal(change["real_roas_30d"]), + format_decimal(change["target_troas"]), + change["reason"], + ] + for index, change in enumerate(plan.rollback_changes, 1) + ], + ) + if plan.watchlist: + print("\nWatchlista bez akcji") + print_table( + ["Nr", "Kampania", "Grupa reklam", "Klikniecia", "Konw. 30d", "ROAS", "Powod"], + [ + [ + str(index), + row["campaign_name"], + row["ad_group_name"], + str(row["clicks_all_time"]), + format_decimal(row["conversions_30d"]), + format_decimal(row["real_roas_30d"]), + row["watchlist_reason"], + ] + for index, row in enumerate(plan.watchlist, 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) + ], + ) + + +def apply_troas_changes(client_config: ClientConfig, plan: ShoppingTroasAgPlan, show_navigation: bool = True) -> None: + changes = plan.rollback_changes + plan.target_changes + changed = 0 + errors: list[str] = [] + if changes: + google_client = get_google_ads_client(use_proto_plus=True) + customer_id = client_config.safe_customer_id + service = google_client.get_service("AdGroupService") + operations = [] + for change in changes: + op = google_client.get_type("AdGroupOperation") + ad_group = op.update + ad_group.resource_name = service.ad_group_path(customer_id, change["ad_group_id"]) + ad_group.target_roas = float(change["target_troas"]) + op.update_mask = field_mask_pb2.FieldMask(paths=["target_roas"]) + operations.append(op) + try: + response = service.mutate_ad_groups(customer_id=customer_id, operations=operations) + changed = len(response.results) + except Exception as exc: + errors.append(str(exc)) + + print("\nWynik wdrozenia zmian tROAS grup reklam") + print(f"Zmieniono grup reklam: {changed}") + print(f"Bledy: {len(errors)}") + for error in errors: + print(f"Blad: {error}") + + if changed and not errors: + baseline = load_baseline(client_config.domain) + baseline_rows = baseline.setdefault("ad_groups", {}) + ts = now_local().isoformat(timespec="seconds") + ad_groups_by_id = {row["ad_group_id"]: row for row in plan.ad_groups} + for change in changes: + source = ad_groups_by_id.get(change["ad_group_id"], {}) + baseline_rows[change["ad_group_id"]] = { + "updated_at": ts, + "campaign_id": change["campaign_id"], + "campaign_name": change["campaign_name"], + "ad_group_id": change["ad_group_id"], + "ad_group_name": change["ad_group_name"], + "previous_troas": change["current_troas"], + "last_target_troas": change["target_troas"], + "baseline_conversions_30d": source.get("conversions_30d", 0), + "baseline_real_roas_30d": source.get("real_roas_30d", 0), + "change_type": change["change_type"], + } + baseline_file = save_baseline(client_config.domain, baseline) + print(f"Baseline tROAS AG: {baseline_file}") + + rows = [ + { + "klient": client_config.domain, + "kampania": change.get("campaign_name", ""), + "grupa reklam": change.get("ad_group_name", ""), + "czynnosc": "Rollback tROAS" if change.get("change_type") == "rollback" else "Podniesienie tROAS", + "produkt": f"{format_decimal(change.get('current_troas', 0))} -> {format_decimal(change.get('target_troas', 0))}", + } + for change in changes + ] + changes_path = append_change_markdown(client_config.domain, TASK_NAME, rows) + history_path = append_history( + client_config.domain, + { + "task": TASK_NAME, + "status": "wdrozono zmiany tROAS AG" if changes and not errors else "audyt oznaczony jako wykonany", + "campaign": ", ".join(change.get("campaign_name", "") for change in changes[:10]), + "summary": { + "ad_groups": len(plan.ad_groups), + "target_changes": len(plan.target_changes), + "rollback_changes": len(plan.rollback_changes), + "changed": changed, + "errors": len(errors), + }, + }, + ) + print(f"Historia JSONL: {history_path}") + print(f"Historia Markdown: {changes_path}") + if show_navigation: + print_next_navigation(client_config.domain) + + +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 run_optimize_shopping_troas_ag( + 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 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')}, a wybrano {client_config.domain}.") + if show_navigation: + print_next_navigation(client_config.domain) + return + plan = ShoppingTroasAgPlan.from_dict(plan_data) + print_shopping_troas_ag_plan(plan) + apply_troas_changes(client_config, plan, show_navigation=show_navigation) + return + + print(f"\nKlient: {client_config.domain}") + print("Przygotowuje plan automatyzacji tROAS per grupa reklam PLA...") + plan = build_shopping_troas_ag_plan(client_config) + print_shopping_troas_ag_plan(plan) + json_path, md_path = save_shopping_troas_ag_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(change["campaign_name"] for change in (plan.rollback_changes + plan.target_changes)[:10]), + "summary": { + "ad_groups": len(plan.ad_groups), + "target_changes": len(plan.target_changes), + "rollback_changes": len(plan.rollback_changes), + "watchlist": len(plan.watchlist), + "knowledge_rules": len(plan.knowledge_rules), + }, + }, + ) + + if plan_only: + print("\nTryb plan-only: zmiany nie zostaly wdrozone.") + if show_navigation: + print_next_navigation(client_config.domain) + return + + print("\nBrak automatycznego wdrozenia. Uzyj zapisanego planu i potwierdzenia, aby wdrozyc zmiany tROAS.") + if show_navigation: + print_next_navigation(client_config.domain)