first commit

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

4
.claude/memory/MEMORY.md Normal file
View File

@@ -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

View File

@@ -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`.

View File

@@ -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]].

21
.env.example Normal file
View File

@@ -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

6
.gitignore vendored Normal file
View File

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

View File

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

271
.sync/Archive/AGENTS.md Normal file
View File

@@ -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 <numer>
```
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 <numer-klienta> --task-number <numer-zadania> --plan-only
```
Preferowana komenda dla wyboru z listy:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --select <wybor> --plan-only
```
Po wyborze calej grupy uruchom:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --select 1.0 --plan-only
```
Po wyborze wszystkich grup uruchom:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --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/<domena>/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 <numer-klienta> --task-number <numer-zadania> --apply-plan <sciezka-do-planu-json> --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/<domena>/history/YYYY-MM-DD.jsonl`
- `clients/<domena>/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.

View File

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

67
.sync/Archive/README.md Normal file
View File

@@ -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/<domena>/plans/`.
Agent czyta plik `.md` albo `.json`, analizuje go i pyta Cie o zgode.
Po Twojej akceptacji agent uruchamia wdrozenie konkretnego planu:
```powershell
python gads.py --client laitica.pl --task sync_pla_cl1 --apply-plan clients/laitica.pl/plans/PLAN.json --confirm-apply TAK
```
## MVP
Pierwsze zadanie:
- pobiera kampanie `[PLA_CL1]` z Google Ads,
- wyciaga segmenty CL1 z nazw kampanii,
- pobiera produkty z adsPRO,
- przygotowuje plan synchronizacji grup reklam,
- czeka na akceptacje przed wdrozeniem zmian,
- zapisuje historie w katalogu klienta.
## Dane i historia
- `config/clients.toml` - lista klientow i identyfikatory kont.
- `config/clients.toml` - takze reguly globalne i wyjatki per klient, np. ustawienia kampanii PLA.
- `.env` - dane dostepowe do Google Ads i adsPRO.
- `clients/<domena>/data/` - pobrane dane robocze.
- `clients/<domena>/history/YYYY-MM-DD.jsonl` - historia do filtrowania po kliencie, dacie i kampanii.
- `clients/<domena>/changes/YYYY-MM-DD.md` - czytelny dziennik zmian.
Narzedzie nie uzywa API modeli AI. Claude Code, Codex albo Gemini CLI moga uruchamiac te same komendy terminalowe.

View File

@@ -0,0 +1,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

View File

@@ -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

View File

@@ -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."

View File

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

View File

@@ -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 <numer>")
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 <nr> --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")

View File

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

1
.sync/FolderType Normal file
View File

@@ -0,0 +1 @@
d8:brandingi0e31:disable_remove_from_all_devicesi0e4:modei0e5:owner5:jacek4:typei2ee

1
.sync/ID Normal file
View File

@@ -0,0 +1 @@
kƒ…?@€E<E282AC>ÏÇ*À×Àïϼ—

54
.sync/IgnoreList Normal file
View File

@@ -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

8
.sync/StreamsList Normal file
View File

@@ -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

0
.sync/root_acl_entry Normal file
View File

397
AGENTS.md Normal file
View File

@@ -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 <numer>
```
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 <numer-klienta> --task-number <numer-zadania> --plan-only
```
Preferowana komenda dla wyboru z listy:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --select <wybor> --plan-only
```
Po wyborze calej grupy uruchom:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --select 1.0 --plan-only
```
Po wyborze wszystkich grup uruchom:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --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/<domena>/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 <numer-klienta> --task-number <numer-zadania> --apply-plan <sciezka-do-planu-json> --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 <numer-klienta> --month <YYYY-MM>
```
Komenda najpierw pobiera dane i zatrzymuje sie przed generowaniem HTML. Tworzy plik roboczy:
```text
scripts/reports/output/<domena>_<YYYY-MM>_recommendations.json
```
Wnioski i rekomendacje przygotowuje agent AI, nie skrypt. Agent ma przeczytac dane raportu i kontekst w pliku rekomendacji, uzupelnic `recommendations` konkretnymi wnioskami, pokazac je uzytkownikowi i zapytac o akceptacje.
Wnioski pisz z perspektywy osoby, ktora obsluguje konto Google Ads klienta. Nie pisz do klienta, ze `warto cos sprawdzic`, `trzeba zweryfikowac` albo `nalezy przeanalizowac`, jakby decyzja byla po jego stronie. Pisz decyzyjnie: co robimy, co zostawiamy, co ograniczamy, co kontrolujemy i jaki jest nastepny krok po naszej stronie. Unikaj bezosobowych, nijakich rekomendacji.
W tekstach raportu dla klienta uzywaj poprawnych polskich znakow. Dotyczy to szczegolnie tytulow, wnioskow i rekomendacji w pliku `recommendations`. Nie zapisuj tam wersji bez ogonkow typu `zwiekszamy`, `wartosc`, `srednia`, jezeli tekst trafi do HTML widocznego dla klienta.
Po akceptacji wnioskow uruchom:
```powershell
python gads.py raport-klienta --client <domena> --month <YYYY-MM> --confirm-recommendations TAK
```
Dopiero wtedy komenda generuje lokalny raport HTML w:
```text
scripts/reports/output/<domena>/<YYYY-MM>/index.html
```
Jeżeli klient ma w `config/clients.toml` ustawione `sales_history_sheet`, historia sprzedaży miesięcznej oraz trzy kafelki w sekcji `E-commerce — Sprzedaż` mają pochodzić z tego arkusza Google Sheet. Arkusz powinien zawierać kolumny: `Miesiąc`, `Transakcje`, `Przychody`, `Średnia wartość koszyka`. Nie zastępuj tych danych GA4, jeżeli arkusz jest skonfigurowany.
Po wygenerowaniu raportu pokaz uzytkownikowi sciezke do pliku i popros o akceptacje przed wysylka na serwer. Nie wysylaj raportu bez jasnej zgody uzytkownika.
Po akceptacji uruchom upload:
```powershell
python gads.py raport-klienta --client <domena> --month <YYYY-MM> --confirm-upload TAK
```
Po wysylce podaj URL:
```text
https://adspro.projectpro.pl/raporty/<slug>/<YYYY-MM>/
```
## Zasady komunikacji
- Pisz po polsku.
- 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/<domena>/history/YYYY-MM-DD.jsonl`
- `clients/<domena>/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.

337
DEVELOPMENT.md Normal file
View File

@@ -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/<domena>/plans/` jako `.json` i `.md`.
4. W trybie `--plan-only` nie wdrazaj zmian.
5. Po akceptacji uzytkownika wdrazaj tylko plan zapisany w JSON.
6. Zapisz historie do:
- `clients/<domena>/history/YYYY-MM-DD.jsonl`
- `clients/<domena>/changes/YYYY-MM-DD.md`
Agent AI prowadzi uzytkownika, ale logika pobierania danych, analizy i wdrozenia zmian ma byc w Pythonie.
## 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 <nr> --plan-only
```
Jesli zadanie wdraza zmiany, przetestuj najpierw tylko `--plan-only`.
## Format komunikacji agentow
Instrukcja dla agentow jest w:
```text
AGENTS.md
```
Po dodaniu nowego typu zadania dopisz tam tylko specjalne zasady, jesli agent ma wiedziec cos ponad standardowy przeplyw.
## 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 "<id_reguly>" --task "<task_id>"
```
- Odrzucenie propozycji:
```powershell
python gads.py wiedza odrzuc --rule-id "<id_reguly>" --task "<task_id>"
```
- 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 "<id_reguly>"
python gads.py wiedza aktywuj --rule-id "<id_reguly>"
python gads.py wiedza duplikat --rule-id "<id_reguly>" --duplicate-of "<id_reguly_nadrzednej>"
```
- 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
```

29259
KNOWLAGE.md Normal file

File diff suppressed because one or more lines are too long

274
OLD_COMMANDS_CHECKLIST.md Normal file
View File

@@ -0,0 +1,274 @@
# Lista sprawdzanych rzeczy ze starego systemu
Zrodlo: stare komendy z `D:\google ads\.claude\commands\analiza-*.md`.
Ten plik jest backlogiem funkcji do stopniowego przenoszenia do `D:\google ads ver 2\` jako grupy zadan i skrypty Python.
## Proces ogolny
### Analiza klienta
- Swiezosc danych klienta przed analiza.
- Odczyt historii pracy klienta.
- Odczyt TODO klienta.
- Ustalenie, od ktorego modulu zaczac.
- Wykonywanie modulow po kolei.
- Skip logic dla modulow miesiecznych.
- Zapis historii po module.
- Podsumowanie koncowe: Health Score, Grade, Quick Wins, Wasted Spend.
## Konto i struktura
### Audyt konta
- Swiezosc plikow danych.
- Komplet danych klienta: branza, model biznesu, kraje, minimalny ROAS, cel biznesowy, AOV.
- Spojnosc CSV: koszt z `kampanie.csv` vs `kampanie_miesieczne.csv`.
- Struktura aktywnych kampanii: typ, strategia, budzet, koszt, konwersje, ROAS, rola.
- Liczba wstrzymanych kampanii per typ.
- Luki strukturalne: brak brand, shopping, remarketing, gdy sa uzasadnione.
- Historia ostatnich analiz per modul.
- Kwalifikacja modulow do wykonania dla klienta.
## Konwersje
### Tracking i jakosc konwersji
- Lista akcji konwersji.
- Primary / secondary per akcja.
- Czy istnieje minimum jedna makrokonwersja jako Primary.
- Czy mikrokonwersje nie sa Primary.
- Duplikaty: Google Ads tag vs GA4 import.
- Wolumen 30 dni dla potencjalnych duplikatow.
- Read-only akcje, ktorych nie wolno modyfikowac.
- Enhanced Conversions dla akcji Primary.
- Consent Mode v2 dla klientow z EU.
- Server-side GTM jako rekomendacja.
- Model atrybucji, preferowany Data Driven.
- Okna konwersji vs cykl zakupowy.
- Naprawy: zmiana Primary, modelu atrybucji, okna konwersji, nazw.
## Feed i Merchant Center
### Zdrowie feedu
- Swiezosc danych Merchant Center.
- Status produktow: active, warning, disapproved.
- Procent aktywnych produktow.
- Produkty odrzucone.
- Agregacja problemow po kodzie issue.
- Landing page errors.
- Broken images.
- Braki atrybutow: GTIN, MPN, brand, Google product category, unit pricing.
- Custom labels CL1-CL4.
- Routing problemow do Shopping, PMax i Produkty.
- Cache feed index dla pozniejszych modulow.
## Kampanie PLA / Shopping
### Struktura i ustawienia PLA
- Identyfikacja aktywnych kampanii Shopping.
- Strategia kampanii: Standard Shopping wymaga Target ROAS.
- ROAS i Docelowy ROAS na poziomie kampanii i grup reklam.
- Grupy reklam bez ShoppingProductAd.
- Liczba reklam vs liczba grup reklam.
- Diagnoza aktywnych grup reklam z 0 wyswietlen.
- Czy listing group `Pozostale` jest wykluczony.
- Priorytet kampanii Shopping.
- Ustawienie lokalizacji: Obecnosc.
### Synchronizacja PLA_CL1
- Kampanie `[PLA_CL1] <segment>`.
- Kampanie `[PLA_CL1] <segment> | catch_all`.
- Zrodlo prawdy CL1 i CL4 dla PLA_CL1: adsPRO.
- Liczba aktywnych grup reklam vs produkty z adsPRO.
- Produkt w zlej kampanii.
- Brakujaca grupa reklam.
- Istniejaca wstrzymana grupa reklam do wlaczenia.
- Unikanie duplikatow grup po nazwie i identyfikatorze produktu.
- Zmiana nazwy grupy, gdy nazwa rozjechala sie z adsPRO.
- Wstrzymanie tylko aktywnych grup reklam, nie juz wstrzymanych.
- Podsumowanie zmian po kampaniach.
### Bestsellery i zombie
- CL4 `bestseller` z GMC jako osobny pipeline.
- Bestsellery vs aktywne grupy reklam.
- Rename zamiast tworzenia nowej grupy, gdy produkt istnieje pod inna nazwa.
- Zombie revival: status, sloty, kolejka, ostatnia ewaluacja.
- Statusy CL4: zombie_queue, zombie, bestseller_candidate, dead.
### Frazy i automatyzacja PLA
- Search terms Shopping: top frazy wg kosztu.
- Frazy z progiem: minimum 50 klikniec i 0 konwersji.
- Brand jako negative w Shopping: nie dodawac.
- Automatyzacja Docelowego ROAS na poziomie grupy reklam.
- Minimum 100 klikniec przed pauzowaniem grupy reklam.
- Podnoszenie Docelowego ROAS po minimum 10 nowych konwersjach.
- Follow-up i rollback po zmianie Docelowego ROAS.
## Kampanie brandowe
- Identyfikacja kampanii brand po brand tokens.
- Trend miesieczny: wyswietlenia, klikniecia, koszt, konwersje, wartosc, CTR, CPC, ROAS.
- Czystosc fraz: brand vs non-brand.
- Procent kosztu non-brand w kampanii brand.
- Top non-brand wg kosztu.
- Pre-check, czy fraza do wykluczenia juz istnieje.
- Close variant leak, gdy fraza mimo wykluczenia nadal generuje ruch.
- Target Impression Share.
- Search budget lost impression share.
- Search rank lost impression share.
- Pokrycie wariantow marki: literowki, bez polskich znakow, z/bez spacji, z/bez `pl`, `sklep`.
- Decyzja: budzet, CPC ceiling, Target Impression Share, negatives.
## DSA
- Identyfikacja aktywnych kampanii DSA.
- Targety stron: URL list, category, page feed, domena.
- Wildcard risk: `*` lub cala domena.
- Page exclusions: cart, checkout, polityka, regulamin, kontakt, koszyk, zamowienie.
- Search terms DSA.
- Kanibalizacja z Search i Brand.
- Frazy do wykluczenia: minimum 50 klikniec i 0 konwersji.
- Pre-check istniejacych wykluczen przed rekomendacja.
- Strategia zalezne od progu 60 konwersji / 30 dni.
## Performance Max
- Identyfikacja aktywnych kampanii PMax.
- Typ kampanii: Feed Only vs full PMax.
- Wolumen konwersji 30 dni.
- AOV i prog strategii.
- Lead gen bez offline conversions: nie rekomendowac PMax.
- Trend miesieczny i erozja ROAS / CR.
- Search terms PMax indywidualne.
- Kategorie search terms PMax jako dodatek.
- Brand leak w PMax.
- Frazy minimum 50 klikniec i 0 konwersji.
- Asset groups: status, wyswietlenia, koszt, konwersje, ROAS.
- Wstrzymane asset groups, ktore wczesniej pracowaly.
- Asset diversity: minimum obrazy i video.
- Campaign-level negatives.
- Search themes.
- CPC resistance: CPC 30 dni vs 90 dni.
- Follow-up po zmianie strategii lub Docelowego ROAS po 30 dniach.
- Rollback, gdy ROAS albo konwersje spadna ponizej progu.
## Search non-brand
- Identyfikacja aktywnych kampanii Search non-brand.
- Wykluczenie kampanii brand i DSA.
- Broad match tylko ze Smart Bidding.
- Quality Score: Expected CTR, Ad Relevance, Landing Page Experience.
- Fallback: sredni Quality Score per grupa reklam.
- Match types per grupa reklam.
- Close variant pollution.
- Search terms cleanup: top frazy i minimum 50 klikniec bez konwersji.
- Cross-check z wykluczeniami.
- RSA quality: minimum 10 naglowkow, 3 opisy, ad strength nie Poor.
- Struktura grup reklam: 1 temat per grupa.
- Grupy reklam z ponad 100 slowami kluczowymi jako kandydat do podzialu.
## Display, Demand Gen, YouTube
- Identyfikacja aktywnych kampanii Display, Demand Gen i Video.
- Placement quality.
- MFA sites.
- Mobile apps spam.
- Placements z minimum 50 klikniec i 0 konwersji.
- Overlap Demand Gen z PMax.
- Demand Gen jako top / mid funnel, PMax jako bottom funnel.
- Asset diversity w Demand Gen: text, image, minimum 1 video.
- Video aspect ratios: 16:9, 1:1, 9:16.
- Demand Gen CPA goal.
## Remarketing i odbiorcy
- Pobranie list odbiorcow.
- Wielkosc list.
- Listy mniejsze niz 1000 uzytkownikow.
- Aktywne listy nieuzywane w kampaniach.
- Uzycie odbiorcow per aktywna kampania.
- Observation / targeting audiences.
- PMax audience signals.
- Custom segments, lists, demographics.
- Customer Match.
- Match rate, jesli dostepny.
## Assety reklamowe
- Swiezosc `ad_assets.csv`.
- Inwentarz per aktywna kampania: sitelinki, callouts, snippets, promotion, price, logo.
- Minimum 4 sitelinki.
- Minimum 4 callouts.
- Minimum 1 structured snippet dla e-commerce.
- Logo assets dla PMax i Demand Gen.
- Price extensions dla e-commerce z konkretnymi cenami.
- Promotion extension przy aktywnych promocjach sezonowych.
## Wykluczenia
- Shared negative lists.
- Liczba fraz w listach.
- Przypisanie list do kampanii.
- Cross-campaign search terms.
- Frazy minimum 50 klikniec i 0 konwersji.
- Cross-check z istniejacymi wykluczeniami.
- Kategorie wykluczen:
- informational,
- competitor,
- free-intent,
- job-seeker.
- Match type: Exact dla pojedynczych fraz, Phrase dla wzorcow.
- Nigdy Broad negatives.
- Konsolidacja do shared list, gdy kandydat pojawia sie w minimum 2 kampaniach.
- Over-blocking audit: czy wykluczenia blokuja frazy z konwersjami.
- Brand jako negative: tak w Search non-brand i DSA, nie w Shopping i PMax Feed Only.
## Ustawienia kampanii
- Ustawienia geograficzne per aktywna kampania.
- Geo type: Presence, nie Presence or Interest.
- Geo targets vs kraje klienta.
- Search partners off dla Search i Shopping, o ile nie ma wyjatku.
- Content network off dla Search.
- Harmonogram emisji vs biznes.
- Bid adjustments per device, geo i schedule.
- Wykrywanie dryfu ustawien wzgledem poprzedniego raportu.
## Budzety
- Pobranie wykorzystania budzetow.
- Suma dziennych budzetow razy 30 vs budzet miesieczny klienta.
- Przekroczenie powyzej 5%.
- Utilization per kampania: spend 30 dni / daily budget * 30.
- Niedowykorzystanie ponizej 60%.
- Przekroczenie powyzej 100%.
- Search budget lost impression share powyzej 10%.
- Shared budgets i zgodnosc z priorytetami.
- Propozycje realokacji: skad zabrac i gdzie dodac.
- Zmiana budzetu, szczegolnie gdy delta przekracza 30%.
## Priorytety przenoszenia do ver 2
### Juz zaczete / czesciowo wdrozone
- Kampanie PLA: synchronizacja PLA_CL1.
- Kampanie PLA: sprawdzenie ustawien lokalizacji i priorytetu.
- Mechanizm: lista klientow, grupy zadan, zadania, plan-only, historia.
### Najbardziej naturalne kolejne zadania
- Kampanie PLA: ShoppingProductAd i `Pozostale` negative.
- Kampanie PLA: bestsellery i CL4.
- Ustawienia kampanii: Search partners, Content network, geo targets.
- Wykluczenia: cross-campaign frazy minimum 50 klikniec i 0 konwersji.
- Brand: czystosc fraz brand i Target Impression Share.
- Budzety: wykorzystanie budzetow i budget-limited.
- Feed: disapproved, landing page errors, image broken.
- Konwersje: primary / secondary, duplikaty, Enhanced Conversions.

197
README.md Normal file
View File

@@ -0,0 +1,197 @@
# Google Ads ver 2
Terminalowe narzedzie do pracy na kontach Google Ads klientow.
Instrukcja pracy dla Claude Code, Codex, Gemini CLI i innych agentow AI jest w `AGENTS.md`.
Instrukcja rozbudowy o nowe grupy i zadania jest w `DEVELOPMENT.md`.
Backlog rzeczy sprawdzanych w starej wersji jest w `OLD_COMMANDS_CHECKLIST.md`.
## Start
1. Uzupelnij `.env` na podstawie `.env.example`.
2. Uzupelnij `config/clients.toml` na podstawie `config/clients.example.toml`.
3. Zainstaluj zaleznosci:
```powershell
python -m pip install -r requirements.txt
```
4. Uruchom menu:
```powershell
python gads.py
```
Albo uruchom konkretne zadanie bez menu:
```powershell
python gads.py --client laitica.pl --task sync_pla_cl1
```
Tryb dla Claude Code, Codex albo Gemini CLI:
```powershell
python gads.py analiza-klienta
python gads.py analiza-klienta --client-number 5
python gads.py analiza-klienta --client-number 5 --task-number 1 --plan-only
```
Po tej komendzie narzedzie zapisze plan w `clients/<domena>/plans/`.
Agent czyta plik `.md` albo `.json`, analizuje go i pyta Cie o zgode.
Po Twojej akceptacji agent uruchamia wdrozenie konkretnego planu:
```powershell
python gads.py --client laitica.pl --task sync_pla_cl1 --apply-plan clients/laitica.pl/plans/PLAN.json --confirm-apply TAK
```
## Raport klienta
Miesieczny raport HTML generuje komenda:
```powershell
python gads.py raport-klienta
python gads.py raport-klienta --client-number 1 --month 2026-04
python gads.py raport-klienta aruba.rzeszow.pl 04.2026
python gads.py analiza-klienta aruba.rzeszow.pl 02-2026
```
Komenda pobiera dane Google Ads, opcjonalnie GA4, e-commerce, Semstorm i linki SEO, a potem zatrzymuje sie przed generowaniem HTML. Tworzy plik roboczy z kontekstem liczbowym i miejscem na wnioski przygotowane przez agenta AI:
```text
scripts/reports/output/<domena>_<YYYY-MM>_recommendations.json
```
Skrypt nie generuje wnioskow samodzielnie. Agent AI uzupelnia `recommendations` w tym pliku z perspektywy osoby obslugujacej konto Google Ads klienta: decyzyjnie opisuje, co robimy, co kontrolujemy i jaki jest nastepny krok po naszej stronie. Nie pisze klientowi, ze `warto cos sprawdzic` albo `nalezy przeanalizowac`, bo to agent/operator konta podejmuje decyzje. Tytuly, wnioski i rekomendacje widoczne dla klienta musza miec poprawne polskie znaki. Po pokazaniu propozycji i akceptacji uzytkownika agent uruchamia:
```powershell
python gads.py raport-klienta --client aruba.rzeszow.pl --month 2026-04 --confirm-recommendations TAK
```
Po zatwierdzeniu wnioskow raport HTML jest zapisywany w:
```text
scripts/reports/output/<domena>/<YYYY-MM>/index.html
```
Historia sprzedaży miesięcznej oraz trzy kafelki w sekcji `E-commerce — Sprzedaż` mogą być zasilane z Google Sheet przypisanego do klienta:
```toml
[clients."example.pl"]
sales_history_sheet = "https://docs.google.com/spreadsheets/d/<id>"
```
Arkusz powinien mieć kolumny: `Miesiąc`, `Transakcje`, `Przychody`, `Średnia wartość koszyka`.
Upload na serwer nie wykonuje sie automatycznie. Po sprawdzeniu lokalnego raportu uruchom:
```powershell
python gads.py raport-klienta --client aruba.rzeszow.pl --month 2026-04 --confirm-upload TAK
```
Wymagane dane FTP w `.env`: `ADSPRO_HOST`, `ADSPRO_USERNAME`, `ADSPRO_PASSWORD`, `ADSPRO_REMOTE_PATH`.
## MVP
Pierwsze zadanie:
- pobiera kampanie `[PLA_CL1]` z Google Ads,
- wyciaga segmenty CL1 z nazw kampanii,
- pobiera produkty z adsPRO,
- przygotowuje plan synchronizacji grup reklam,
- czeka na akceptacje przed wdrozeniem zmian,
- zapisuje historie w katalogu klienta.
## Dane i historia
- `config/clients.toml` - lista klientow i identyfikatory kont.
- `config/clients.toml` - takze reguly globalne i wyjatki per klient, np. ustawienia kampanii PLA.
- `.env` - dane dostepowe do Google Ads i adsPRO.
- `clients/<domena>/data/` - pobrane dane robocze.
- `clients/<domena>/history/YYYY-MM-DD.jsonl` - historia do filtrowania po kliencie, dacie i kampanii.
- `clients/<domena>/changes/YYYY-MM-DD.md` - czytelny dziennik zmian.
## Lokalna baza wiedzy
Baza wiedzy ma lokalny magazyn regul i opcjonalny importer przez API. API modeli jest uzywane tylko przy dodawaniu nowej wiedzy, nie podczas analizy klienta.
```powershell
python gads.py wiedza init
python gads.py wiedza dodaj --file "knowledge/sources/sample_lancedb_w055.md" --source "stara_lancedb_W055" --dry-run
python gads.py wiedza dodaj --file "knowledge/sources/sample_lancedb_w055.md" --source "stara_lancedb_W055"
python gads.py wiedza propozycje
python gads.py wiedza zatwierdz --rule-id "<id_reguly>" --task "<task_id>"
python gads.py wiedza szukaj "pmax shopping"
python gads.py wiedza reguly --task check_pla_settings
python gads.py wiedza lista --topic shopping
python gads.py wiedza statystyki
python gads.py wiedza indeksuj
python gads.py wiedza szukaj-ai "czy PMax kanibalizuje Display?"
python gads.py wiedza import-stare --from "D:\google ads\lancedb"
python gads.py wiedza przypisz
```
Pliki bazy wiedzy:
- `knowledge/sources/` - materialy zrodlowe do pozniejszego importu.
- `knowledge/rules.jsonl` - atomowe reguly dla narzedzia.
- `knowledge/imports.jsonl` - historia importow.
- `knowledge/lancedb/` - metadane indeksu semantycznego. Fizyczny indeks LanceDB jest domyslnie w `%LOCALAPPDATA%\google-ads-ver2-knowledge-lancedb`, zeby uniknac problemow zapisu w katalogach synchronizowanych.
Na innym komputerze przenosi sie `rules.jsonl`, a indeks LanceDB odbudowuje sie lokalnie:
```powershell
python gads.py wiedza indeksuj
```
Stara baza LanceDB moze byc przeniesiona jednorazowo bez przypisywania do zadan:
```powershell
python gads.py wiedza import-stare --from "D:\google ads\lancedb"
```
Pozniejsze przypisywanie nieprzypisanych regul do aktualnych zadan:
```powershell
python gads.py wiedza przypisz
python gads.py wiedza przypisz --restart
```
`wiedza przypisz` domyslnie pokazuje jedna regule, pelny kontekst decyzji i konczy porcje po odpowiedzi. Reguly ida w kolejnosci zapisanej w `knowledge/rules.jsonl`, zeby latwo bylo porownac ekran z otwartym plikiem. W przegladzie mozna wpisac numer zadania albo `task_id`, `P` zeby pominac, `Q` zeby przerwac, oraz `U` zeby usunac biezaca regule z `knowledge/rules.jsonl`. Usuniecie wymaga dodatkowego potwierdzenia `USUN`; po usunieciach odswiez indeks przez `python gads.py wiedza indeksuj`.
Reczne dodawanie pojedynczej reguly przez agenta AI:
```text
Przypisz regule:
- tresc reguly do oceny
```
Agent najpierw sprawdza aktualne zadania w `config/tasks.toml`, ocenia czy regule warto dodac, proponuje docelowe brzmienie, typ, temat, ryzyko, rekomendacje i zadanie docelowe. Przy kazdej regule agent musi tez zaproponowac policzalny `machine_condition` i `machine_effect`, jezeli regule da sie bezpiecznie zastosowac w skrypcie. Jesli regula wymaga oceny eksperckiej albo danych, ktorych zadanie nie pobiera, agent ma jasno napisac, ze nie proponuje automatycznego warunku i regule nalezy traktowac jako kontekst dla agenta AI/czlowieka. Agent nie zapisuje nic do `knowledge/rules.jsonl`, dopoki uzytkownik jasno nie odpowie `Dodaj`. Po akceptacji agent dopisuje jedna kompletna linie JSONL do `knowledge/rules.jsonl`, uzywajac tylko istniejacych `task_id`. Jesli nie ma dobrego zadania, agent powinien zaproponowac niedodawanie reguly albo odlozenie jej do czasu dodania nowego zadania.
`machine_condition` opisuje twarde warunki na polach dostepnych w planie zadania, np. `channel_type`, `conversions_30d`, `bidding_strategy_type`. `machine_effect` opisuje wplyw na rekomendacje, np. poziom ostroznosci, akcje i powod. Skrypt moze automatycznie stosowac tylko takie reguly, ktore maja policzalny warunek oparty o dostepne dane.
Wieksza porcje przegladu uruchamiaj jawnie:
```powershell
python gads.py wiedza przypisz --limit 10
```
Po imporcie skrypt pokazuje proponowane powiazania regul z zadaniami i pyta, czy dopisac je do `task_ids`. Bez odpowiedzi `TAK` propozycja zostaje oczekujaca albo odrzucona, ale nie staje sie regula zadania.
Przy wiekszej bazie uzywaj statusow zamiast recznego kasowania regul:
```powershell
python gads.py wiedza archiwizuj --rule-id "<id_reguly>"
python gads.py wiedza aktywuj --rule-id "<id_reguly>"
python gads.py wiedza duplikat --rule-id "<id_reguly>" --duplicate-of "<id_reguly_nadrzednej>"
```
Do importu przez API dodaj do `.env`:
```text
OPENAI_API_KEY=...
KNOWLEDGE_OPENAI_MODEL=gpt-4.1-mini
KNOWLEDGE_EMBEDDING_MODEL=text-embedding-3-small
KNOWLEDGE_LANCEDB_DIR=C:\opcjonalna\sciezka\poza\synchronizacja
```
Narzedzie nie uzywa API modeli AI podczas analizy klienta. Claude Code, Codex albo Gemini CLI moga uruchamiac te same komendy terminalowe.

View File

@@ -0,0 +1,23 @@
[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
[global_rules.budget_usage]
min_days_between_budget_changes = 7
# Wyjatek per klient:
# [clients."example.pl".pla_settings]
# require_high_priority = false
# require_presence_only = true

60
config/tasks.toml Normal file
View File

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

155
config/tasks_backup.toml Normal file
View File

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

6
gads.py Normal file
View File

@@ -0,0 +1,6 @@
from src.gads_v2.cli import main
if __name__ == "__main__":
main()

246
knowledge/README.md Normal file
View File

@@ -0,0 +1,246 @@
# Lokalna baza wiedzy
Ten katalog jest nowym magazynem wiedzy dla projektu `google ads ver 2`.
## Struktura katalogu
- `sources/` - surowe materialy do przetworzenia w kolejnych etapach.
- `rules.jsonl` - atomowe reguly wykorzystywane przez narzedzie.
- `imports.jsonl` - historia importow wiedzy.
- `lancedb/` - metadane indeksu semantycznego; fizyczne pliki LanceDB sa domyslnie w `%LOCALAPPDATA%\google-ads-ver2-knowledge-lancedb`.
`rules.jsonl` jest zrodlem prawdy. LanceDB jest tylko indeksem do wyszukiwania semantycznego i mozna go zawsze odbudowac z `rules.jsonl`.
Przenoszenie projektu na inny komputer:
- Przenosza sie: `knowledge/rules.jsonl`, `knowledge/sources/`, `knowledge/imports.jsonl` i dokumentacja.
- Nie musi przenosic sie: fizyczny katalog LanceDB z `%LOCALAPPDATA%`.
- Po przeniesieniu projektu uruchom:
```powershell
python -m pip install -r requirements.txt
python gads.py wiedza indeksuj
```
To odbuduje lokalny indeks LanceDB na nowym komputerze.
API modeli jest uzywane podczas:
- `wiedza dodaj` - ekstrakcja regul z materialu zrodlowego.
- `wiedza indeksuj` - utworzenie embeddingow aktywnych regul.
- `wiedza szukaj-ai` - embedding zapytania uzytkownika.
Analiza klienta Google Ads nie wywoluje API modeli.
## Komendy
Inicjalizacja katalogow i pustych plikow:
```powershell
python gads.py wiedza init
```
Test pliku bez kosztu API i bez zapisu regul:
```powershell
python gads.py wiedza dodaj --file "knowledge/sources/sample_lancedb_w055.md" --source "stara_lancedb_W055" --dry-run
```
Import przez API:
```powershell
python gads.py wiedza dodaj --file "knowledge/sources/sample_lancedb_w055.md" --source "stara_lancedb_W055"
```
Import calej starej bazy LanceDB bez API i bez przypisywania do zadan:
```powershell
python gads.py wiedza import-stare --from "D:\google ads\lancedb"
```
Ten import:
- czyta tabele `fakty`,
- przenosi kazdy rekord jako aktywna regule,
- zostawia `task_ids` i `suggested_task_ids` puste,
- pomija rekordy, ktore juz istnieja po `id`,
- po imporcie wymaga odswiezenia indeksu przez `python gads.py wiedza indeksuj`.
Po imporcie skrypt pokazuje propozycje przypisania regul do zadan i pyta o kazda z nich:
```text
Dodac regule <id_reguly> do zadania <task_id>?
```
Odpowiedzi:
- `TAK` - dopisuje `task_id` do reguly.
- `NIE` - odrzuca propozycje.
- Enter - zostawia propozycje jako oczekujaca do pozniejszej decyzji.
Lista oczekujacych propozycji:
```powershell
python gads.py wiedza propozycje
```
Wznawialny przeglad regul bez przypisan do zadan:
```powershell
python gads.py wiedza przypisz
```
Komenda przechodzi po aktywnych regulach, ktore maja puste `task_ids`, w kolejnosci zapisanej w `knowledge/rules.jsonl`. To celowe: uzytkownik moze latwo porownac przeglad z otwartym plikiem i z kolejnoscia importu. Skrypt pokazuje liste aktualnych zadan i pyta, do ktorego zadania dodac regule. Mozna wpisac:
- numer zadania, np. `2`,
- kilka numerow po przecinku, np. `2,4`,
- techniczny `task_id`, np. `check_pla_settings`,
- `P`, aby pominac regule i przejsc dalej,
- `U`, aby trwale usunac regule z `knowledge/rules.jsonl`,
- `Q`, aby przerwac bez przesuwania kursora.
Domyslnie komenda pokazuje jedna regule, pyta o decyzje i konczy porcje. To jest preferowany tryb pracy, bo agent i uzytkownik moga ocenic pelny kontekst bez pospiechu. Wieksza porcje wlaczaj tylko swiadomie, np.:
```powershell
python gads.py wiedza przypisz --limit 10
```
Usuwanie przez `U` wymaga dodatkowego potwierdzenia tekstem `USUN`. Po potwierdzeniu rekord jest fizycznie usuwany z `rules.jsonl`, a postep przegladu jest zapisywany tak, zeby kolejne uruchomienie zaczelo od nastepnej reguly. Po usunieciach uruchom `python gads.py wiedza indeksuj`, zeby LanceDB nie zawierala starych rekordow. Tej opcji uzywaj dla ewidentnie blednych, pustych albo bezuzytecznych rekordow z importu. Jesli regula jest poprawna, ale nie ma byc uzywana teraz, lepiej wpisac `P` albo oznaczyc ja poza przegladem jako `archived`.
Postep jest zapisywany w `knowledge/review_state.json`. Aby zaczac od poczatku:
```powershell
python gads.py wiedza przypisz --restart
```
Po dodaniu nowych zadan do `config/tasks.toml` uruchom ponownie `python gads.py wiedza przypisz --restart`, zeby przejrzec nieprzypisane reguly pod katem nowych zadan.
Reczna akceptacja pojedynczej propozycji:
```powershell
python gads.py wiedza zatwierdz --rule-id "<id_reguly>" --task "<task_id>"
```
Reczne odrzucenie pojedynczej propozycji:
```powershell
python gads.py wiedza odrzuc --rule-id "<id_reguly>" --task "<task_id>"
```
Wyszukiwanie tekstowe po zapisanych regulach:
```powershell
python gads.py wiedza szukaj "pmax display remarketing"
```
Budowa indeksu semantycznego LanceDB:
```powershell
python gads.py wiedza indeksuj
```
Domyslnie fizyczny indeks LanceDB jest poza katalogiem projektu, bo na Windows katalogi synchronizowane potrafia blokowac operacje zapisu wymagane przez LanceDB. Sciezke mozna nadpisac zmienna:
```text
KNOWLEDGE_LANCEDB_DIR=C:\sciezka\do\lokalnego\lancedb
```
Wyszukiwanie semantyczne:
```powershell
python gads.py wiedza szukaj-ai "czy PMax kanibalizuje kampanie Display?"
```
Lista regul z filtrami:
```powershell
python gads.py wiedza lista
python gads.py wiedza lista --topic shopping
python gads.py wiedza lista --task check_pla_settings
python gads.py wiedza lista --status archived
python gads.py wiedza lista --source "stara_lancedb"
```
Statystyki bazy:
```powershell
python gads.py wiedza statystyki
```
Statusy regul:
```powershell
python gads.py wiedza archiwizuj --rule-id "<id_reguly>"
python gads.py wiedza aktywuj --rule-id "<id_reguly>"
python gads.py wiedza duplikat --rule-id "<id_reguly>" --duplicate-of "<id_reguly_nadrzednej>"
```
Lista regul przypisanych do zadania:
```powershell
python gads.py wiedza reguly --task check_pla_settings
```
## Konfiguracja API
Do importu przez API potrzebny jest klucz w `.env`:
```text
OPENAI_API_KEY=...
```
Opcjonalnie mozna ustawic model:
```text
KNOWLEDGE_OPENAI_MODEL=gpt-4.1-mini
KNOWLEDGE_EMBEDDING_MODEL=text-embedding-3-small
KNOWLEDGE_LANCEDB_DIR=C:\opcjonalna\sciezka\poza\synchronizacja
```
Mozna tez podac model jednorazowo:
```powershell
python gads.py wiedza dodaj --file "knowledge/sources/sample_lancedb_w055.md" --source "stara_lancedb_W055" --model gpt-4.1-mini
```
## Schemat reguly
Kazdy wiersz `rules.jsonl` jest osobnym obiektem JSON. Wymagane pola:
- `id` - stabilny identyfikator reguly, bez spacji.
- `status` - `active`, `draft`, `archived` albo `duplicate`.
- `topic` - krotki temat, np. `search`, `pmax`, `shopping`, `konwersje`, `gtm-tracking`.
- `task_ids` - lista zadan zaakceptowanych przez uzytkownika; importer nie powinien dopisywac ich bez akceptacji.
- `suggested_task_ids` - propozycje zadan do akceptacji; skrypt pokazuje je uzytkownikowi po imporcie.
- `rule_type` - `audit_check`, `recommendation`, `warning` albo `implementation_note`.
- `condition` - kiedy regula ma znaczenie.
- `recommendation` - co agent albo narzedzie powinno zrobic.
- `risk` - jakie ryzyko ogranicza regula.
- `source` - czytelna nazwa zrodla podana w `--source`.
- `source_file` - plik zrodlowy, z ktorego powstala regula.
- `confidence` - `low`, `medium` albo `high`.
- `created_at` - data utworzenia reguly.
- `updated_at` - data ostatniej zmiany metadanych, statusu lub przypisan.
- `duplicate_of` - ID reguly nadrzednej, gdy `status` ma wartosc `duplicate`.
- `supersedes` - lista ID regul zastapionych przez te regule.
- `text` - jednozdaniowa wersja reguly do wyszukiwania i wyswietlania.
Przykladowy rekord `rules.jsonl`:
```json
{"id":"search_partners_quality_check","status":"active","topic":"search","task_ids":[],"suggested_task_ids":["check_search_settings"],"rule_type":"audit_check","condition":"Kampania Search ma wlaczona siec partnerska Google","recommendation":"Sprawdz wyniki Search Partners osobno przed rekomendacja pozostawienia tej opcji.","risk":"Mozliwy ruch niskiej jakosci lub zawyzone konwersje.","source":"manual","source_file":"knowledge/sources/search.md","confidence":"medium","created_at":"2026-05-14T18:00:00","updated_at":"2026-05-14T18:00:00","duplicate_of":"","supersedes":[],"text":"Search Partners sprawdzaj osobno przy problemach z jakoscia ruchu."}
```
## Zasady dla agentow AI
- Nie wpisuj recznie duzych pakietow regul do `rules.jsonl`, jesli material moze przejsc przez `wiedza dodaj`.
- Przed importem wrzuc material do `knowledge/sources/` albo wskaz istniejacy plik.
- Najpierw uruchom `--dry-run`, zeby sprawdzic sciezke, zrodlo i model bez kosztu API.
- Po imporcie sprawdz wynik przez `python gads.py wiedza szukaj "<temat>"`.
- Nie dopisuj `task_ids` recznie po imporcie. Uzyj pytan skryptu albo komendy `wiedza zatwierdz`.
- Nie usuwaj historycznych regul recznie. Uzyj `wiedza archiwizuj` albo `wiedza duplikat`, zeby zachowac slady decyzji.
- Po wiekszych zmianach w `rules.jsonl` uruchom `python gads.py wiedza indeksuj`, zeby odswiezyc LanceDB.
- Nie edytuj plikow LanceDB recznie. To indeks, nie zrodlo prawdy.
- Reguly w `rules.jsonl` maja wspierac plany i checklisty. Nie sa zgoda na wdrozenie zmian na koncie Google Ads.
- Jesli regula dotyczy przyszlego zadania, zostaw `task_ids` i `suggested_task_ids` puste, a pozniej dopisz zadanie w `config/tasks.toml`.
- Nie zapisuj kluczy API w tym katalogu. Klucze trzymamy w `.env`.

6
knowledge/imports.jsonl Normal file
View File

@@ -0,0 +1,6 @@
{"created_at": "2026-05-14T18:25:20", "file": "knowledge\\sources\\sample_lancedb_w055.md", "model": "gpt-4.1-mini", "notes": "Zasady dotyczą głównie remarketingu dynamicznego, strategii stawek przy starcie kampanii Search oraz hierarchii priorytetów typów kampanii Performance Max. Uwzględniono istniejace task_id do powiązania reguł z odpowiednimi funkcjonalnościami narzędzia.", "rules_count": 9, "source": "stara_lancedb_W055"}
{"created_at": "2026-05-14T18:33:36", "file": "knowledge\\sources\\test_pla_settings_acceptance.md", "model": "gpt-4.1-mini", "notes": "Reguly odnosza sie do ustawien lokalizacji i priorytetu kampanii PLA oraz procesu przygotowania zmian z uwzglednieniem wyjatkow klienta.", "rules_count": 3, "source": "test_pla_settings_acceptance"}
{"created_at": "2026-05-14T18:55:30", "file": "D:\\google ads\\lancedb\\fakty.lance", "model": "none", "notes": "Import starej tabeli LanceDB bez API i bez przypisywania regul do zadan.", "rules_count": 3, "skipped_existing_count": 0, "source": "legacy_lancedb:fakty"}
{"created_at": "2026-05-14T18:56:39", "file": "D:\\google ads\\lancedb\\fakty.lance", "model": "none", "notes": "Import starej tabeli LanceDB bez API i bez przypisywania regul do zadan.", "rules_count": 1512, "skipped_existing_count": 277, "source": "legacy_lancedb:fakty"}
{"created_at": "2026-05-14T18:57:32", "file": "D:\\google ads\\lancedb\\fakty.lance", "model": "none", "notes": "Import starej tabeli LanceDB bez API i bez przypisywania regul do zadan.", "rules_count": 274, "skipped_existing_count": 1515, "source": "legacy_lancedb:fakty"}

View File

@@ -0,0 +1 @@

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
{
"unassigned": {
"last_queue_index": 0,
"last_rule_id": "dynamic_remarketing_pixel_required",
"last_sort_key": [
"remarketing",
"dynamic_remarketing_pixel_required"
],
"updated_at": "2026-05-14T19:11:27"
}
}

3
knowledge/rules.jsonl Normal file
View File

@@ -0,0 +1,3 @@
{"condition":"Nowa kampania Search bez historii danych używa automatycznej strategii opartej o konwersje albo opiera się na słabych, zbyt ogólnych lub niewiarygodnych konwersjach.","confidence":"high","created_at":"2026-05-14T22:00:00","duplicate_of":"","id":"search_new_campaign_no_weak_conversion_automation","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":{"action":"nie przechodź na automatyzację konwersyjną","level":"ostroznie","reason_prefix":"Reguła wiedzy"},"recommendation":"Nie startuj kampanii Search od automatyzacji opartej o konwersje, jeśli konto nie ma jakościowych danych. Najpierw ustaw konkretne cele konwersji, sprawdź pomiar i zbuduj sensowną strukturę słów kluczowych.","risk":"Algorytm może uczyć się na zbyt małej albo błędnej liczbie sygnałów, przez co kampania będzie źle wydawać budżet i zbierze dane niskiej jakości.","rule_type":"warning","source":"manual","source_file":"KNOWLAGE.md","status":"active","suggested_task_ids":[],"supersedes":[],"task_ids":["check_bidding_strategies"],"text":"Nowa kampania Search bez historii nie powinna startować od automatyzacji opartej o słabe konwersje. Najpierw zadbaj o konkretne cele, poprawny pomiar i sensowną strukturę słów kluczowych.","topic":"strategie_stawek","updated_at":"2026-05-14T22:00:00"}
{"condition":"Kampania używa strategii opartej o Docelowy ROAS, ma wystarczająco danych do oceny, a rzeczywisty ROAS jest wyraźnie niższy od ustawionego celu.","confidence":"high","created_at":"2026-05-14T22:35:00","duplicate_of":"","id":"target_roas_too_high_can_limit_spend","machine_condition":{"all":[{"field":"target_assessment","op":"eq","value":"Docelowy ROAS prawdopodobnie za wysoki"},{"field":"conversions_30d","op":"gte","value":15},{"field":"bidding_strategy_type","op":"in","value":["TARGET_ROAS","MAXIMIZE_CONVERSION_VALUE"]}]},"machine_effect":{"action":"sprawdź, czy Docelowy ROAS nie blokuje wydatków","level":"do decyzji","reason":"Docelowy ROAS jest wyższy niż aktualna efektywność kampanii; w planie trzeba rozdzielić problem kampanii od zbyt restrykcyjnego celu.","reason_prefix":"Reguła wiedzy"},"recommendation":"W planie oznacz, że Docelowy ROAS może być zbyt restrykcyjny. Przed zmianą celu wskaż, czy problem wynika z jakości kampanii, oferty lub feedu, czy z samego celu ustawionego zbyt wysoko względem aktualnej efektywności.","risk":"Zbyt wysoki Docelowy ROAS może ograniczać wydatki, zmniejszać liczbę wyświetleń i blokować skalowanie kampanii, mimo że kampania mogłaby generować wartościowy ruch przy mniej restrykcyjnym celu.","rule_type":"warning","source":"manual","source_file":"KNOWLAGE.md","status":"active","suggested_task_ids":[],"supersedes":[],"task_ids":["check_bidding_strategies"],"text":"Docelowy ROAS zbyt wysoki względem aktualnej efektywności może blokować wydatki. W planie wskazuj, czy problemem jest kampania, czy zbyt restrykcyjny cel.","topic":"strategie_stawek","updated_at":"2026-05-14T22:35:00"}
{"condition":"Plan zaklada zmiane budzetu albo celu Smart Bidding, szczegolnie gdy zmiana jest duza albo nastepuje krotko po innej zmianie budzetu, strategii lub celu.","confidence":"high","created_at":"2026-05-14T22:38:50","duplicate_of":"","id":"smart_bidding_sequential_budget_and_target_changes","machine_condition":{"all":[{"field":"bidding_strategy_type","op":"in","value":["MAXIMIZE_CONVERSIONS","TARGET_CPA","MAXIMIZE_CONVERSION_VALUE","TARGET_ROAS"]},{"field":"budget_context","op":"eq","value":"budzet zmieniony w ostatnich 7 dniach"}]},"machine_effect":{"action":"nie zmieniaj teraz celu ani strategii Smart Bidding","level":"czekaj","reason_prefix":"Regula wiedzy"},"recommendation":"Zmiany budzetow i celow Smart Bidding wprowadzaj sekwencyjnie. Nie zmieniaj jednoczesnie budzetu i Docelowego ROAS albo Docelowego CPA, jesli nie jest to swiadoma decyzja. Duze zmiany oznaczaj jako wyzsze ryzyko uczenia algorytmu i rekomenduj obserwacje wynikow przed kolejna zmiana.","risk":"Jednoczesne albo zbyt duze zmiany moga uruchomic niestabilny okres uczenia, utrudnic ocene przyczyny zmian wynikow i doprowadzic do nadmiernego wydawania budzetu albo utraty wolumenu konwersji.","rule_type":"warning","source":"manual","source_file":"KNOWLAGE.md","status":"active","suggested_task_ids":[],"supersedes":[],"task_ids":["check_budget_usage","check_bidding_strategies","optimize_shopping_troas_ag"],"text":"Zmiany budzetow i celow w Smart Bidding wprowadzaj sekwencyjnie. Duze zmiany oznaczaj jako wyzsze ryzyko uczenia algorytmu.","topic":"strategie_stawek","updated_at":"2026-05-14T22:49:00"}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,22 @@
# Przykladowa wiedza ze starej LanceDB: W055
Ten plik sluzy do testowania komendy:
```powershell
python gads.py wiedza dodaj --file "knowledge/sources/sample_lancedb_w055.md" --source "stara_lancedb_W055" --dry-run
```
Po dodaniu `OPENAI_API_KEY` do `.env` ten sam plik mozna zaimportowac bez `--dry-run`.
## Remarketing dynamiczny: piksel Google Ads vs listy GA4
Dynamiczny remarketing produktow w e-commerce wymaga piksela Google Ads albo rownowaznego tagowania, ktore przekazuje parametry e-commerce, szczegolnie identyfikatory produktow. Same listy GA4 nie wystarcza do wyswietlania konkretnych produktow ogladanych przez uzytkownika. Remarketing ogolny, zwlaszcza dla uslug i bez dopasowania konkretnych produktow, mozna budowac na listach GA4. Oba podejscia moga dzialac rownolegle: tag Google Ads do remarketingu dynamicznego z produktami, listy GA4 do remarketingu ogolnego i zaawansowanej segmentacji.
## Start kampanii Search: strategia stawek i konwersje
Przy starcie nowej kampanii Search bez historii nie nalezy bezrefleksyjnie uzywac Maksymalizacji konwersji, bo algorytm nie wie jeszcze, pod jakie zachowania optymalizowac. Page view nie powinien byc konwersja podstawowa, bo kazda wizyta staje sie wtedy konwersja i algorytm uczy sie na zlych danych. Konwersja powinna byc konkretna akcja, np. sprzedaz, formularz albo umowienie kontaktu. Zasieg slow kluczowych trzeba sprawdzac w Planerze slow kluczowych, a nie tylko w Google Trends, bo Trends pokazuje trend bez realnego wolumenu.
## Hierarchia priorytetow: PMax vs inne typy kampanii
Performance Max ma wyzszy priorytet niz Display, Discovery i standardowe kampanie produktowe. Moze przejmowac ruch, ktory normalnie trafilby do tych kampanii. Performance Max ma nizszy priorytet niz kampanie Search, wiec reklamy tekstowe nadal moga obslugiwac zapytania przed PMax. Przy jednoczesnym dzialaniu PMax i Display remarketing dynamiczny kampania Display moze byc duplikatem lub byc kanibalizowana przez PMax.

View File

@@ -0,0 +1,10 @@
# Test importu wiedzy: ustawienia kampanii PLA
Ten plik sluzy do testu przeplywu akceptacji przypisania reguly do zadania `check_pla_settings`.
## Regula: lokalizacja i priorytet kampanii PLA
Kampanie produktowe PLA powinny byc sprawdzane pod katem ustawien lokalizacji i priorytetu. Jezeli globalna regula klienta wymaga obecnosci uzytkownika w lokalizacji docelowej, kampania powinna miec ustawienie targetowania lokalizacji typu presence only, a nie presence or interest. Jezeli globalna regula wymaga wysokiego priorytetu kampanii PLA, kampania powinna miec wysoki priorytet, chyba ze klient ma jawny wyjatek w `config/clients.toml`.
Przed wdrozeniem zmian skrypt powinien przygotowac plan, pokazac ktore kampanie wymagaja zmiany, uwzglednic wyjatki klienta i nie wdrazac nic bez akceptacji uzytkownika.

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
google-ads>=25.0.0
requests>=2.31.0
lancedb>=0.30.0
google-analytics-data>=0.18.0
google-analytics-admin>=0.22.0
python-dotenv>=1.0.0
playwright>=1.40.0

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,716 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Raport Luty 2026 &mdash; Aruba Rzeszow</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--primary: #0d8b8b;
--primary-dark: #065a5a;
--primary-light: #e8f5f5;
--green: #27ae60;
--red: #e74c3c;
--gray: #6c757d;
--light-gray: #f8f9fa;
--border: #e9ecef;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: #333;
background: #f5f5f5;
line-height: 1.6;
}
.hero {
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 60%, #10a5a5 100%);
color: white;
padding: 60px 40px;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: 0; right: 0;
width: 50%;
height: 100%;
background: url("data:image/svg+xml,%3Csvg width='400' height='400' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cpattern id='grid' width='40' height='40' patternUnits='userSpaceOnUse'%3E%3Ccircle cx='20' cy='20' r='1.5' fill='rgba(255,255,255,0.15)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='400' height='400' fill='url(%23grid)'/%3E%3Cline x1='20' y1='60' x2='100' y2='20' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='100' y1='20' x2='180' y2='80' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='180' y1='80' x2='260' y2='40' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='260' y1='40' x2='340' y2='100' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='60' y1='140' x2='140' y2='120' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='140' y1='120' x2='220' y2='180' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='220' y1='180' x2='300' y2='140' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='300' y1='140' x2='380' y2='200' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3C/svg%3E") repeat;
opacity: 0.7;
}
.hero-content {
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 50px;
}
.logo svg {
height: 40px;
}
.logo-text {
font-size: 22px;
font-weight: 700;
letter-spacing: 3px;
text-transform: uppercase;
}
.logo-subtitle {
font-size: 11px;
letter-spacing: 2px;
opacity: 0.85;
text-transform: lowercase;
}
.hero h1 {
font-size: 42px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 16px;
}
.hero .meta {
font-size: 18px;
opacity: 0.9;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
}
.report-section {
background: white;
border-radius: 12px;
padding: 32px;
margin: 24px auto;
max-width: 1400px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.section-title {
color: var(--primary-dark);
font-size: 24px;
font-weight: 700;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 3px solid var(--primary);
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.kpi-card {
background: var(--light-gray);
border-radius: 10px;
padding: 20px;
text-align: center;
border: 1px solid var(--border);
}
.kpi-label {
font-size: 12px;
color: var(--primary);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.kpi-value {
font-size: 28px;
font-weight: 700;
color: #2c3e50;
white-space: nowrap;
}
.kpi-unit {
font-size: 14px;
font-weight: 400;
color: var(--gray);
}
.kpi-change {
font-size: 13px;
margin-top: 6px;
font-weight: 500;
}
.chart-container {
margin: 24px 0;
}
.chart-container h3 {
color: var(--primary-dark);
margin-bottom: 12px;
font-size: 16px;
}
.chart-container canvas {
max-height: 300px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table thead {
background: var(--primary);
color: white;
}
.data-table th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.data-table tbody tr:hover {
background: var(--primary-light);
}
.data-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.badge-search { background: #dbeafe; color: #1e40af; }
.badge-performance_max { background: #fef3c7; color: #92400e; }
.badge-shopping { background: #d1fae5; color: #065f46; }
.badge-display { background: #ede9fe; color: #5b21b6; }
.badge-demand_gen { background: #fce7f3; color: #9d174d; }
.summary-box {
background: var(--primary-light);
border-left: 4px solid var(--primary);
padding: 24px;
border-radius: 0 10px 10px 0;
font-size: 15px;
line-height: 1.8;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 24px;
}
.bar {
height: 8px;
background: var(--primary);
border-radius: 4px;
min-width: 4px;
}
.recommendations-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.rec-item {
display: flex;
gap: 16px;
padding: 16px;
background: var(--light-gray);
border-radius: 8px;
border-left: 4px solid var(--primary);
}
.rec-icon {
font-size: 20px;
flex-shrink: 0;
margin-top: 2px;
}
.rec-item p {
margin-top: 4px;
color: var(--gray);
font-size: 14px;
}
.questions-box {
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border-left: 4px solid #f59e0b;
border-radius: 10px;
padding: 24px 28px;
margin-top: 16px;
}
.questions-box h3 {
margin: 0 0 8px 0;
color: #92400e;
font-size: 18px;
}
.questions-box .intro {
color: #78350f;
font-size: 14px;
margin-bottom: 16px;
}
.questions-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.q-item {
display: flex;
gap: 14px;
padding: 14px 16px;
background: rgba(255,255,255,0.7);
border-radius: 8px;
}
.q-num {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: #f59e0b;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
}
.q-item strong { color: #92400e; display: block; margin-bottom: 4px; }
.q-item p { margin: 0; color: #57534e; font-size: 14px; line-height: 1.6; }
.footer {
text-align: center;
padding: 32px;
color: var(--gray);
font-size: 13px;
}
.footer a {
color: var(--primary);
text-decoration: none;
}
@media (max-width: 768px) {
.hero { padding: 40px 20px; }
.hero h1 { font-size: 28px; }
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
.report-section { padding: 20px; margin: 12px; }
}
@media print {
body { background: white; }
.report-section { box-shadow: none; page-break-inside: avoid; }
.hero { padding: 30px; }
}
</style>
</head>
<body>
<!-- HERO / TITLE -->
<header class="hero">
<div class="hero-content">
<div class="logo">
<img src="https://www.project-pro.pl/upload/filemanager/Project-Design/logos/project-pro/logo-white.svg" alt="Project-Pro" style="height: 40px;">
</div>
<h1>Raport z działań marketingowych</h1>
<div class="meta">Aruba Rzeszow &mdash; Luty 2026</div>
</div>
</header>
<!-- RECOMMENDATIONS -->
<section class="report-section" id="recommendations">
<h2 class="section-title">Wnioski i rekomendacje</h2>
<div class="recommendations-list">
<div class="rec-item"><span class="rec-icon">&#9888;</span><div><strong>Spadek konwersji do obserwacji</strong><p>Liczba konwersji spadla o 30.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu.</p></div></div><div class="rec-item"><span class="rec-icon">&#128200;</span><div><strong>ROAS liczony z Google Ads</strong><p>ROAS z Google Ads wyniosl 9.50. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu.</p></div></div>
</div>
</section>
<!-- GA4 SECTION -->
<!-- E-COMMERCE -->
<!-- PRODUCT OPTIMIZATIONS -->
<!-- TOP ADS PRODUCTS -->
<!-- YEAR OVER YEAR -->
<!-- GOOGLE ADS KPIs -->
<section class="report-section" id="ads-kpi">
<h2 class="section-title">Google Ads &mdash; Podsumowanie</h2>
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">Wyświetlenia</div>
<div class="kpi-value">181 763<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -12.6% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Kliknięcia</div>
<div class="kpi-value">4 628<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -27.0% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">CTR</div>
<div class="kpi-value">2.5<span class="kpi-unit">%</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -16.4% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Konwersje</div>
<div class="kpi-value">214<span class="kpi-unit"></span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -30.2% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Koszt</div>
<div class="kpi-value">3788.97<span class="kpi-unit"> PLN</span></div>
<div class="kpi-change" style="color: #27ae60">
&#9660; -0.3% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">CPA</div>
<div class="kpi-value">17.63<span class="kpi-unit"> PLN</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9650; +42.8% vs Styczeń
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">ROAS</div>
<div class="kpi-value">9.50<span class="kpi-unit">x</span></div>
<div class="kpi-change" style="color: #e74c3c">
&#9660; -30.7% vs Styczeń
</div>
</div></div>
</section>
<!-- DAILY CHART -->
<section class="report-section" id="ads-chart">
<h2 class="section-title">Google Ads &mdash; Aktywność dzienna</h2>
<div class="chart-container">
<canvas id="dailyClicksChart"></canvas>
</div>
</section>
<!-- CAMPAIGNS TABLE -->
<section class="report-section" id="campaigns">
<h2 class="section-title">Kampanie</h2>
<table class="data-table">
<thead>
<tr>
<th>Kampania</th>
<th>Typ</th>
<th>Wyświetlenia</th>
<th>Kliknięcia</th>
<th>CTR</th>
<th>Konwersje</th>
<th>Koszt</th>
<th>CPA</th>
</tr>
</thead>
<tbody>
<tr>
<td>[Search] brand</td>
<td><span class="badge badge-search">SEARCH</span></td>
<td class="num">1 572</td>
<td class="num">495</td>
<td class="num">31.5%</td>
<td class="num">24</td>
<td class="num">430.53 PLN</td>
<td class="num">17.94 PLN</td>
</tr>
<tr>
<td>[DSA] produkty</td>
<td><span class="badge badge-search">SEARCH</span></td>
<td class="num">16 608</td>
<td class="num">1 208</td>
<td class="num">7.3%</td>
<td class="num">12</td>
<td class="num">445.16 PLN</td>
<td class="num">37.41 PLN</td>
</tr>
<tr>
<td>[PMax] products (catch-all)</td>
<td><span class="badge badge-performance_max">PERFORMANCE_MAX</span></td>
<td class="num">158 661</td>
<td class="num">2 886</td>
<td class="num">1.8%</td>
<td class="num">178</td>
<td class="num">2828.52 PLN</td>
<td class="num">15.89 PLN</td>
</tr>
<tr>
<td>[PLA] produkty (bestsellers)</td>
<td><span class="badge badge-shopping">SHOPPING</span></td>
<td class="num">4 922</td>
<td class="num">39</td>
<td class="num">0.8%</td>
<td class="num">1</td>
<td class="num">84.76 PLN</td>
<td class="num">84.76 PLN</td>
</tr></tbody>
</table>
</section>
<!-- SEARCH TERMS -->
<section class="report-section" id="search-terms">
<h2 class="section-title">Najpopularniejsze frazy wyszukiwania</h2>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Fraza</th>
<th>Wyświetlenia</th>
<th>Kliknięcia</th>
<th>CTR</th>
<th>Konwersje</th>
</tr>
</thead>
<tbody>
<tr>
<td class="num">1</td>
<td>aruba rzeszów</td>
<td class="num">770</td>
<td class="num">251</td>
<td class="num">32.6%</td>
<td class="num">11</td>
</tr>
<tr>
<td class="num">2</td>
<td>aruba hurtownia</td>
<td class="num">113</td>
<td class="num">45</td>
<td class="num">39.8%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">3</td>
<td>onygen krem</td>
<td class="num">1 114</td>
<td class="num">34</td>
<td class="num">3.0%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">4</td>
<td>aruba rzeszow</td>
<td class="num">117</td>
<td class="num">29</td>
<td class="num">24.8%</td>
<td class="num">2</td>
</tr>
<tr>
<td class="num">5</td>
<td>aruba sklep</td>
<td class="num">54</td>
<td class="num">29</td>
<td class="num">53.7%</td>
<td class="num">3</td>
</tr>
<tr>
<td class="num">6</td>
<td>makijaż permanentny brwi</td>
<td class="num">217</td>
<td class="num">20</td>
<td class="num">9.2%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">7</td>
<td>autoklaw</td>
<td class="num">98</td>
<td class="num">18</td>
<td class="num">18.4%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">8</td>
<td>brwi permanentne</td>
<td class="num">231</td>
<td class="num">18</td>
<td class="num">7.8%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">9</td>
<td>aruba kosmetyki</td>
<td class="num">30</td>
<td class="num">15</td>
<td class="num">50.0%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">10</td>
<td>hurtownia aruba</td>
<td class="num">52</td>
<td class="num">14</td>
<td class="num">26.9%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">11</td>
<td>radiofrekwencja mikroigłowa</td>
<td class="num">342</td>
<td class="num">14</td>
<td class="num">4.1%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">12</td>
<td>hurtownia aruba rzeszów</td>
<td class="num">48</td>
<td class="num">13</td>
<td class="num">27.1%</td>
<td class="num">1</td>
</tr>
<tr>
<td class="num">13</td>
<td>pielęgnacja brwi po makijażu permanentnym</td>
<td class="num">85</td>
<td class="num">13</td>
<td class="num">15.3%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">14</td>
<td>gen factor</td>
<td class="num">236</td>
<td class="num">11</td>
<td class="num">4.7%</td>
<td class="num">0</td>
</tr>
<tr>
<td class="num">15</td>
<td>aruba hurtownia kosmetyczna</td>
<td class="num">22</td>
<td class="num">10</td>
<td class="num">45.5%</td>
<td class="num">2</td>
</tr></tbody>
</table>
</section>
<!-- NEGATIVE KEYWORDS ADDED -->
<!-- SEMSTORM SEO -->
<!-- SEO ACTIVITIES -->
<!-- SEO LINKS -->
<!-- SUMMARY -->
<section class="report-section" id="summary">
<h2 class="section-title">Podsumowanie miesiąca</h2>
<div class="summary-box">
Odnotowano 214 konwersji w tym miesiącu.
</div>
</section>
<!-- RECOMMENDATIONS moved to top -->
<!-- FOOTER -->
<div class="footer">
Raport wygenerowany przez <a href="https://www.project-pro.pl">Project-Pro</a> &mdash; marketing w wersji PRO
</div>
<script>
// Daily clicks chart
var ctx1 = document.getElementById('dailyClicksChart').getContext('2d');
new Chart(ctx1, {
type: 'line',
data: {
labels: ["02-01", "02-02", "02-03", "02-04", "02-05", "02-06", "02-07", "02-08", "02-09", "02-10", "02-11", "02-12", "02-13", "02-14", "02-15", "02-16", "02-17", "02-18", "02-19", "02-20", "02-21", "02-22", "02-23", "02-24", "02-25", "02-26", "02-27", "02-28"],
datasets: [{
label: 'Kliknięcia',
data: [210, 164, 188, 242, 204, 198, 163, 208, 202, 206, 193, 169, 153, 113, 139, 174, 148, 137, 112, 143, 139, 151, 160, 184, 157, 141, 141, 89],
borderColor: '#0d8b8b',
backgroundColor: 'rgba(13,139,139,0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#0d8b8b',
}, {
label: 'Wyświetlenia',
data: [7761, 8752, 6894, 6890, 7048, 8251, 6007, 8393, 6761, 8531, 6071, 5122, 6360, 4092, 5897, 6193, 6761, 6894, 5773, 6152, 6529, 5916, 7070, 7262, 6054, 4538, 5064, 4727],
borderColor: '#95a5a6',
backgroundColor: 'transparent',
borderDash: [5, 5],
tension: 0.3,
pointRadius: 0,
yAxisID: 'y1',
}]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
y: { beginAtZero: true, position: 'left', grid: { color: '#f0f0f0' } },
y1: { beginAtZero: true, position: 'right', grid: { display: false } },
x: { grid: { display: false }, ticks: { maxTicksLimit: 10 } }
}
}
});
</script>
</body>
</html>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

2
src/gads_v2/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
__version__ = "0.1.0"

67
src/gads_v2/cleanup.py Normal file
View File

@@ -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)."
)

1783
src/gads_v2/cli.py Normal file

File diff suppressed because it is too large Load Diff

70
src/gads_v2/config.py Normal file
View File

@@ -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

33
src/gads_v2/google_ads.py Normal file
View File

@@ -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

49
src/gads_v2/history.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,2 @@
"""Local knowledge store helpers."""

View File

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

View File

@@ -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,
}

View File

@@ -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() + "..."

View File

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

197
src/gads_v2/reminders.py Normal file
View File

@@ -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")

25
src/gads_v2/table.py Normal file
View File

@@ -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("", "", ""))

126
src/gads_v2/task_catalog.py Normal file
View File

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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,
)

View File

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

View File

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

View File

@@ -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 |

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More