update
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
# Memory Index
|
||||
|
||||
- [Format listy klientów](feedback_client_list_format.md) — listy klientów prezentować jako numerowaną tabelę markdown
|
||||
@@ -6,4 +6,19 @@ GOOGLE_ADS_MANAGER_ACCOUNT_ID=8976093870
|
||||
|
||||
ADSPRO_API_URL=https://adspro.projectpro.pl/api.php
|
||||
ADSPRO_API_KEY=42bda4bafa556546990c817b7ec1feab8ca5ed3e134ac797
|
||||
ADSPRO_HOST=host700513.hostido.net.pl
|
||||
ADSPRO_USERNAME=www@adspro.projectpro.pl
|
||||
ADSPRO_PASSWORD=S75kmnqmD5heMZzJQf5n
|
||||
ADSPRO_REMOTE_PATH=/public_html
|
||||
|
||||
# Import wiedzy przez OpenAI API
|
||||
# Uzywane tylko przez: python gads.py wiedza dodaj ...
|
||||
OPENAI_API_KEY=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
|
||||
|
||||
# Opcjonalnie: model do ekstrakcji regul wiedzy.
|
||||
# Mozesz tez podac model jednorazowo przez --model.
|
||||
KNOWLEDGE_OPENAI_MODEL=gpt-4.1-mini
|
||||
|
||||
# Google Analytics 4 API
|
||||
GA4_REFRESH_TOKEN=1//09_hLDPStXz3QCgYIARAAGAkSNwF-L9IrKlofxXOfEYoI9jyiHebuffRwcLtBnUQB48jBU3De4MvTOLyZ5FYLRapCt5bcLjsvyxc
|
||||
GA4_PROPERTY_ID_ibra-makeup.pl=401893399
|
||||
|
||||
6
.sync/Archive/.gitignore
vendored
Normal file
6
.sync/Archive/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
config/clients.toml
|
||||
clients/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -166,6 +166,80 @@ Popros uzytkownika tylko o numer. Po wyborze:
|
||||
- `2` pokaz liste klientow,
|
||||
- `3` zakoncz.
|
||||
|
||||
## Raport klienta
|
||||
|
||||
Gdy uzytkownik poprosi o raport klienta albo poda komende w stylu:
|
||||
|
||||
```text
|
||||
analiza-klienta aruba.rzeszow.pl 02-2026
|
||||
```
|
||||
|
||||
uruchom:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta aruba.rzeszow.pl 02-2026
|
||||
```
|
||||
|
||||
To jest alias dla generowania miesiecznego raportu HTML klienta. Obslugiwane sa formaty miesiaca `MM-YYYY`, `MM.YYYY` i `YYYY-MM`.
|
||||
|
||||
Jesli uzytkownik napisze tylko:
|
||||
|
||||
```text
|
||||
raport-klienta
|
||||
```
|
||||
|
||||
uruchom:
|
||||
|
||||
```powershell
|
||||
python gads.py raport-klienta
|
||||
```
|
||||
|
||||
Pokaz uzytkownikowi liste klientow i popros o numer. Po wyborze klienta oraz miesiaca uruchom:
|
||||
|
||||
```powershell
|
||||
python gads.py raport-klienta --client-number <numer-klienta> --month <YYYY-MM>
|
||||
```
|
||||
|
||||
Komenda najpierw pobiera dane i zatrzymuje sie przed generowaniem HTML. Tworzy plik roboczy:
|
||||
|
||||
```text
|
||||
scripts/reports/output/<domena>_<YYYY-MM>_recommendations.json
|
||||
```
|
||||
|
||||
Wnioski i rekomendacje przygotowuje agent AI, nie skrypt. Agent ma przeczytac dane raportu i kontekst w pliku rekomendacji, uzupelnic `recommendations` konkretnymi wnioskami, pokazac je uzytkownikowi i zapytac o akceptacje.
|
||||
|
||||
Wnioski pisz z perspektywy osoby, ktora obsluguje konto Google Ads klienta. Nie pisz do klienta, ze `warto cos sprawdzic`, `trzeba zweryfikowac` albo `nalezy przeanalizowac`, jakby decyzja byla po jego stronie. Pisz decyzyjnie: co robimy, co zostawiamy, co ograniczamy, co kontrolujemy i jaki jest nastepny krok po naszej stronie. Unikaj bezosobowych, nijakich rekomendacji.
|
||||
|
||||
W tekstach raportu dla klienta uzywaj poprawnych polskich znakow. Dotyczy to szczegolnie tytulow, wnioskow i rekomendacji w pliku `recommendations`. Nie zapisuj tam wersji bez ogonkow typu `zwiekszamy`, `wartosc`, `srednia`, jezeli tekst trafi do HTML widocznego dla klienta.
|
||||
|
||||
Po akceptacji wnioskow uruchom:
|
||||
|
||||
```powershell
|
||||
python gads.py raport-klienta --client <domena> --month <YYYY-MM> --confirm-recommendations TAK
|
||||
```
|
||||
|
||||
Dopiero wtedy komenda generuje lokalny raport HTML w:
|
||||
|
||||
```text
|
||||
scripts/reports/output/<domena>/<YYYY-MM>/index.html
|
||||
```
|
||||
|
||||
Jeżeli klient ma w `config/clients.toml` ustawione `sales_history_sheet`, historia sprzedaży miesięcznej oraz trzy kafelki w sekcji `E-commerce — Sprzedaż` mają pochodzić z tego arkusza Google Sheet. Arkusz powinien zawierać kolumny: `Miesiąc`, `Transakcje`, `Przychody`, `Średnia wartość koszyka`. Nie zastępuj tych danych GA4, jeżeli arkusz jest skonfigurowany.
|
||||
|
||||
Po wygenerowaniu raportu pokaz uzytkownikowi sciezke do pliku i popros o akceptacje przed wysylka na serwer. Nie wysylaj raportu bez jasnej zgody uzytkownika.
|
||||
|
||||
Po akceptacji uruchom upload:
|
||||
|
||||
```powershell
|
||||
python gads.py raport-klienta --client <domena> --month <YYYY-MM> --confirm-upload TAK
|
||||
```
|
||||
|
||||
Po wysylce podaj URL:
|
||||
|
||||
```text
|
||||
https://adspro.projectpro.pl/raporty/<slug>/<YYYY-MM>/
|
||||
```
|
||||
|
||||
## Zasady komunikacji
|
||||
|
||||
- Pisz po polsku.
|
||||
@@ -245,6 +319,58 @@ Przed wdrozeniem tytulow agent musi uzupelnic docelowe wartosci tytulow w zapisa
|
||||
Przed wdrozeniem kategorii agent musi uzupelnic docelowe wartosci kategorii w zapisanym planie JSON i dopiero wtedy zapytac uzytkownika o zgode.
|
||||
Unit pricing moze byc proponowany przez skrypt, jezeli da sie go jednoznacznie odczytac z nazwy produktu.
|
||||
|
||||
## Reczne przypisywanie regul
|
||||
|
||||
Gdy uzytkownik napisze:
|
||||
|
||||
```text
|
||||
Przypisz regule:
|
||||
- tresc reguly do oceny
|
||||
```
|
||||
|
||||
agent ma potraktowac to jako prosbe o kuracje pojedynczej reguly wiedzy.
|
||||
|
||||
Kolejnosc pracy:
|
||||
|
||||
1. Sprawdz aktualne grupy i zadania w `config/tasks.toml`.
|
||||
2. Nie przywracaj usunietych zadan ani grup. Jesli lista zadan jest ograniczona, uznaj to za swiadoma decyzje uzytkownika.
|
||||
3. Ocen, czy regule warto dodac do narzedzia.
|
||||
4. Zaproponuj docelowe brzmienie reguly: `condition`, `recommendation`, `risk`, `rule_type`, `topic`, `confidence` i docelowe `task_ids`.
|
||||
5. Zaproponuj policzalny `machine_condition` i `machine_effect`, jezeli regule da sie bezpiecznie zastosowac w skrypcie na danych pobieranych przez dane zadanie.
|
||||
6. `machine_condition` ma uzywac tylko pol, ktore naprawde istnieja w planie danego zadania, np. `channel_type`, `conversions_30d`, `bidding_strategy_type`, `budget_context`, `search_budget_lost_impression_share`.
|
||||
7. Jesli nie da sie zbudowac bezpiecznego warunku maszynowego, napisz to wprost i zaproponuj zapis reguly bez automatycznego wplywu, jako kontekst dla agenta AI/czlowieka.
|
||||
8. Uzywaj tylko istniejacych identyfikatorow zadan z `config/tasks.toml`.
|
||||
9. Jesli nie ma dobrego zadania, powiedz, ze regule lepiej odlozyc albo dodac dopiero po utworzeniu nowego zadania.
|
||||
10. Nie zapisuj reguly do `knowledge/rules.jsonl`, dopoki uzytkownik jasno nie odpowie `Dodaj`.
|
||||
11. Po odpowiedzi `Dodaj` dopisz jedna kompletna linie JSONL do `knowledge/rules.jsonl`.
|
||||
|
||||
Przy zapisie do `knowledge/rules.jsonl` uzupelnij pola: `id`, `status`, `topic`, `task_ids`, `suggested_task_ids`, `rule_type`, `condition`, `recommendation`, `risk`, `source`, `source_file`, `confidence`, `duplicate_of`, `supersedes`, `text`, `created_at`, `updated_at`. Jezeli regula ma dzialac automatycznie, dodaj tez `machine_condition` i `machine_effect`.
|
||||
|
||||
Przyklad policzalnej czesci reguly:
|
||||
|
||||
```json
|
||||
{
|
||||
"machine_condition": {
|
||||
"all": [
|
||||
{"field": "channel_type", "op": "eq", "value": "SEARCH"},
|
||||
{"field": "conversions_30d", "op": "lt", "value": 15},
|
||||
{"field": "bidding_strategy_type", "op": "in", "value": ["MAXIMIZE_CONVERSIONS", "TARGET_CPA", "MAXIMIZE_CONVERSION_VALUE", "TARGET_ROAS"]}
|
||||
]
|
||||
},
|
||||
"machine_effect": {
|
||||
"level": "ostroznie",
|
||||
"action": "nie przechodz na automatyzacje konwersyjna",
|
||||
"reason_prefix": "Regula wiedzy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ten tryb jest reczna alternatywa dla komendy:
|
||||
|
||||
```powershell
|
||||
python gads.py wiedza przypisz
|
||||
```
|
||||
|
||||
## Reguly i wyjatki klientow
|
||||
|
||||
Ustawienia globalne i wyjatki per klient sa w:
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
# Rozbudowa narzedzia
|
||||
|
||||
Ten plik opisuje, jak dodawac nowe grupy zadan, zadania i skrypty, zeby kolejny agent nie musial projektowac procesu od zera.
|
||||
|
||||
## Zasada architektury
|
||||
|
||||
Kazde zadanie powinno dzialac w tym samym modelu:
|
||||
|
||||
1. Pobierz aktualne dane.
|
||||
2. Zbuduj plan.
|
||||
3. Zapisz plan do `clients/<domena>/plans/` jako `.json` i `.md`.
|
||||
4. W trybie `--plan-only` nie wdrazaj zmian.
|
||||
5. Po akceptacji uzytkownika wdrazaj tylko plan zapisany w JSON.
|
||||
6. Zapisz historie do:
|
||||
- `clients/<domena>/history/YYYY-MM-DD.jsonl`
|
||||
- `clients/<domena>/changes/YYYY-MM-DD.md`
|
||||
|
||||
Agent AI prowadzi uzytkownika, ale logika pobierania danych, analizy i wdrozenia zmian ma byc w Pythonie.
|
||||
|
||||
## Dodanie nowej grupy zadan
|
||||
|
||||
Przed dodaniem wiekszego zakresu sprawdz:
|
||||
|
||||
```text
|
||||
OLD_COMMANDS_CHECKLIST.md
|
||||
```
|
||||
|
||||
To jest lista rzeczy sprawdzanych przez stary system z `D:\google ads\`.
|
||||
|
||||
Edytuj:
|
||||
|
||||
```text
|
||||
config/tasks.toml
|
||||
```
|
||||
|
||||
Dodaj nowa grupe:
|
||||
|
||||
```toml
|
||||
[[groups]]
|
||||
id = "search"
|
||||
name = "Kampanie Search"
|
||||
```
|
||||
|
||||
Zadania beda numerowane automatycznie jako `2.1`, `2.2`, itd. w zaleznosci od kolejnosci grup.
|
||||
|
||||
## Dodanie nowego zadania do grupy
|
||||
|
||||
W `config/tasks.toml` dodaj zadanie pod odpowiednia grupa:
|
||||
|
||||
```toml
|
||||
[[groups.tasks]]
|
||||
id = "check_search_settings"
|
||||
name = "Sprawdzenie ustawien"
|
||||
description = "Sprawdza ustawienia kampanii Search wedlug regul globalnych i wyjatkow klienta."
|
||||
```
|
||||
|
||||
`id` jest techniczne i musi byc stabilne. `name` i `description` sa dla uzytkownika.
|
||||
|
||||
## Plik zadania w Pythonie
|
||||
|
||||
Dodaj modul w:
|
||||
|
||||
```text
|
||||
src/gads_v2/tasks/
|
||||
```
|
||||
|
||||
Przyklad nazwy:
|
||||
|
||||
```text
|
||||
src/gads_v2/tasks/search_settings_check.py
|
||||
```
|
||||
|
||||
Minimalny wzorzec funkcji:
|
||||
|
||||
```python
|
||||
def run_check_search_settings(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
Wymagania:
|
||||
|
||||
- `plan_only=True` zawsze tylko zapisuje plan.
|
||||
- `apply_plan_path` wdraza tylko wskazany plan JSON.
|
||||
- `confirm_apply` musi wymagac wartosci `TAK`.
|
||||
- `show_navigation=False` musi ukrywac pytanie `Co dalej`, bo uzywa tego tryb sekwencji.
|
||||
|
||||
## Struktura planu
|
||||
|
||||
Plan powinien miec klase lub slownik z metodami:
|
||||
|
||||
```python
|
||||
to_dict()
|
||||
from_dict()
|
||||
```
|
||||
|
||||
Plan JSON musi zawierac:
|
||||
|
||||
```json
|
||||
{
|
||||
"created_at": "...",
|
||||
"client": "example.pl",
|
||||
"task": "task_id",
|
||||
"changes": []
|
||||
}
|
||||
```
|
||||
|
||||
Plan Markdown powinien zawierac:
|
||||
|
||||
- krotkie podsumowanie,
|
||||
- tabele po kampaniach, jesli zadanie dotyczy kampanii,
|
||||
- tabele planowanych dzialan,
|
||||
- ostrzezenia lub pominiete reguly.
|
||||
|
||||
## Podpiecie zadania do CLI
|
||||
|
||||
Edytuj:
|
||||
|
||||
```text
|
||||
src/gads_v2/cli.py
|
||||
```
|
||||
|
||||
1. Zaimportuj funkcje zadania:
|
||||
|
||||
```python
|
||||
from .tasks.search_settings_check import run_check_search_settings
|
||||
```
|
||||
|
||||
2. Dodaj `id` do argumentu `--task`:
|
||||
|
||||
```python
|
||||
parser.add_argument("--task", choices=["sync_pla_cl1", "check_pla_settings", "check_search_settings"], ...)
|
||||
```
|
||||
|
||||
3. Dodaj obsluge w `run_task()`:
|
||||
|
||||
```python
|
||||
if task_id == "check_search_settings":
|
||||
run_check_search_settings(
|
||||
client,
|
||||
global_rules,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
```
|
||||
|
||||
## Reguly globalne i wyjatki klientow
|
||||
|
||||
Reguly trzymaj w:
|
||||
|
||||
```text
|
||||
config/clients.toml
|
||||
```
|
||||
|
||||
Przyklad globalny:
|
||||
|
||||
```toml
|
||||
[global_rules.search_settings]
|
||||
require_presence_only = true
|
||||
require_search_partners_off = true
|
||||
```
|
||||
|
||||
Wyjatek per klient:
|
||||
|
||||
```toml
|
||||
[clients."example.pl".search_settings]
|
||||
require_search_partners_off = false
|
||||
```
|
||||
|
||||
W kodzie uzywaj:
|
||||
|
||||
```python
|
||||
rules = client_config.effective_rules(global_rules, "search_settings")
|
||||
```
|
||||
|
||||
## Numeracja i wybory
|
||||
|
||||
Lista zadan uzywa formatu:
|
||||
|
||||
```text
|
||||
1.1 - pierwsze zadanie w pierwszej grupie
|
||||
1.2 - drugie zadanie w pierwszej grupie
|
||||
1.0 - wszystkie zadania z pierwszej grupy
|
||||
ALL - wszystkie zadania ze wszystkich grup
|
||||
```
|
||||
|
||||
Nie dodawaj recznej numeracji do `tasks.toml`. Numeracja wynika z kolejnosci grup i zadan.
|
||||
|
||||
## Test po dodaniu zadania
|
||||
|
||||
Uruchom:
|
||||
|
||||
```powershell
|
||||
python -m compileall -q gads.py src
|
||||
python gads.py analiza-klienta --client-number 1
|
||||
python gads.py analiza-klienta --client-number 1 --select <nr> --plan-only
|
||||
```
|
||||
|
||||
Jesli zadanie wdraza zmiany, przetestuj najpierw tylko `--plan-only`.
|
||||
|
||||
## Format komunikacji agentow
|
||||
|
||||
Instrukcja dla agentow jest w:
|
||||
|
||||
```text
|
||||
AGENTS.md
|
||||
```
|
||||
|
||||
Po dodaniu nowego typu zadania dopisz tam tylko specjalne zasady, jesli agent ma wiedziec cos ponad standardowy przeplyw.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Google Ads ver 2
|
||||
|
||||
Terminalowe narzedzie do pracy na kontach Google Ads klientow.
|
||||
|
||||
Instrukcja pracy dla Claude Code, Codex, Gemini CLI i innych agentow AI jest w `AGENTS.md`.
|
||||
Instrukcja rozbudowy o nowe grupy i zadania jest w `DEVELOPMENT.md`.
|
||||
Backlog rzeczy sprawdzanych w starej wersji jest w `OLD_COMMANDS_CHECKLIST.md`.
|
||||
|
||||
## Start
|
||||
|
||||
1. Uzupelnij `.env` na podstawie `.env.example`.
|
||||
2. Uzupelnij `config/clients.toml` na podstawie `config/clients.example.toml`.
|
||||
3. Zainstaluj zaleznosci:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Uruchom menu:
|
||||
|
||||
```powershell
|
||||
python gads.py
|
||||
```
|
||||
|
||||
Albo uruchom konkretne zadanie bez menu:
|
||||
|
||||
```powershell
|
||||
python gads.py --client laitica.pl --task sync_pla_cl1
|
||||
```
|
||||
|
||||
Tryb dla Claude Code, Codex albo Gemini CLI:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta
|
||||
python gads.py analiza-klienta --client-number 5
|
||||
python gads.py analiza-klienta --client-number 5 --task-number 1 --plan-only
|
||||
```
|
||||
|
||||
Po tej komendzie narzedzie zapisze plan w `clients/<domena>/plans/`.
|
||||
Agent czyta plik `.md` albo `.json`, analizuje go i pyta Cie o zgode.
|
||||
Po Twojej akceptacji agent uruchamia wdrozenie konkretnego planu:
|
||||
|
||||
```powershell
|
||||
python gads.py --client laitica.pl --task sync_pla_cl1 --apply-plan clients/laitica.pl/plans/PLAN.json --confirm-apply TAK
|
||||
```
|
||||
|
||||
## MVP
|
||||
|
||||
Pierwsze zadanie:
|
||||
|
||||
- pobiera kampanie `[PLA_CL1]` z Google Ads,
|
||||
- wyciaga segmenty CL1 z nazw kampanii,
|
||||
- pobiera produkty z adsPRO,
|
||||
- przygotowuje plan synchronizacji grup reklam,
|
||||
- czeka na akceptacje przed wdrozeniem zmian,
|
||||
- zapisuje historie w katalogu klienta.
|
||||
|
||||
## Dane i historia
|
||||
|
||||
- `config/clients.toml` - lista klientow i identyfikatory kont.
|
||||
- `config/clients.toml` - takze reguly globalne i wyjatki per klient, np. ustawienia kampanii PLA.
|
||||
- `.env` - dane dostepowe do Google Ads i adsPRO.
|
||||
- `clients/<domena>/data/` - pobrane dane robocze.
|
||||
- `clients/<domena>/history/YYYY-MM-DD.jsonl` - historia do filtrowania po kliencie, dacie i kampanii.
|
||||
- `clients/<domena>/changes/YYYY-MM-DD.md` - czytelny dziennik zmian.
|
||||
|
||||
Narzedzie nie uzywa API modeli AI. Claude Code, Codex albo Gemini CLI moga uruchamiac te same komendy terminalowe.
|
||||
52
.sync/Archive/config/clients.1.toml
Normal file
52
.sync/Archive/config/clients.1.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
[clients."pomysloweprezenty.pl"]
|
||||
google_ads_customer_id = "941-605-1782"
|
||||
adspro_client_id = "2"
|
||||
|
||||
[clients."innsi.pl"]
|
||||
google_ads_customer_id = "133-343-6346"
|
||||
adspro_client_id = "5"
|
||||
|
||||
[clients."van-dam.pl"]
|
||||
google_ads_customer_id = "570-658-4790"
|
||||
|
||||
[clients."investagd.pl"]
|
||||
google_ads_customer_id = "229-855-5588"
|
||||
adspro_client_id = "8"
|
||||
|
||||
[clients."wyprzedaze.pl"]
|
||||
google_ads_customer_id = "775-249-3197"
|
||||
adspro_client_id = "10"
|
||||
|
||||
[clients."laitica.pl"]
|
||||
google_ads_customer_id = "262-567-7205"
|
||||
adspro_client_id = "9"
|
||||
|
||||
[clients."studio-zoe.pl"]
|
||||
google_ads_customer_id = "387-166-1050"
|
||||
|
||||
[clients."aruba.rzeszow.pl"]
|
||||
google_ads_customer_id = "374-470-8609"
|
||||
adspro_client_id = "3"
|
||||
sales_history_sheet = "https://docs.google.com/spreadsheets/d/1tkT1WgOi41uj7D0Q8ZTD0qINsd11VVsd7G9Q7M8kAN8/"
|
||||
seo_works_history_sheet = "https://docs.google.com/spreadsheets/d/1lle7nkl0ykkJMlo2eA8AuV7Sd705ia6bmh7RM38nsRg/"
|
||||
seo_links_history_sheet = "https://docs.google.com/spreadsheets/d/1b4uCBzNSFFxIv2X1fTsqZcdDr7NZZYOIsmc9kLJqkq0/"
|
||||
|
||||
[clients."ibra-makeup.pl"]
|
||||
google_ads_customer_id = "818-919-2566"
|
||||
adspro_client_id = "4"
|
||||
sales_history_sheet = "https://docs.google.com/spreadsheets/d/1OGqRluQgd2vCVxbmVLhPd00q8RWucSc0L4GdcGAl8vU"
|
||||
|
||||
[global_rules]
|
||||
max_create_groups_without_extra_confirm = 100
|
||||
max_pause_groups_without_extra_confirm = 100
|
||||
|
||||
[global_rules.pla_settings]
|
||||
require_presence_only = true
|
||||
require_high_priority = true
|
||||
|
||||
[global_rules.product_feed_optimization]
|
||||
limit = 10
|
||||
min_days_between_title_changes = 30
|
||||
|
||||
[global_rules.budget_usage]
|
||||
min_days_between_budget_changes = 7
|
||||
@@ -1,20 +0,0 @@
|
||||
[clients."example.pl"]
|
||||
google_ads_customer_id = "123-456-7890"
|
||||
adspro_client_id = "1"
|
||||
|
||||
[global_rules]
|
||||
max_create_groups_without_extra_confirm = 100
|
||||
max_pause_groups_without_extra_confirm = 100
|
||||
|
||||
[global_rules.pla_settings]
|
||||
require_presence_only = true
|
||||
require_high_priority = true
|
||||
|
||||
[global_rules.product_feed_optimization]
|
||||
limit = 10
|
||||
min_days_between_title_changes = 30
|
||||
|
||||
# Wyjatek per klient:
|
||||
# [clients."example.pl".pla_settings]
|
||||
# require_high_priority = false
|
||||
# require_presence_only = true
|
||||
@@ -9,10 +9,6 @@ adspro_client_id = "5"
|
||||
[clients."van-dam.pl"]
|
||||
google_ads_customer_id = "570-658-4790"
|
||||
|
||||
[clients."sklep.ele-comp.pl"]
|
||||
google_ads_customer_id = "489-092-9476"
|
||||
adspro_client_id = "7"
|
||||
|
||||
[clients."investagd.pl"]
|
||||
google_ads_customer_id = "229-855-5588"
|
||||
adspro_client_id = "8"
|
||||
@@ -35,6 +31,7 @@ adspro_client_id = "3"
|
||||
[clients."ibra-makeup.pl"]
|
||||
google_ads_customer_id = "818-919-2566"
|
||||
adspro_client_id = "4"
|
||||
sales_history_sheet = "https://docs.google.com/spreadsheets/d/1OGqRluQgd2vCVxbmVLhPd00q8RWucSc0L4GdcGAl8vU"
|
||||
|
||||
[global_rules]
|
||||
max_create_groups_without_extra_confirm = 100
|
||||
@@ -47,3 +44,6 @@ require_high_priority = true
|
||||
[global_rules.product_feed_optimization]
|
||||
limit = 10
|
||||
min_days_between_title_changes = 30
|
||||
|
||||
[global_rules.budget_usage]
|
||||
min_days_between_budget_changes = 7
|
||||
|
||||
78
.sync/Archive/config/tasks.1.toml
Normal file
78
.sync/Archive/config/tasks.1.toml
Normal file
@@ -0,0 +1,78 @@
|
||||
[[groups]]
|
||||
id = "campaigns_structure"
|
||||
name = "Ustawienia kampanii"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_pla_settings"
|
||||
name = "Sprawdzenie ustawien PLA"
|
||||
description = "Sprawdza ustawienia lokalizacji i priorytetu kampanii PLA wedlug regul globalnych i wyjatkow klienta."
|
||||
|
||||
[[groups]]
|
||||
id = "search_campaigns"
|
||||
name = "Kampanie Search"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_search_basic_settings"
|
||||
name = "Sprawdzenie podstawowych ustawien Search"
|
||||
description = "Sprawdza podstawowe ustawienia kampanii Search i przygotowuje korekty: lokalizacja Obecnosc, wylaczona siec reklamowa oraz wylaczeni partnerzy wyszukiwania."
|
||||
|
||||
[[groups]]
|
||||
id = "measurement_conversions"
|
||||
name = "Pomiar i konwersje"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_conversion_tracking"
|
||||
name = "Sprawdzenie pomiaru konwersji"
|
||||
description = "Pobiera akcje konwersji z Google Ads, sprawdza dane i wartosci oraz proponuje wlaczenie roznych wartosci konwersji dla e-commerce."
|
||||
|
||||
[[groups]]
|
||||
id = "product_campaigns"
|
||||
name = "Kampanie produktowe"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "sync_pla_cl1"
|
||||
name = "Synchronizacja kampanii PLA_CL1"
|
||||
description = "Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "optimize_shopping_troas_ag"
|
||||
name = "Automatyzacja tROAS per grupa reklam PLA"
|
||||
description = "Analizuje Standard Shopping PLA z ostatnich 30 dni i przygotowuje plan stopniowej zmiany tROAS na poziomie grup reklam."
|
||||
|
||||
[[groups]]
|
||||
id = "products_feed_shopping"
|
||||
name = "Produkty, feed i Shopping"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "optimize_product_titles"
|
||||
name = "Optymalizacja tytulow produktow"
|
||||
description = "Pobiera produkty z adsPRO i przygotowuje plan optymalizacji tytulow produktow."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "optimize_product_categories"
|
||||
name = "Optymalizacja kategorii Google"
|
||||
description = "Pobiera produkty z adsPRO bez kategorii Google i przygotowuje plan decyzji agenta AI."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "fill_product_unit_pricing"
|
||||
name = "Uzupelnienie unit pricing"
|
||||
description = "Pobiera produkty z adsPRO bez unit pricing i przygotowuje plan uzupelnienia miary oraz miary bazowej."
|
||||
|
||||
[[groups]]
|
||||
id = "budget_bidding_alerts"
|
||||
name = "Budzety, stawki i alerty"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_budget_usage"
|
||||
name = "Sprawdzenie wykorzystania budzetu"
|
||||
description = "Sprawdza tygodniowe wykorzystanie budzetow, kampanie ograniczone budzetem i ryzyko zbyt szybkiego albo zbyt wolnego wydawania srodkow."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_bidding_strategies"
|
||||
name = "Sprawdzenie strategii stawek"
|
||||
description = "Sprawdza strategie ustalania stawek, cele Docelowy ROAS/Docelowy CPA, wolumen konwersji i ryzyko zbyt szybkich zmian."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_account_anomalies"
|
||||
name = "Sprawdzenie anomalii konta"
|
||||
description = "Sprawdza nagle spadki i wzrosty kosztu, klikniec, konwersji, wartosci konwersji, CTR, CPC i ROAS na poziomie kampanii."
|
||||
@@ -1,20 +1,25 @@
|
||||
[[groups]]
|
||||
id = "pla"
|
||||
name = "Kampanie PLA"
|
||||
id = "campaigns_structure"
|
||||
name = "Kampanie i struktura"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "sync_pla_cl1"
|
||||
name = "Synchronizacja kampanii PLA_CL1"
|
||||
description = "Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "optimize_shopping_troas_ag"
|
||||
name = "Automatyzacja tROAS per grupa reklam PLA"
|
||||
description = "Analizuje Standard Shopping PLA z ostatnich 30 dni i przygotowuje plan stopniowej zmiany tROAS na poziomie grup reklam."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_pla_settings"
|
||||
name = "Sprawdzenie ustawien"
|
||||
name = "Sprawdzenie ustawien PLA"
|
||||
description = "Sprawdza ustawienia lokalizacji i priorytetu kampanii PLA wedlug regul globalnych i wyjatkow klienta."
|
||||
|
||||
[[groups]]
|
||||
id = "products"
|
||||
name = "Produkty"
|
||||
id = "products_feed_shopping"
|
||||
name = "Produkty, feed i Shopping"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "optimize_product_titles"
|
||||
@@ -30,3 +35,21 @@ description = "Pobiera produkty z adsPRO bez kategorii Google i przygotowuje pla
|
||||
id = "fill_product_unit_pricing"
|
||||
name = "Uzupelnienie unit pricing"
|
||||
description = "Pobiera produkty z adsPRO bez unit pricing i przygotowuje plan uzupelnienia miary oraz miary bazowej."
|
||||
[[groups]]
|
||||
id = "budget_bidding_alerts"
|
||||
name = "Budzety, stawki i alerty"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_budget_usage"
|
||||
name = "Sprawdzenie wykorzystania budzetu"
|
||||
description = "Sprawdza tygodniowe wykorzystanie budzetow, kampanie ograniczone budzetem i ryzyko zbyt szybkiego albo zbyt wolnego wydawania srodkow."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_bidding_strategies"
|
||||
name = "Sprawdzenie strategii stawek"
|
||||
description = "Sprawdza strategie ustalania stawek, cele Docelowy ROAS/Docelowy CPA, wolumen konwersji i ryzyko zbyt szybkich zmian."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_account_anomalies"
|
||||
name = "Sprawdzenie anomalii konta"
|
||||
description = "Sprawdza nagle spadki i wzrosty kosztu, klikniec, konwersji, wartosci konwersji, CTR, CPC i ROAS na poziomie kampanii."
|
||||
|
||||
165
.sync/Archive/config/tasks_backup.toml
Normal file
165
.sync/Archive/config/tasks_backup.toml
Normal file
@@ -0,0 +1,165 @@
|
||||
[[groups]]
|
||||
id = "campaigns_structure"
|
||||
name = "Kampanie i struktura"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "sync_pla_cl1"
|
||||
name = "Synchronizacja kampanii PLA_CL1"
|
||||
description = "Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_pla_settings"
|
||||
name = "Sprawdzenie ustawien PLA"
|
||||
description = "Sprawdza ustawienia lokalizacji i priorytetu kampanii PLA wedlug regul globalnych i wyjatkow klienta."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_search_basic_settings"
|
||||
name = "Sprawdzenie podstawowych ustawien Search"
|
||||
description = "Sprawdza podstawowe ustawienia kampanii Search: lokalizacje, sieci, jezyki i inne proste ustawienia konfiguracyjne."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_pmax_structure"
|
||||
name = "Sprawdzenie struktury PMax"
|
||||
description = "Sprawdza kampanie Performance Max, asset groups, feed, brand/non-brand i ryzyka kanibalizacji."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_feed_merchant_quality"
|
||||
name = "Sprawdzenie feedu i Merchant Center"
|
||||
description = "Sprawdza jakosc feedu produktowego, potencjalne problemy Merchant Center, atrybuty produktow i ryzyka odrzucen."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_shopping_product_statuses"
|
||||
name = "Sprawdzenie statusow produktow Shopping"
|
||||
description = "Sprawdza produkty niedostepne, odrzucone, ograniczone albo bez emisji w Shopping i PMax."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_shopping_product_performance"
|
||||
name = "Sprawdzenie wynikow produktow Shopping"
|
||||
description = "Sprawdza wyniki produktow Shopping z ostatnich 30 dni: koszt, klikniecia, konwersje, wartosc, ROAS i CPA."
|
||||
|
||||
[[groups]]
|
||||
id = "measurement_audiences"
|
||||
name = "Pomiar, konwersje i odbiorcy"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_conversion_tracking"
|
||||
name = "Sprawdzenie pomiaru konwersji"
|
||||
description = "Sprawdza, czy konto ma poprawnie ustawione konwersje, tagowanie, GA4/Google Ads i czy dane nadaja sie do optymalizacji kampanii."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_conversion_action_performance"
|
||||
name = "Sprawdzenie akcji konwersji"
|
||||
description = "Sprawdza, ktore akcje konwersji generuja wynik w kampaniach i czy rozklad konwersji wymaga recznej oceny pomiaru."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_remarketing_setup"
|
||||
name = "Sprawdzenie remarketingu"
|
||||
description = "Sprawdza podstawy remarketingu: listy odbiorcow, dynamiczny remarketing, tagowanie produktowe i ryzyka konfliktu z PMax."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_gender_performance"
|
||||
name = "Sprawdzenie plci odbiorcow"
|
||||
description = "Sprawdza wyniki kampanii wedlug plci odbiorcow, bez dodawania wykluczen demograficznych i bez korekt stawek."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_age_performance"
|
||||
name = "Sprawdzenie wieku odbiorcow"
|
||||
description = "Sprawdza wyniki kampanii wedlug przedzialow wieku odbiorcow, bez dodawania wykluczen demograficznych i bez korekt stawek."
|
||||
|
||||
|
||||
[[groups]]
|
||||
id = "traffic_keywords_landing"
|
||||
name = "Ruch, slowa i strony docelowe"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_search_terms"
|
||||
name = "Analiza zapytan i wykluczen"
|
||||
description = "Sprawdza zapytania uzytkownikow, niepasujacy ruch, broad match, frazy brand/non-brand i kandydatow do wykluczen."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_ad_group_performance"
|
||||
name = "Sprawdzenie grup reklam"
|
||||
description = "Sprawdza koszt, klikniecia, konwersje, wartosc konwersji, ROAS, CPA i CTR na poziomie grup reklam."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_keyword_statuses"
|
||||
name = "Sprawdzenie statusow slow kluczowych"
|
||||
description = "Sprawdza odrzucone, ograniczone, wstrzymane albo technicznie problematyczne slowa kluczowe."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_keyword_quality_score"
|
||||
name = "Sprawdzenie Wyniku Jakosci slow kluczowych"
|
||||
description = "Sprawdza Wynik Jakosci slow kluczowych, jakosc reklamy, strone docelowa, przewidywany CTR i wyniki."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_landing_page_performance"
|
||||
name = "Sprawdzenie stron docelowych"
|
||||
description = "Sprawdza wyniki stron docelowych: koszt, klikniecia, konwersje, wartosc konwersji, ROAS, CPA, CTR i wspolczynnik konwersji."
|
||||
|
||||
[[groups]]
|
||||
id = "ads_assets_statuses"
|
||||
name = "Reklamy, zasoby i statusy"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_rsa_assets"
|
||||
name = "Sprawdzenie reklam RSA i zasobow"
|
||||
description = "Sprawdza reklamy RSA, naglowki, teksty, assety, DKI i podstawowe ryzyka kreacji."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_ad_asset_statuses"
|
||||
name = "Sprawdzenie statusow reklam i zasobow"
|
||||
description = "Sprawdza odrzucone, ograniczone, wstrzymane albo oczekujace reklamy i zasoby."
|
||||
|
||||
[[groups]]
|
||||
id = "settings_segments_auctions"
|
||||
name = "Ustawienia, segmenty i aukcje"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_campaign_locations"
|
||||
name = "Sprawdzenie lokalizacji kampanii"
|
||||
description = "Sprawdza lokalizacje, wykluczone lokalizacje i tryb kierowania lokalizacja w kampaniach."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_campaign_networks"
|
||||
name = "Sprawdzenie sieci kampanii"
|
||||
description = "Sprawdza ustawienia sieci kampanii: Google Search, partnerow wyszukiwania i siec reklamowa."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_campaign_languages"
|
||||
name = "Sprawdzenie jezykow kampanii"
|
||||
description = "Sprawdza ustawienia jezykow kampanii i oznacza kampanie wymagajace recznej oceny dopasowania jezyka do rynku klienta."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_ad_schedules"
|
||||
name = "Sprawdzenie harmonogramu reklam"
|
||||
description = "Sprawdza harmonogram emisji reklam, kampanie dzialajace 24/7 i nietypowe godziny emisji."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_device_performance"
|
||||
name = "Sprawdzenie urzadzen"
|
||||
description = "Sprawdza wyniki wedlug urzadzen: komputer, telefon i tablet, pokazujac koszt, konwersje, wartosc konwersji, ROAS, CPA i udzial kosztu."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_day_of_week_performance"
|
||||
name = "Sprawdzenie dni tygodnia"
|
||||
description = "Sprawdza wyniki wedlug dni tygodnia z ostatnich 30 dni: koszt, klikniecia, konwersje, wartosc konwersji, ROAS i CPA."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_hour_of_day_performance"
|
||||
name = "Sprawdzenie godzin dnia"
|
||||
description = "Sprawdza wyniki wedlug godzin dnia z ostatnich 30 dni, aby wskazac godziny wymagajace oceny."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_network_performance"
|
||||
name = "Sprawdzenie efektywnosci sieci"
|
||||
description = "Sprawdza realne wyniki wedlug sieci emisji, np. Search, partnerzy, Display i Shopping."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_impression_share"
|
||||
name = "Sprawdzenie udzialu w wyswietleniach"
|
||||
description = "Sprawdza udzial w wyswietleniach, utrate przez budzet i utrate przez ranking na poziomie kampanii."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_auction_insights"
|
||||
name = "Sprawdzenie Auction Insights"
|
||||
description = "Sprawdza konkurentow w aukcji, overlap rate, outranking share, position above rate oraz top i absolute top rate."
|
||||
@@ -1,3 +0,0 @@
|
||||
google-ads>=25.0.0
|
||||
requests>=2.31.0
|
||||
|
||||
803
.sync/Archive/scripts/reports/fetch_monthly_report_data.py
Normal file
803
.sync/Archive/scripts/reports/fetch_monthly_report_data.py
Normal file
@@ -0,0 +1,803 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pobiera dane z Google Ads API + GA4 za wskazany miesiąc i zapisuje jako JSON.
|
||||
|
||||
Użycie:
|
||||
python scripts/reports/fetch_monthly_report_data.py --customer studio-zoe.pl --month 2026-02
|
||||
python scripts/reports/fetch_monthly_report_data.py --customer 3871661050 --month 2026-02 --output output/report.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import calendar
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import io
|
||||
import tomllib
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from lib.gads_client import get_client, get_customer_id, run_query
|
||||
import requests
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from src.gads_v2.config import load_env
|
||||
|
||||
load_env(ROOT / ".env")
|
||||
|
||||
|
||||
def load_client_report_config(domain):
|
||||
"""Load scalar report settings for a client from config/clients.toml."""
|
||||
config_path = ROOT / "config" / "clients.toml"
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
||||
return data.get("clients", {}).get(domain, {})
|
||||
|
||||
|
||||
def parse_month(month_str):
|
||||
"""Parse YYYY-MM to (year, month) and calculate date range."""
|
||||
year, month = map(int, month_str.split("-"))
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
start = f"{year}-{month:02d}-01"
|
||||
end = f"{year}-{month:02d}-{last_day:02d}"
|
||||
return year, month, start, end
|
||||
|
||||
|
||||
def prev_month(year, month):
|
||||
"""Calculate previous month's date range."""
|
||||
if month == 1:
|
||||
py, pm = year - 1, 12
|
||||
else:
|
||||
py, pm = year, month - 1
|
||||
last_day = calendar.monthrange(py, pm)[1]
|
||||
start = f"{py}-{pm:02d}-01"
|
||||
end = f"{py}-{pm:02d}-{last_day:02d}"
|
||||
return py, pm, start, end
|
||||
|
||||
|
||||
def pct_change(current, previous):
|
||||
"""Calculate percentage change, handling zero division."""
|
||||
if previous == 0:
|
||||
return 100.0 if current > 0 else 0.0
|
||||
return round(((current - previous) / previous) * 100, 1)
|
||||
|
||||
|
||||
def normalize_header(value):
|
||||
value = (value or "").strip().lower()
|
||||
replacements = {
|
||||
"ą": "a",
|
||||
"ć": "c",
|
||||
"ę": "e",
|
||||
"ł": "l",
|
||||
"ń": "n",
|
||||
"ó": "o",
|
||||
"ś": "s",
|
||||
"ź": "z",
|
||||
"ż": "z",
|
||||
}
|
||||
for src, dst in replacements.items():
|
||||
value = value.replace(src, dst)
|
||||
return re.sub(r"[^a-z0-9]+", "", value)
|
||||
|
||||
|
||||
def parse_money(value):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
text = text.replace("PLN", "").replace("zl", "").replace("zł", "")
|
||||
text = text.replace("\u00a0", " ").replace(" ", "")
|
||||
if "," in text and "." in text:
|
||||
text = text.replace(".", "").replace(",", ".")
|
||||
elif "," in text:
|
||||
text = text.replace(",", ".")
|
||||
text = re.sub(r"[^0-9.\-]", "", text)
|
||||
return round(float(text), 2) if text else 0.0
|
||||
|
||||
|
||||
def parse_int_value(value):
|
||||
return int(round(parse_money(value)))
|
||||
|
||||
|
||||
def parse_history_month(value):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if re.fullmatch(r"\d{4}-\d{2}", text):
|
||||
return text
|
||||
if re.fullmatch(r"\d{2}[.-]\d{4}", text):
|
||||
month, year = re.split(r"[.-]", text)
|
||||
return f"{int(year):04d}-{int(month):02d}"
|
||||
if re.fullmatch(r"\d{4}[./-]\d{1,2}[./-]\d{1,2}", text):
|
||||
year, month, _day = re.split(r"[./-]", text)
|
||||
return f"{int(year):04d}-{int(month):02d}"
|
||||
if re.fullmatch(r"\d{1,2}[./-]\d{1,2}[./-]\d{4}", text):
|
||||
_day, month, year = re.split(r"[./-]", text)
|
||||
return f"{int(year):04d}-{int(month):02d}"
|
||||
return text
|
||||
|
||||
|
||||
def parse_sheet_config(sheet_config):
|
||||
value = str(sheet_config or "").strip()
|
||||
if not value:
|
||||
return "", "0"
|
||||
if value.startswith("http"):
|
||||
parsed = urlparse(value)
|
||||
match = re.search(r"/spreadsheets/d/([^/]+)", parsed.path)
|
||||
spreadsheet_id = match.group(1) if match else value
|
||||
query_gid = parse_qs(parsed.query).get("gid", [None])[0]
|
||||
fragment_gid = parse_qs(parsed.fragment).get("gid", [None])[0]
|
||||
return spreadsheet_id, query_gid or fragment_gid or ""
|
||||
if ":" in value:
|
||||
return value.split(":", 1)
|
||||
return value, ""
|
||||
|
||||
|
||||
def fetch_sales_history_from_sheet(domain, sheet_config):
|
||||
"""Fetch monthly sales history from a public Google Sheet CSV export."""
|
||||
spreadsheet_id, gid = parse_sheet_config(sheet_config)
|
||||
export_url = f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}/gviz/tq?tqx=out:csv"
|
||||
if gid:
|
||||
export_url += f"&gid={gid}"
|
||||
response = requests.get(export_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
response.encoding = "utf-8"
|
||||
|
||||
reader = csv.DictReader(io.StringIO(response.text))
|
||||
history = []
|
||||
for row in reader:
|
||||
normalized = {normalize_header(key): value for key, value in row.items()}
|
||||
month = parse_history_month(
|
||||
normalized.get("month")
|
||||
or normalized.get("miesiac")
|
||||
or normalized.get("data")
|
||||
or normalized.get("date")
|
||||
)
|
||||
revenue = parse_money(
|
||||
normalized.get("revenue")
|
||||
or normalized.get("przychod")
|
||||
or normalized.get("przychody")
|
||||
or normalized.get("sprzedaz")
|
||||
or normalized.get("wartosc")
|
||||
)
|
||||
transactions = parse_int_value(
|
||||
normalized.get("transactions")
|
||||
or normalized.get("transakcje")
|
||||
or normalized.get("zamowienia")
|
||||
or normalized.get("orders")
|
||||
)
|
||||
if not month or not revenue:
|
||||
continue
|
||||
aov = parse_money(
|
||||
normalized.get("aov")
|
||||
or normalized.get("sredniakoszyka")
|
||||
or normalized.get("sredniawartosckoszyka")
|
||||
or normalized.get("sredniawartosczamowienia")
|
||||
)
|
||||
if not aov and transactions:
|
||||
aov = round(revenue / transactions, 2)
|
||||
history.append({
|
||||
"month": month,
|
||||
"transactions": transactions,
|
||||
"revenue": revenue,
|
||||
"aov": aov,
|
||||
"source": "google_sheet",
|
||||
})
|
||||
return sorted(history, key=lambda item: item["month"])
|
||||
|
||||
|
||||
def apply_sheet_ecommerce(report, sales_history, month, previous_month):
|
||||
"""Use Google Sheet sales data for e-commerce KPI cards."""
|
||||
by_month = {row["month"]: row for row in sales_history}
|
||||
current = by_month.get(month)
|
||||
if not current:
|
||||
return False
|
||||
previous = by_month.get(previous_month, {"transactions": 0, "revenue": 0.0, "aov": 0.0})
|
||||
has_previous = previous_month in by_month
|
||||
ecommerce = (report.get("ga4") or {}).get("ecommerce") or {}
|
||||
ecommerce["current"] = {
|
||||
"transactions": current.get("transactions", 0),
|
||||
"revenue": current.get("revenue", 0.0),
|
||||
"aov": current.get("aov", 0.0),
|
||||
}
|
||||
ecommerce["previous"] = {
|
||||
"transactions": previous.get("transactions", 0),
|
||||
"revenue": previous.get("revenue", 0.0),
|
||||
"aov": previous.get("aov", 0.0),
|
||||
}
|
||||
ecommerce["mom_change"] = {
|
||||
"transactions_pct": pct_change(ecommerce["current"]["transactions"], ecommerce["previous"]["transactions"]) if has_previous else None,
|
||||
"revenue_pct": pct_change(ecommerce["current"]["revenue"], ecommerce["previous"]["revenue"]) if has_previous else None,
|
||||
"aov_pct": pct_change(ecommerce["current"]["aov"], ecommerce["previous"]["aov"]) if has_previous else None,
|
||||
}
|
||||
ecommerce["source"] = "google_sheet"
|
||||
if report.get("ga4") is None:
|
||||
report["ga4"] = {}
|
||||
report["ga4"]["ecommerce"] = ecommerce
|
||||
return True
|
||||
|
||||
|
||||
def fetch_google_ads_data(client, customer_id, start_date, end_date):
|
||||
"""Fetch campaign metrics for a date range."""
|
||||
query = f"""
|
||||
SELECT campaign.id, campaign.name, campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
metrics.impressions, metrics.clicks,
|
||||
metrics.cost_micros, metrics.conversions,
|
||||
metrics.conversions_value,
|
||||
metrics.ctr, metrics.average_cpc
|
||||
FROM campaign
|
||||
WHERE segments.date BETWEEN '{start_date}' AND '{end_date}'
|
||||
AND campaign.status != 'REMOVED'
|
||||
"""
|
||||
rows = run_query(client, customer_id, query)
|
||||
|
||||
campaigns = {}
|
||||
for r in rows:
|
||||
cid = str(r.campaign.id)
|
||||
if cid not in campaigns:
|
||||
campaigns[cid] = {
|
||||
"id": cid,
|
||||
"name": r.campaign.name,
|
||||
"status": r.campaign.status.name,
|
||||
"type": r.campaign.advertising_channel_type.name,
|
||||
"impressions": 0, "clicks": 0, "cost": 0.0,
|
||||
"conversions": 0.0, "conversion_value": 0.0,
|
||||
"ctr": 0.0, "cpc": 0.0,
|
||||
}
|
||||
c = campaigns[cid]
|
||||
c["impressions"] += r.metrics.impressions
|
||||
c["clicks"] += r.metrics.clicks
|
||||
c["cost"] += r.metrics.cost_micros / 1_000_000
|
||||
c["conversions"] += r.metrics.conversions
|
||||
c["conversion_value"] += r.metrics.conversions_value
|
||||
|
||||
# Calculate derived metrics
|
||||
for c in campaigns.values():
|
||||
c["cost"] = round(c["cost"], 2)
|
||||
c["conversions"] = round(c["conversions"], 1)
|
||||
c["conversion_value"] = round(c["conversion_value"], 2)
|
||||
c["ctr"] = round((c["clicks"] / c["impressions"] * 100) if c["impressions"] else 0, 2)
|
||||
c["cpc"] = round((c["cost"] / c["clicks"]) if c["clicks"] else 0, 2)
|
||||
c["cpa"] = round((c["cost"] / c["conversions"]) if c["conversions"] else 0, 2)
|
||||
c["roas"] = round((c["conversion_value"] / c["cost"]) if c["cost"] else 0, 2)
|
||||
|
||||
return list(campaigns.values())
|
||||
|
||||
|
||||
def calc_totals(campaigns):
|
||||
"""Sum up totals across campaigns."""
|
||||
t = {"impressions": 0, "clicks": 0, "cost": 0.0, "conversions": 0.0, "conversion_value": 0.0}
|
||||
for c in campaigns:
|
||||
t["impressions"] += c["impressions"]
|
||||
t["clicks"] += c["clicks"]
|
||||
t["cost"] += c["cost"]
|
||||
t["conversions"] += c["conversions"]
|
||||
t["conversion_value"] += c.get("conversion_value", 0.0)
|
||||
|
||||
t["cost"] = round(t["cost"], 2)
|
||||
t["conversions"] = round(t["conversions"], 1)
|
||||
t["conversion_value"] = round(t["conversion_value"], 2)
|
||||
t["ctr"] = round((t["clicks"] / t["impressions"] * 100) if t["impressions"] else 0, 2)
|
||||
t["cpc"] = round((t["cost"] / t["clicks"]) if t["clicks"] else 0, 2)
|
||||
t["cpa"] = round((t["cost"] / t["conversions"]) if t["conversions"] else 0, 2)
|
||||
t["roas"] = round((t["conversion_value"] / t["cost"]) if t["cost"] else 0, 2)
|
||||
return t
|
||||
|
||||
|
||||
def fetch_daily_data(client, customer_id, start_date, end_date):
|
||||
"""Fetch daily breakdown for charts."""
|
||||
query = f"""
|
||||
SELECT segments.date,
|
||||
metrics.impressions, metrics.clicks, metrics.cost_micros
|
||||
FROM campaign
|
||||
WHERE segments.date BETWEEN '{start_date}' AND '{end_date}'
|
||||
AND campaign.status != 'REMOVED'
|
||||
"""
|
||||
rows = run_query(client, customer_id, query)
|
||||
|
||||
daily = {}
|
||||
for r in rows:
|
||||
d = r.segments.date
|
||||
if d not in daily:
|
||||
daily[d] = {"date": d, "impressions": 0, "clicks": 0, "cost": 0.0}
|
||||
daily[d]["impressions"] += r.metrics.impressions
|
||||
daily[d]["clicks"] += r.metrics.clicks
|
||||
daily[d]["cost"] += r.metrics.cost_micros / 1_000_000
|
||||
|
||||
result = sorted(daily.values(), key=lambda x: x["date"])
|
||||
for d in result:
|
||||
d["cost"] = round(d["cost"], 2)
|
||||
return result
|
||||
|
||||
|
||||
def fetch_search_terms(client, customer_id, start_date, end_date, limit=15):
|
||||
"""Fetch top search terms by clicks."""
|
||||
query = f"""
|
||||
SELECT search_term_view.search_term,
|
||||
metrics.impressions, metrics.clicks,
|
||||
metrics.cost_micros, metrics.conversions
|
||||
FROM search_term_view
|
||||
WHERE segments.date BETWEEN '{start_date}' AND '{end_date}'
|
||||
ORDER BY metrics.clicks DESC
|
||||
LIMIT {limit}
|
||||
"""
|
||||
rows = run_query(client, customer_id, query)
|
||||
|
||||
terms = []
|
||||
for r in rows:
|
||||
clicks = r.metrics.clicks
|
||||
impressions = r.metrics.impressions
|
||||
terms.append({
|
||||
"term": r.search_term_view.search_term,
|
||||
"impressions": impressions,
|
||||
"clicks": clicks,
|
||||
"cost": round(r.metrics.cost_micros / 1_000_000, 2),
|
||||
"conversions": round(r.metrics.conversions, 1),
|
||||
"ctr": round((clicks / impressions * 100) if impressions else 0, 2),
|
||||
})
|
||||
return terms
|
||||
|
||||
|
||||
def fetch_ga4_data(property_id, start_date, end_date, prev_start, prev_end):
|
||||
"""Fetch GA4 data: sessions, users, traffic sources, devices."""
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.analytics.data_v1beta import BetaAnalyticsDataClient
|
||||
from google.analytics.data_v1beta.types import (
|
||||
RunReportRequest, DateRange, Metric, Dimension, OrderBy,
|
||||
)
|
||||
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=os.environ["GA4_REFRESH_TOKEN"],
|
||||
client_id=os.environ["GOOGLE_ADS_OAUTH2_CLIENT_ID"],
|
||||
client_secret=os.environ["GOOGLE_ADS_OAUTH2_CLIENT_SECRET"],
|
||||
token_uri="https://oauth2.googleapis.com/token",
|
||||
)
|
||||
client = BetaAnalyticsDataClient(credentials=credentials)
|
||||
prop = f"properties/{property_id}"
|
||||
|
||||
# 1. Sessions & Users (current + previous month)
|
||||
def get_totals(sd, ed):
|
||||
resp = client.run_report(RunReportRequest(
|
||||
property=prop,
|
||||
date_ranges=[DateRange(start_date=sd, end_date=ed)],
|
||||
metrics=[
|
||||
Metric(name="sessions"),
|
||||
Metric(name="totalUsers"),
|
||||
Metric(name="newUsers"),
|
||||
Metric(name="screenPageViews"),
|
||||
Metric(name="averageSessionDuration"),
|
||||
Metric(name="bounceRate"),
|
||||
],
|
||||
))
|
||||
row = resp.rows[0] if resp.rows else None
|
||||
if not row:
|
||||
return {"sessions": 0, "users": 0, "new_users": 0, "pageviews": 0, "avg_duration": 0, "bounce_rate": 0}
|
||||
return {
|
||||
"sessions": int(row.metric_values[0].value),
|
||||
"users": int(row.metric_values[1].value),
|
||||
"new_users": int(row.metric_values[2].value),
|
||||
"pageviews": int(row.metric_values[3].value),
|
||||
"avg_duration": round(float(row.metric_values[4].value), 1),
|
||||
"bounce_rate": round(float(row.metric_values[5].value) * 100, 1),
|
||||
}
|
||||
|
||||
current = get_totals(start_date, end_date)
|
||||
previous = get_totals(prev_start, prev_end)
|
||||
|
||||
# 2. Traffic sources
|
||||
resp = client.run_report(RunReportRequest(
|
||||
property=prop,
|
||||
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
|
||||
dimensions=[Dimension(name="sessionSourceMedium")],
|
||||
metrics=[Metric(name="sessions")],
|
||||
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="sessions"), desc=True)],
|
||||
limit=10,
|
||||
))
|
||||
sources = []
|
||||
for row in resp.rows:
|
||||
sources.append({
|
||||
"source_medium": row.dimension_values[0].value,
|
||||
"sessions": int(row.metric_values[0].value),
|
||||
})
|
||||
|
||||
# 3. Devices
|
||||
resp = client.run_report(RunReportRequest(
|
||||
property=prop,
|
||||
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
|
||||
dimensions=[Dimension(name="deviceCategory")],
|
||||
metrics=[Metric(name="sessions")],
|
||||
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="sessions"), desc=True)],
|
||||
))
|
||||
devices = []
|
||||
for row in resp.rows:
|
||||
devices.append({
|
||||
"device": row.dimension_values[0].value,
|
||||
"sessions": int(row.metric_values[0].value),
|
||||
})
|
||||
|
||||
# 4. Daily sessions (for chart)
|
||||
resp = client.run_report(RunReportRequest(
|
||||
property=prop,
|
||||
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
|
||||
dimensions=[Dimension(name="date")],
|
||||
metrics=[Metric(name="sessions"), Metric(name="totalUsers")],
|
||||
order_bys=[OrderBy(dimension=OrderBy.DimensionOrderBy(dimension_name="date"))],
|
||||
))
|
||||
daily_sessions = []
|
||||
for row in resp.rows:
|
||||
raw = row.dimension_values[0].value
|
||||
formatted = f"{raw[:4]}-{raw[4:6]}-{raw[6:]}"
|
||||
daily_sessions.append({
|
||||
"date": formatted,
|
||||
"sessions": int(row.metric_values[0].value),
|
||||
"users": int(row.metric_values[1].value),
|
||||
})
|
||||
|
||||
return {
|
||||
"current": current,
|
||||
"previous": previous,
|
||||
"mom_change": {
|
||||
"sessions_pct": pct_change(current["sessions"], previous["sessions"]),
|
||||
"users_pct": pct_change(current["users"], previous["users"]),
|
||||
"new_users_pct": pct_change(current["new_users"], previous["new_users"]),
|
||||
"pageviews_pct": pct_change(current["pageviews"], previous["pageviews"]),
|
||||
"avg_duration_pct": pct_change(current["avg_duration"], previous["avg_duration"]),
|
||||
"bounce_rate_pct": pct_change(current["bounce_rate"], previous["bounce_rate"]),
|
||||
},
|
||||
"sources": sources,
|
||||
"devices": devices,
|
||||
"daily": daily_sessions,
|
||||
}
|
||||
|
||||
|
||||
def fetch_ga4_ecommerce(property_id, start_date, end_date, prev_start, prev_end):
|
||||
"""Fetch GA4 e-commerce data: transactions, revenue, AOV."""
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.analytics.data_v1beta import BetaAnalyticsDataClient
|
||||
from google.analytics.data_v1beta.types import (
|
||||
RunReportRequest, DateRange, Metric, Dimension, OrderBy,
|
||||
)
|
||||
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=os.environ["GA4_REFRESH_TOKEN"],
|
||||
client_id=os.environ["GOOGLE_ADS_OAUTH2_CLIENT_ID"],
|
||||
client_secret=os.environ["GOOGLE_ADS_OAUTH2_CLIENT_SECRET"],
|
||||
token_uri="https://oauth2.googleapis.com/token",
|
||||
)
|
||||
client = BetaAnalyticsDataClient(credentials=credentials)
|
||||
prop = f"properties/{property_id}"
|
||||
|
||||
def get_ecom(sd, ed):
|
||||
resp = client.run_report(RunReportRequest(
|
||||
property=prop,
|
||||
date_ranges=[DateRange(start_date=sd, end_date=ed)],
|
||||
metrics=[
|
||||
Metric(name="transactions"),
|
||||
Metric(name="purchaseRevenue"),
|
||||
Metric(name="averagePurchaseRevenue"),
|
||||
],
|
||||
))
|
||||
row = resp.rows[0] if resp.rows else None
|
||||
if not row:
|
||||
return {"transactions": 0, "revenue": 0.0, "aov": 0.0}
|
||||
return {
|
||||
"transactions": int(row.metric_values[0].value),
|
||||
"revenue": round(float(row.metric_values[1].value), 2),
|
||||
"aov": round(float(row.metric_values[2].value), 2),
|
||||
}
|
||||
|
||||
current = get_ecom(start_date, end_date)
|
||||
previous = get_ecom(prev_start, prev_end)
|
||||
|
||||
# Daily revenue chart
|
||||
resp = client.run_report(RunReportRequest(
|
||||
property=prop,
|
||||
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
|
||||
dimensions=[Dimension(name="date")],
|
||||
metrics=[Metric(name="purchaseRevenue"), Metric(name="transactions")],
|
||||
order_bys=[OrderBy(dimension=OrderBy.DimensionOrderBy(dimension_name="date"))],
|
||||
))
|
||||
daily_revenue = []
|
||||
for row in resp.rows:
|
||||
raw = row.dimension_values[0].value
|
||||
formatted = f"{raw[:4]}-{raw[4:6]}-{raw[6:]}"
|
||||
daily_revenue.append({
|
||||
"date": formatted,
|
||||
"revenue": round(float(row.metric_values[0].value), 2),
|
||||
"transactions": int(row.metric_values[1].value),
|
||||
})
|
||||
|
||||
# Revenue by source
|
||||
resp = client.run_report(RunReportRequest(
|
||||
property=prop,
|
||||
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
|
||||
dimensions=[Dimension(name="sessionSourceMedium")],
|
||||
metrics=[Metric(name="purchaseRevenue"), Metric(name="transactions")],
|
||||
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="purchaseRevenue"), desc=True)],
|
||||
limit=10,
|
||||
))
|
||||
revenue_by_source = []
|
||||
for row in resp.rows:
|
||||
revenue_by_source.append({
|
||||
"source_medium": row.dimension_values[0].value,
|
||||
"revenue": round(float(row.metric_values[0].value), 2),
|
||||
"transactions": int(row.metric_values[1].value),
|
||||
})
|
||||
|
||||
# Top products by revenue
|
||||
resp = client.run_report(RunReportRequest(
|
||||
property=prop,
|
||||
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
|
||||
dimensions=[Dimension(name="itemName")],
|
||||
metrics=[
|
||||
Metric(name="itemRevenue"),
|
||||
Metric(name="itemsPurchased"),
|
||||
],
|
||||
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="itemRevenue"), desc=True)],
|
||||
limit=10,
|
||||
))
|
||||
top_products = []
|
||||
for row in resp.rows:
|
||||
top_products.append({
|
||||
"name": row.dimension_values[0].value,
|
||||
"revenue": round(float(row.metric_values[0].value), 2),
|
||||
"quantity": int(row.metric_values[1].value),
|
||||
})
|
||||
|
||||
return {
|
||||
"current": current,
|
||||
"previous": previous,
|
||||
"mom_change": {
|
||||
"transactions_pct": pct_change(current["transactions"], previous["transactions"]),
|
||||
"revenue_pct": pct_change(current["revenue"], previous["revenue"]),
|
||||
"aov_pct": pct_change(current["aov"], previous["aov"]),
|
||||
},
|
||||
"daily": daily_revenue,
|
||||
"revenue_by_source": revenue_by_source,
|
||||
"top_products": top_products,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Pobierz dane do raportu miesięcznego")
|
||||
parser.add_argument("--customer", required=True, help="Domena lub Google Ads customer ID")
|
||||
parser.add_argument("--month", required=True, help="Miesiąc raportu (YYYY-MM)")
|
||||
parser.add_argument("--output", help="Ścieżka do pliku JSON")
|
||||
parser.add_argument("--ga4-property", help="GA4 Property ID (domyślnie z .env)")
|
||||
parser.add_argument("--skip-ga4", action="store_true", help="Pomiń dane GA4")
|
||||
args = parser.parse_args()
|
||||
|
||||
customer_id = get_customer_id(args.customer)
|
||||
client = get_client(use_proto_plus=True)
|
||||
|
||||
year, month, start_date, end_date = parse_month(args.month)
|
||||
py, pm, prev_start, prev_end = prev_month(year, month)
|
||||
month_names_pl = {
|
||||
1: "Styczeń", 2: "Luty", 3: "Marzec", 4: "Kwiecień",
|
||||
5: "Maj", 6: "Czerwiec", 7: "Lipiec", 8: "Sierpień",
|
||||
9: "Wrzesień", 10: "Październik", 11: "Listopad", 12: "Grudzień",
|
||||
}
|
||||
|
||||
# Resolve domain name for output
|
||||
domain = args.customer if not args.customer.replace("-", "").isdigit() else args.customer
|
||||
|
||||
print(f"Pobieram dane Google Ads: {domain} za {args.month}...")
|
||||
|
||||
# Google Ads data
|
||||
campaigns = fetch_google_ads_data(client, customer_id, start_date, end_date)
|
||||
prev_campaigns = fetch_google_ads_data(client, customer_id, prev_start, prev_end)
|
||||
totals = calc_totals(campaigns)
|
||||
prev_totals = calc_totals(prev_campaigns)
|
||||
daily = fetch_daily_data(client, customer_id, start_date, end_date)
|
||||
search_terms = fetch_search_terms(client, customer_id, start_date, end_date)
|
||||
|
||||
mom_change = {
|
||||
"impressions_pct": pct_change(totals["impressions"], prev_totals["impressions"]),
|
||||
"clicks_pct": pct_change(totals["clicks"], prev_totals["clicks"]),
|
||||
"cost_pct": pct_change(totals["cost"], prev_totals["cost"]),
|
||||
"conversions_pct": pct_change(totals["conversions"], prev_totals["conversions"]),
|
||||
"conversion_value_pct": pct_change(totals["conversion_value"], prev_totals["conversion_value"]),
|
||||
"ctr_pct": pct_change(totals["ctr"], prev_totals["ctr"]),
|
||||
"cpc_pct": pct_change(totals["cpc"], prev_totals["cpc"]),
|
||||
"cpa_pct": pct_change(totals["cpa"], prev_totals["cpa"]),
|
||||
"roas_pct": pct_change(totals["roas"], prev_totals["roas"]),
|
||||
}
|
||||
|
||||
report = {
|
||||
"client": domain,
|
||||
"month": args.month,
|
||||
"month_name": month_names_pl[month],
|
||||
"year": year,
|
||||
"prev_month": f"{py}-{pm:02d}",
|
||||
"prev_month_name": month_names_pl[pm],
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"google_ads": {
|
||||
"campaigns": campaigns,
|
||||
"totals": totals,
|
||||
"prev_totals": prev_totals,
|
||||
"mom_change": mom_change,
|
||||
"daily": daily,
|
||||
"search_terms": search_terms,
|
||||
},
|
||||
}
|
||||
|
||||
# GA4 data
|
||||
if not args.skip_ga4:
|
||||
ga4_property = args.ga4_property
|
||||
if not ga4_property:
|
||||
# Try to find GA4 property in .env
|
||||
env_key = f"GA4_PROPERTY_ID_{domain}"
|
||||
ga4_property = os.environ.get(env_key)
|
||||
|
||||
if ga4_property:
|
||||
print(f"Pobieram dane GA4 (property: {ga4_property})...")
|
||||
try:
|
||||
ga4 = fetch_ga4_data(ga4_property, start_date, end_date, prev_start, prev_end)
|
||||
report["ga4"] = ga4
|
||||
print(f" GA4: {ga4['current']['sessions']} sesji, {ga4['current']['users']} uzytkownikow")
|
||||
except Exception as e:
|
||||
print(f" UWAGA: Blad GA4: {e}")
|
||||
report["ga4"] = None
|
||||
else:
|
||||
print(f" Brak GA4 Property ID w .env ({env_key}) - pomijam GA4")
|
||||
report["ga4"] = None
|
||||
else:
|
||||
report["ga4"] = None
|
||||
|
||||
# Semstorm SEO data
|
||||
semstorm_login = os.environ.get("SEMSTORM_LOGIN", "")
|
||||
if semstorm_login:
|
||||
print(f"Pobieram dane Semstorm...")
|
||||
try:
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from fetch_semstorm_data import fetch_domain_stats
|
||||
semstorm = fetch_domain_stats(domain, args.month)
|
||||
report["semstorm"] = semstorm
|
||||
if semstorm and semstorm.get("current"):
|
||||
cur = semstorm["current"]
|
||||
print(f" Semstorm: TOP3={cur['top3']}, TOP10={cur['top10']}, TOP50={cur['top50']}, traffic={cur['traffic']}")
|
||||
except Exception as e:
|
||||
print(f" UWAGA: Blad Semstorm: {e}")
|
||||
report["semstorm"] = None
|
||||
else:
|
||||
report["semstorm"] = None
|
||||
|
||||
# E-commerce data: Shoper (primary) or GA4 (fallback)
|
||||
shoper_key = f"SHOPER_API_URL_{domain}"
|
||||
if os.environ.get(shoper_key):
|
||||
print(f"Pobieram dane e-commerce ze Shoper...")
|
||||
try:
|
||||
from fetch_shoper_data import fetch_shoper_ecommerce
|
||||
shoper_ecom = fetch_shoper_ecommerce(domain, args.month, f"{py}-{pm:02d}")
|
||||
if shoper_ecom and shoper_ecom["current"]["transactions"] > 0:
|
||||
# Get revenue_by_source and top_products from GA4
|
||||
if report.get("ga4") and ga4_property:
|
||||
try:
|
||||
ga4_ecom = fetch_ga4_ecommerce(ga4_property, start_date, end_date, prev_start, prev_end)
|
||||
if ga4_ecom:
|
||||
shoper_ecom["revenue_by_source"] = ga4_ecom.get("revenue_by_source", [])
|
||||
shoper_ecom["top_products"] = ga4_ecom.get("top_products", [])
|
||||
except Exception as e:
|
||||
print(f" UWAGA: GA4 revenue_by_source/top_products: {e}")
|
||||
shoper_ecom["revenue_by_source"] = []
|
||||
shoper_ecom["top_products"] = []
|
||||
if report.get("ga4") is None:
|
||||
report["ga4"] = {}
|
||||
report["ga4"]["ecommerce"] = shoper_ecom
|
||||
cur = shoper_ecom["current"]
|
||||
print(f" Shoper: {cur['transactions']} zamówień, {cur['revenue']:.2f} PLN, AOV {cur['aov']:.2f} PLN")
|
||||
else:
|
||||
if report.get("ga4"):
|
||||
report["ga4"]["ecommerce"] = None
|
||||
except Exception as e:
|
||||
print(f" UWAGA: Blad Shoper: {e}")
|
||||
if report.get("ga4"):
|
||||
report["ga4"]["ecommerce"] = None
|
||||
elif report.get("ga4") and ga4_property:
|
||||
print(f"Pobieram dane GA4 e-commerce...")
|
||||
try:
|
||||
ecom = fetch_ga4_ecommerce(ga4_property, start_date, end_date, prev_start, prev_end)
|
||||
if ecom and ecom["current"]["transactions"] > 0:
|
||||
report["ga4"]["ecommerce"] = ecom
|
||||
cur = ecom["current"]
|
||||
print(f" E-commerce (GA4): {cur['transactions']} transakcji, {cur['revenue']:.2f} PLN przychodu")
|
||||
else:
|
||||
report["ga4"]["ecommerce"] = None
|
||||
except Exception as e:
|
||||
print(f" UWAGA: Blad GA4 e-commerce: {e}")
|
||||
if report.get("ga4"):
|
||||
report["ga4"]["ecommerce"] = None
|
||||
|
||||
# Monthly sales history for chart. Prefer client Google Sheet when configured.
|
||||
client_report_config = load_client_report_config(domain)
|
||||
sales_history_sheet = client_report_config.get("sales_history_sheet") or os.environ.get(f"GSHEET_SALES_HISTORY_{domain}")
|
||||
report_start = os.environ.get(f"REPORT_START_DATE_{domain}")
|
||||
if sales_history_sheet:
|
||||
try:
|
||||
sales_history = fetch_sales_history_from_sheet(domain, sales_history_sheet)
|
||||
if apply_sheet_ecommerce(report, sales_history, args.month, f"{py}-{pm:02d}"):
|
||||
current_sheet = report["ga4"]["ecommerce"]["current"]
|
||||
print(
|
||||
f" E-commerce (Google Sheet): {current_sheet['transactions']} transakcji, "
|
||||
f"{current_sheet['revenue']:.2f} PLN przychodu"
|
||||
)
|
||||
filtered = [e for e in sales_history if not report_start or e["month"] >= report_start]
|
||||
report["sales_history"] = filtered
|
||||
print(f" Historia sprzedaży z Google Sheet: {len(filtered)} miesięcy")
|
||||
except Exception as e:
|
||||
report["sales_history"] = []
|
||||
print(f" UWAGA: Nie udalo sie pobrac historii sprzedazy z Google Sheet: {e}")
|
||||
else:
|
||||
ecom_data = report.get("ga4", {}).get("ecommerce") if report.get("ga4") else None
|
||||
if ecom_data and ecom_data.get("current", {}).get("transactions", 0) > 0:
|
||||
history_path = ROOT / "clients" / domain / "sales_history.json"
|
||||
history_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
sales_history = []
|
||||
if history_path.exists():
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
sales_history = json.load(f)
|
||||
cur_entry = {
|
||||
"month": args.month,
|
||||
"transactions": ecom_data["current"]["transactions"],
|
||||
"revenue": ecom_data["current"]["revenue"],
|
||||
"aov": ecom_data["current"]["aov"],
|
||||
"source": ecom_data.get("source", "ga4"),
|
||||
}
|
||||
by_month = {e["month"]: e for e in sales_history}
|
||||
by_month[args.month] = cur_entry
|
||||
sales_history = sorted(by_month.values(), key=lambda x: x["month"])
|
||||
with open(history_path, "w", encoding="utf-8") as f:
|
||||
json.dump(sales_history, f, indent=2, ensure_ascii=False)
|
||||
filtered = [e for e in sales_history if not report_start or e["month"] >= report_start]
|
||||
report["sales_history"] = filtered
|
||||
print(f" Historia sprzedaży: {len(filtered)} miesięcy zapisanych")
|
||||
else:
|
||||
report["sales_history"] = []
|
||||
|
||||
# SEO links from Google Sheets
|
||||
seo_links_key = f"GSHEET_SEO_LINKS_{domain}"
|
||||
if os.environ.get(seo_links_key):
|
||||
print(f"Pobieram linki SEO...")
|
||||
try:
|
||||
from fetch_seo_links import fetch_seo_links, fetch_seo_activities
|
||||
seo_links = fetch_seo_links(domain, args.month)
|
||||
report["seo_links"] = seo_links or []
|
||||
print(f" Linki SEO: {len(report['seo_links'])} w {args.month}")
|
||||
# SEO activities (text box)
|
||||
seo_act_key = f"GSHEET_SEO_ACTIVITIES_{domain}"
|
||||
if os.environ.get(seo_act_key):
|
||||
seo_activities = fetch_seo_activities(domain, args.month)
|
||||
report["seo_activities"] = seo_activities
|
||||
if seo_activities:
|
||||
print(f" Działania SEO: {len(seo_activities)} znaków")
|
||||
except Exception as e:
|
||||
print(f" UWAGA: Blad SEO links: {e}")
|
||||
report["seo_links"] = []
|
||||
else:
|
||||
report["seo_links"] = []
|
||||
|
||||
# Output
|
||||
if args.output:
|
||||
output_path = Path(args.output)
|
||||
else:
|
||||
output_path = ROOT / "scripts" / "reports" / "output" / f"{domain}_{args.month}.json"
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\nZapisano: {output_path}")
|
||||
print(f"Google Ads: {totals['clicks']} klikniec, {totals['conversions']} konwersji, {totals['cost']:.2f} PLN")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,716 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Raport Kwiecień 2026 — Aruba Rzeszow</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #0d8b8b;
|
||||
--primary-dark: #065a5a;
|
||||
--primary-light: #e8f5f5;
|
||||
--green: #27ae60;
|
||||
--red: #e74c3c;
|
||||
--gray: #6c757d;
|
||||
--light-gray: #f8f9fa;
|
||||
--border: #e9ecef;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 60%, #10a5a5 100%);
|
||||
color: white;
|
||||
padding: 60px 40px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: url("data:image/svg+xml,%3Csvg width='400' height='400' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cpattern id='grid' width='40' height='40' patternUnits='userSpaceOnUse'%3E%3Ccircle cx='20' cy='20' r='1.5' fill='rgba(255,255,255,0.15)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='400' height='400' fill='url(%23grid)'/%3E%3Cline x1='20' y1='60' x2='100' y2='20' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='100' y1='20' x2='180' y2='80' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='180' y1='80' x2='260' y2='40' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='260' y1='40' x2='340' y2='100' stroke='rgba(255,255,255,0.08)' stroke-width='1'/%3E%3Cline x1='60' y1='140' x2='140' y2='120' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='140' y1='120' x2='220' y2='180' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='220' y1='180' x2='300' y2='140' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3Cline x1='300' y1='140' x2='380' y2='200' stroke='rgba(255,255,255,0.06)' stroke-width='1'/%3E%3C/svg%3E") repeat;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.85;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero .meta {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.report-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
margin: 24px auto;
|
||||
max-width: 1400px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: var(--primary-dark);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
background: var(--light-gray);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kpi-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.kpi-change {
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chart-container canvas {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.data-table .num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-search { background: #dbeafe; color: #1e40af; }
|
||||
.badge-performance_max { background: #fef3c7; color: #92400e; }
|
||||
.badge-shopping { background: #d1fae5; color: #065f46; }
|
||||
.badge-display { background: #ede9fe; color: #5b21b6; }
|
||||
.badge-demand_gen { background: #fce7f3; color: #9d174d; }
|
||||
|
||||
.summary-box {
|
||||
background: var(--primary-light);
|
||||
border-left: 4px solid var(--primary);
|
||||
padding: 24px;
|
||||
border-radius: 0 10px 10px 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
background: var(--primary);
|
||||
border-radius: 4px;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.recommendations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rec-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--light-gray);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.rec-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rec-item p {
|
||||
margin-top: 4px;
|
||||
color: var(--gray);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.questions-box {
|
||||
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
|
||||
border-left: 4px solid #f59e0b;
|
||||
border-radius: 10px;
|
||||
padding: 24px 28px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.questions-box h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #92400e;
|
||||
font-size: 18px;
|
||||
}
|
||||
.questions-box .intro {
|
||||
color: #78350f;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.questions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.q-item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255,255,255,0.7);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.q-num {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.q-item strong { color: #92400e; display: block; margin-bottom: 4px; }
|
||||
.q-item p { margin: 0; color: #57534e; font-size: 14px; line-height: 1.6; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--gray);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero { padding: 40px 20px; }
|
||||
.hero h1 { font-size: 28px; }
|
||||
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
.report-section { padding: 20px; margin: 12px; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: white; }
|
||||
.report-section { box-shadow: none; page-break-inside: avoid; }
|
||||
.hero { padding: 30px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- HERO / TITLE -->
|
||||
<header class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="logo">
|
||||
<img src="https://www.project-pro.pl/upload/filemanager/Project-Design/logos/project-pro/logo-white.svg" alt="Project-Pro" style="height: 40px;">
|
||||
</div>
|
||||
<h1>Raport z działań marketingowych</h1>
|
||||
<div class="meta">Aruba Rzeszow — Kwiecień 2026</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- RECOMMENDATIONS -->
|
||||
|
||||
<section class="report-section" id="recommendations">
|
||||
<h2 class="section-title">Wnioski i rekomendacje</h2>
|
||||
<div class="recommendations-list">
|
||||
<div class="rec-item"><span class="rec-icon">⚠</span><div><strong>Spadek konwersji do obserwacji</strong><p>Liczba konwersji spadla o 8.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu.</p></div></div><div class="rec-item"><span class="rec-icon">📈</span><div><strong>ROAS liczony z Google Ads</strong><p>ROAS z Google Ads wyniosl 8.47. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu.</p></div></div><div class="rec-item"><span class="rec-icon">🔍</span><div><strong>Kontrola wzrostu kosztu</strong><p>Koszt reklam wzrosl o 12.2% miesiac do miesiaca. Warto porownac wzrost kosztu ze wzrostem konwersji i wartosci konwersji.</p></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- GA4 SECTION -->
|
||||
|
||||
|
||||
<!-- E-COMMERCE -->
|
||||
|
||||
|
||||
<!-- PRODUCT OPTIMIZATIONS -->
|
||||
|
||||
|
||||
<!-- TOP ADS PRODUCTS -->
|
||||
|
||||
|
||||
<!-- YEAR OVER YEAR -->
|
||||
|
||||
|
||||
<!-- GOOGLE ADS KPIs -->
|
||||
<section class="report-section" id="ads-kpi">
|
||||
<h2 class="section-title">Google Ads — Podsumowanie</h2>
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Wyświetlenia</div>
|
||||
<div class="kpi-value">172 277<span class="kpi-unit"></span></div>
|
||||
<div class="kpi-change" style="color: #e74c3c">
|
||||
▼ -0.6% vs Marzec
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Kliknięcia</div>
|
||||
<div class="kpi-value">3 826<span class="kpi-unit"></span></div>
|
||||
<div class="kpi-change" style="color: #27ae60">
|
||||
▲ +2.5% vs Marzec
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">CTR</div>
|
||||
<div class="kpi-value">2.2<span class="kpi-unit">%</span></div>
|
||||
<div class="kpi-change" style="color: #27ae60">
|
||||
▲ +3.3% vs Marzec
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Konwersje</div>
|
||||
<div class="kpi-value">199<span class="kpi-unit"></span></div>
|
||||
<div class="kpi-change" style="color: #e74c3c">
|
||||
▼ -8.2% vs Marzec
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Koszt</div>
|
||||
<div class="kpi-value">4880.74<span class="kpi-unit"> PLN</span></div>
|
||||
<div class="kpi-change" style="color: #e74c3c">
|
||||
▲ +12.2% vs Marzec
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">CPA</div>
|
||||
<div class="kpi-value">24.46<span class="kpi-unit"> PLN</span></div>
|
||||
<div class="kpi-change" style="color: #e74c3c">
|
||||
▲ +22.2% vs Marzec
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">ROAS</div>
|
||||
<div class="kpi-value">8.47<span class="kpi-unit">x</span></div>
|
||||
<div class="kpi-change" style="color: #e74c3c">
|
||||
▼ -1.5% vs Marzec
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<!-- DAILY CHART -->
|
||||
<section class="report-section" id="ads-chart">
|
||||
<h2 class="section-title">Google Ads — Aktywność dzienna</h2>
|
||||
<div class="chart-container">
|
||||
<canvas id="dailyClicksChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CAMPAIGNS TABLE -->
|
||||
<section class="report-section" id="campaigns">
|
||||
<h2 class="section-title">Kampanie</h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kampania</th>
|
||||
<th>Typ</th>
|
||||
<th>Wyświetlenia</th>
|
||||
<th>Kliknięcia</th>
|
||||
<th>CTR</th>
|
||||
<th>Konwersje</th>
|
||||
<th>Koszt</th>
|
||||
<th>CPA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>[Search] brand</td>
|
||||
<td><span class="badge badge-search">SEARCH</span></td>
|
||||
<td class="num">1 614</td>
|
||||
<td class="num">483</td>
|
||||
<td class="num">29.9%</td>
|
||||
<td class="num">27</td>
|
||||
<td class="num">337.96 PLN</td>
|
||||
<td class="num">12.52 PLN</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>[DSA] produkty</td>
|
||||
<td><span class="badge badge-search">SEARCH</span></td>
|
||||
<td class="num">9 984</td>
|
||||
<td class="num">694</td>
|
||||
<td class="num">7.0%</td>
|
||||
<td class="num">23</td>
|
||||
<td class="num">1098.15 PLN</td>
|
||||
<td class="num">47.75 PLN</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>[PMax] products (catch-all)</td>
|
||||
<td><span class="badge badge-performance_max">PERFORMANCE_MAX</span></td>
|
||||
<td class="num">138 921</td>
|
||||
<td class="num">2 164</td>
|
||||
<td class="num">1.6%</td>
|
||||
<td class="num">106</td>
|
||||
<td class="num">2762.99 PLN</td>
|
||||
<td class="num">25.94 PLN</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>[PLA] produkty (bestsellers)</td>
|
||||
<td><span class="badge badge-shopping">SHOPPING</span></td>
|
||||
<td class="num">21 758</td>
|
||||
<td class="num">485</td>
|
||||
<td class="num">2.2%</td>
|
||||
<td class="num">43</td>
|
||||
<td class="num">681.64 PLN</td>
|
||||
<td class="num">15.85 PLN</td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- SEARCH TERMS -->
|
||||
<section class="report-section" id="search-terms">
|
||||
<h2 class="section-title">Najpopularniejsze frazy wyszukiwania</h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Fraza</th>
|
||||
<th>Wyświetlenia</th>
|
||||
<th>Kliknięcia</th>
|
||||
<th>CTR</th>
|
||||
<th>Konwersje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="num">1</td>
|
||||
<td>aruba rzeszów</td>
|
||||
<td class="num">836</td>
|
||||
<td class="num">246</td>
|
||||
<td class="num">29.4%</td>
|
||||
<td class="num">16</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">2</td>
|
||||
<td>gen factor</td>
|
||||
<td class="num">858</td>
|
||||
<td class="num">59</td>
|
||||
<td class="num">6.9%</td>
|
||||
<td class="num">1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">3</td>
|
||||
<td>aruba hurtownia</td>
|
||||
<td class="num">122</td>
|
||||
<td class="num">48</td>
|
||||
<td class="num">39.3%</td>
|
||||
<td class="num">4</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">4</td>
|
||||
<td>aruba rzeszow</td>
|
||||
<td class="num">127</td>
|
||||
<td class="num">39</td>
|
||||
<td class="num">30.7%</td>
|
||||
<td class="num">0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">5</td>
|
||||
<td>gen factor green</td>
|
||||
<td class="num">207</td>
|
||||
<td class="num">21</td>
|
||||
<td class="num">10.1%</td>
|
||||
<td class="num">2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">6</td>
|
||||
<td>gen factor</td>
|
||||
<td class="num">604</td>
|
||||
<td class="num">21</td>
|
||||
<td class="num">3.5%</td>
|
||||
<td class="num">1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">7</td>
|
||||
<td>verru immuno</td>
|
||||
<td class="num">495</td>
|
||||
<td class="num">19</td>
|
||||
<td class="num">3.8%</td>
|
||||
<td class="num">2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">8</td>
|
||||
<td>aruba sklep</td>
|
||||
<td class="num">48</td>
|
||||
<td class="num">17</td>
|
||||
<td class="num">35.4%</td>
|
||||
<td class="num">1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">9</td>
|
||||
<td>aurumaris</td>
|
||||
<td class="num">113</td>
|
||||
<td class="num">13</td>
|
||||
<td class="num">11.5%</td>
|
||||
<td class="num">0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">10</td>
|
||||
<td>aruba hurtownia kosmetyczna</td>
|
||||
<td class="num">25</td>
|
||||
<td class="num">12</td>
|
||||
<td class="num">48.0%</td>
|
||||
<td class="num">1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">11</td>
|
||||
<td>aruba kosmetyki</td>
|
||||
<td class="num">33</td>
|
||||
<td class="num">12</td>
|
||||
<td class="num">36.4%</td>
|
||||
<td class="num">1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">12</td>
|
||||
<td>gen factor 09</td>
|
||||
<td class="num">47</td>
|
||||
<td class="num">11</td>
|
||||
<td class="num">23.4%</td>
|
||||
<td class="num">0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">13</td>
|
||||
<td>genfactor</td>
|
||||
<td class="num">111</td>
|
||||
<td class="num">11</td>
|
||||
<td class="num">9.9%</td>
|
||||
<td class="num">2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">14</td>
|
||||
<td>podopharm verru immuno</td>
|
||||
<td class="num">230</td>
|
||||
<td class="num">11</td>
|
||||
<td class="num">4.8%</td>
|
||||
<td class="num">2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="num">15</td>
|
||||
<td>hurtownia aruba</td>
|
||||
<td class="num">32</td>
|
||||
<td class="num">10</td>
|
||||
<td class="num">31.2%</td>
|
||||
<td class="num">0</td>
|
||||
</tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- NEGATIVE KEYWORDS ADDED -->
|
||||
|
||||
|
||||
<!-- SEMSTORM SEO -->
|
||||
|
||||
|
||||
<!-- SEO ACTIVITIES -->
|
||||
|
||||
|
||||
<!-- SEO LINKS -->
|
||||
|
||||
|
||||
<!-- SUMMARY -->
|
||||
<section class="report-section" id="summary">
|
||||
<h2 class="section-title">Podsumowanie miesiąca</h2>
|
||||
<div class="summary-box">
|
||||
Odnotowano 199 konwersji w tym miesiącu. Ruch z reklam wzrósł o 2.5% (3826 kliknięć).
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RECOMMENDATIONS moved to top -->
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div class="footer">
|
||||
Raport wygenerowany przez <a href="https://www.project-pro.pl">Project-Pro</a> — marketing w wersji PRO
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Daily clicks chart
|
||||
var ctx1 = document.getElementById('dailyClicksChart').getContext('2d');
|
||||
new Chart(ctx1, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ["04-01", "04-02", "04-03", "04-04", "04-05", "04-06", "04-07", "04-08", "04-09", "04-10", "04-11", "04-12", "04-13", "04-14", "04-15", "04-16", "04-17", "04-18", "04-19", "04-20", "04-21", "04-22", "04-23", "04-24", "04-25", "04-26", "04-27", "04-28", "04-29", "04-30"],
|
||||
datasets: [{
|
||||
label: 'Kliknięcia',
|
||||
data: [102, 108, 72, 54, 39, 96, 130, 166, 137, 112, 95, 114, 185, 176, 164, 149, 107, 101, 114, 196, 163, 210, 170, 116, 112, 131, 144, 132, 135, 96],
|
||||
borderColor: '#0d8b8b',
|
||||
backgroundColor: 'rgba(13,139,139,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#0d8b8b',
|
||||
}, {
|
||||
label: 'Wyświetlenia',
|
||||
data: [6909, 5632, 4210, 3045, 2088, 3976, 5853, 7519, 6605, 4340, 3177, 4104, 7332, 7941, 7296, 6191, 4557, 3621, 5409, 7762, 7615, 9246, 9234, 5931, 5078, 5786, 6014, 6078, 5629, 4099],
|
||||
borderColor: '#95a5a6',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
yAxisID: 'y1',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
y: { beginAtZero: true, position: 'left', grid: { color: '#f0f0f0' } },
|
||||
y1: { beginAtZero: true, position: 'right', grid: { display: false } },
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: 10 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,429 @@
|
||||
{
|
||||
"client": "aruba.rzeszow.pl",
|
||||
"month": "2026-04",
|
||||
"month_name": "Kwiecień",
|
||||
"year": 2026,
|
||||
"prev_month": "2026-03",
|
||||
"prev_month_name": "Marzec",
|
||||
"generated_at": "2026-05-14T23:23:53.496703",
|
||||
"google_ads": {
|
||||
"campaigns": [
|
||||
{
|
||||
"id": "19591441631",
|
||||
"name": "[Search] brand",
|
||||
"status": "ENABLED",
|
||||
"type": "SEARCH",
|
||||
"impressions": 1614,
|
||||
"clicks": 483,
|
||||
"cost": 337.96,
|
||||
"conversions": 27.0,
|
||||
"conversion_value": 7967.63,
|
||||
"ctr": 29.93,
|
||||
"cpc": 0.7,
|
||||
"cpa": 12.52,
|
||||
"roas": 23.58
|
||||
},
|
||||
{
|
||||
"id": "20561423980",
|
||||
"name": "[DSA] produkty",
|
||||
"status": "ENABLED",
|
||||
"type": "SEARCH",
|
||||
"impressions": 9984,
|
||||
"clicks": 694,
|
||||
"cost": 1098.15,
|
||||
"conversions": 23.0,
|
||||
"conversion_value": 6600.7,
|
||||
"ctr": 6.95,
|
||||
"cpc": 1.58,
|
||||
"cpa": 47.75,
|
||||
"roas": 6.01
|
||||
},
|
||||
{
|
||||
"id": "21260050298",
|
||||
"name": "[PMax] products (catch-all)",
|
||||
"status": "ENABLED",
|
||||
"type": "PERFORMANCE_MAX",
|
||||
"impressions": 138921,
|
||||
"clicks": 2164,
|
||||
"cost": 2762.99,
|
||||
"conversions": 106.5,
|
||||
"conversion_value": 19390.88,
|
||||
"ctr": 1.56,
|
||||
"cpc": 1.28,
|
||||
"cpa": 25.94,
|
||||
"roas": 7.02
|
||||
},
|
||||
{
|
||||
"id": "22926581178",
|
||||
"name": "[PLA] produkty (bestsellers)",
|
||||
"status": "ENABLED",
|
||||
"type": "SHOPPING",
|
||||
"impressions": 21758,
|
||||
"clicks": 485,
|
||||
"cost": 681.64,
|
||||
"conversions": 43.0,
|
||||
"conversion_value": 7367.07,
|
||||
"ctr": 2.23,
|
||||
"cpc": 1.41,
|
||||
"cpa": 15.85,
|
||||
"roas": 10.81
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"impressions": 172277,
|
||||
"clicks": 3826,
|
||||
"cost": 4880.74,
|
||||
"conversions": 199.5,
|
||||
"conversion_value": 41326.28,
|
||||
"ctr": 2.22,
|
||||
"cpc": 1.28,
|
||||
"cpa": 24.46,
|
||||
"roas": 8.47
|
||||
},
|
||||
"prev_totals": {
|
||||
"impressions": 173273,
|
||||
"clicks": 3733,
|
||||
"cost": 4351.39,
|
||||
"conversions": 217.4,
|
||||
"conversion_value": 37429.84,
|
||||
"ctr": 2.15,
|
||||
"cpc": 1.17,
|
||||
"cpa": 20.02,
|
||||
"roas": 8.6
|
||||
},
|
||||
"mom_change": {
|
||||
"impressions_pct": -0.6,
|
||||
"clicks_pct": 2.5,
|
||||
"cost_pct": 12.2,
|
||||
"conversions_pct": -8.2,
|
||||
"ctr_pct": 3.3,
|
||||
"cpc_pct": 9.4,
|
||||
"cpa_pct": 22.2
|
||||
},
|
||||
"daily": [
|
||||
{
|
||||
"date": "2026-04-01",
|
||||
"impressions": 6909,
|
||||
"clicks": 102,
|
||||
"cost": 120.77
|
||||
},
|
||||
{
|
||||
"date": "2026-04-02",
|
||||
"impressions": 5632,
|
||||
"clicks": 108,
|
||||
"cost": 167.66
|
||||
},
|
||||
{
|
||||
"date": "2026-04-03",
|
||||
"impressions": 4210,
|
||||
"clicks": 72,
|
||||
"cost": 95.19
|
||||
},
|
||||
{
|
||||
"date": "2026-04-04",
|
||||
"impressions": 3045,
|
||||
"clicks": 54,
|
||||
"cost": 101.11
|
||||
},
|
||||
{
|
||||
"date": "2026-04-05",
|
||||
"impressions": 2088,
|
||||
"clicks": 39,
|
||||
"cost": 49.2
|
||||
},
|
||||
{
|
||||
"date": "2026-04-06",
|
||||
"impressions": 3976,
|
||||
"clicks": 96,
|
||||
"cost": 112.82
|
||||
},
|
||||
{
|
||||
"date": "2026-04-07",
|
||||
"impressions": 5853,
|
||||
"clicks": 130,
|
||||
"cost": 138.02
|
||||
},
|
||||
{
|
||||
"date": "2026-04-08",
|
||||
"impressions": 7519,
|
||||
"clicks": 166,
|
||||
"cost": 225.05
|
||||
},
|
||||
{
|
||||
"date": "2026-04-09",
|
||||
"impressions": 6605,
|
||||
"clicks": 137,
|
||||
"cost": 165.58
|
||||
},
|
||||
{
|
||||
"date": "2026-04-10",
|
||||
"impressions": 4340,
|
||||
"clicks": 112,
|
||||
"cost": 130.39
|
||||
},
|
||||
{
|
||||
"date": "2026-04-11",
|
||||
"impressions": 3177,
|
||||
"clicks": 95,
|
||||
"cost": 92.74
|
||||
},
|
||||
{
|
||||
"date": "2026-04-12",
|
||||
"impressions": 4104,
|
||||
"clicks": 114,
|
||||
"cost": 116.26
|
||||
},
|
||||
{
|
||||
"date": "2026-04-13",
|
||||
"impressions": 7332,
|
||||
"clicks": 185,
|
||||
"cost": 201.76
|
||||
},
|
||||
{
|
||||
"date": "2026-04-14",
|
||||
"impressions": 7941,
|
||||
"clicks": 176,
|
||||
"cost": 232.59
|
||||
},
|
||||
{
|
||||
"date": "2026-04-15",
|
||||
"impressions": 7296,
|
||||
"clicks": 164,
|
||||
"cost": 186.57
|
||||
},
|
||||
{
|
||||
"date": "2026-04-16",
|
||||
"impressions": 6191,
|
||||
"clicks": 149,
|
||||
"cost": 165.26
|
||||
},
|
||||
{
|
||||
"date": "2026-04-17",
|
||||
"impressions": 4557,
|
||||
"clicks": 107,
|
||||
"cost": 95.56
|
||||
},
|
||||
{
|
||||
"date": "2026-04-18",
|
||||
"impressions": 3621,
|
||||
"clicks": 101,
|
||||
"cost": 118.02
|
||||
},
|
||||
{
|
||||
"date": "2026-04-19",
|
||||
"impressions": 5409,
|
||||
"clicks": 114,
|
||||
"cost": 175.25
|
||||
},
|
||||
{
|
||||
"date": "2026-04-20",
|
||||
"impressions": 7762,
|
||||
"clicks": 196,
|
||||
"cost": 239.2
|
||||
},
|
||||
{
|
||||
"date": "2026-04-21",
|
||||
"impressions": 7615,
|
||||
"clicks": 163,
|
||||
"cost": 262.91
|
||||
},
|
||||
{
|
||||
"date": "2026-04-22",
|
||||
"impressions": 9246,
|
||||
"clicks": 210,
|
||||
"cost": 265.25
|
||||
},
|
||||
{
|
||||
"date": "2026-04-23",
|
||||
"impressions": 9234,
|
||||
"clicks": 170,
|
||||
"cost": 222.45
|
||||
},
|
||||
{
|
||||
"date": "2026-04-24",
|
||||
"impressions": 5931,
|
||||
"clicks": 116,
|
||||
"cost": 202.37
|
||||
},
|
||||
{
|
||||
"date": "2026-04-25",
|
||||
"impressions": 5078,
|
||||
"clicks": 112,
|
||||
"cost": 174.69
|
||||
},
|
||||
{
|
||||
"date": "2026-04-26",
|
||||
"impressions": 5786,
|
||||
"clicks": 131,
|
||||
"cost": 162.94
|
||||
},
|
||||
{
|
||||
"date": "2026-04-27",
|
||||
"impressions": 6014,
|
||||
"clicks": 144,
|
||||
"cost": 191.42
|
||||
},
|
||||
{
|
||||
"date": "2026-04-28",
|
||||
"impressions": 6078,
|
||||
"clicks": 132,
|
||||
"cost": 181.99
|
||||
},
|
||||
{
|
||||
"date": "2026-04-29",
|
||||
"impressions": 5629,
|
||||
"clicks": 135,
|
||||
"cost": 166.02
|
||||
},
|
||||
{
|
||||
"date": "2026-04-30",
|
||||
"impressions": 4099,
|
||||
"clicks": 96,
|
||||
"cost": 121.72
|
||||
}
|
||||
],
|
||||
"search_terms": [
|
||||
{
|
||||
"term": "aruba rzeszów",
|
||||
"impressions": 836,
|
||||
"clicks": 246,
|
||||
"cost": 131.67,
|
||||
"conversions": 16.0,
|
||||
"ctr": 29.43
|
||||
},
|
||||
{
|
||||
"term": "gen factor",
|
||||
"impressions": 858,
|
||||
"clicks": 59,
|
||||
"cost": 134.33,
|
||||
"conversions": 1.0,
|
||||
"ctr": 6.88
|
||||
},
|
||||
{
|
||||
"term": "aruba hurtownia",
|
||||
"impressions": 122,
|
||||
"clicks": 48,
|
||||
"cost": 26.45,
|
||||
"conversions": 4.0,
|
||||
"ctr": 39.34
|
||||
},
|
||||
{
|
||||
"term": "aruba rzeszow",
|
||||
"impressions": 127,
|
||||
"clicks": 39,
|
||||
"cost": 24.46,
|
||||
"conversions": 0.0,
|
||||
"ctr": 30.71
|
||||
},
|
||||
{
|
||||
"term": "gen factor green",
|
||||
"impressions": 207,
|
||||
"clicks": 21,
|
||||
"cost": 46.65,
|
||||
"conversions": 2.0,
|
||||
"ctr": 10.14
|
||||
},
|
||||
{
|
||||
"term": "gen factor",
|
||||
"impressions": 604,
|
||||
"clicks": 21,
|
||||
"cost": 25.05,
|
||||
"conversions": 1.0,
|
||||
"ctr": 3.48
|
||||
},
|
||||
{
|
||||
"term": "verru immuno",
|
||||
"impressions": 495,
|
||||
"clicks": 19,
|
||||
"cost": 27.24,
|
||||
"conversions": 2.0,
|
||||
"ctr": 3.84
|
||||
},
|
||||
{
|
||||
"term": "aruba sklep",
|
||||
"impressions": 48,
|
||||
"clicks": 17,
|
||||
"cost": 3.4,
|
||||
"conversions": 1.0,
|
||||
"ctr": 35.42
|
||||
},
|
||||
{
|
||||
"term": "aurumaris",
|
||||
"impressions": 113,
|
||||
"clicks": 13,
|
||||
"cost": 14.52,
|
||||
"conversions": 0.0,
|
||||
"ctr": 11.5
|
||||
},
|
||||
{
|
||||
"term": "aruba hurtownia kosmetyczna",
|
||||
"impressions": 25,
|
||||
"clicks": 12,
|
||||
"cost": 7.85,
|
||||
"conversions": 1.0,
|
||||
"ctr": 48.0
|
||||
},
|
||||
{
|
||||
"term": "aruba kosmetyki",
|
||||
"impressions": 33,
|
||||
"clicks": 12,
|
||||
"cost": 3.27,
|
||||
"conversions": 1.0,
|
||||
"ctr": 36.36
|
||||
},
|
||||
{
|
||||
"term": "gen factor 09",
|
||||
"impressions": 47,
|
||||
"clicks": 11,
|
||||
"cost": 15.43,
|
||||
"conversions": 0.0,
|
||||
"ctr": 23.4
|
||||
},
|
||||
{
|
||||
"term": "genfactor",
|
||||
"impressions": 111,
|
||||
"clicks": 11,
|
||||
"cost": 27.84,
|
||||
"conversions": 2.0,
|
||||
"ctr": 9.91
|
||||
},
|
||||
{
|
||||
"term": "podopharm verru immuno",
|
||||
"impressions": 230,
|
||||
"clicks": 11,
|
||||
"cost": 15.75,
|
||||
"conversions": 2.0,
|
||||
"ctr": 4.78
|
||||
},
|
||||
{
|
||||
"term": "hurtownia aruba",
|
||||
"impressions": 32,
|
||||
"clicks": 10,
|
||||
"cost": 7.31,
|
||||
"conversions": 0.0,
|
||||
"ctr": 31.25
|
||||
}
|
||||
]
|
||||
},
|
||||
"ga4": null,
|
||||
"semstorm": null,
|
||||
"sales_history": [],
|
||||
"seo_links": [],
|
||||
"recommendations": [
|
||||
{
|
||||
"icon": "⚠",
|
||||
"title": "Spadek konwersji do obserwacji",
|
||||
"text": "Liczba konwersji spadla o 8.2% miesiac do miesiaca. Rekomendujemy sprawdzenie kampanii o najwiekszym spadku wolumenu."
|
||||
},
|
||||
{
|
||||
"icon": "📈",
|
||||
"title": "ROAS liczony z Google Ads",
|
||||
"text": "ROAS z Google Ads wyniosl 8.47. Ten wskaznik liczymy z wartosci konwersji Google Ads, nie z przychodow sklepu."
|
||||
},
|
||||
{
|
||||
"icon": "🔍",
|
||||
"title": "Kontrola wzrostu kosztu",
|
||||
"text": "Koszt reklam wzrosl o 12.2% miesiac do miesiaca. Warto porownac wzrost kosztu ze wzrostem konwersji i wartosci konwersji."
|
||||
}
|
||||
]
|
||||
}
|
||||
1821
.sync/Archive/src/gads_v2/cli.1.py
Normal file
1821
.sync/Archive/src/gads_v2/cli.1.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
126
.sync/Archive/src/gads_v2/task_catalog.py
Normal file
126
.sync/Archive/src/gads_v2/task_catalog.py
Normal 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"]],
|
||||
)
|
||||
268
.sync/Archive/src/gads_v2/tasks/conversion_tracking_check.py
Normal file
268
.sync/Archive/src/gads_v2/tasks/conversion_tracking_check.py
Normal 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)
|
||||
@@ -1,912 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from google.protobuf import field_mask_pb2
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
|
||||
|
||||
CSV_COLS = [
|
||||
"id", "offer_id", "title", "availability", "channel", "content_language",
|
||||
"target_country", "feed_label", "brand", "google_product_category",
|
||||
"custom_label_0", "custom_label_1", "custom_label_2", "custom_label_3",
|
||||
"custom_label_4", "link",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncPlan:
|
||||
campaigns: list[dict]
|
||||
groups_total: int
|
||||
groups_with_product_id: int
|
||||
create_plan: list[dict]
|
||||
enable_plan: list[dict]
|
||||
pause_plan: list[dict]
|
||||
rename_plan: list[dict]
|
||||
warnings: list[str]
|
||||
unmatched_groups: list[dict] | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
def serialize_rows(rows: list[dict]) -> list[dict]:
|
||||
serialized = []
|
||||
for item in rows:
|
||||
row = {}
|
||||
for key, value in item.items():
|
||||
if isinstance(value, set):
|
||||
row[key] = sorted(value)
|
||||
else:
|
||||
row[key] = value
|
||||
serialized.append(row)
|
||||
return serialized
|
||||
|
||||
return {
|
||||
"task": "sync_pla_cl1",
|
||||
"campaigns": serialize_rows(self.campaigns),
|
||||
"groups_total": self.groups_total,
|
||||
"groups_with_product_id": self.groups_with_product_id,
|
||||
"create_plan": serialize_rows(self.create_plan),
|
||||
"enable_plan": serialize_rows(self.enable_plan),
|
||||
"pause_plan": serialize_rows(self.pause_plan),
|
||||
"rename_plan": serialize_rows(self.rename_plan),
|
||||
"warnings": self.warnings,
|
||||
"unmatched_groups": serialize_rows(self.unmatched_groups or []),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SyncPlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
groups_total=int(data.get("groups_total", 0)),
|
||||
groups_with_product_id=int(data.get("groups_with_product_id", 0)),
|
||||
create_plan=data.get("create_plan", []),
|
||||
enable_plan=data.get("enable_plan", []),
|
||||
pause_plan=data.get("pause_plan", []),
|
||||
rename_plan=data.get("rename_plan", []),
|
||||
warnings=data.get("warnings", []),
|
||||
unmatched_groups=data.get("unmatched_groups", []),
|
||||
)
|
||||
|
||||
|
||||
def campaign_action_summary(plan: SyncPlan) -> list[dict]:
|
||||
campaign_names = set()
|
||||
for action_name in ("create_plan", "enable_plan", "pause_plan", "rename_plan"):
|
||||
for row in getattr(plan, action_name):
|
||||
if row.get("campaign_name"):
|
||||
campaign_names.add(row["campaign_name"])
|
||||
|
||||
create_counts = Counter(row["campaign_name"] for row in plan.create_plan)
|
||||
enable_counts = Counter(row["campaign_name"] for row in plan.enable_plan)
|
||||
pause_counts = Counter(row["campaign_name"] for row in plan.pause_plan)
|
||||
rename_counts = Counter(row["campaign_name"] for row in plan.rename_plan)
|
||||
|
||||
return [
|
||||
{
|
||||
"campaign_name": name,
|
||||
"create": create_counts.get(name, 0),
|
||||
"enable": enable_counts.get(name, 0),
|
||||
"pause": pause_counts.get(name, 0),
|
||||
"rename": rename_counts.get(name, 0),
|
||||
}
|
||||
for name in sorted(campaign_names)
|
||||
]
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
return " ".join(
|
||||
(value or "")
|
||||
.lower()
|
||||
.replace("–", "-")
|
||||
.replace("—", "-")
|
||||
.replace("|", "-")
|
||||
.replace("„", "")
|
||||
.replace("”", "")
|
||||
.replace('"', "")
|
||||
.split()
|
||||
)
|
||||
|
||||
|
||||
def parse_allowed_labels(campaign_name: str) -> set[str]:
|
||||
match = re.search(r"\]\s*(.+)$", campaign_name)
|
||||
raw = match.group(1).strip() if match else campaign_name
|
||||
if "|" in raw:
|
||||
raw = raw.split("|", 1)[0].strip()
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
|
||||
|
||||
def fetch_adspro_products(client: ClientConfig, segments: list[str]) -> list[dict]:
|
||||
api_url = os.environ.get("ADSPRO_API_URL")
|
||||
api_key = os.environ.get("ADSPRO_API_KEY")
|
||||
if not api_url or not api_key:
|
||||
raise RuntimeError("Brak ADSPRO_API_URL lub ADSPRO_API_KEY w .env.")
|
||||
if not client.adspro_client_id:
|
||||
raise RuntimeError(f"Brak adspro_client_id dla {client.domain} w config/clients.toml.")
|
||||
|
||||
by_offer_id = {}
|
||||
for segment in segments:
|
||||
response = requests.post(
|
||||
api_url,
|
||||
data={
|
||||
"action": "products_get_by_cl1",
|
||||
"api_key": api_key,
|
||||
"client_id": client.adspro_client_id,
|
||||
"custom_label_1": segment,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("result") == "error":
|
||||
raise RuntimeError(f"adsPRO zwrocil blad dla CL1={segment}: {data.get('message')}")
|
||||
for product in data.get("products", []):
|
||||
offer_id = product.get("offer_id") or ""
|
||||
if offer_id:
|
||||
by_offer_id[offer_id] = {
|
||||
"id": "",
|
||||
"offer_id": offer_id,
|
||||
"title": product.get("title", "") or "",
|
||||
"availability": "",
|
||||
"channel": "",
|
||||
"content_language": "",
|
||||
"target_country": "",
|
||||
"feed_label": "",
|
||||
"brand": "",
|
||||
"google_product_category": product.get("google_product_category", "") or "",
|
||||
"custom_label_0": "",
|
||||
"custom_label_1": product.get("custom_label_1", "") or "",
|
||||
"custom_label_2": "",
|
||||
"custom_label_3": product.get("custom_label_3", "") or "",
|
||||
"custom_label_4": product.get("custom_label_4", "") or "",
|
||||
"link": "",
|
||||
}
|
||||
return list(by_offer_id.values())
|
||||
|
||||
|
||||
def save_products_csv(domain: str, products: list[dict]) -> Path:
|
||||
out = client_dir(domain) / "data"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
path = out / "merchant_produkty_adspro.csv"
|
||||
with path.open("w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=CSV_COLS)
|
||||
writer.writeheader()
|
||||
writer.writerows(products)
|
||||
return path
|
||||
|
||||
|
||||
def save_plan_files(domain: str, plan: SyncPlan, products_count: int) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_sync_pla_cl1"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
"products_count": products_count,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Synchronizacja kampanii PLA_CL1",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie PLA_CL1: {len(plan.campaigns)}",
|
||||
f"- Produkty z adsPRO: {products_count}",
|
||||
f"- Grupy reklam obecnie: {plan.groups_total}",
|
||||
f"- Grupy reklam z identyfikatorem produktu: {plan.groups_with_product_id}",
|
||||
f"- Do utworzenia: {len(plan.create_plan)}",
|
||||
f"- Do włączenia: {len(plan.enable_plan)}",
|
||||
f"- Do wstrzymania: {len(plan.pause_plan)}",
|
||||
f"- Do zmiany nazwy: {len(plan.rename_plan)}",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {warning}" for warning in plan.warnings)
|
||||
lines.append("")
|
||||
summary = campaign_action_summary(plan)
|
||||
if summary:
|
||||
lines.extend(["## Podsumowanie po kampaniach", "", "| Kampania | Utworz | Wlacz | Wstrzymaj | Zmien nazwe |", "| --- | ---: | ---: | ---: | ---: |"])
|
||||
for row in summary:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['create']} | {row['enable']} | {row['pause']} | {row['rename']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.unmatched_groups:
|
||||
lines.extend(["## Grupy reklam bez dopasowania w adsPRO", "", "| Kampania | Grupa reklam | Status | Identyfikator produktu |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.unmatched_groups:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['ad_group_status']} | {row.get('offer_id', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.create_plan:
|
||||
lines.extend(["## Grupy reklam do utworzenia", "", "| Kampania | Grupa reklam | Produkt | Powod |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.create_plan:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']} | {row['reason']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.enable_plan:
|
||||
lines.extend(["## Grupy reklam do wlaczenia", "", "| Kampania | Grupa reklam | Produkt | Powod |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.enable_plan:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']} | {row['reason']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.pause_plan:
|
||||
lines.extend(["## Grupy reklam do wstrzymania", "", "| Kampania | Grupa reklam | Powod |", "| --- | --- | --- |"])
|
||||
for row in plan.pause_plan:
|
||||
lines.append(f"| {row['campaign_name']} | {row['ad_group_name']} | {row['reason']} |")
|
||||
lines.append("")
|
||||
if plan.rename_plan:
|
||||
lines.extend(["## Nazwy grup reklam do zmiany", "", "| Kampania | Obecna nazwa | Nowa nazwa |", "| --- | --- | --- |"])
|
||||
for row in plan.rename_plan:
|
||||
lines.append(f"| {row['campaign_name']} | {row['old_name']} | {row['new_name']} |")
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def build_plan(client, customer_id: str, products: list[dict]) -> SyncPlan:
|
||||
campaign_rows = run_query(
|
||||
client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT campaign.id, campaign.name, campaign.status
|
||||
FROM campaign
|
||||
WHERE campaign.name LIKE '%PLA_CL1%'
|
||||
AND campaign.status = 'ENABLED'
|
||||
""",
|
||||
)
|
||||
campaigns = [
|
||||
{
|
||||
"id": str(row.campaign.id),
|
||||
"name": row.campaign.name,
|
||||
"status": row.campaign.status.name,
|
||||
"allowed": parse_allowed_labels(row.campaign.name),
|
||||
}
|
||||
for row in campaign_rows
|
||||
]
|
||||
if not campaigns:
|
||||
return SyncPlan([], 0, 0, [], [], [], [], ["Nie znaleziono kampanii [PLA_CL1]."], [])
|
||||
|
||||
label_to_campaign = {}
|
||||
for campaign in campaigns:
|
||||
for label in campaign["allowed"]:
|
||||
label_to_campaign[label] = campaign
|
||||
|
||||
by_offer_id = {}
|
||||
by_title_norm = defaultdict(list)
|
||||
by_label = defaultdict(list)
|
||||
for product in products:
|
||||
offer_id = (product.get("offer_id") or "").strip()
|
||||
title = (product.get("title") or "").strip()
|
||||
label = (product.get("custom_label_1") or "").strip()
|
||||
if offer_id:
|
||||
by_offer_id[offer_id] = product
|
||||
if title:
|
||||
by_title_norm[normalize_text(title)].append(product)
|
||||
if label and title:
|
||||
by_label[label].append(product)
|
||||
|
||||
campaign_ids = ", ".join(c["id"] for c in campaigns)
|
||||
group_rows = run_query(
|
||||
client,
|
||||
customer_id,
|
||||
f"""
|
||||
SELECT ad_group.id, ad_group.name, ad_group.status, campaign.id, campaign.name
|
||||
FROM ad_group
|
||||
WHERE campaign.id IN ({campaign_ids})
|
||||
AND ad_group.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
criterion_rows = run_query(
|
||||
client,
|
||||
customer_id,
|
||||
f"""
|
||||
SELECT ad_group.id,
|
||||
ad_group_criterion.listing_group.case_value.product_item_id.value,
|
||||
ad_group_criterion.listing_group.type,
|
||||
ad_group_criterion.negative
|
||||
FROM ad_group_criterion
|
||||
WHERE campaign.id IN ({campaign_ids})
|
||||
AND ad_group_criterion.type = 'LISTING_GROUP'
|
||||
AND ad_group_criterion.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
|
||||
group_to_offer = {}
|
||||
for row in criterion_rows:
|
||||
if row.ad_group_criterion.negative:
|
||||
continue
|
||||
if row.ad_group_criterion.listing_group.type.name != "UNIT":
|
||||
continue
|
||||
offer_id = row.ad_group_criterion.listing_group.case_value.product_item_id.value
|
||||
if offer_id:
|
||||
group_to_offer.setdefault(str(row.ad_group.id), offer_id)
|
||||
|
||||
enabled_offers_by_campaign = defaultdict(set)
|
||||
existing_groups_by_campaign_offer = defaultdict(list)
|
||||
existing_groups_by_campaign_name = defaultdict(list)
|
||||
all_groups = []
|
||||
for row in group_rows:
|
||||
group_id = str(row.ad_group.id)
|
||||
record = {
|
||||
"ad_group_id": group_id,
|
||||
"ad_group_name": row.ad_group.name,
|
||||
"ad_group_status": row.ad_group.status.name,
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"allowed": parse_allowed_labels(row.campaign.name),
|
||||
"offer_id": group_to_offer.get(group_id, ""),
|
||||
}
|
||||
all_groups.append(record)
|
||||
existing_groups_by_campaign_name[(record["campaign_id"], normalize_text(record["ad_group_name"]))].append(record)
|
||||
if record["offer_id"]:
|
||||
existing_groups_by_campaign_offer[(record["campaign_id"], record["offer_id"])].append(record)
|
||||
if record["ad_group_status"] == "ENABLED":
|
||||
enabled_offers_by_campaign[record["campaign_id"]].add(record["offer_id"])
|
||||
|
||||
wrong_groups = []
|
||||
groups_without_match = []
|
||||
active_groups_without_match = []
|
||||
rename_plan = []
|
||||
for group in all_groups:
|
||||
product = by_offer_id.get(group["offer_id"]) if group["offer_id"] else None
|
||||
match_via = "offer_id" if product else None
|
||||
if not product:
|
||||
candidates = by_title_norm.get(normalize_text(group["ad_group_name"])) or []
|
||||
if candidates:
|
||||
product = candidates[0]
|
||||
match_via = "title"
|
||||
if not product:
|
||||
groups_without_match.append(group)
|
||||
if group["ad_group_status"] == "ENABLED":
|
||||
active_groups_without_match.append(group)
|
||||
continue
|
||||
label = (product.get("custom_label_1") or "").strip()
|
||||
if not label:
|
||||
if group["ad_group_status"] == "ENABLED":
|
||||
active_groups_without_match.append(group)
|
||||
continue
|
||||
if label not in group["allowed"]:
|
||||
wrong_groups.append((group, product))
|
||||
continue
|
||||
adspro_title = (product.get("title") or "").strip()
|
||||
if (
|
||||
group["ad_group_status"] == "ENABLED"
|
||||
and match_via == "offer_id"
|
||||
and adspro_title
|
||||
and group["ad_group_name"] != adspro_title
|
||||
):
|
||||
rename_plan.append(
|
||||
{
|
||||
"ad_group_id": group["ad_group_id"],
|
||||
"campaign_id": group["campaign_id"],
|
||||
"campaign_name": group["campaign_name"],
|
||||
"old_name": group["ad_group_name"],
|
||||
"new_name": adspro_title,
|
||||
}
|
||||
)
|
||||
|
||||
create_plan = []
|
||||
enable_by_id = {}
|
||||
pause_by_id = {}
|
||||
|
||||
def plan_enable_or_create(campaign: dict, product: dict, fallback_name: str, reason: str) -> None:
|
||||
offer_id = (product.get("offer_id") or "").strip()
|
||||
title = (product.get("title") or "").strip() or fallback_name
|
||||
if not offer_id or not title:
|
||||
return
|
||||
if offer_id in enabled_offers_by_campaign[campaign["id"]]:
|
||||
return
|
||||
|
||||
existing_candidates = existing_groups_by_campaign_offer.get((campaign["id"], offer_id), [])
|
||||
if not existing_candidates:
|
||||
existing_candidates = existing_groups_by_campaign_name.get((campaign["id"], normalize_text(title)), [])
|
||||
|
||||
paused_candidate = next((group for group in existing_candidates if group["ad_group_status"] == "PAUSED"), None)
|
||||
if paused_candidate:
|
||||
enable_by_id[paused_candidate["ad_group_id"]] = {
|
||||
"ad_group_id": paused_candidate["ad_group_id"],
|
||||
"ad_group_name": paused_candidate["ad_group_name"],
|
||||
"campaign_id": paused_candidate["campaign_id"],
|
||||
"campaign_name": paused_candidate["campaign_name"],
|
||||
"product_id": offer_id,
|
||||
"reason": reason,
|
||||
}
|
||||
enabled_offers_by_campaign[campaign["id"]].add(offer_id)
|
||||
return
|
||||
|
||||
existing_active = next(
|
||||
(group for group in existing_candidates if group["ad_group_status"] == "ENABLED"),
|
||||
None,
|
||||
)
|
||||
if existing_active:
|
||||
enabled_offers_by_campaign[campaign["id"]].add(offer_id)
|
||||
return
|
||||
|
||||
create_plan.append(
|
||||
{
|
||||
"campaign_id": campaign["id"],
|
||||
"campaign_name": campaign["name"],
|
||||
"ad_group_name": title,
|
||||
"product_id": offer_id,
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
enabled_offers_by_campaign[campaign["id"]].add(offer_id)
|
||||
|
||||
for group, product in wrong_groups:
|
||||
offer_id = (product.get("offer_id") or "").strip()
|
||||
label = (product.get("custom_label_1") or "").strip()
|
||||
target = label_to_campaign.get(label)
|
||||
if target and offer_id:
|
||||
plan_enable_or_create(target, product, group["ad_group_name"], "produkt jest w zlej kampanii")
|
||||
if group["ad_group_status"] == "ENABLED":
|
||||
pause_by_id[group["ad_group_id"]] = {
|
||||
"ad_group_id": group["ad_group_id"],
|
||||
"ad_group_name": group["ad_group_name"],
|
||||
"campaign_id": group["campaign_id"],
|
||||
"campaign_name": group["campaign_name"],
|
||||
"reason": "produkt jest w zlej kampanii",
|
||||
}
|
||||
|
||||
for campaign in campaigns:
|
||||
for label in campaign["allowed"]:
|
||||
for product in by_label.get(label, []):
|
||||
offer_id = (product.get("offer_id") or "").strip()
|
||||
title = (product.get("title") or "").strip()
|
||||
if not offer_id or not title:
|
||||
continue
|
||||
if offer_id in enabled_offers_by_campaign[campaign["id"]]:
|
||||
continue
|
||||
plan_enable_or_create(campaign, product, title, "brakuje aktywnej grupy reklam")
|
||||
|
||||
for group in active_groups_without_match:
|
||||
pause_by_id[group["ad_group_id"]] = {
|
||||
"ad_group_id": group["ad_group_id"],
|
||||
"ad_group_name": group["ad_group_name"],
|
||||
"campaign_id": group["campaign_id"],
|
||||
"campaign_name": group["campaign_name"],
|
||||
"reason": "brak dopasowania w adsPRO",
|
||||
}
|
||||
|
||||
grouped = defaultdict(list)
|
||||
for group in all_groups:
|
||||
if group["ad_group_status"] != "ENABLED" or group["ad_group_id"] in pause_by_id or not group["offer_id"]:
|
||||
continue
|
||||
grouped[(group["campaign_id"], group["offer_id"])].append(group)
|
||||
for group_list in grouped.values():
|
||||
if len(group_list) <= 1:
|
||||
continue
|
||||
for group in sorted(group_list, key=lambda item: int(item["ad_group_id"]))[:-1]:
|
||||
pause_by_id[group["ad_group_id"]] = {
|
||||
"ad_group_id": group["ad_group_id"],
|
||||
"ad_group_name": group["ad_group_name"],
|
||||
"campaign_id": group["campaign_id"],
|
||||
"campaign_name": group["campaign_name"],
|
||||
"reason": "duplikat produktu w kampanii",
|
||||
}
|
||||
|
||||
pause_plan = [pause_by_id[key] for key in sorted(pause_by_id, key=int)]
|
||||
enable_plan = [enable_by_id[key] for key in sorted(enable_by_id, key=int)]
|
||||
pause_ids = set(pause_by_id)
|
||||
rename_plan = [row for row in rename_plan if row["ad_group_id"] not in pause_ids]
|
||||
|
||||
warnings = []
|
||||
if groups_without_match:
|
||||
warnings.append(f"Grupy reklam bez dopasowania w adsPRO: {len(groups_without_match)}.")
|
||||
|
||||
return SyncPlan(
|
||||
campaigns=campaigns,
|
||||
groups_total=len(all_groups),
|
||||
groups_with_product_id=sum(1 for g in all_groups if g["offer_id"]),
|
||||
create_plan=create_plan,
|
||||
enable_plan=enable_plan,
|
||||
pause_plan=pause_plan,
|
||||
rename_plan=rename_plan,
|
||||
warnings=warnings,
|
||||
unmatched_groups=groups_without_match,
|
||||
)
|
||||
|
||||
|
||||
def create_ad_group_with_listing(client, customer_id: str, campaign_id: str, product_id: str, ad_group_name: str):
|
||||
service = client.get_service("GoogleAdsService")
|
||||
ad_group_service = client.get_service("AdGroupService")
|
||||
campaign_resource = ad_group_service.campaign_path(customer_id, campaign_id)
|
||||
|
||||
ad_group_temp = f"customers/{customer_id}/adGroups/-1"
|
||||
root_temp = f"customers/{customer_id}/adGroupCriteria/-1~-2"
|
||||
operations = []
|
||||
|
||||
group_op = client.get_type("MutateOperation")
|
||||
group = group_op.ad_group_operation.create
|
||||
group.resource_name = ad_group_temp
|
||||
group.name = ad_group_name
|
||||
group.campaign = campaign_resource
|
||||
group.status = client.enums.AdGroupStatusEnum.ENABLED
|
||||
group.type_ = client.enums.AdGroupTypeEnum.SHOPPING_PRODUCT_ADS
|
||||
operations.append(group_op)
|
||||
|
||||
root_op = client.get_type("MutateOperation")
|
||||
root = root_op.ad_group_criterion_operation.create
|
||||
root.resource_name = root_temp
|
||||
root.ad_group = ad_group_temp
|
||||
root.status = client.enums.AdGroupCriterionStatusEnum.ENABLED
|
||||
root.listing_group.type_ = client.enums.ListingGroupTypeEnum.SUBDIVISION
|
||||
operations.append(root_op)
|
||||
|
||||
product_op = client.get_type("MutateOperation")
|
||||
product = product_op.ad_group_criterion_operation.create
|
||||
product.ad_group = ad_group_temp
|
||||
product.status = client.enums.AdGroupCriterionStatusEnum.ENABLED
|
||||
product.listing_group.type_ = client.enums.ListingGroupTypeEnum.UNIT
|
||||
product.listing_group.parent_ad_group_criterion = root_temp
|
||||
product.listing_group.case_value.product_item_id.value = product_id
|
||||
product.cpc_bid_micros = 1_000_000
|
||||
operations.append(product_op)
|
||||
|
||||
other_op = client.get_type("MutateOperation")
|
||||
other = other_op.ad_group_criterion_operation.create
|
||||
other.ad_group = ad_group_temp
|
||||
other.negative = True
|
||||
other.status = client.enums.AdGroupCriterionStatusEnum.ENABLED
|
||||
other.listing_group.type_ = client.enums.ListingGroupTypeEnum.UNIT
|
||||
other.listing_group.parent_ad_group_criterion = root_temp
|
||||
client.copy_from(other.listing_group.case_value.product_item_id, client.get_type("ProductItemIdInfo"))
|
||||
operations.append(other_op)
|
||||
|
||||
ad_op = client.get_type("MutateOperation")
|
||||
ad = ad_op.ad_group_ad_operation.create
|
||||
ad.ad_group = ad_group_temp
|
||||
ad.status = client.enums.AdGroupAdStatusEnum.ENABLED
|
||||
ad.ad.shopping_product_ad._pb.SetInParent()
|
||||
operations.append(ad_op)
|
||||
|
||||
service.mutate(customer_id=customer_id, mutate_operations=operations)
|
||||
|
||||
|
||||
def pause_ad_groups(client, customer_id: str, ad_group_ids: list[str]) -> int:
|
||||
service = client.get_service("AdGroupService")
|
||||
changed = 0
|
||||
for index in range(0, len(ad_group_ids), 500):
|
||||
operations = []
|
||||
for ad_group_id in ad_group_ids[index:index + 500]:
|
||||
op = client.get_type("AdGroupOperation")
|
||||
group = op.update
|
||||
group.resource_name = service.ad_group_path(customer_id, ad_group_id)
|
||||
group.status = client.enums.AdGroupStatusEnum.PAUSED
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=["status"])
|
||||
operations.append(op)
|
||||
if operations:
|
||||
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
|
||||
changed += len(response.results)
|
||||
return changed
|
||||
|
||||
|
||||
def enable_ad_groups(client, customer_id: str, ad_group_ids: list[str]) -> int:
|
||||
if not ad_group_ids:
|
||||
return 0
|
||||
service = client.get_service("AdGroupService")
|
||||
changed = 0
|
||||
for index in range(0, len(ad_group_ids), 500):
|
||||
operations = []
|
||||
for ad_group_id in ad_group_ids[index:index + 500]:
|
||||
op = client.get_type("AdGroupOperation")
|
||||
group = op.update
|
||||
group.resource_name = service.ad_group_path(customer_id, ad_group_id)
|
||||
group.status = client.enums.AdGroupStatusEnum.ENABLED
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=["status"])
|
||||
operations.append(op)
|
||||
if operations:
|
||||
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
|
||||
changed += len(response.results)
|
||||
return changed
|
||||
|
||||
|
||||
def rename_ad_groups(client, customer_id: str, renames: list[dict]) -> int:
|
||||
service = client.get_service("AdGroupService")
|
||||
changed = 0
|
||||
for index in range(0, len(renames), 500):
|
||||
operations = []
|
||||
for row in renames[index:index + 500]:
|
||||
op = client.get_type("AdGroupOperation")
|
||||
group = op.update
|
||||
group.resource_name = service.ad_group_path(customer_id, row["ad_group_id"])
|
||||
group.name = row["new_name"]
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=["name"])
|
||||
operations.append(op)
|
||||
if operations:
|
||||
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
|
||||
changed += len(response.results)
|
||||
return changed
|
||||
|
||||
|
||||
def print_plan(plan: SyncPlan) -> None:
|
||||
print("\nPlan synchronizacji PLA_CL1")
|
||||
print(f"Kampanie PLA_CL1: {len(plan.campaigns)}")
|
||||
print(f"Grupy reklam obecnie: {plan.groups_total}")
|
||||
print(f"Grupy reklam z identyfikatorem produktu: {plan.groups_with_product_id}")
|
||||
print(f"Do utworzenia: {len(plan.create_plan)}")
|
||||
print(f"Do włączenia: {len(plan.enable_plan)}")
|
||||
print(f"Do wstrzymania: {len(plan.pause_plan)}")
|
||||
print(f"Do zmiany nazwy: {len(plan.rename_plan)}")
|
||||
for warning in plan.warnings:
|
||||
print(f"Uwaga: {warning}")
|
||||
|
||||
summary = campaign_action_summary(plan)
|
||||
if summary:
|
||||
print("\nPodsumowanie po kampaniach:")
|
||||
for row in summary:
|
||||
print(
|
||||
f" {row['campaign_name']} | "
|
||||
f"utwórz={row['create']} | włącz={row['enable']} | "
|
||||
f"wstrzymaj={row['pause']} | zmień nazwę={row['rename']}"
|
||||
)
|
||||
|
||||
for row in plan.create_plan[:20]:
|
||||
print(f" Utworz: {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']}")
|
||||
if len(plan.create_plan) > 20:
|
||||
print(f" ... oraz {len(plan.create_plan) - 20} kolejnych grup reklam do utworzenia")
|
||||
for row in plan.enable_plan[:20]:
|
||||
print(f" Włącz: {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']}")
|
||||
if len(plan.enable_plan) > 20:
|
||||
print(f" ... oraz {len(plan.enable_plan) - 20} kolejnych grup reklam do włączenia")
|
||||
for row in plan.pause_plan[:20]:
|
||||
print(f" Wstrzymaj: {row['campaign_name']} | {row['ad_group_name']} | {row['reason']}")
|
||||
if len(plan.pause_plan) > 20:
|
||||
print(f" ... oraz {len(plan.pause_plan) - 20} kolejnych grup reklam do wstrzymania")
|
||||
for row in plan.rename_plan[:20]:
|
||||
print(f" Zmien nazwe: {row['ad_group_id']} | {row['old_name'][:50]} -> {row['new_name'][:50]}")
|
||||
if len(plan.rename_plan) > 20:
|
||||
print(f" ... oraz {len(plan.rename_plan) - 20} kolejnych nazw do zmiany")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_sync_plan(client_config: ClientConfig, plan: SyncPlan, show_navigation: bool = True) -> None:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
|
||||
created = 0
|
||||
create_errors = 0
|
||||
for row in plan.create_plan:
|
||||
try:
|
||||
create_ad_group_with_listing(
|
||||
google_client,
|
||||
customer_id,
|
||||
row["campaign_id"],
|
||||
row["product_id"],
|
||||
row["ad_group_name"],
|
||||
)
|
||||
created += 1
|
||||
except Exception as exc:
|
||||
create_errors += 1
|
||||
print(f"Blad tworzenia grupy reklam {row['ad_group_name']}: {exc}")
|
||||
|
||||
pause_ids = [row["ad_group_id"] for row in plan.pause_plan]
|
||||
enable_ids = [row["ad_group_id"] for row in plan.enable_plan]
|
||||
enabled = enable_ad_groups(google_client, customer_id, enable_ids) if enable_ids else 0
|
||||
paused = pause_ad_groups(google_client, customer_id, pause_ids) if pause_ids else 0
|
||||
renamed = rename_ad_groups(google_client, customer_id, plan.rename_plan) if plan.rename_plan else 0
|
||||
|
||||
print("\nWynik wdrozenia zmian")
|
||||
print(f"Utworzono grup reklam: {created}")
|
||||
print(f"Włączono grup reklam: {enabled}")
|
||||
print(f"Bledy tworzenia: {create_errors}")
|
||||
print(f"Wstrzymano grup reklam: {paused}")
|
||||
print(f"Zmieniono nazwy grup reklam: {renamed}")
|
||||
|
||||
rows = []
|
||||
rows.extend(
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": row["campaign_name"],
|
||||
"czynnosc": "włączono grupę reklam",
|
||||
"grupa reklam": row["ad_group_name"],
|
||||
"produkt": row["product_id"],
|
||||
}
|
||||
for row in plan.enable_plan
|
||||
)
|
||||
rows.extend(
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": row["campaign_name"],
|
||||
"czynnosc": "utworzono grupe reklam",
|
||||
"grupa reklam": row["ad_group_name"],
|
||||
"produkt": row["product_id"],
|
||||
}
|
||||
for row in plan.create_plan
|
||||
)
|
||||
rows.extend(
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": row["campaign_name"],
|
||||
"czynnosc": "wstrzymano grupe reklam",
|
||||
"grupa reklam": row["ad_group_name"],
|
||||
"produkt": row["reason"],
|
||||
}
|
||||
for row in plan.pause_plan
|
||||
)
|
||||
rows.extend(
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": row["campaign_name"],
|
||||
"czynnosc": "zmieniono nazwe grupy reklam",
|
||||
"grupa reklam": row["old_name"],
|
||||
"produkt": row["new_name"],
|
||||
}
|
||||
for row in plan.rename_plan
|
||||
)
|
||||
changes_path = append_change_markdown(client_config.domain, "Synchronizacja kampanii PLA_CL1", rows)
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Synchronizacja kampanii PLA_CL1",
|
||||
"status": "wdrozono zmiany",
|
||||
"campaign": ", ".join(c["name"] for c in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"created": created,
|
||||
"enabled": enabled,
|
||||
"create_errors": create_errors,
|
||||
"paused": paused,
|
||||
"renamed": renamed,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_sync_pla_cl1(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(
|
||||
f"Plan jest dla klienta {plan_data.get('client')}, "
|
||||
f"a wybrano {client_config.domain}."
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = SyncPlan.from_dict(plan_data)
|
||||
print_plan(plan)
|
||||
apply_sync_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
started = now_local()
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Pobieram kampanie PLA_CL1 i produkty z adsPRO...")
|
||||
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
|
||||
campaign_rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT campaign.id, campaign.name, campaign.status
|
||||
FROM campaign
|
||||
WHERE campaign.name LIKE '%PLA_CL1%'
|
||||
AND campaign.status = 'ENABLED'
|
||||
""",
|
||||
)
|
||||
segments = sorted(
|
||||
{
|
||||
label
|
||||
for row in campaign_rows
|
||||
for label in parse_allowed_labels(row.campaign.name)
|
||||
}
|
||||
)
|
||||
if not segments:
|
||||
print("Nie znaleziono segmentow CL1 w kampaniach [PLA_CL1].")
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Synchronizacja kampanii PLA_CL1",
|
||||
"status": "brak kampanii",
|
||||
"campaign": "",
|
||||
},
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("Segmenty CL1: " + ", ".join(segments))
|
||||
products = fetch_adspro_products(client_config, segments)
|
||||
products_path = save_products_csv(client_config.domain, products)
|
||||
print(f"Pobrano produkty z adsPRO: {len(products)}")
|
||||
print(f"Zapisano dane: {products_path}")
|
||||
|
||||
plan = build_plan(google_client, customer_id, products)
|
||||
print_plan(plan)
|
||||
json_path, md_path = save_plan_files(client_config.domain, plan, len(products))
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Synchronizacja kampanii PLA_CL1",
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(c["name"] for c in plan.campaigns[:10]),
|
||||
"created_at": started.isoformat(timespec="seconds"),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"products": len(products),
|
||||
"create": len(plan.create_plan),
|
||||
"enable": len(plan.enable_plan),
|
||||
"pause": len(plan.pause_plan),
|
||||
"rename": len(plan.rename_plan),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
if not plan.create_plan and not plan.enable_plan and not plan.pause_plan and not plan.rename_plan:
|
||||
print("\nBrak zmian do wdrozenia.")
|
||||
append_change_markdown(client_config.domain, "Synchronizacja kampanii PLA_CL1", [])
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
answer = input("\nWpisz TAK, aby wdrozyc powyzsze zmiany: ").strip()
|
||||
if answer != "TAK":
|
||||
print("Przerwano. Zmiany nie zostaly wdrozone.")
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Synchronizacja kampanii PLA_CL1",
|
||||
"status": "odrzucono wdrozenie",
|
||||
"campaign": ", ".join(c["name"] for c in plan.campaigns[:10]),
|
||||
},
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
apply_sync_plan(client_config, plan, show_navigation=show_navigation)
|
||||
394
.sync/Archive/src/gads_v2/tasks/search_basic_settings_check.py
Normal file
394
.sync/Archive/src/gads_v2/tasks/search_basic_settings_check.py
Normal file
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
from ..knowledge.store import rules_for_task
|
||||
from ..table import print_table
|
||||
|
||||
|
||||
TASK_ID = "check_search_basic_settings"
|
||||
TASK_NAME = "Sprawdzenie podstawowych ustawien Search"
|
||||
|
||||
|
||||
SCOPE = [
|
||||
{
|
||||
"area": "Lokalizacje",
|
||||
"check": "Sprawdz typ kierowania lokalizacji, zwlaszcza Obecnosc vs Obecnosc lub zainteresowanie.",
|
||||
},
|
||||
{
|
||||
"area": "Sieci",
|
||||
"check": "Sprawdz, czy kampanie Search nie maja niechcaco wlaczonej sieci reklamowej albo partnerow wyszukiwania.",
|
||||
},
|
||||
{
|
||||
"area": "Jezyki",
|
||||
"check": "Sprawdz, czy ustawienia jezykowe sa zgodne z rynkiem klienta.",
|
||||
},
|
||||
{
|
||||
"area": "Harmonogram reklam",
|
||||
"check": "Sprawdz, czy harmonogram jest swiadomie ustawiony albo czy kampania dziala caly czas.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_SCOPE = [
|
||||
"budzety i wykorzystanie budzetu",
|
||||
"strategie stawek i uczenie strategii",
|
||||
"zapytania uzytkownikow oraz wykluczenia",
|
||||
"reklamy RSA i zasoby reklam",
|
||||
"wyniki kampanii oraz rentownosc",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchBasicSettingsPlan:
|
||||
campaigns: list[dict]
|
||||
scope: list[dict]
|
||||
out_of_scope: list[str]
|
||||
knowledge_rules: list[dict]
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"task": TASK_ID,
|
||||
"task_name": TASK_NAME,
|
||||
"campaigns": self.campaigns,
|
||||
"scope": self.scope,
|
||||
"out_of_scope": self.out_of_scope,
|
||||
"knowledge_rules": self.knowledge_rules,
|
||||
"warnings": self.warnings,
|
||||
"changes": [],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SearchBasicSettingsPlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
scope=data.get("scope", []),
|
||||
out_of_scope=data.get("out_of_scope", []),
|
||||
knowledge_rules=data.get("knowledge_rules", []),
|
||||
warnings=data.get("warnings", []),
|
||||
)
|
||||
|
||||
|
||||
def enum_name(value) -> str:
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
return str(value)
|
||||
|
||||
|
||||
def human_geo(value: str) -> str:
|
||||
return {
|
||||
"PRESENCE": "Obecnosc",
|
||||
"PRESENCE_OR_INTEREST": "Obecnosc lub zainteresowanie",
|
||||
"SEARCH_INTEREST": "Zainteresowanie wyszukiwaniem",
|
||||
}.get(value, value)
|
||||
|
||||
|
||||
def yes_no(value: bool) -> str:
|
||||
return "TAK" if value else "NIE"
|
||||
|
||||
|
||||
def fetch_search_campaigns(client_config: ClientConfig) -> list[dict]:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
rows = run_query(
|
||||
google_client,
|
||||
client_config.safe_customer_id,
|
||||
"""
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.name,
|
||||
campaign.status,
|
||||
campaign.advertising_channel_type,
|
||||
campaign.geo_target_type_setting.positive_geo_target_type,
|
||||
campaign.network_settings.target_google_search,
|
||||
campaign.network_settings.target_search_network,
|
||||
campaign.network_settings.target_partner_search_network,
|
||||
campaign.network_settings.target_content_network
|
||||
FROM campaign
|
||||
WHERE campaign.advertising_channel_type = 'SEARCH'
|
||||
AND campaign.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
campaigns = []
|
||||
for row in rows:
|
||||
campaign = row.campaign
|
||||
positive_geo = enum_name(campaign.geo_target_type_setting.positive_geo_target_type)
|
||||
campaigns.append(
|
||||
{
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"status": enum_name(campaign.status),
|
||||
"positive_geo_target_type": positive_geo,
|
||||
"positive_geo_target_type_label": human_geo(positive_geo),
|
||||
"target_google_search": bool(campaign.network_settings.target_google_search),
|
||||
"target_search_network": bool(campaign.network_settings.target_search_network),
|
||||
"target_partner_search_network": bool(campaign.network_settings.target_partner_search_network),
|
||||
"target_content_network": bool(campaign.network_settings.target_content_network),
|
||||
}
|
||||
)
|
||||
return campaigns
|
||||
|
||||
|
||||
def build_search_basic_settings_plan(client_config: ClientConfig) -> SearchBasicSettingsPlan:
|
||||
warnings = []
|
||||
try:
|
||||
campaigns = fetch_search_campaigns(client_config)
|
||||
except Exception as exc:
|
||||
campaigns = []
|
||||
warnings.append(f"Nie udalo sie pobrac kampanii Search z Google Ads API: {exc}")
|
||||
|
||||
if not campaigns:
|
||||
warnings.append("Nie znaleziono kampanii Search albo nie udalo sie ich pobrac.")
|
||||
|
||||
rules = rules_for_task(TASK_ID)
|
||||
knowledge_rules = [
|
||||
{
|
||||
"id": rule.id,
|
||||
"topic": rule.topic,
|
||||
"rule_type": rule.rule_type,
|
||||
"condition": rule.condition,
|
||||
"recommendation": rule.recommendation,
|
||||
"risk": rule.risk,
|
||||
"source": rule.source,
|
||||
}
|
||||
for rule in rules
|
||||
]
|
||||
if not knowledge_rules:
|
||||
warnings.append(
|
||||
"Do tego zadania nie przypisano jeszcze regul z bazy wiedzy. "
|
||||
"Uzyj `python gads.py wiedza przypisz --restart`, gdy bedziemy wybierac reguly dla Search."
|
||||
)
|
||||
|
||||
return SearchBasicSettingsPlan(
|
||||
campaigns=campaigns,
|
||||
scope=SCOPE,
|
||||
out_of_scope=OUT_OF_SCOPE,
|
||||
knowledge_rules=knowledge_rules,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def save_search_basic_settings_plan(domain: str, plan: SearchBasicSettingsPlan) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_{TASK_ID}"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Sprawdzenie podstawowych ustawien Search",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie Search: {len(plan.campaigns)}",
|
||||
f"- Reguly wiedzy przypisane do zadania: {len(plan.knowledge_rules)}",
|
||||
"- Zmiany do wdrozenia: 0",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {item}" for item in plan.warnings)
|
||||
lines.append("")
|
||||
lines.extend(["## Zakres zadania", "", "| Obszar | Co sprawdzic |", "| --- | --- |"])
|
||||
for row in plan.scope:
|
||||
lines.append(f"| {row.get('area', '')} | {row.get('check', '')} |")
|
||||
lines.append("")
|
||||
lines.extend(["## Poza zakresem tego zadania", ""])
|
||||
lines.extend(f"- {item}" for item in plan.out_of_scope)
|
||||
lines.append("")
|
||||
if plan.campaigns:
|
||||
lines.extend(
|
||||
[
|
||||
"## Kampanie Search",
|
||||
"",
|
||||
"| Kampania | Status | Lokalizacje | Google Search | Search Network | Partnerzy | Siec reklamowa |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for campaign in plan.campaigns:
|
||||
lines.append(
|
||||
f"| {campaign['campaign_name']} | {campaign['status']} | "
|
||||
f"{campaign.get('positive_geo_target_type_label', campaign['positive_geo_target_type'])} | "
|
||||
f"{yes_no(campaign['target_google_search'])} | "
|
||||
f"{yes_no(campaign['target_search_network'])} | "
|
||||
f"{yes_no(campaign['target_partner_search_network'])} | "
|
||||
f"{yes_no(campaign['target_content_network'])} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.knowledge_rules:
|
||||
lines.extend(
|
||||
[
|
||||
"## Reguly z bazy wiedzy",
|
||||
"",
|
||||
"| ID | Temat | Rekomendacja | Ryzyko |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for rule in plan.knowledge_rules:
|
||||
lines.append(
|
||||
f"| {rule.get('id', '')} | {rule.get('topic', '')} | "
|
||||
f"{rule.get('recommendation', '')} | {rule.get('risk', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def print_search_basic_settings_plan(plan: SearchBasicSettingsPlan) -> None:
|
||||
print("\nPlan sprawdzenia podstawowych ustawien Search")
|
||||
print_table(
|
||||
["Metryka", "Liczba"],
|
||||
[
|
||||
["Kampanie Search", str(len(plan.campaigns))],
|
||||
["Reguly wiedzy", str(len(plan.knowledge_rules))],
|
||||
["Zmiany do wdrozenia", "0"],
|
||||
],
|
||||
)
|
||||
if plan.warnings:
|
||||
print("\nUwagi")
|
||||
print_table(["Nr", "Uwaga"], [[str(index), item] for index, item in enumerate(plan.warnings, 1)])
|
||||
print("\nZakres zadania")
|
||||
print_table(
|
||||
["Nr", "Obszar", "Co sprawdzic"],
|
||||
[[str(index), row["area"], row["check"]] for index, row in enumerate(plan.scope, 1)],
|
||||
)
|
||||
print("\nPoza zakresem")
|
||||
print_table(["Nr", "Nie analizujemy tutaj"], [[str(index), item] for index, item in enumerate(plan.out_of_scope, 1)])
|
||||
if plan.campaigns:
|
||||
print("\nKampanie Search")
|
||||
print_table(
|
||||
["Nr", "Kampania", "Status", "Lokalizacje", "Partnerzy", "Siec reklamowa"],
|
||||
[
|
||||
[
|
||||
str(index),
|
||||
campaign["campaign_name"],
|
||||
campaign["status"],
|
||||
campaign.get("positive_geo_target_type_label", campaign["positive_geo_target_type"]),
|
||||
yes_no(campaign["target_partner_search_network"]),
|
||||
yes_no(campaign["target_content_network"]),
|
||||
]
|
||||
for index, campaign in enumerate(plan.campaigns, 1)
|
||||
],
|
||||
)
|
||||
if plan.knowledge_rules:
|
||||
print("\nReguly z bazy wiedzy")
|
||||
print_table(
|
||||
["Nr", "ID", "Temat", "Rekomendacja"],
|
||||
[
|
||||
[str(index), rule["id"], rule["topic"], rule["recommendation"]]
|
||||
for index, rule in enumerate(plan.knowledge_rules[:10], 1)
|
||||
],
|
||||
)
|
||||
if len(plan.knowledge_rules) > 10:
|
||||
print(f"... oraz {len(plan.knowledge_rules) - 10} kolejnych regul")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_search_basic_settings_plan(
|
||||
client_config: ClientConfig,
|
||||
plan: SearchBasicSettingsPlan,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
print("\nTo zadanie jest audytem ustawien i nie wdraza zmian na koncie Google Ads.")
|
||||
changes_path = append_change_markdown(client_config.domain, TASK_NAME, [])
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "audyt oznaczony jako wykonany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_check_search_basic_settings(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
_ = global_rules
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do oznaczenia audytu jako wykonanego wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(f"Plan jest dla klienta {plan_data.get('client')}, a wybrano {client_config.domain}.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = SearchBasicSettingsPlan.from_dict(plan_data)
|
||||
print_search_basic_settings_plan(plan)
|
||||
apply_search_basic_settings_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Przygotowuje plan sprawdzenia podstawowych ustawien Search...")
|
||||
plan = build_search_basic_settings_plan(client_config)
|
||||
print_search_basic_settings_plan(plan)
|
||||
json_path, md_path = save_search_basic_settings_plan(client_config.domain, plan)
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": TASK_NAME,
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(campaign["campaign_name"] for campaign in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"knowledge_rules": len(plan.knowledge_rules),
|
||||
"changes": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("\nBrak zmian do wdrozenia. To zadanie tworzy plan audytu podstawowych ustawien.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
Reference in New Issue
Block a user