first commit

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

View File

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

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

@@ -0,0 +1,271 @@
# Instrukcja dla agentow AI
Ten projekt jest terminalowym narzedziem do pracy na kontach Google Ads.
Agent AI nie uzywa API modeli. Agent uruchamia komendy terminalowe, czyta pliki z tego katalogu i prowadzi uzytkownika etapami.
## Najwazniejszy przeplyw
Uzytkownik nie ma pamietac nazw technicznych zadan.
Gdy uzytkownik napisze:
```text
analiza-klienta
```
uruchom:
```powershell
python gads.py analiza-klienta
```
Pokaz uzytkownikowi liste klientow i popros o numer.
Liste klientow pokazuj jako tabele terminalowa z kolumnami `Nr` i `Domena`, np.:
Zawsze pokazuj pelna liste klientow widoczna w terminalu.
Nie streszczaj jej tekstem typu `Masz 10 klientow`.
Nie ukrywaj listy za komunikatem o rozwinieciu wyniku narzedzia, np. `+24 lines` albo `ctrl+o to expand`.
Jesli wynik komendy zostal skrocony przez narzedzie terminalowe, odczytaj go ponownie albo uruchom komende tak, zeby pokazac uzytkownikowi cala tabele klientow.
```text
┌────┬──────────────────────┐
│ Nr │ Domena │
├────┼──────────────────────┤
│ 1 │ aruba.rzeszow.pl │
└────┴──────────────────────┘
```
Po wyborze numeru klienta uruchom:
```powershell
python gads.py analiza-klienta --client-number <numer>
```
Pokaz uzytkownikowi liste zadan. Zadania musza byc prezentowane z podzialem na grupy nadrzedne, np. `KAMPANIE PLA`, `WYKLUCZENIA`, `KAMPANIE SEARCH`.
Nie wolno pokazywac samej tabeli zadan bez naglowka grupy. Nawet gdy jest tylko jedna grupa i jedno zadanie, pokaz naglowek grupy.
Nie wolno laczyc zadan i opcji zbiorczych w jednej tabeli. Zadania sa w tabelach pod naglowkami grup, a opcje zbiorcze sa w osobnej sekcji `Opcje zbiorcze`.
Nie pokazuj tak:
```text
┌─────┬─────────────────────────────────┐
│ Nr │ Zadanie │
├─────┼─────────────────────────────────┤
│ 1.1 │ Synchronizacja kampanii PLA_CL1 │
│ 1.2 │ Sprawdzenie ustawien │
│ 1.0 │ Wszystkie z grupy PLA │
│ ALL │ Wszystkie zadania │
└─────┴─────────────────────────────────┘
```
To jest bledne, bo miesza pojedyncze zadania z wyborami zbiorczymi i usuwa naglowek grupy.
Format listy zadan:
```text
Klient: investagd.pl
========================================================================
KAMPANIE PLA
========================================================================
┌────┬─────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────┐
│ Nr │ Zadanie │ Opis │
├────┼─────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 1.1 │ Synchronizacja kampanii PLA_CL1 │ Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam. │
└────┴─────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────┘
```
W kazdej grupie pokazuj zadania jako tabele terminalowa z kolumnami `Nr`, `Zadanie`, `Opis`.
Po tabelach zadan pokaz tez `Opcje zbiorcze`:
```text
Opcje zbiorcze
┌─────┬────────────────────────────────────────────┐
│ Nr │ Zakres │
├─────┼────────────────────────────────────────────┤
│ 1.0 │ Wszystkie zadania z grupy: Kampanie PLA │
├─────┼────────────────────────────────────────────┤
│ ALL │ Wszystkie zadania ze wszystkich grup │
└─────┴────────────────────────────────────────────┘
```
Numeracja ma format `grupa.zadanie`.
Przyklad: `1.1` to pierwsze zadanie w pierwszej grupie, `2.3` to trzecie zadanie w drugiej grupie.
Opcja zbiorcza grupy zawsze konczy sie na `.0`, np. `1.0`, `2.0`.
`ALL` pokazuj tylko raz na koncu listy opcji zbiorczych.
Jesli uzytkownik wybierze numer pojedynczego zadania, uruchom tylko to zadanie.
Jesli uzytkownik wybierze `1.0`, `2.0` itd., uruchom wszystkie zadania z tej grupy po kolei.
Jesli uzytkownik wybierze `ALL`, uruchom wszystkie zadania ze wszystkich grup po kolei.
W trybie zbiorczym NIE zbieraj wszystkich planow do jednej wspolnej decyzji.
Kazde zadanie obsluguj osobno:
1. uruchom `--plan-only` dla pierwszego zadania,
2. przeczytaj plan pierwszego zadania,
3. pokaz krotka analize pierwszego zadania,
4. zapytaj, czy wdrozyc ten jeden plan,
5. po decyzji uzytkownika wdroz albo pomin ten jeden plan,
6. dopiero potem przejdz do kolejnego zadania.
Nie pytaj: `Czy wdrozyc oba plany?`.
Pytaj: `Czy wdrozyc plan zadania 1/2?`.
Po wyborze numeru zadania uruchom najpierw tylko przygotowanie planu:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --task-number <numer-zadania> --plan-only
```
Preferowana komenda dla wyboru z listy:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --select <wybor> --plan-only
```
Po wyborze calej grupy uruchom:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --select 1.0 --plan-only
```
Po wyborze wszystkich grup uruchom:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --select ALL --plan-only
```
Zadania zbiorcze musza isc sekwencyjnie: nastepne zadanie startuje dopiero po zakonczeniu poprzedniego, lacznie z decyzja uzytkownika o wdrozeniu albo pominieciu planu.
Nastepnie odczytaj zapisany plan z:
```text
clients/<domena>/plans/
```
Przeanalizuj plan krotko i konkretnie. Nie wdrazaj zmian bez zgody uzytkownika.
Po akceptacji uzytkownika uruchom wdrozenie dokladnie zapisanego planu:
```powershell
python gads.py analiza-klienta --client-number <numer-klienta> --task-number <numer-zadania> --apply-plan <sciezka-do-planu-json> --confirm-apply TAK
```
Po wykonaniu zadania, odrzuceniu planu albo decyzji o niewdrazaniu zmian zapytaj uzytkownika, co dalej:
```text
Co dalej?
1. Lista zadan tego samego klienta
2. Lista klientow
3. Zakoncz
```
Popros uzytkownika tylko o numer. Po wyborze:
- `1` pokaz liste zadan tego samego klienta,
- `2` pokaz liste klientow,
- `3` zakoncz.
## Zasady komunikacji
- Pisz po polsku.
- Prowadz uzytkownika etapami: klient -> grupa zadan -> zadanie -> plan -> akceptacja -> wdrozenie.
- Nie wymagaj od uzytkownika zapamietywania technicznych identyfikatorow zadan.
- Nie uzywaj skrotow bez potrzeby. Pisz `grupa reklam`, nie `AG`.
- Pisz `wdrozenie zmian`, nie `mutacja`.
- Pisz `Docelowy ROAS`, nie `tROAS`, chyba ze cytujesz nazwe techniczna.
- Odpowiedzi analityczne maja byc krotkie: co zostanie zrobione, ile elementow, jakie ryzyko, czy rekomendujesz wdrozenie.
- Odpowiedzi analityczne po odczytaniu planu musza zawierac tabele. Nie streszczaj planu samymi punktami.
Minimalny format analizy planu:
```text
Zadanie 1/2: Synchronizacja kampanii PLA_CL1
Podsumowanie po kampaniach
┌──────────────────────┬────────┬───────┬───────────┬─────────────┐
│ Kampania │ Utworz │ Wlacz │ Wstrzymaj │ Zmien nazwe │
├──────────────────────┼────────┼───────┼───────────┼─────────────┤
│ [PLA_CL1] pozostale │ 0 │ 1 │ 0 │ 0 │
│ [PLA_CL1] worki │ 0 │ 8 │ 0 │ 0 │
└──────────────────────┴────────┴───────┴───────────┴─────────────┘
Najwazniejsze dzialania
┌────┬─────────────────────┬────────────────────────────────────────┐
│ Nr │ Kampania │ Dzialanie │
├────┼─────────────────────┼────────────────────────────────────────┤
│ 1 │ [PLA_CL1] pozostale │ Wlacz grupe reklam: nazwa grupy │
└────┴─────────────────────┴────────────────────────────────────────┘
Ryzyko: niskie.
Rekomendacja: wdrozyc.
```
Jesli lista dzialan jest dluga, pokaz tabele z pierwszymi 10 pozycjami i dopisz liczbe pozostalych. Nadal pokaz pelne podsumowanie po kampaniach.
## Bezpieczenstwo
- Najpierw zawsze tworz plan przez `--plan-only`.
- Nie wdrazaj planu, dopoki uzytkownik jasno nie zaakceptuje.
- Wdrazaj tylko plan zapisany w pliku JSON.
- Po wdrozeniu sprawdz i podaj sciezki historii:
- `clients/<domena>/history/YYYY-MM-DD.jsonl`
- `clients/<domena>/changes/YYYY-MM-DD.md`
- Po wykonaniu albo odrzuceniu zadania zawsze zaproponuj powrot do listy zadan albo listy klientow.
## Konfiguracja zadan
Lista grup i zadan jest w:
```text
config/tasks.toml
```
Dodawaj nowe zadania do odpowiednich grup, z czytelna nazwa dla uzytkownika.
Szczegolowa instrukcja rozbudowy narzedzia jest w:
```text
DEVELOPMENT.md
```
## Zadania: Produkty
Grupa `Produkty` jest podzielona na trzy osobne zadania:
- `Optymalizacja tytulow produktow` pobiera z adsPRO tylko produkty bez zoptymalizowanego tytulu.
- `Optymalizacja kategorii Google` pobiera z adsPRO tylko produkty bez kategorii Google.
- `Uzupelnienie unit pricing` pobiera z adsPRO tylko produkty bez unit pricing.
Nie mieszaj tych zakresow w jednym planie.
Tytuly produktow wybiera agent AI po analizie produktu, tytulu bazowego, strony produktu albo kontekstu klienta.
Kategorie Google wybiera agent AI po analizie produktu, tytulu, strony produktu albo kontekstu klienta.
Skrypt nie wybiera automatycznie tytulow ani kategorii Google.
Przed wdrozeniem tytulow agent musi uzupelnic docelowe wartosci tytulow w zapisanym planie JSON i dopiero wtedy zapytac uzytkownika o zgode.
Przed wdrozeniem kategorii agent musi uzupelnic docelowe wartosci kategorii w zapisanym planie JSON i dopiero wtedy zapytac uzytkownika o zgode.
Unit pricing moze byc proponowany przez skrypt, jezeli da sie go jednoznacznie odczytac z nazwy produktu.
## Reguly i wyjatki klientow
Ustawienia globalne i wyjatki per klient sa w:
```text
config/clients.toml
```
Przyklad globalnych regul dla kampanii PLA:
```toml
[global_rules.pla_settings]
require_presence_only = true
require_high_priority = true
```
Wyjatek per klient:
```toml
[clients."example.pl".pla_settings]
require_high_priority = false
```
Jesli klient ma wylaczona regule, agent nie powinien sugerowac wdrozenia tej zmiany.

View File

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

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

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

View File

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

View File

@@ -0,0 +1,49 @@
[clients."pomysloweprezenty.pl"]
google_ads_customer_id = "941-605-1782"
adspro_client_id = "2"
[clients."innsi.pl"]
google_ads_customer_id = "133-343-6346"
adspro_client_id = "5"
[clients."van-dam.pl"]
google_ads_customer_id = "570-658-4790"
[clients."sklep.ele-comp.pl"]
google_ads_customer_id = "489-092-9476"
adspro_client_id = "7"
[clients."investagd.pl"]
google_ads_customer_id = "229-855-5588"
adspro_client_id = "8"
[clients."wyprzedaze.pl"]
google_ads_customer_id = "775-249-3197"
adspro_client_id = "10"
[clients."laitica.pl"]
google_ads_customer_id = "262-567-7205"
adspro_client_id = "9"
[clients."studio-zoe.pl"]
google_ads_customer_id = "387-166-1050"
[clients."aruba.rzeszow.pl"]
google_ads_customer_id = "374-470-8609"
adspro_client_id = "3"
[clients."ibra-makeup.pl"]
google_ads_customer_id = "818-919-2566"
adspro_client_id = "4"
[global_rules]
max_create_groups_without_extra_confirm = 100
max_pause_groups_without_extra_confirm = 100
[global_rules.pla_settings]
require_presence_only = true
require_high_priority = true
[global_rules.product_feed_optimization]
limit = 10
min_days_between_title_changes = 30

View File

@@ -0,0 +1,32 @@
[[groups]]
id = "pla"
name = "Kampanie PLA"
[[groups.tasks]]
id = "sync_pla_cl1"
name = "Synchronizacja kampanii PLA_CL1"
description = "Porownuje kampanie [PLA_CL1] z produktami w adsPRO i przygotowuje plan zmian grup reklam."
[[groups.tasks]]
id = "check_pla_settings"
name = "Sprawdzenie ustawien"
description = "Sprawdza ustawienia lokalizacji i priorytetu kampanii PLA wedlug regul globalnych i wyjatkow klienta."
[[groups]]
id = "products"
name = "Produkty"
[[groups.tasks]]
id = "optimize_product_titles"
name = "Optymalizacja tytulow produktow"
description = "Pobiera produkty z adsPRO i przygotowuje plan optymalizacji tytulow produktow."
[[groups.tasks]]
id = "optimize_product_categories"
name = "Optymalizacja kategorii Google"
description = "Pobiera produkty z adsPRO bez kategorii Google i przygotowuje plan decyzji agenta AI."
[[groups.tasks]]
id = "fill_product_unit_pricing"
name = "Uzupelnienie unit pricing"
description = "Pobiera produkty z adsPRO bez unit pricing i przygotowuje plan uzupelnienia miary oraz miary bazowej."

View File

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

View File

@@ -0,0 +1,354 @@
from __future__ import annotations
import argparse
import sys
from .config import load_config, load_env
from .table import print_table
from .task_catalog import (
load_groups,
load_tasks,
print_task_list,
task_by_number,
task_by_selection,
tasks_by_group_number,
tasks_by_selection_group,
)
from .tasks.pla_settings_check import run_check_pla_settings
from .tasks.pla_cl1_sync import run_sync_pla_cl1
from .tasks.product_feed_optimization import (
run_fill_product_unit_pricing,
run_optimize_product_categories,
run_optimize_product_feed,
run_optimize_product_titles,
)
def choose_index(label: str, options: list[str]) -> int | None:
print(f"\n{label}")
for i, option in enumerate(options, 1):
print(f"{i}. {option}")
try:
raw = input("Wybierz numer albo Enter aby wyjsc: ").strip()
except EOFError:
print()
return None
if not raw:
return None
try:
idx = int(raw)
except ValueError:
print("Nieprawidlowy numer.")
return None
if idx < 1 or idx > len(options):
print("Nieprawidlowy numer.")
return None
return idx - 1
def main() -> None:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description="Google Ads ver 2")
parser.add_argument(
"command",
nargs="?",
choices=["analiza-klienta"],
help="Tryb prowadzony etapami dla agenta lub terminala",
)
parser.add_argument("--client", help="Domena klienta z config/clients.toml")
parser.add_argument("--client-number", type=int, help="Numer klienta z listy analiza-klienta")
parser.add_argument(
"--task",
choices=[
"sync_pla_cl1",
"check_pla_settings",
"optimize_product_feed",
"optimize_product_titles",
"optimize_product_categories",
"fill_product_unit_pricing",
],
help="Zadanie do uruchomienia bez menu",
)
parser.add_argument("--select", help="Wybór z listy zadan, np. 1.1, 1.0 albo ALL")
parser.add_argument("--task-number", type=int, help="Numer zadania z listy analiza-klienta")
parser.add_argument("--group-number", type=int, help="Uruchom wszystkie zadania z grupy o podanym numerze")
parser.add_argument("--group-all-current", action="store_true", help="Uruchom wszystkie zadania z pierwszej widocznej grupy")
parser.add_argument("--all-groups", action="store_true", help="Uruchom wszystkie zadania ze wszystkich grup")
parser.add_argument("--plan-only", action="store_true", help="Tylko przygotuj plan i zapisz go do pliku")
parser.add_argument("--apply-plan", help="Wdroz zapisany plan JSON")
parser.add_argument(
"--confirm-apply",
help="Wymagane przy --apply-plan. Uzyj dokladnie: TAK",
)
args = parser.parse_args()
load_env()
try:
cfg = load_config()
except Exception as exc:
print(exc)
sys.exit(1)
domains = sorted(cfg.clients)
if not domains:
print("Brak klientow w config/clients.toml.")
return
tasks = load_tasks()
groups = load_groups()
selected_domain = args.client
if args.client_number:
if args.client_number < 1 or args.client_number > len(domains):
print(f"Nie ma klienta numer {args.client_number}.")
return
selected_domain = domains[args.client_number - 1]
if args.command == "analiza-klienta":
if not selected_domain:
print("\nWybierz klienta:")
print_table(["Nr", "Domena"], [[str(i), domain] for i, domain in enumerate(domains, 1)])
print("\nNastepny krok:")
print("python gads.py analiza-klienta --client-number <numer>")
return
if selected_domain not in cfg.clients:
print(f"Nie znaleziono klienta {selected_domain} w config/clients.toml.")
return
if not args.select and not args.task_number and not args.group_number and not args.group_all_current and not args.all_groups:
print(f"\nKlient: {selected_domain}")
print_task_list(tasks)
print("\nNastepny krok:")
print(
"python gads.py analiza-klienta "
f"--client-number {domains.index(selected_domain) + 1} "
"--select <nr> --plan-only"
)
return
if args.select:
selected = args.select.strip()
if selected.upper() == "ALL":
run_task_sequence(
tasks,
cfg.clients[selected_domain],
cfg.global_rules,
plan_only=args.plan_only,
)
return
selected_group_tasks = tasks_by_selection_group(tasks, groups, selected)
if selected_group_tasks:
run_task_sequence(
selected_group_tasks,
cfg.clients[selected_domain],
cfg.global_rules,
plan_only=args.plan_only,
)
return
selected_task = task_by_selection(tasks, selected)
if not selected_task:
print(f"Nie ma wyboru {selected}.")
return
run_task(
selected_task.id,
cfg.clients[selected_domain],
cfg.global_rules,
plan_only=args.plan_only,
apply_plan_path=args.apply_plan,
confirm_apply=args.confirm_apply,
)
return
if args.group_all_current:
first_group_number = groups[0].number if groups else None
selected_tasks = tasks_by_group_number(tasks, groups, first_group_number) if first_group_number else []
if not selected_tasks:
print("Brak zadan w pierwszej grupie.")
return
run_task_sequence(
selected_tasks,
cfg.clients[selected_domain],
cfg.global_rules,
plan_only=args.plan_only,
)
return
if args.group_number:
selected_tasks = tasks_by_group_number(tasks, groups, args.group_number)
if not selected_tasks:
print(f"Nie ma grupy numer {args.group_number}.")
return
run_task_sequence(
selected_tasks,
cfg.clients[selected_domain],
cfg.global_rules,
plan_only=args.plan_only,
)
return
if args.all_groups:
run_task_sequence(
tasks,
cfg.clients[selected_domain],
cfg.global_rules,
plan_only=args.plan_only,
)
return
selected_task = task_by_number(tasks, args.task_number)
if not selected_task:
print(f"Nie ma zadania numer {args.task_number}.")
return
run_task(
selected_task.id,
cfg.clients[selected_domain],
cfg.global_rules,
plan_only=args.plan_only,
apply_plan_path=args.apply_plan,
confirm_apply=args.confirm_apply,
)
return
if args.client or args.task:
if not args.client or not args.task:
print("Dla trybu bez menu podaj jednoczesnie --client i --task.")
return
if args.client not in cfg.clients:
print(f"Nie znaleziono klienta {args.client} w config/clients.toml.")
return
run_task(
args.task,
cfg.clients[args.client],
cfg.global_rules,
plan_only=args.plan_only,
apply_plan_path=args.apply_plan,
confirm_apply=args.confirm_apply,
)
return
client_idx = choose_index("Klient", domains)
if client_idx is None:
return
client = cfg.clients[domains[client_idx]]
task_labels = [task.name for task in tasks]
task_idx = choose_index("Zadanie", task_labels)
if task_idx is None:
return
run_task(tasks[task_idx].id, client, cfg.global_rules)
def run_task(
task_id,
client,
global_rules,
plan_only: bool = False,
apply_plan_path: str | None = None,
confirm_apply: str | None = None,
show_navigation: bool = True,
) -> None:
if task_id == "sync_pla_cl1":
run_sync_pla_cl1(
client,
global_rules,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
return
if task_id == "check_pla_settings":
run_check_pla_settings(
client,
global_rules,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
return
if task_id == "optimize_product_feed":
run_optimize_product_feed(
client,
global_rules,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
return
if task_id == "optimize_product_titles":
run_optimize_product_titles(
client,
global_rules,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
return
if task_id == "optimize_product_categories":
run_optimize_product_categories(
client,
global_rules,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
return
if task_id == "fill_product_unit_pricing":
run_fill_product_unit_pricing(
client,
global_rules,
plan_only=plan_only,
apply_plan_path=apply_plan_path,
confirm_apply=confirm_apply,
show_navigation=show_navigation,
)
return
print(f"Zadanie {task_id} nie ma jeszcze implementacji.")
def run_task_sequence(tasks, client, global_rules, plan_only: bool = False) -> None:
total = len(tasks)
if plan_only:
print("Tryb zbiorczy plan-only przygotuje plany po kolei.")
print("Agent powinien analizowac i wdrazac kazdy plan osobno, przed przejsciem do kolejnego zadania.")
for index, task in enumerate(tasks, 1):
print()
print("#" * 72)
print(f"Zadanie {index}/{total}: {task.group_name} / {task.name}")
print("#" * 72)
run_task(task.id, client, global_rules, plan_only=plan_only, show_navigation=False)
print()
print("Zakonczono sekwencje zadan.")
print_sequence_navigation(client.domain)
def print_sequence_navigation(domain: str) -> None:
print("\nCo dalej:")
print(f"1. Lista zadan klienta {domain}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client {domain}")
print("2 -> python gads.py analiza-klienta")
def print_next_navigation(client_number: int | None = None) -> None:
print("\nCo dalej:")
if client_number:
print(f"1. Lista zadan klienta numer {client_number}")
print("2. Lista klientow")
print("3. Zakoncz")
print("\nKomendy:")
print(f"1 -> python gads.py analiza-klienta --client-number {client_number}")
print("2 -> python gads.py analiza-klienta")
else:
print("1. Lista klientow")
print("2. Zakoncz")
print("\nKomendy:")
print("1 -> python gads.py analiza-klienta")

View File

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

1
.sync/FolderType Normal file
View File

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

1
.sync/ID Normal file
View File

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

54
.sync/IgnoreList Normal file
View File

@@ -0,0 +1,54 @@
# IgnoreList is a UTF-8 encoded .txt file that helps you specify single files, paths and rules
# for ignoring during the synchronization job. It supports "?" and "*" wildcard symbols.
#
#
# OS generated files #
$RECYCLE.BIN
$Recycle.Bin
System Volume Information
ehthumbs.db
desktop.ini
Thumbs.db
lost+found
.DocumentRevisions-V100
.TemporaryItems
.fseventsd
.icloud
.iCloud
.DS_Store
.DS_Store?
.Spotlight-V100
.Trashes
.Trash-*
.trashed-*
~*
*~
.~lock.*
*.part
*.filepart
.csync_journal.db
.csync_journal.db.tmp
*.swn
*.swp
*.swo
*.crdownload
.@__thumb
.thumbnails
._*
*.tmp
*.tmp.chck
.dropbox
.dropbox.attr
.dropbox.cache
.streams
.caches
.Statuses
.teamdrive
.SynologyWorkingDirectory
@eaDir
@SynoResource
#SynoRecycle
#snapshot
#recycle
.!@#$recycle
DfsrPrivate

8
.sync/StreamsList Normal file
View File

@@ -0,0 +1,8 @@
# StreamsList is a UTF-8 encoded .txt file that helps you specify alternate streams,
# xattrs and resource forks white list. It supports "?" and "*" wildcard symbols.
#
#
#
com.apple.metadata:_kMDItemUserTags
com.apple.ResourceFork
com.apple.metadata:kMDItemFinderComment

0
.sync/root_acl_entry Normal file
View File