first commit
This commit is contained in:
3
.sync/Archive/.claude/memory/MEMORY.md
Normal file
3
.sync/Archive/.claude/memory/MEMORY.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Memory Index
|
||||
|
||||
- [Format listy klientów](feedback_client_list_format.md) — listy klientów prezentować jako numerowaną tabelę markdown
|
||||
271
.sync/Archive/AGENTS.md
Normal file
271
.sync/Archive/AGENTS.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Instrukcja dla agentow AI
|
||||
|
||||
Ten projekt jest terminalowym narzedziem do pracy na kontach Google Ads.
|
||||
Agent AI nie uzywa API modeli. Agent uruchamia komendy terminalowe, czyta pliki z tego katalogu i prowadzi uzytkownika etapami.
|
||||
|
||||
## Najwazniejszy przeplyw
|
||||
|
||||
Uzytkownik nie ma pamietac nazw technicznych zadan.
|
||||
|
||||
Gdy uzytkownik napisze:
|
||||
|
||||
```text
|
||||
analiza-klienta
|
||||
```
|
||||
|
||||
uruchom:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta
|
||||
```
|
||||
|
||||
Pokaz uzytkownikowi liste klientow i popros o numer.
|
||||
Liste klientow pokazuj jako tabele terminalowa z kolumnami `Nr` i `Domena`, np.:
|
||||
|
||||
Zawsze pokazuj pelna liste klientow widoczna w terminalu.
|
||||
Nie streszczaj jej tekstem typu `Masz 10 klientow`.
|
||||
Nie ukrywaj listy za komunikatem o rozwinieciu wyniku narzedzia, np. `+24 lines` albo `ctrl+o to expand`.
|
||||
Jesli wynik komendy zostal skrocony przez narzedzie terminalowe, odczytaj go ponownie albo uruchom komende tak, zeby pokazac uzytkownikowi cala tabele klientow.
|
||||
|
||||
```text
|
||||
┌────┬──────────────────────┐
|
||||
│ Nr │ Domena │
|
||||
├────┼──────────────────────┤
|
||||
│ 1 │ aruba.rzeszow.pl │
|
||||
└────┴──────────────────────┘
|
||||
```
|
||||
|
||||
Po wyborze numeru klienta uruchom:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta --client-number <numer>
|
||||
```
|
||||
|
||||
Pokaz uzytkownikowi liste zadan. Zadania musza byc prezentowane z podzialem na grupy nadrzedne, np. `KAMPANIE PLA`, `WYKLUCZENIA`, `KAMPANIE SEARCH`.
|
||||
Nie wolno pokazywac samej tabeli zadan bez naglowka grupy. Nawet gdy jest tylko jedna grupa i jedno zadanie, pokaz naglowek grupy.
|
||||
Nie wolno laczyc zadan i opcji zbiorczych w jednej tabeli. Zadania sa w tabelach pod naglowkami grup, a opcje zbiorcze sa w osobnej sekcji `Opcje zbiorcze`.
|
||||
|
||||
Nie pokazuj tak:
|
||||
|
||||
```text
|
||||
┌─────┬─────────────────────────────────┐
|
||||
│ Nr │ Zadanie │
|
||||
├─────┼─────────────────────────────────┤
|
||||
│ 1.1 │ Synchronizacja kampanii PLA_CL1 │
|
||||
│ 1.2 │ Sprawdzenie ustawien │
|
||||
│ 1.0 │ Wszystkie z grupy PLA │
|
||||
│ ALL │ Wszystkie zadania │
|
||||
└─────┴─────────────────────────────────┘
|
||||
```
|
||||
|
||||
To jest bledne, bo miesza pojedyncze zadania z wyborami zbiorczymi i usuwa naglowek grupy.
|
||||
|
||||
Format listy zadan:
|
||||
|
||||
```text
|
||||
Klient: investagd.pl
|
||||
|
||||
========================================================================
|
||||
KAMPANIE PLA
|
||||
========================================================================
|
||||
┌────┬─────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Nr │ Zadanie │ Opis │
|
||||
├────┼─────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 1.1 │ Synchronizacja kampanii PLA_CL1 │ Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam. │
|
||||
└────┴─────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
W kazdej grupie pokazuj zadania jako tabele terminalowa z kolumnami `Nr`, `Zadanie`, `Opis`.
|
||||
Po tabelach zadan pokaz tez `Opcje zbiorcze`:
|
||||
|
||||
```text
|
||||
Opcje zbiorcze
|
||||
┌─────┬────────────────────────────────────────────┐
|
||||
│ Nr │ Zakres │
|
||||
├─────┼────────────────────────────────────────────┤
|
||||
│ 1.0 │ Wszystkie zadania z grupy: Kampanie PLA │
|
||||
├─────┼────────────────────────────────────────────┤
|
||||
│ ALL │ Wszystkie zadania ze wszystkich grup │
|
||||
└─────┴────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Numeracja ma format `grupa.zadanie`.
|
||||
Przyklad: `1.1` to pierwsze zadanie w pierwszej grupie, `2.3` to trzecie zadanie w drugiej grupie.
|
||||
Opcja zbiorcza grupy zawsze konczy sie na `.0`, np. `1.0`, `2.0`.
|
||||
`ALL` pokazuj tylko raz na koncu listy opcji zbiorczych.
|
||||
|
||||
Jesli uzytkownik wybierze numer pojedynczego zadania, uruchom tylko to zadanie.
|
||||
Jesli uzytkownik wybierze `1.0`, `2.0` itd., uruchom wszystkie zadania z tej grupy po kolei.
|
||||
Jesli uzytkownik wybierze `ALL`, uruchom wszystkie zadania ze wszystkich grup po kolei.
|
||||
|
||||
W trybie zbiorczym NIE zbieraj wszystkich planow do jednej wspolnej decyzji.
|
||||
Kazde zadanie obsluguj osobno:
|
||||
|
||||
1. uruchom `--plan-only` dla pierwszego zadania,
|
||||
2. przeczytaj plan pierwszego zadania,
|
||||
3. pokaz krotka analize pierwszego zadania,
|
||||
4. zapytaj, czy wdrozyc ten jeden plan,
|
||||
5. po decyzji uzytkownika wdroz albo pomin ten jeden plan,
|
||||
6. dopiero potem przejdz do kolejnego zadania.
|
||||
|
||||
Nie pytaj: `Czy wdrozyc oba plany?`.
|
||||
Pytaj: `Czy wdrozyc plan zadania 1/2?`.
|
||||
|
||||
Po wyborze numeru zadania uruchom najpierw tylko przygotowanie planu:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta --client-number <numer-klienta> --task-number <numer-zadania> --plan-only
|
||||
```
|
||||
|
||||
Preferowana komenda dla wyboru z listy:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta --client-number <numer-klienta> --select <wybor> --plan-only
|
||||
```
|
||||
|
||||
Po wyborze calej grupy uruchom:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta --client-number <numer-klienta> --select 1.0 --plan-only
|
||||
```
|
||||
|
||||
Po wyborze wszystkich grup uruchom:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta --client-number <numer-klienta> --select ALL --plan-only
|
||||
```
|
||||
|
||||
Zadania zbiorcze musza isc sekwencyjnie: nastepne zadanie startuje dopiero po zakonczeniu poprzedniego, lacznie z decyzja uzytkownika o wdrozeniu albo pominieciu planu.
|
||||
|
||||
Nastepnie odczytaj zapisany plan z:
|
||||
|
||||
```text
|
||||
clients/<domena>/plans/
|
||||
```
|
||||
|
||||
Przeanalizuj plan krotko i konkretnie. Nie wdrazaj zmian bez zgody uzytkownika.
|
||||
|
||||
Po akceptacji uzytkownika uruchom wdrozenie dokladnie zapisanego planu:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta --client-number <numer-klienta> --task-number <numer-zadania> --apply-plan <sciezka-do-planu-json> --confirm-apply TAK
|
||||
```
|
||||
|
||||
Po wykonaniu zadania, odrzuceniu planu albo decyzji o niewdrazaniu zmian zapytaj uzytkownika, co dalej:
|
||||
|
||||
```text
|
||||
Co dalej?
|
||||
1. Lista zadan tego samego klienta
|
||||
2. Lista klientow
|
||||
3. Zakoncz
|
||||
```
|
||||
|
||||
Popros uzytkownika tylko o numer. Po wyborze:
|
||||
|
||||
- `1` pokaz liste zadan tego samego klienta,
|
||||
- `2` pokaz liste klientow,
|
||||
- `3` zakoncz.
|
||||
|
||||
## Zasady komunikacji
|
||||
|
||||
- Pisz po polsku.
|
||||
- Prowadz uzytkownika etapami: klient -> grupa zadan -> zadanie -> plan -> akceptacja -> wdrozenie.
|
||||
- Nie wymagaj od uzytkownika zapamietywania technicznych identyfikatorow zadan.
|
||||
- Nie uzywaj skrotow bez potrzeby. Pisz `grupa reklam`, nie `AG`.
|
||||
- Pisz `wdrozenie zmian`, nie `mutacja`.
|
||||
- Pisz `Docelowy ROAS`, nie `tROAS`, chyba ze cytujesz nazwe techniczna.
|
||||
- Odpowiedzi analityczne maja byc krotkie: co zostanie zrobione, ile elementow, jakie ryzyko, czy rekomendujesz wdrozenie.
|
||||
- Odpowiedzi analityczne po odczytaniu planu musza zawierac tabele. Nie streszczaj planu samymi punktami.
|
||||
|
||||
Minimalny format analizy planu:
|
||||
|
||||
```text
|
||||
Zadanie 1/2: Synchronizacja kampanii PLA_CL1
|
||||
|
||||
Podsumowanie po kampaniach
|
||||
┌──────────────────────┬────────┬───────┬───────────┬─────────────┐
|
||||
│ Kampania │ Utworz │ Wlacz │ Wstrzymaj │ Zmien nazwe │
|
||||
├──────────────────────┼────────┼───────┼───────────┼─────────────┤
|
||||
│ [PLA_CL1] pozostale │ 0 │ 1 │ 0 │ 0 │
|
||||
│ [PLA_CL1] worki │ 0 │ 8 │ 0 │ 0 │
|
||||
└──────────────────────┴────────┴───────┴───────────┴─────────────┘
|
||||
|
||||
Najwazniejsze dzialania
|
||||
┌────┬─────────────────────┬────────────────────────────────────────┐
|
||||
│ Nr │ Kampania │ Dzialanie │
|
||||
├────┼─────────────────────┼────────────────────────────────────────┤
|
||||
│ 1 │ [PLA_CL1] pozostale │ Wlacz grupe reklam: nazwa grupy │
|
||||
└────┴─────────────────────┴────────────────────────────────────────┘
|
||||
|
||||
Ryzyko: niskie.
|
||||
Rekomendacja: wdrozyc.
|
||||
```
|
||||
|
||||
Jesli lista dzialan jest dluga, pokaz tabele z pierwszymi 10 pozycjami i dopisz liczbe pozostalych. Nadal pokaz pelne podsumowanie po kampaniach.
|
||||
|
||||
## Bezpieczenstwo
|
||||
|
||||
- Najpierw zawsze tworz plan przez `--plan-only`.
|
||||
- Nie wdrazaj planu, dopoki uzytkownik jasno nie zaakceptuje.
|
||||
- Wdrazaj tylko plan zapisany w pliku JSON.
|
||||
- Po wdrozeniu sprawdz i podaj sciezki historii:
|
||||
- `clients/<domena>/history/YYYY-MM-DD.jsonl`
|
||||
- `clients/<domena>/changes/YYYY-MM-DD.md`
|
||||
- Po wykonaniu albo odrzuceniu zadania zawsze zaproponuj powrot do listy zadan albo listy klientow.
|
||||
|
||||
## Konfiguracja zadan
|
||||
|
||||
Lista grup i zadan jest w:
|
||||
|
||||
```text
|
||||
config/tasks.toml
|
||||
```
|
||||
|
||||
Dodawaj nowe zadania do odpowiednich grup, z czytelna nazwa dla uzytkownika.
|
||||
|
||||
Szczegolowa instrukcja rozbudowy narzedzia jest w:
|
||||
|
||||
```text
|
||||
DEVELOPMENT.md
|
||||
```
|
||||
|
||||
## Zadania: Produkty
|
||||
|
||||
Grupa `Produkty` jest podzielona na trzy osobne zadania:
|
||||
|
||||
- `Optymalizacja tytulow produktow` pobiera z adsPRO tylko produkty bez zoptymalizowanego tytulu.
|
||||
- `Optymalizacja kategorii Google` pobiera z adsPRO tylko produkty bez kategorii Google.
|
||||
- `Uzupelnienie unit pricing` pobiera z adsPRO tylko produkty bez unit pricing.
|
||||
|
||||
Nie mieszaj tych zakresow w jednym planie.
|
||||
Tytuly produktow wybiera agent AI po analizie produktu, tytulu bazowego, strony produktu albo kontekstu klienta.
|
||||
Kategorie Google wybiera agent AI po analizie produktu, tytulu, strony produktu albo kontekstu klienta.
|
||||
Skrypt nie wybiera automatycznie tytulow ani kategorii Google.
|
||||
Przed wdrozeniem tytulow agent musi uzupelnic docelowe wartosci tytulow w zapisanym planie JSON i dopiero wtedy zapytac uzytkownika o zgode.
|
||||
Przed wdrozeniem kategorii agent musi uzupelnic docelowe wartosci kategorii w zapisanym planie JSON i dopiero wtedy zapytac uzytkownika o zgode.
|
||||
Unit pricing moze byc proponowany przez skrypt, jezeli da sie go jednoznacznie odczytac z nazwy produktu.
|
||||
|
||||
## Reguly i wyjatki klientow
|
||||
|
||||
Ustawienia globalne i wyjatki per klient sa w:
|
||||
|
||||
```text
|
||||
config/clients.toml
|
||||
```
|
||||
|
||||
Przyklad globalnych regul dla kampanii PLA:
|
||||
|
||||
```toml
|
||||
[global_rules.pla_settings]
|
||||
require_presence_only = true
|
||||
require_high_priority = true
|
||||
```
|
||||
|
||||
Wyjatek per klient:
|
||||
|
||||
```toml
|
||||
[clients."example.pl".pla_settings]
|
||||
require_high_priority = false
|
||||
```
|
||||
|
||||
Jesli klient ma wylaczona regule, agent nie powinien sugerowac wdrozenia tej zmiany.
|
||||
218
.sync/Archive/DEVELOPMENT.md
Normal file
218
.sync/Archive/DEVELOPMENT.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Rozbudowa narzedzia
|
||||
|
||||
Ten plik opisuje, jak dodawac nowe grupy zadan, zadania i skrypty, zeby kolejny agent nie musial projektowac procesu od zera.
|
||||
|
||||
## Zasada architektury
|
||||
|
||||
Kazde zadanie powinno dzialac w tym samym modelu:
|
||||
|
||||
1. Pobierz aktualne dane.
|
||||
2. Zbuduj plan.
|
||||
3. Zapisz plan do `clients/<domena>/plans/` jako `.json` i `.md`.
|
||||
4. W trybie `--plan-only` nie wdrazaj zmian.
|
||||
5. Po akceptacji uzytkownika wdrazaj tylko plan zapisany w JSON.
|
||||
6. Zapisz historie do:
|
||||
- `clients/<domena>/history/YYYY-MM-DD.jsonl`
|
||||
- `clients/<domena>/changes/YYYY-MM-DD.md`
|
||||
|
||||
Agent AI prowadzi uzytkownika, ale logika pobierania danych, analizy i wdrozenia zmian ma byc w Pythonie.
|
||||
|
||||
## Dodanie nowej grupy zadan
|
||||
|
||||
Przed dodaniem wiekszego zakresu sprawdz:
|
||||
|
||||
```text
|
||||
OLD_COMMANDS_CHECKLIST.md
|
||||
```
|
||||
|
||||
To jest lista rzeczy sprawdzanych przez stary system z `D:\google ads\`.
|
||||
|
||||
Edytuj:
|
||||
|
||||
```text
|
||||
config/tasks.toml
|
||||
```
|
||||
|
||||
Dodaj nowa grupe:
|
||||
|
||||
```toml
|
||||
[[groups]]
|
||||
id = "search"
|
||||
name = "Kampanie Search"
|
||||
```
|
||||
|
||||
Zadania beda numerowane automatycznie jako `2.1`, `2.2`, itd. w zaleznosci od kolejnosci grup.
|
||||
|
||||
## Dodanie nowego zadania do grupy
|
||||
|
||||
W `config/tasks.toml` dodaj zadanie pod odpowiednia grupa:
|
||||
|
||||
```toml
|
||||
[[groups.tasks]]
|
||||
id = "check_search_settings"
|
||||
name = "Sprawdzenie ustawien"
|
||||
description = "Sprawdza ustawienia kampanii Search wedlug regul globalnych i wyjatkow klienta."
|
||||
```
|
||||
|
||||
`id` jest techniczne i musi byc stabilne. `name` i `description` sa dla uzytkownika.
|
||||
|
||||
## Plik zadania w Pythonie
|
||||
|
||||
Dodaj modul w:
|
||||
|
||||
```text
|
||||
src/gads_v2/tasks/
|
||||
```
|
||||
|
||||
Przyklad nazwy:
|
||||
|
||||
```text
|
||||
src/gads_v2/tasks/search_settings_check.py
|
||||
```
|
||||
|
||||
Minimalny wzorzec funkcji:
|
||||
|
||||
```python
|
||||
def run_check_search_settings(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
Wymagania:
|
||||
|
||||
- `plan_only=True` zawsze tylko zapisuje plan.
|
||||
- `apply_plan_path` wdraza tylko wskazany plan JSON.
|
||||
- `confirm_apply` musi wymagac wartosci `TAK`.
|
||||
- `show_navigation=False` musi ukrywac pytanie `Co dalej`, bo uzywa tego tryb sekwencji.
|
||||
|
||||
## Struktura planu
|
||||
|
||||
Plan powinien miec klase lub slownik z metodami:
|
||||
|
||||
```python
|
||||
to_dict()
|
||||
from_dict()
|
||||
```
|
||||
|
||||
Plan JSON musi zawierac:
|
||||
|
||||
```json
|
||||
{
|
||||
"created_at": "...",
|
||||
"client": "example.pl",
|
||||
"task": "task_id",
|
||||
"changes": []
|
||||
}
|
||||
```
|
||||
|
||||
Plan Markdown powinien zawierac:
|
||||
|
||||
- krotkie podsumowanie,
|
||||
- tabele po kampaniach, jesli zadanie dotyczy kampanii,
|
||||
- tabele planowanych dzialan,
|
||||
- ostrzezenia lub pominiete reguly.
|
||||
|
||||
## Podpiecie zadania do CLI
|
||||
|
||||
Edytuj:
|
||||
|
||||
```text
|
||||
src/gads_v2/cli.py
|
||||
```
|
||||
|
||||
1. Zaimportuj funkcje zadania:
|
||||
|
||||
```python
|
||||
from .tasks.search_settings_check import run_check_search_settings
|
||||
```
|
||||
|
||||
2. Dodaj `id` do argumentu `--task`:
|
||||
|
||||
```python
|
||||
parser.add_argument("--task", choices=["sync_pla_cl1", "check_pla_settings", "check_search_settings"], ...)
|
||||
```
|
||||
|
||||
3. Dodaj obsluge w `run_task()`:
|
||||
|
||||
```python
|
||||
if task_id == "check_search_settings":
|
||||
run_check_search_settings(
|
||||
client,
|
||||
global_rules,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
```
|
||||
|
||||
## Reguly globalne i wyjatki klientow
|
||||
|
||||
Reguly trzymaj w:
|
||||
|
||||
```text
|
||||
config/clients.toml
|
||||
```
|
||||
|
||||
Przyklad globalny:
|
||||
|
||||
```toml
|
||||
[global_rules.search_settings]
|
||||
require_presence_only = true
|
||||
require_search_partners_off = true
|
||||
```
|
||||
|
||||
Wyjatek per klient:
|
||||
|
||||
```toml
|
||||
[clients."example.pl".search_settings]
|
||||
require_search_partners_off = false
|
||||
```
|
||||
|
||||
W kodzie uzywaj:
|
||||
|
||||
```python
|
||||
rules = client_config.effective_rules(global_rules, "search_settings")
|
||||
```
|
||||
|
||||
## Numeracja i wybory
|
||||
|
||||
Lista zadan uzywa formatu:
|
||||
|
||||
```text
|
||||
1.1 - pierwsze zadanie w pierwszej grupie
|
||||
1.2 - drugie zadanie w pierwszej grupie
|
||||
1.0 - wszystkie zadania z pierwszej grupy
|
||||
ALL - wszystkie zadania ze wszystkich grup
|
||||
```
|
||||
|
||||
Nie dodawaj recznej numeracji do `tasks.toml`. Numeracja wynika z kolejnosci grup i zadan.
|
||||
|
||||
## Test po dodaniu zadania
|
||||
|
||||
Uruchom:
|
||||
|
||||
```powershell
|
||||
python -m compileall -q gads.py src
|
||||
python gads.py analiza-klienta --client-number 1
|
||||
python gads.py analiza-klienta --client-number 1 --select <nr> --plan-only
|
||||
```
|
||||
|
||||
Jesli zadanie wdraza zmiany, przetestuj najpierw tylko `--plan-only`.
|
||||
|
||||
## Format komunikacji agentow
|
||||
|
||||
Instrukcja dla agentow jest w:
|
||||
|
||||
```text
|
||||
AGENTS.md
|
||||
```
|
||||
|
||||
Po dodaniu nowego typu zadania dopisz tam tylko specjalne zasady, jesli agent ma wiedziec cos ponad standardowy przeplyw.
|
||||
67
.sync/Archive/README.md
Normal file
67
.sync/Archive/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Google Ads ver 2
|
||||
|
||||
Terminalowe narzedzie do pracy na kontach Google Ads klientow.
|
||||
|
||||
Instrukcja pracy dla Claude Code, Codex, Gemini CLI i innych agentow AI jest w `AGENTS.md`.
|
||||
Instrukcja rozbudowy o nowe grupy i zadania jest w `DEVELOPMENT.md`.
|
||||
Backlog rzeczy sprawdzanych w starej wersji jest w `OLD_COMMANDS_CHECKLIST.md`.
|
||||
|
||||
## Start
|
||||
|
||||
1. Uzupelnij `.env` na podstawie `.env.example`.
|
||||
2. Uzupelnij `config/clients.toml` na podstawie `config/clients.example.toml`.
|
||||
3. Zainstaluj zaleznosci:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Uruchom menu:
|
||||
|
||||
```powershell
|
||||
python gads.py
|
||||
```
|
||||
|
||||
Albo uruchom konkretne zadanie bez menu:
|
||||
|
||||
```powershell
|
||||
python gads.py --client laitica.pl --task sync_pla_cl1
|
||||
```
|
||||
|
||||
Tryb dla Claude Code, Codex albo Gemini CLI:
|
||||
|
||||
```powershell
|
||||
python gads.py analiza-klienta
|
||||
python gads.py analiza-klienta --client-number 5
|
||||
python gads.py analiza-klienta --client-number 5 --task-number 1 --plan-only
|
||||
```
|
||||
|
||||
Po tej komendzie narzedzie zapisze plan w `clients/<domena>/plans/`.
|
||||
Agent czyta plik `.md` albo `.json`, analizuje go i pyta Cie o zgode.
|
||||
Po Twojej akceptacji agent uruchamia wdrozenie konkretnego planu:
|
||||
|
||||
```powershell
|
||||
python gads.py --client laitica.pl --task sync_pla_cl1 --apply-plan clients/laitica.pl/plans/PLAN.json --confirm-apply TAK
|
||||
```
|
||||
|
||||
## MVP
|
||||
|
||||
Pierwsze zadanie:
|
||||
|
||||
- pobiera kampanie `[PLA_CL1]` z Google Ads,
|
||||
- wyciaga segmenty CL1 z nazw kampanii,
|
||||
- pobiera produkty z adsPRO,
|
||||
- przygotowuje plan synchronizacji grup reklam,
|
||||
- czeka na akceptacje przed wdrozeniem zmian,
|
||||
- zapisuje historie w katalogu klienta.
|
||||
|
||||
## Dane i historia
|
||||
|
||||
- `config/clients.toml` - lista klientow i identyfikatory kont.
|
||||
- `config/clients.toml` - takze reguly globalne i wyjatki per klient, np. ustawienia kampanii PLA.
|
||||
- `.env` - dane dostepowe do Google Ads i adsPRO.
|
||||
- `clients/<domena>/data/` - pobrane dane robocze.
|
||||
- `clients/<domena>/history/YYYY-MM-DD.jsonl` - historia do filtrowania po kliencie, dacie i kampanii.
|
||||
- `clients/<domena>/changes/YYYY-MM-DD.md` - czytelny dziennik zmian.
|
||||
|
||||
Narzedzie nie uzywa API modeli AI. Claude Code, Codex albo Gemini CLI moga uruchamiac te same komendy terminalowe.
|
||||
20
.sync/Archive/config/clients.example.toml
Normal file
20
.sync/Archive/config/clients.example.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[clients."example.pl"]
|
||||
google_ads_customer_id = "123-456-7890"
|
||||
adspro_client_id = "1"
|
||||
|
||||
[global_rules]
|
||||
max_create_groups_without_extra_confirm = 100
|
||||
max_pause_groups_without_extra_confirm = 100
|
||||
|
||||
[global_rules.pla_settings]
|
||||
require_presence_only = true
|
||||
require_high_priority = true
|
||||
|
||||
[global_rules.product_feed_optimization]
|
||||
limit = 10
|
||||
min_days_between_title_changes = 30
|
||||
|
||||
# Wyjatek per klient:
|
||||
# [clients."example.pl".pla_settings]
|
||||
# require_high_priority = false
|
||||
# require_presence_only = true
|
||||
49
.sync/Archive/config/clients.toml
Normal file
49
.sync/Archive/config/clients.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[clients."pomysloweprezenty.pl"]
|
||||
google_ads_customer_id = "941-605-1782"
|
||||
adspro_client_id = "2"
|
||||
|
||||
[clients."innsi.pl"]
|
||||
google_ads_customer_id = "133-343-6346"
|
||||
adspro_client_id = "5"
|
||||
|
||||
[clients."van-dam.pl"]
|
||||
google_ads_customer_id = "570-658-4790"
|
||||
|
||||
[clients."sklep.ele-comp.pl"]
|
||||
google_ads_customer_id = "489-092-9476"
|
||||
adspro_client_id = "7"
|
||||
|
||||
[clients."investagd.pl"]
|
||||
google_ads_customer_id = "229-855-5588"
|
||||
adspro_client_id = "8"
|
||||
|
||||
[clients."wyprzedaze.pl"]
|
||||
google_ads_customer_id = "775-249-3197"
|
||||
adspro_client_id = "10"
|
||||
|
||||
[clients."laitica.pl"]
|
||||
google_ads_customer_id = "262-567-7205"
|
||||
adspro_client_id = "9"
|
||||
|
||||
[clients."studio-zoe.pl"]
|
||||
google_ads_customer_id = "387-166-1050"
|
||||
|
||||
[clients."aruba.rzeszow.pl"]
|
||||
google_ads_customer_id = "374-470-8609"
|
||||
adspro_client_id = "3"
|
||||
|
||||
[clients."ibra-makeup.pl"]
|
||||
google_ads_customer_id = "818-919-2566"
|
||||
adspro_client_id = "4"
|
||||
|
||||
[global_rules]
|
||||
max_create_groups_without_extra_confirm = 100
|
||||
max_pause_groups_without_extra_confirm = 100
|
||||
|
||||
[global_rules.pla_settings]
|
||||
require_presence_only = true
|
||||
require_high_priority = true
|
||||
|
||||
[global_rules.product_feed_optimization]
|
||||
limit = 10
|
||||
min_days_between_title_changes = 30
|
||||
32
.sync/Archive/config/tasks.toml
Normal file
32
.sync/Archive/config/tasks.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[[groups]]
|
||||
id = "pla"
|
||||
name = "Kampanie PLA"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "sync_pla_cl1"
|
||||
name = "Synchronizacja kampanii PLA_CL1"
|
||||
description = "Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "check_pla_settings"
|
||||
name = "Sprawdzenie ustawien"
|
||||
description = "Sprawdza ustawienia lokalizacji i priorytetu kampanii PLA wedlug regul globalnych i wyjatkow klienta."
|
||||
|
||||
[[groups]]
|
||||
id = "products"
|
||||
name = "Produkty"
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "optimize_product_titles"
|
||||
name = "Optymalizacja tytulow produktow"
|
||||
description = "Pobiera produkty z adsPRO i przygotowuje plan optymalizacji tytulow produktow."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "optimize_product_categories"
|
||||
name = "Optymalizacja kategorii Google"
|
||||
description = "Pobiera produkty z adsPRO bez kategorii Google i przygotowuje plan decyzji agenta AI."
|
||||
|
||||
[[groups.tasks]]
|
||||
id = "fill_product_unit_pricing"
|
||||
name = "Uzupelnienie unit pricing"
|
||||
description = "Pobiera produkty z adsPRO bez unit pricing i przygotowuje plan uzupelnienia miary oraz miary bazowej."
|
||||
3
.sync/Archive/requirements.txt
Normal file
3
.sync/Archive/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
google-ads>=25.0.0
|
||||
requests>=2.31.0
|
||||
|
||||
354
.sync/Archive/src/gads_v2/cli.py
Normal file
354
.sync/Archive/src/gads_v2/cli.py
Normal file
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from .config import load_config, load_env
|
||||
from .table import print_table
|
||||
from .task_catalog import (
|
||||
load_groups,
|
||||
load_tasks,
|
||||
print_task_list,
|
||||
task_by_number,
|
||||
task_by_selection,
|
||||
tasks_by_group_number,
|
||||
tasks_by_selection_group,
|
||||
)
|
||||
from .tasks.pla_settings_check import run_check_pla_settings
|
||||
from .tasks.pla_cl1_sync import run_sync_pla_cl1
|
||||
from .tasks.product_feed_optimization import (
|
||||
run_fill_product_unit_pricing,
|
||||
run_optimize_product_categories,
|
||||
run_optimize_product_feed,
|
||||
run_optimize_product_titles,
|
||||
)
|
||||
|
||||
|
||||
def choose_index(label: str, options: list[str]) -> int | None:
|
||||
print(f"\n{label}")
|
||||
for i, option in enumerate(options, 1):
|
||||
print(f"{i}. {option}")
|
||||
try:
|
||||
raw = input("Wybierz numer albo Enter aby wyjsc: ").strip()
|
||||
except EOFError:
|
||||
print()
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
idx = int(raw)
|
||||
except ValueError:
|
||||
print("Nieprawidlowy numer.")
|
||||
return None
|
||||
if idx < 1 or idx > len(options):
|
||||
print("Nieprawidlowy numer.")
|
||||
return None
|
||||
return idx - 1
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
parser = argparse.ArgumentParser(description="Google Ads ver 2")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
nargs="?",
|
||||
choices=["analiza-klienta"],
|
||||
help="Tryb prowadzony etapami dla agenta lub terminala",
|
||||
)
|
||||
parser.add_argument("--client", help="Domena klienta z config/clients.toml")
|
||||
parser.add_argument("--client-number", type=int, help="Numer klienta z listy analiza-klienta")
|
||||
parser.add_argument(
|
||||
"--task",
|
||||
choices=[
|
||||
"sync_pla_cl1",
|
||||
"check_pla_settings",
|
||||
"optimize_product_feed",
|
||||
"optimize_product_titles",
|
||||
"optimize_product_categories",
|
||||
"fill_product_unit_pricing",
|
||||
],
|
||||
help="Zadanie do uruchomienia bez menu",
|
||||
)
|
||||
parser.add_argument("--select", help="Wybór z listy zadan, np. 1.1, 1.0 albo ALL")
|
||||
parser.add_argument("--task-number", type=int, help="Numer zadania z listy analiza-klienta")
|
||||
parser.add_argument("--group-number", type=int, help="Uruchom wszystkie zadania z grupy o podanym numerze")
|
||||
parser.add_argument("--group-all-current", action="store_true", help="Uruchom wszystkie zadania z pierwszej widocznej grupy")
|
||||
parser.add_argument("--all-groups", action="store_true", help="Uruchom wszystkie zadania ze wszystkich grup")
|
||||
parser.add_argument("--plan-only", action="store_true", help="Tylko przygotuj plan i zapisz go do pliku")
|
||||
parser.add_argument("--apply-plan", help="Wdroz zapisany plan JSON")
|
||||
parser.add_argument(
|
||||
"--confirm-apply",
|
||||
help="Wymagane przy --apply-plan. Uzyj dokladnie: TAK",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
load_env()
|
||||
try:
|
||||
cfg = load_config()
|
||||
except Exception as exc:
|
||||
print(exc)
|
||||
sys.exit(1)
|
||||
|
||||
domains = sorted(cfg.clients)
|
||||
if not domains:
|
||||
print("Brak klientow w config/clients.toml.")
|
||||
return
|
||||
|
||||
tasks = load_tasks()
|
||||
groups = load_groups()
|
||||
selected_domain = args.client
|
||||
if args.client_number:
|
||||
if args.client_number < 1 or args.client_number > len(domains):
|
||||
print(f"Nie ma klienta numer {args.client_number}.")
|
||||
return
|
||||
selected_domain = domains[args.client_number - 1]
|
||||
|
||||
if args.command == "analiza-klienta":
|
||||
if not selected_domain:
|
||||
print("\nWybierz klienta:")
|
||||
print_table(["Nr", "Domena"], [[str(i), domain] for i, domain in enumerate(domains, 1)])
|
||||
print("\nNastepny krok:")
|
||||
print("python gads.py analiza-klienta --client-number <numer>")
|
||||
return
|
||||
|
||||
if selected_domain not in cfg.clients:
|
||||
print(f"Nie znaleziono klienta {selected_domain} w config/clients.toml.")
|
||||
return
|
||||
|
||||
if not args.select and not args.task_number and not args.group_number and not args.group_all_current and not args.all_groups:
|
||||
print(f"\nKlient: {selected_domain}")
|
||||
print_task_list(tasks)
|
||||
print("\nNastepny krok:")
|
||||
print(
|
||||
"python gads.py analiza-klienta "
|
||||
f"--client-number {domains.index(selected_domain) + 1} "
|
||||
"--select <nr> --plan-only"
|
||||
)
|
||||
return
|
||||
|
||||
if args.select:
|
||||
selected = args.select.strip()
|
||||
if selected.upper() == "ALL":
|
||||
run_task_sequence(
|
||||
tasks,
|
||||
cfg.clients[selected_domain],
|
||||
cfg.global_rules,
|
||||
plan_only=args.plan_only,
|
||||
)
|
||||
return
|
||||
selected_group_tasks = tasks_by_selection_group(tasks, groups, selected)
|
||||
if selected_group_tasks:
|
||||
run_task_sequence(
|
||||
selected_group_tasks,
|
||||
cfg.clients[selected_domain],
|
||||
cfg.global_rules,
|
||||
plan_only=args.plan_only,
|
||||
)
|
||||
return
|
||||
selected_task = task_by_selection(tasks, selected)
|
||||
if not selected_task:
|
||||
print(f"Nie ma wyboru {selected}.")
|
||||
return
|
||||
run_task(
|
||||
selected_task.id,
|
||||
cfg.clients[selected_domain],
|
||||
cfg.global_rules,
|
||||
plan_only=args.plan_only,
|
||||
apply_plan_path=args.apply_plan,
|
||||
confirm_apply=args.confirm_apply,
|
||||
)
|
||||
return
|
||||
|
||||
if args.group_all_current:
|
||||
first_group_number = groups[0].number if groups else None
|
||||
selected_tasks = tasks_by_group_number(tasks, groups, first_group_number) if first_group_number else []
|
||||
if not selected_tasks:
|
||||
print("Brak zadan w pierwszej grupie.")
|
||||
return
|
||||
run_task_sequence(
|
||||
selected_tasks,
|
||||
cfg.clients[selected_domain],
|
||||
cfg.global_rules,
|
||||
plan_only=args.plan_only,
|
||||
)
|
||||
return
|
||||
|
||||
if args.group_number:
|
||||
selected_tasks = tasks_by_group_number(tasks, groups, args.group_number)
|
||||
if not selected_tasks:
|
||||
print(f"Nie ma grupy numer {args.group_number}.")
|
||||
return
|
||||
run_task_sequence(
|
||||
selected_tasks,
|
||||
cfg.clients[selected_domain],
|
||||
cfg.global_rules,
|
||||
plan_only=args.plan_only,
|
||||
)
|
||||
return
|
||||
|
||||
if args.all_groups:
|
||||
run_task_sequence(
|
||||
tasks,
|
||||
cfg.clients[selected_domain],
|
||||
cfg.global_rules,
|
||||
plan_only=args.plan_only,
|
||||
)
|
||||
return
|
||||
|
||||
selected_task = task_by_number(tasks, args.task_number)
|
||||
if not selected_task:
|
||||
print(f"Nie ma zadania numer {args.task_number}.")
|
||||
return
|
||||
run_task(
|
||||
selected_task.id,
|
||||
cfg.clients[selected_domain],
|
||||
cfg.global_rules,
|
||||
plan_only=args.plan_only,
|
||||
apply_plan_path=args.apply_plan,
|
||||
confirm_apply=args.confirm_apply,
|
||||
)
|
||||
return
|
||||
|
||||
if args.client or args.task:
|
||||
if not args.client or not args.task:
|
||||
print("Dla trybu bez menu podaj jednoczesnie --client i --task.")
|
||||
return
|
||||
if args.client not in cfg.clients:
|
||||
print(f"Nie znaleziono klienta {args.client} w config/clients.toml.")
|
||||
return
|
||||
run_task(
|
||||
args.task,
|
||||
cfg.clients[args.client],
|
||||
cfg.global_rules,
|
||||
plan_only=args.plan_only,
|
||||
apply_plan_path=args.apply_plan,
|
||||
confirm_apply=args.confirm_apply,
|
||||
)
|
||||
return
|
||||
|
||||
client_idx = choose_index("Klient", domains)
|
||||
if client_idx is None:
|
||||
return
|
||||
client = cfg.clients[domains[client_idx]]
|
||||
|
||||
task_labels = [task.name for task in tasks]
|
||||
task_idx = choose_index("Zadanie", task_labels)
|
||||
if task_idx is None:
|
||||
return
|
||||
|
||||
run_task(tasks[task_idx].id, client, cfg.global_rules)
|
||||
|
||||
|
||||
def run_task(
|
||||
task_id,
|
||||
client,
|
||||
global_rules,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
if task_id == "sync_pla_cl1":
|
||||
run_sync_pla_cl1(
|
||||
client,
|
||||
global_rules,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
if task_id == "check_pla_settings":
|
||||
run_check_pla_settings(
|
||||
client,
|
||||
global_rules,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
if task_id == "optimize_product_feed":
|
||||
run_optimize_product_feed(
|
||||
client,
|
||||
global_rules,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
if task_id == "optimize_product_titles":
|
||||
run_optimize_product_titles(
|
||||
client,
|
||||
global_rules,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
if task_id == "optimize_product_categories":
|
||||
run_optimize_product_categories(
|
||||
client,
|
||||
global_rules,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
if task_id == "fill_product_unit_pricing":
|
||||
run_fill_product_unit_pricing(
|
||||
client,
|
||||
global_rules,
|
||||
plan_only=plan_only,
|
||||
apply_plan_path=apply_plan_path,
|
||||
confirm_apply=confirm_apply,
|
||||
show_navigation=show_navigation,
|
||||
)
|
||||
return
|
||||
print(f"Zadanie {task_id} nie ma jeszcze implementacji.")
|
||||
|
||||
|
||||
def run_task_sequence(tasks, client, global_rules, plan_only: bool = False) -> None:
|
||||
total = len(tasks)
|
||||
if plan_only:
|
||||
print("Tryb zbiorczy plan-only przygotuje plany po kolei.")
|
||||
print("Agent powinien analizowac i wdrazac kazdy plan osobno, przed przejsciem do kolejnego zadania.")
|
||||
for index, task in enumerate(tasks, 1):
|
||||
print()
|
||||
print("#" * 72)
|
||||
print(f"Zadanie {index}/{total}: {task.group_name} / {task.name}")
|
||||
print("#" * 72)
|
||||
run_task(task.id, client, global_rules, plan_only=plan_only, show_navigation=False)
|
||||
print()
|
||||
print("Zakonczono sekwencje zadan.")
|
||||
print_sequence_navigation(client.domain)
|
||||
|
||||
|
||||
def print_sequence_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def print_next_navigation(client_number: int | None = None) -> None:
|
||||
print("\nCo dalej:")
|
||||
if client_number:
|
||||
print(f"1. Lista zadan klienta numer {client_number}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client-number {client_number}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
else:
|
||||
print("1. Lista klientow")
|
||||
print("2. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print("1 -> python gads.py analiza-klienta")
|
||||
912
.sync/Archive/src/gads_v2/tasks/pla_cl1_sync.py
Normal file
912
.sync/Archive/src/gads_v2/tasks/pla_cl1_sync.py
Normal file
@@ -0,0 +1,912 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from google.protobuf import field_mask_pb2
|
||||
|
||||
from ..config import ClientConfig, client_dir
|
||||
from ..google_ads import get_google_ads_client, run_query
|
||||
from ..history import append_change_markdown, append_history, now_local
|
||||
|
||||
|
||||
CSV_COLS = [
|
||||
"id", "offer_id", "title", "availability", "channel", "content_language",
|
||||
"target_country", "feed_label", "brand", "google_product_category",
|
||||
"custom_label_0", "custom_label_1", "custom_label_2", "custom_label_3",
|
||||
"custom_label_4", "link",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncPlan:
|
||||
campaigns: list[dict]
|
||||
groups_total: int
|
||||
groups_with_product_id: int
|
||||
create_plan: list[dict]
|
||||
enable_plan: list[dict]
|
||||
pause_plan: list[dict]
|
||||
rename_plan: list[dict]
|
||||
warnings: list[str]
|
||||
unmatched_groups: list[dict] | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
def serialize_rows(rows: list[dict]) -> list[dict]:
|
||||
serialized = []
|
||||
for item in rows:
|
||||
row = {}
|
||||
for key, value in item.items():
|
||||
if isinstance(value, set):
|
||||
row[key] = sorted(value)
|
||||
else:
|
||||
row[key] = value
|
||||
serialized.append(row)
|
||||
return serialized
|
||||
|
||||
return {
|
||||
"task": "sync_pla_cl1",
|
||||
"campaigns": serialize_rows(self.campaigns),
|
||||
"groups_total": self.groups_total,
|
||||
"groups_with_product_id": self.groups_with_product_id,
|
||||
"create_plan": serialize_rows(self.create_plan),
|
||||
"enable_plan": serialize_rows(self.enable_plan),
|
||||
"pause_plan": serialize_rows(self.pause_plan),
|
||||
"rename_plan": serialize_rows(self.rename_plan),
|
||||
"warnings": self.warnings,
|
||||
"unmatched_groups": serialize_rows(self.unmatched_groups or []),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SyncPlan":
|
||||
return cls(
|
||||
campaigns=data.get("campaigns", []),
|
||||
groups_total=int(data.get("groups_total", 0)),
|
||||
groups_with_product_id=int(data.get("groups_with_product_id", 0)),
|
||||
create_plan=data.get("create_plan", []),
|
||||
enable_plan=data.get("enable_plan", []),
|
||||
pause_plan=data.get("pause_plan", []),
|
||||
rename_plan=data.get("rename_plan", []),
|
||||
warnings=data.get("warnings", []),
|
||||
unmatched_groups=data.get("unmatched_groups", []),
|
||||
)
|
||||
|
||||
|
||||
def campaign_action_summary(plan: SyncPlan) -> list[dict]:
|
||||
campaign_names = set()
|
||||
for action_name in ("create_plan", "enable_plan", "pause_plan", "rename_plan"):
|
||||
for row in getattr(plan, action_name):
|
||||
if row.get("campaign_name"):
|
||||
campaign_names.add(row["campaign_name"])
|
||||
|
||||
create_counts = Counter(row["campaign_name"] for row in plan.create_plan)
|
||||
enable_counts = Counter(row["campaign_name"] for row in plan.enable_plan)
|
||||
pause_counts = Counter(row["campaign_name"] for row in plan.pause_plan)
|
||||
rename_counts = Counter(row["campaign_name"] for row in plan.rename_plan)
|
||||
|
||||
return [
|
||||
{
|
||||
"campaign_name": name,
|
||||
"create": create_counts.get(name, 0),
|
||||
"enable": enable_counts.get(name, 0),
|
||||
"pause": pause_counts.get(name, 0),
|
||||
"rename": rename_counts.get(name, 0),
|
||||
}
|
||||
for name in sorted(campaign_names)
|
||||
]
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
return " ".join(
|
||||
(value or "")
|
||||
.lower()
|
||||
.replace("–", "-")
|
||||
.replace("—", "-")
|
||||
.replace("|", "-")
|
||||
.replace("„", "")
|
||||
.replace("”", "")
|
||||
.replace('"', "")
|
||||
.split()
|
||||
)
|
||||
|
||||
|
||||
def parse_allowed_labels(campaign_name: str) -> set[str]:
|
||||
match = re.search(r"\]\s*(.+)$", campaign_name)
|
||||
raw = match.group(1).strip() if match else campaign_name
|
||||
if "|" in raw:
|
||||
raw = raw.split("|", 1)[0].strip()
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
|
||||
|
||||
def fetch_adspro_products(client: ClientConfig, segments: list[str]) -> list[dict]:
|
||||
api_url = os.environ.get("ADSPRO_API_URL")
|
||||
api_key = os.environ.get("ADSPRO_API_KEY")
|
||||
if not api_url or not api_key:
|
||||
raise RuntimeError("Brak ADSPRO_API_URL lub ADSPRO_API_KEY w .env.")
|
||||
if not client.adspro_client_id:
|
||||
raise RuntimeError(f"Brak adspro_client_id dla {client.domain} w config/clients.toml.")
|
||||
|
||||
by_offer_id = {}
|
||||
for segment in segments:
|
||||
response = requests.post(
|
||||
api_url,
|
||||
data={
|
||||
"action": "products_get_by_cl1",
|
||||
"api_key": api_key,
|
||||
"client_id": client.adspro_client_id,
|
||||
"custom_label_1": segment,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
data = response.json()
|
||||
if data.get("result") == "error":
|
||||
raise RuntimeError(f"adsPRO zwrocil blad dla CL1={segment}: {data.get('message')}")
|
||||
for product in data.get("products", []):
|
||||
offer_id = product.get("offer_id") or ""
|
||||
if offer_id:
|
||||
by_offer_id[offer_id] = {
|
||||
"id": "",
|
||||
"offer_id": offer_id,
|
||||
"title": product.get("title", "") or "",
|
||||
"availability": "",
|
||||
"channel": "",
|
||||
"content_language": "",
|
||||
"target_country": "",
|
||||
"feed_label": "",
|
||||
"brand": "",
|
||||
"google_product_category": product.get("google_product_category", "") or "",
|
||||
"custom_label_0": "",
|
||||
"custom_label_1": product.get("custom_label_1", "") or "",
|
||||
"custom_label_2": "",
|
||||
"custom_label_3": product.get("custom_label_3", "") or "",
|
||||
"custom_label_4": product.get("custom_label_4", "") or "",
|
||||
"link": "",
|
||||
}
|
||||
return list(by_offer_id.values())
|
||||
|
||||
|
||||
def save_products_csv(domain: str, products: list[dict]) -> Path:
|
||||
out = client_dir(domain) / "data"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
path = out / "merchant_produkty_adspro.csv"
|
||||
with path.open("w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=CSV_COLS)
|
||||
writer.writeheader()
|
||||
writer.writerows(products)
|
||||
return path
|
||||
|
||||
|
||||
def save_plan_files(domain: str, plan: SyncPlan, products_count: int) -> tuple[Path, Path]:
|
||||
ts = now_local()
|
||||
base = client_dir(domain) / "plans"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{ts.strftime('%Y-%m-%d_%H-%M-%S')}_sync_pla_cl1"
|
||||
json_path = base / f"{stem}.json"
|
||||
md_path = base / f"{stem}.md"
|
||||
|
||||
payload = {
|
||||
"created_at": ts.isoformat(timespec="seconds"),
|
||||
"client": domain,
|
||||
"products_count": products_count,
|
||||
**plan.to_dict(),
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
lines = [
|
||||
"# Plan: Synchronizacja kampanii PLA_CL1",
|
||||
"",
|
||||
f"Klient: {domain}",
|
||||
f"Utworzono: {ts.isoformat(timespec='seconds')}",
|
||||
"",
|
||||
"## Podsumowanie",
|
||||
"",
|
||||
f"- Kampanie PLA_CL1: {len(plan.campaigns)}",
|
||||
f"- Produkty z adsPRO: {products_count}",
|
||||
f"- Grupy reklam obecnie: {plan.groups_total}",
|
||||
f"- Grupy reklam z identyfikatorem produktu: {plan.groups_with_product_id}",
|
||||
f"- Do utworzenia: {len(plan.create_plan)}",
|
||||
f"- Do włączenia: {len(plan.enable_plan)}",
|
||||
f"- Do wstrzymania: {len(plan.pause_plan)}",
|
||||
f"- Do zmiany nazwy: {len(plan.rename_plan)}",
|
||||
"",
|
||||
]
|
||||
if plan.warnings:
|
||||
lines.extend(["## Uwagi", ""])
|
||||
lines.extend(f"- {warning}" for warning in plan.warnings)
|
||||
lines.append("")
|
||||
summary = campaign_action_summary(plan)
|
||||
if summary:
|
||||
lines.extend(["## Podsumowanie po kampaniach", "", "| Kampania | Utworz | Wlacz | Wstrzymaj | Zmien nazwe |", "| --- | ---: | ---: | ---: | ---: |"])
|
||||
for row in summary:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['create']} | {row['enable']} | {row['pause']} | {row['rename']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.unmatched_groups:
|
||||
lines.extend(["## Grupy reklam bez dopasowania w adsPRO", "", "| Kampania | Grupa reklam | Status | Identyfikator produktu |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.unmatched_groups:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['ad_group_status']} | {row.get('offer_id', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.create_plan:
|
||||
lines.extend(["## Grupy reklam do utworzenia", "", "| Kampania | Grupa reklam | Produkt | Powod |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.create_plan:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']} | {row['reason']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.enable_plan:
|
||||
lines.extend(["## Grupy reklam do wlaczenia", "", "| Kampania | Grupa reklam | Produkt | Powod |", "| --- | --- | --- | --- |"])
|
||||
for row in plan.enable_plan:
|
||||
lines.append(
|
||||
f"| {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']} | {row['reason']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if plan.pause_plan:
|
||||
lines.extend(["## Grupy reklam do wstrzymania", "", "| Kampania | Grupa reklam | Powod |", "| --- | --- | --- |"])
|
||||
for row in plan.pause_plan:
|
||||
lines.append(f"| {row['campaign_name']} | {row['ad_group_name']} | {row['reason']} |")
|
||||
lines.append("")
|
||||
if plan.rename_plan:
|
||||
lines.extend(["## Nazwy grup reklam do zmiany", "", "| Kampania | Obecna nazwa | Nowa nazwa |", "| --- | --- | --- |"])
|
||||
for row in plan.rename_plan:
|
||||
lines.append(f"| {row['campaign_name']} | {row['old_name']} | {row['new_name']} |")
|
||||
lines.append("")
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return json_path, md_path
|
||||
|
||||
|
||||
def build_plan(client, customer_id: str, products: list[dict]) -> SyncPlan:
|
||||
campaign_rows = run_query(
|
||||
client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT campaign.id, campaign.name, campaign.status
|
||||
FROM campaign
|
||||
WHERE campaign.name LIKE '%PLA_CL1%'
|
||||
AND campaign.status = 'ENABLED'
|
||||
""",
|
||||
)
|
||||
campaigns = [
|
||||
{
|
||||
"id": str(row.campaign.id),
|
||||
"name": row.campaign.name,
|
||||
"status": row.campaign.status.name,
|
||||
"allowed": parse_allowed_labels(row.campaign.name),
|
||||
}
|
||||
for row in campaign_rows
|
||||
]
|
||||
if not campaigns:
|
||||
return SyncPlan([], 0, 0, [], [], [], [], ["Nie znaleziono kampanii [PLA_CL1]."], [])
|
||||
|
||||
label_to_campaign = {}
|
||||
for campaign in campaigns:
|
||||
for label in campaign["allowed"]:
|
||||
label_to_campaign[label] = campaign
|
||||
|
||||
by_offer_id = {}
|
||||
by_title_norm = defaultdict(list)
|
||||
by_label = defaultdict(list)
|
||||
for product in products:
|
||||
offer_id = (product.get("offer_id") or "").strip()
|
||||
title = (product.get("title") or "").strip()
|
||||
label = (product.get("custom_label_1") or "").strip()
|
||||
if offer_id:
|
||||
by_offer_id[offer_id] = product
|
||||
if title:
|
||||
by_title_norm[normalize_text(title)].append(product)
|
||||
if label and title:
|
||||
by_label[label].append(product)
|
||||
|
||||
campaign_ids = ", ".join(c["id"] for c in campaigns)
|
||||
group_rows = run_query(
|
||||
client,
|
||||
customer_id,
|
||||
f"""
|
||||
SELECT ad_group.id, ad_group.name, ad_group.status, campaign.id, campaign.name
|
||||
FROM ad_group
|
||||
WHERE campaign.id IN ({campaign_ids})
|
||||
AND ad_group.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
criterion_rows = run_query(
|
||||
client,
|
||||
customer_id,
|
||||
f"""
|
||||
SELECT ad_group.id,
|
||||
ad_group_criterion.listing_group.case_value.product_item_id.value,
|
||||
ad_group_criterion.listing_group.type,
|
||||
ad_group_criterion.negative
|
||||
FROM ad_group_criterion
|
||||
WHERE campaign.id IN ({campaign_ids})
|
||||
AND ad_group_criterion.type = 'LISTING_GROUP'
|
||||
AND ad_group_criterion.status != 'REMOVED'
|
||||
""",
|
||||
)
|
||||
|
||||
group_to_offer = {}
|
||||
for row in criterion_rows:
|
||||
if row.ad_group_criterion.negative:
|
||||
continue
|
||||
if row.ad_group_criterion.listing_group.type.name != "UNIT":
|
||||
continue
|
||||
offer_id = row.ad_group_criterion.listing_group.case_value.product_item_id.value
|
||||
if offer_id:
|
||||
group_to_offer.setdefault(str(row.ad_group.id), offer_id)
|
||||
|
||||
enabled_offers_by_campaign = defaultdict(set)
|
||||
existing_groups_by_campaign_offer = defaultdict(list)
|
||||
existing_groups_by_campaign_name = defaultdict(list)
|
||||
all_groups = []
|
||||
for row in group_rows:
|
||||
group_id = str(row.ad_group.id)
|
||||
record = {
|
||||
"ad_group_id": group_id,
|
||||
"ad_group_name": row.ad_group.name,
|
||||
"ad_group_status": row.ad_group.status.name,
|
||||
"campaign_id": str(row.campaign.id),
|
||||
"campaign_name": row.campaign.name,
|
||||
"allowed": parse_allowed_labels(row.campaign.name),
|
||||
"offer_id": group_to_offer.get(group_id, ""),
|
||||
}
|
||||
all_groups.append(record)
|
||||
existing_groups_by_campaign_name[(record["campaign_id"], normalize_text(record["ad_group_name"]))].append(record)
|
||||
if record["offer_id"]:
|
||||
existing_groups_by_campaign_offer[(record["campaign_id"], record["offer_id"])].append(record)
|
||||
if record["ad_group_status"] == "ENABLED":
|
||||
enabled_offers_by_campaign[record["campaign_id"]].add(record["offer_id"])
|
||||
|
||||
wrong_groups = []
|
||||
groups_without_match = []
|
||||
active_groups_without_match = []
|
||||
rename_plan = []
|
||||
for group in all_groups:
|
||||
product = by_offer_id.get(group["offer_id"]) if group["offer_id"] else None
|
||||
match_via = "offer_id" if product else None
|
||||
if not product:
|
||||
candidates = by_title_norm.get(normalize_text(group["ad_group_name"])) or []
|
||||
if candidates:
|
||||
product = candidates[0]
|
||||
match_via = "title"
|
||||
if not product:
|
||||
groups_without_match.append(group)
|
||||
if group["ad_group_status"] == "ENABLED":
|
||||
active_groups_without_match.append(group)
|
||||
continue
|
||||
label = (product.get("custom_label_1") or "").strip()
|
||||
if not label:
|
||||
if group["ad_group_status"] == "ENABLED":
|
||||
active_groups_without_match.append(group)
|
||||
continue
|
||||
if label not in group["allowed"]:
|
||||
wrong_groups.append((group, product))
|
||||
continue
|
||||
adspro_title = (product.get("title") or "").strip()
|
||||
if (
|
||||
group["ad_group_status"] == "ENABLED"
|
||||
and match_via == "offer_id"
|
||||
and adspro_title
|
||||
and group["ad_group_name"] != adspro_title
|
||||
):
|
||||
rename_plan.append(
|
||||
{
|
||||
"ad_group_id": group["ad_group_id"],
|
||||
"campaign_id": group["campaign_id"],
|
||||
"campaign_name": group["campaign_name"],
|
||||
"old_name": group["ad_group_name"],
|
||||
"new_name": adspro_title,
|
||||
}
|
||||
)
|
||||
|
||||
create_plan = []
|
||||
enable_by_id = {}
|
||||
pause_by_id = {}
|
||||
|
||||
def plan_enable_or_create(campaign: dict, product: dict, fallback_name: str, reason: str) -> None:
|
||||
offer_id = (product.get("offer_id") or "").strip()
|
||||
title = (product.get("title") or "").strip() or fallback_name
|
||||
if not offer_id or not title:
|
||||
return
|
||||
if offer_id in enabled_offers_by_campaign[campaign["id"]]:
|
||||
return
|
||||
|
||||
existing_candidates = existing_groups_by_campaign_offer.get((campaign["id"], offer_id), [])
|
||||
if not existing_candidates:
|
||||
existing_candidates = existing_groups_by_campaign_name.get((campaign["id"], normalize_text(title)), [])
|
||||
|
||||
paused_candidate = next((group for group in existing_candidates if group["ad_group_status"] == "PAUSED"), None)
|
||||
if paused_candidate:
|
||||
enable_by_id[paused_candidate["ad_group_id"]] = {
|
||||
"ad_group_id": paused_candidate["ad_group_id"],
|
||||
"ad_group_name": paused_candidate["ad_group_name"],
|
||||
"campaign_id": paused_candidate["campaign_id"],
|
||||
"campaign_name": paused_candidate["campaign_name"],
|
||||
"product_id": offer_id,
|
||||
"reason": reason,
|
||||
}
|
||||
enabled_offers_by_campaign[campaign["id"]].add(offer_id)
|
||||
return
|
||||
|
||||
existing_active = next(
|
||||
(group for group in existing_candidates if group["ad_group_status"] == "ENABLED"),
|
||||
None,
|
||||
)
|
||||
if existing_active:
|
||||
enabled_offers_by_campaign[campaign["id"]].add(offer_id)
|
||||
return
|
||||
|
||||
create_plan.append(
|
||||
{
|
||||
"campaign_id": campaign["id"],
|
||||
"campaign_name": campaign["name"],
|
||||
"ad_group_name": title,
|
||||
"product_id": offer_id,
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
enabled_offers_by_campaign[campaign["id"]].add(offer_id)
|
||||
|
||||
for group, product in wrong_groups:
|
||||
offer_id = (product.get("offer_id") or "").strip()
|
||||
label = (product.get("custom_label_1") or "").strip()
|
||||
target = label_to_campaign.get(label)
|
||||
if target and offer_id:
|
||||
plan_enable_or_create(target, product, group["ad_group_name"], "produkt jest w zlej kampanii")
|
||||
if group["ad_group_status"] == "ENABLED":
|
||||
pause_by_id[group["ad_group_id"]] = {
|
||||
"ad_group_id": group["ad_group_id"],
|
||||
"ad_group_name": group["ad_group_name"],
|
||||
"campaign_id": group["campaign_id"],
|
||||
"campaign_name": group["campaign_name"],
|
||||
"reason": "produkt jest w zlej kampanii",
|
||||
}
|
||||
|
||||
for campaign in campaigns:
|
||||
for label in campaign["allowed"]:
|
||||
for product in by_label.get(label, []):
|
||||
offer_id = (product.get("offer_id") or "").strip()
|
||||
title = (product.get("title") or "").strip()
|
||||
if not offer_id or not title:
|
||||
continue
|
||||
if offer_id in enabled_offers_by_campaign[campaign["id"]]:
|
||||
continue
|
||||
plan_enable_or_create(campaign, product, title, "brakuje aktywnej grupy reklam")
|
||||
|
||||
for group in active_groups_without_match:
|
||||
pause_by_id[group["ad_group_id"]] = {
|
||||
"ad_group_id": group["ad_group_id"],
|
||||
"ad_group_name": group["ad_group_name"],
|
||||
"campaign_id": group["campaign_id"],
|
||||
"campaign_name": group["campaign_name"],
|
||||
"reason": "brak dopasowania w adsPRO",
|
||||
}
|
||||
|
||||
grouped = defaultdict(list)
|
||||
for group in all_groups:
|
||||
if group["ad_group_status"] != "ENABLED" or group["ad_group_id"] in pause_by_id or not group["offer_id"]:
|
||||
continue
|
||||
grouped[(group["campaign_id"], group["offer_id"])].append(group)
|
||||
for group_list in grouped.values():
|
||||
if len(group_list) <= 1:
|
||||
continue
|
||||
for group in sorted(group_list, key=lambda item: int(item["ad_group_id"]))[:-1]:
|
||||
pause_by_id[group["ad_group_id"]] = {
|
||||
"ad_group_id": group["ad_group_id"],
|
||||
"ad_group_name": group["ad_group_name"],
|
||||
"campaign_id": group["campaign_id"],
|
||||
"campaign_name": group["campaign_name"],
|
||||
"reason": "duplikat produktu w kampanii",
|
||||
}
|
||||
|
||||
pause_plan = [pause_by_id[key] for key in sorted(pause_by_id, key=int)]
|
||||
enable_plan = [enable_by_id[key] for key in sorted(enable_by_id, key=int)]
|
||||
pause_ids = set(pause_by_id)
|
||||
rename_plan = [row for row in rename_plan if row["ad_group_id"] not in pause_ids]
|
||||
|
||||
warnings = []
|
||||
if groups_without_match:
|
||||
warnings.append(f"Grupy reklam bez dopasowania w adsPRO: {len(groups_without_match)}.")
|
||||
|
||||
return SyncPlan(
|
||||
campaigns=campaigns,
|
||||
groups_total=len(all_groups),
|
||||
groups_with_product_id=sum(1 for g in all_groups if g["offer_id"]),
|
||||
create_plan=create_plan,
|
||||
enable_plan=enable_plan,
|
||||
pause_plan=pause_plan,
|
||||
rename_plan=rename_plan,
|
||||
warnings=warnings,
|
||||
unmatched_groups=groups_without_match,
|
||||
)
|
||||
|
||||
|
||||
def create_ad_group_with_listing(client, customer_id: str, campaign_id: str, product_id: str, ad_group_name: str):
|
||||
service = client.get_service("GoogleAdsService")
|
||||
ad_group_service = client.get_service("AdGroupService")
|
||||
campaign_resource = ad_group_service.campaign_path(customer_id, campaign_id)
|
||||
|
||||
ad_group_temp = f"customers/{customer_id}/adGroups/-1"
|
||||
root_temp = f"customers/{customer_id}/adGroupCriteria/-1~-2"
|
||||
operations = []
|
||||
|
||||
group_op = client.get_type("MutateOperation")
|
||||
group = group_op.ad_group_operation.create
|
||||
group.resource_name = ad_group_temp
|
||||
group.name = ad_group_name
|
||||
group.campaign = campaign_resource
|
||||
group.status = client.enums.AdGroupStatusEnum.ENABLED
|
||||
group.type_ = client.enums.AdGroupTypeEnum.SHOPPING_PRODUCT_ADS
|
||||
operations.append(group_op)
|
||||
|
||||
root_op = client.get_type("MutateOperation")
|
||||
root = root_op.ad_group_criterion_operation.create
|
||||
root.resource_name = root_temp
|
||||
root.ad_group = ad_group_temp
|
||||
root.status = client.enums.AdGroupCriterionStatusEnum.ENABLED
|
||||
root.listing_group.type_ = client.enums.ListingGroupTypeEnum.SUBDIVISION
|
||||
operations.append(root_op)
|
||||
|
||||
product_op = client.get_type("MutateOperation")
|
||||
product = product_op.ad_group_criterion_operation.create
|
||||
product.ad_group = ad_group_temp
|
||||
product.status = client.enums.AdGroupCriterionStatusEnum.ENABLED
|
||||
product.listing_group.type_ = client.enums.ListingGroupTypeEnum.UNIT
|
||||
product.listing_group.parent_ad_group_criterion = root_temp
|
||||
product.listing_group.case_value.product_item_id.value = product_id
|
||||
product.cpc_bid_micros = 1_000_000
|
||||
operations.append(product_op)
|
||||
|
||||
other_op = client.get_type("MutateOperation")
|
||||
other = other_op.ad_group_criterion_operation.create
|
||||
other.ad_group = ad_group_temp
|
||||
other.negative = True
|
||||
other.status = client.enums.AdGroupCriterionStatusEnum.ENABLED
|
||||
other.listing_group.type_ = client.enums.ListingGroupTypeEnum.UNIT
|
||||
other.listing_group.parent_ad_group_criterion = root_temp
|
||||
client.copy_from(other.listing_group.case_value.product_item_id, client.get_type("ProductItemIdInfo"))
|
||||
operations.append(other_op)
|
||||
|
||||
ad_op = client.get_type("MutateOperation")
|
||||
ad = ad_op.ad_group_ad_operation.create
|
||||
ad.ad_group = ad_group_temp
|
||||
ad.status = client.enums.AdGroupAdStatusEnum.ENABLED
|
||||
ad.ad.shopping_product_ad._pb.SetInParent()
|
||||
operations.append(ad_op)
|
||||
|
||||
service.mutate(customer_id=customer_id, mutate_operations=operations)
|
||||
|
||||
|
||||
def pause_ad_groups(client, customer_id: str, ad_group_ids: list[str]) -> int:
|
||||
service = client.get_service("AdGroupService")
|
||||
changed = 0
|
||||
for index in range(0, len(ad_group_ids), 500):
|
||||
operations = []
|
||||
for ad_group_id in ad_group_ids[index:index + 500]:
|
||||
op = client.get_type("AdGroupOperation")
|
||||
group = op.update
|
||||
group.resource_name = service.ad_group_path(customer_id, ad_group_id)
|
||||
group.status = client.enums.AdGroupStatusEnum.PAUSED
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=["status"])
|
||||
operations.append(op)
|
||||
if operations:
|
||||
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
|
||||
changed += len(response.results)
|
||||
return changed
|
||||
|
||||
|
||||
def enable_ad_groups(client, customer_id: str, ad_group_ids: list[str]) -> int:
|
||||
if not ad_group_ids:
|
||||
return 0
|
||||
service = client.get_service("AdGroupService")
|
||||
changed = 0
|
||||
for index in range(0, len(ad_group_ids), 500):
|
||||
operations = []
|
||||
for ad_group_id in ad_group_ids[index:index + 500]:
|
||||
op = client.get_type("AdGroupOperation")
|
||||
group = op.update
|
||||
group.resource_name = service.ad_group_path(customer_id, ad_group_id)
|
||||
group.status = client.enums.AdGroupStatusEnum.ENABLED
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=["status"])
|
||||
operations.append(op)
|
||||
if operations:
|
||||
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
|
||||
changed += len(response.results)
|
||||
return changed
|
||||
|
||||
|
||||
def rename_ad_groups(client, customer_id: str, renames: list[dict]) -> int:
|
||||
service = client.get_service("AdGroupService")
|
||||
changed = 0
|
||||
for index in range(0, len(renames), 500):
|
||||
operations = []
|
||||
for row in renames[index:index + 500]:
|
||||
op = client.get_type("AdGroupOperation")
|
||||
group = op.update
|
||||
group.resource_name = service.ad_group_path(customer_id, row["ad_group_id"])
|
||||
group.name = row["new_name"]
|
||||
op.update_mask = field_mask_pb2.FieldMask(paths=["name"])
|
||||
operations.append(op)
|
||||
if operations:
|
||||
response = service.mutate_ad_groups(customer_id=customer_id, operations=operations)
|
||||
changed += len(response.results)
|
||||
return changed
|
||||
|
||||
|
||||
def print_plan(plan: SyncPlan) -> None:
|
||||
print("\nPlan synchronizacji PLA_CL1")
|
||||
print(f"Kampanie PLA_CL1: {len(plan.campaigns)}")
|
||||
print(f"Grupy reklam obecnie: {plan.groups_total}")
|
||||
print(f"Grupy reklam z identyfikatorem produktu: {plan.groups_with_product_id}")
|
||||
print(f"Do utworzenia: {len(plan.create_plan)}")
|
||||
print(f"Do włączenia: {len(plan.enable_plan)}")
|
||||
print(f"Do wstrzymania: {len(plan.pause_plan)}")
|
||||
print(f"Do zmiany nazwy: {len(plan.rename_plan)}")
|
||||
for warning in plan.warnings:
|
||||
print(f"Uwaga: {warning}")
|
||||
|
||||
summary = campaign_action_summary(plan)
|
||||
if summary:
|
||||
print("\nPodsumowanie po kampaniach:")
|
||||
for row in summary:
|
||||
print(
|
||||
f" {row['campaign_name']} | "
|
||||
f"utwórz={row['create']} | włącz={row['enable']} | "
|
||||
f"wstrzymaj={row['pause']} | zmień nazwę={row['rename']}"
|
||||
)
|
||||
|
||||
for row in plan.create_plan[:20]:
|
||||
print(f" Utworz: {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']}")
|
||||
if len(plan.create_plan) > 20:
|
||||
print(f" ... oraz {len(plan.create_plan) - 20} kolejnych grup reklam do utworzenia")
|
||||
for row in plan.enable_plan[:20]:
|
||||
print(f" Włącz: {row['campaign_name']} | {row['ad_group_name']} | {row['product_id']}")
|
||||
if len(plan.enable_plan) > 20:
|
||||
print(f" ... oraz {len(plan.enable_plan) - 20} kolejnych grup reklam do włączenia")
|
||||
for row in plan.pause_plan[:20]:
|
||||
print(f" Wstrzymaj: {row['campaign_name']} | {row['ad_group_name']} | {row['reason']}")
|
||||
if len(plan.pause_plan) > 20:
|
||||
print(f" ... oraz {len(plan.pause_plan) - 20} kolejnych grup reklam do wstrzymania")
|
||||
for row in plan.rename_plan[:20]:
|
||||
print(f" Zmien nazwe: {row['ad_group_id']} | {row['old_name'][:50]} -> {row['new_name'][:50]}")
|
||||
if len(plan.rename_plan) > 20:
|
||||
print(f" ... oraz {len(plan.rename_plan) - 20} kolejnych nazw do zmiany")
|
||||
|
||||
|
||||
def print_next_navigation(domain: str) -> None:
|
||||
print("\nCo dalej:")
|
||||
print(f"1. Lista zadan klienta {domain}")
|
||||
print("2. Lista klientow")
|
||||
print("3. Zakoncz")
|
||||
print("\nKomendy:")
|
||||
print(f"1 -> python gads.py analiza-klienta --client {domain}")
|
||||
print("2 -> python gads.py analiza-klienta")
|
||||
|
||||
|
||||
def apply_sync_plan(client_config: ClientConfig, plan: SyncPlan, show_navigation: bool = True) -> None:
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
|
||||
created = 0
|
||||
create_errors = 0
|
||||
for row in plan.create_plan:
|
||||
try:
|
||||
create_ad_group_with_listing(
|
||||
google_client,
|
||||
customer_id,
|
||||
row["campaign_id"],
|
||||
row["product_id"],
|
||||
row["ad_group_name"],
|
||||
)
|
||||
created += 1
|
||||
except Exception as exc:
|
||||
create_errors += 1
|
||||
print(f"Blad tworzenia grupy reklam {row['ad_group_name']}: {exc}")
|
||||
|
||||
pause_ids = [row["ad_group_id"] for row in plan.pause_plan]
|
||||
enable_ids = [row["ad_group_id"] for row in plan.enable_plan]
|
||||
enabled = enable_ad_groups(google_client, customer_id, enable_ids) if enable_ids else 0
|
||||
paused = pause_ad_groups(google_client, customer_id, pause_ids) if pause_ids else 0
|
||||
renamed = rename_ad_groups(google_client, customer_id, plan.rename_plan) if plan.rename_plan else 0
|
||||
|
||||
print("\nWynik wdrozenia zmian")
|
||||
print(f"Utworzono grup reklam: {created}")
|
||||
print(f"Włączono grup reklam: {enabled}")
|
||||
print(f"Bledy tworzenia: {create_errors}")
|
||||
print(f"Wstrzymano grup reklam: {paused}")
|
||||
print(f"Zmieniono nazwy grup reklam: {renamed}")
|
||||
|
||||
rows = []
|
||||
rows.extend(
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": row["campaign_name"],
|
||||
"czynnosc": "włączono grupę reklam",
|
||||
"grupa reklam": row["ad_group_name"],
|
||||
"produkt": row["product_id"],
|
||||
}
|
||||
for row in plan.enable_plan
|
||||
)
|
||||
rows.extend(
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": row["campaign_name"],
|
||||
"czynnosc": "utworzono grupe reklam",
|
||||
"grupa reklam": row["ad_group_name"],
|
||||
"produkt": row["product_id"],
|
||||
}
|
||||
for row in plan.create_plan
|
||||
)
|
||||
rows.extend(
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": row["campaign_name"],
|
||||
"czynnosc": "wstrzymano grupe reklam",
|
||||
"grupa reklam": row["ad_group_name"],
|
||||
"produkt": row["reason"],
|
||||
}
|
||||
for row in plan.pause_plan
|
||||
)
|
||||
rows.extend(
|
||||
{
|
||||
"klient": client_config.domain,
|
||||
"kampania": row["campaign_name"],
|
||||
"czynnosc": "zmieniono nazwe grupy reklam",
|
||||
"grupa reklam": row["old_name"],
|
||||
"produkt": row["new_name"],
|
||||
}
|
||||
for row in plan.rename_plan
|
||||
)
|
||||
changes_path = append_change_markdown(client_config.domain, "Synchronizacja kampanii PLA_CL1", rows)
|
||||
history_path = append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Synchronizacja kampanii PLA_CL1",
|
||||
"status": "wdrozono zmiany",
|
||||
"campaign": ", ".join(c["name"] for c in plan.campaigns[:10]),
|
||||
"summary": {
|
||||
"created": created,
|
||||
"enabled": enabled,
|
||||
"create_errors": create_errors,
|
||||
"paused": paused,
|
||||
"renamed": renamed,
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Historia JSONL: {history_path}")
|
||||
print(f"Historia Markdown: {changes_path}")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
|
||||
|
||||
def run_sync_pla_cl1(
|
||||
client_config: ClientConfig,
|
||||
global_rules: dict,
|
||||
plan_only: bool = False,
|
||||
apply_plan_path: str | None = None,
|
||||
confirm_apply: str | None = None,
|
||||
show_navigation: bool = True,
|
||||
) -> None:
|
||||
if apply_plan_path:
|
||||
if confirm_apply != "TAK":
|
||||
print("Do wdrozenia planu wymagane jest --confirm-apply TAK.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan_data = json.loads(Path(apply_plan_path).read_text(encoding="utf-8"))
|
||||
if plan_data.get("client") != client_config.domain:
|
||||
print(
|
||||
f"Plan jest dla klienta {plan_data.get('client')}, "
|
||||
f"a wybrano {client_config.domain}."
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
plan = SyncPlan.from_dict(plan_data)
|
||||
print_plan(plan)
|
||||
apply_sync_plan(client_config, plan, show_navigation=show_navigation)
|
||||
return
|
||||
|
||||
started = now_local()
|
||||
print(f"\nKlient: {client_config.domain}")
|
||||
print("Pobieram kampanie PLA_CL1 i produkty z adsPRO...")
|
||||
|
||||
google_client = get_google_ads_client(use_proto_plus=True)
|
||||
customer_id = client_config.safe_customer_id
|
||||
|
||||
campaign_rows = run_query(
|
||||
google_client,
|
||||
customer_id,
|
||||
"""
|
||||
SELECT campaign.id, campaign.name, campaign.status
|
||||
FROM campaign
|
||||
WHERE campaign.name LIKE '%PLA_CL1%'
|
||||
AND campaign.status = 'ENABLED'
|
||||
""",
|
||||
)
|
||||
segments = sorted(
|
||||
{
|
||||
label
|
||||
for row in campaign_rows
|
||||
for label in parse_allowed_labels(row.campaign.name)
|
||||
}
|
||||
)
|
||||
if not segments:
|
||||
print("Nie znaleziono segmentow CL1 w kampaniach [PLA_CL1].")
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Synchronizacja kampanii PLA_CL1",
|
||||
"status": "brak kampanii",
|
||||
"campaign": "",
|
||||
},
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
print("Segmenty CL1: " + ", ".join(segments))
|
||||
products = fetch_adspro_products(client_config, segments)
|
||||
products_path = save_products_csv(client_config.domain, products)
|
||||
print(f"Pobrano produkty z adsPRO: {len(products)}")
|
||||
print(f"Zapisano dane: {products_path}")
|
||||
|
||||
plan = build_plan(google_client, customer_id, products)
|
||||
print_plan(plan)
|
||||
json_path, md_path = save_plan_files(client_config.domain, plan, len(products))
|
||||
print(f"\nPlan JSON: {json_path}")
|
||||
print(f"Plan Markdown: {md_path}")
|
||||
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Synchronizacja kampanii PLA_CL1",
|
||||
"status": "plan przygotowany",
|
||||
"campaign": ", ".join(c["name"] for c in plan.campaigns[:10]),
|
||||
"created_at": started.isoformat(timespec="seconds"),
|
||||
"summary": {
|
||||
"campaigns": len(plan.campaigns),
|
||||
"products": len(products),
|
||||
"create": len(plan.create_plan),
|
||||
"enable": len(plan.enable_plan),
|
||||
"pause": len(plan.pause_plan),
|
||||
"rename": len(plan.rename_plan),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if plan_only:
|
||||
print("\nTryb plan-only: zmiany nie zostaly wdrozone.")
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
if not plan.create_plan and not plan.enable_plan and not plan.pause_plan and not plan.rename_plan:
|
||||
print("\nBrak zmian do wdrozenia.")
|
||||
append_change_markdown(client_config.domain, "Synchronizacja kampanii PLA_CL1", [])
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
answer = input("\nWpisz TAK, aby wdrozyc powyzsze zmiany: ").strip()
|
||||
if answer != "TAK":
|
||||
print("Przerwano. Zmiany nie zostaly wdrozone.")
|
||||
append_history(
|
||||
client_config.domain,
|
||||
{
|
||||
"task": "Synchronizacja kampanii PLA_CL1",
|
||||
"status": "odrzucono wdrozenie",
|
||||
"campaign": ", ".join(c["name"] for c in plan.campaigns[:10]),
|
||||
},
|
||||
)
|
||||
if show_navigation:
|
||||
print_next_navigation(client_config.domain)
|
||||
return
|
||||
|
||||
apply_sync_plan(client_config, plan, show_navigation=show_navigation)
|
||||
1
.sync/FolderType
Normal file
1
.sync/FolderType
Normal file
@@ -0,0 +1 @@
|
||||
d8:brandingi0e31:disable_remove_from_all_devicesi0e4:modei0e5:owner5:jacek4:typei2ee
|
||||
54
.sync/IgnoreList
Normal file
54
.sync/IgnoreList
Normal file
@@ -0,0 +1,54 @@
|
||||
# IgnoreList is a UTF-8 encoded .txt file that helps you specify single files, paths and rules
|
||||
# for ignoring during the synchronization job. It supports "?" and "*" wildcard symbols.
|
||||
#
|
||||
#
|
||||
# OS generated files #
|
||||
$RECYCLE.BIN
|
||||
$Recycle.Bin
|
||||
System Volume Information
|
||||
ehthumbs.db
|
||||
desktop.ini
|
||||
Thumbs.db
|
||||
lost+found
|
||||
.DocumentRevisions-V100
|
||||
.TemporaryItems
|
||||
.fseventsd
|
||||
.icloud
|
||||
.iCloud
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
.Trash-*
|
||||
.trashed-*
|
||||
~*
|
||||
*~
|
||||
.~lock.*
|
||||
*.part
|
||||
*.filepart
|
||||
.csync_journal.db
|
||||
.csync_journal.db.tmp
|
||||
*.swn
|
||||
*.swp
|
||||
*.swo
|
||||
*.crdownload
|
||||
.@__thumb
|
||||
.thumbnails
|
||||
._*
|
||||
*.tmp
|
||||
*.tmp.chck
|
||||
.dropbox
|
||||
.dropbox.attr
|
||||
.dropbox.cache
|
||||
.streams
|
||||
.caches
|
||||
.Statuses
|
||||
.teamdrive
|
||||
.SynologyWorkingDirectory
|
||||
@eaDir
|
||||
@SynoResource
|
||||
#SynoRecycle
|
||||
#snapshot
|
||||
#recycle
|
||||
.!@#$recycle
|
||||
DfsrPrivate
|
||||
8
.sync/StreamsList
Normal file
8
.sync/StreamsList
Normal file
@@ -0,0 +1,8 @@
|
||||
# StreamsList is a UTF-8 encoded .txt file that helps you specify alternate streams,
|
||||
# xattrs and resource forks white list. It supports "?" and "*" wildcard symbols.
|
||||
#
|
||||
#
|
||||
#
|
||||
com.apple.metadata:_kMDItemUserTags
|
||||
com.apple.ResourceFork
|
||||
com.apple.metadata:kMDItemFinderComment
|
||||
0
.sync/root_acl_entry
Normal file
0
.sync/root_acl_entry
Normal file
Reference in New Issue
Block a user